feat(workspace): interview and follow-up workflow
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase. Interview preparation gets a durable, user-owned store. There were already two per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are caches that regenerate when their context signature changes — anything a user typed into them would eventually be overwritten. InterviewPrepItem is the side nothing regenerates, covering company research, technical notes, behavioural answers, STAR examples and the user's own questions in one table, because those categories differ only by label and adding one must not need a migration. Each item records whether the user wrote it or accepted a suggestion, and an IsPrepared flag makes the section double as the preparation checklist. Generation stays in the existing AiWorkspaceService "interview" module, appended to AiInteraction as before. A suggestion is history until the user adds it as a prep item; opening the section generates nothing. Follow-up reuses what exists rather than adding a tracker. The date is JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted service already act on, so reminders keep working with no new wiring. The task stays an ApplicationChecklistItem in the follow-up category — the section counts open tasks without owning them. The record is a FollowUpSet JobEvent, the same type the rest of the app emits. Communication is untouched: Correspondence already owns recruiter contacts, history and notes, and the workspace already mounted it. The timeline interpreter learned five more types — InterviewScheduled, InterviewCompleted and OfferReceived as milestones, FollowUpCreated and FollowUpCompleted as routine, deliberately outside the milestone spine so it stays a summary of what actually happened. JobEvent remains the history source. InterviewPrepItems is reconciler-owned with a no-op migration, guarded on JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary key, varchar owner and title, tinyint flag, datetime(6), composite index inside the key limit. 371 backend tests, 128 frontend tests, Release build and the production build all pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<AiInteraction> AiInteractions => Set<AiInteraction>();
|
||||
public DbSet<ApplicationChecklistItem> ApplicationChecklistItems => Set<ApplicationChecklistItem>();
|
||||
public DbSet<CoverLetterVersion> CoverLetterVersions => Set<CoverLetterVersion>();
|
||||
public DbSet<InterviewPrepItem> InterviewPrepItems => Set<InterviewPrepItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -384,6 +385,23 @@ namespace JobTrackerApi.Data
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Phase 5.5: user-owned interview preparation. Unlike InterviewPrepNote (an AI cache), nothing
|
||||
// regenerates this. docs/architecture/application-workspace.md.
|
||||
modelBuilder.Entity<InterviewPrepItem>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
// varchar (not longtext) for the indexed columns — see the CvVariant note above.
|
||||
modelBuilder.Entity<InterviewPrepItem>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<InterviewPrepItem>().Property(x => x.Category).HasMaxLength(32);
|
||||
modelBuilder.Entity<InterviewPrepItem>().Property(x => x.Source).HasMaxLength(16);
|
||||
modelBuilder.Entity<InterviewPrepItem>().Property(x => x.Title).HasMaxLength(500);
|
||||
modelBuilder.Entity<InterviewPrepItem>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.SortOrder });
|
||||
modelBuilder.Entity<InterviewPrepItem>()
|
||||
.HasOne(x => x.JobApplication)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
// Common config for CareerProfile's relational children. The 1:many FK + cascade delete is
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Phase 5.5 — Interview preparation and follow-up. The properties under test: prep content belongs to
|
||||
// the user and nothing regenerates it, follow-ups reuse the existing FollowUpAt + checklist rather
|
||||
// than a second reminder system, and the timeline reads the new lifecycle events.
|
||||
public sealed class InterviewPrepTests
|
||||
{
|
||||
private static (JobTrackerContext db, InterviewPrepService prep, ApplicationTimelineService timeline) New(string userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(s => s.UserId).Returns(userId);
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
return (db, new InterviewPrepService(db), new ApplicationTimelineService(db));
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner, Action<JobApplication>? tweak = null)
|
||||
{
|
||||
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = owner,
|
||||
CompanyId = company.Id,
|
||||
JobTitle = "Senior Backend Developer",
|
||||
Status = "Interview",
|
||||
};
|
||||
tweak?.Invoke(job);
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
// ---------- Part 1: interview preparation ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Prep_items_group_by_category_in_preparation_order()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Question, "What does success look like?", null, null, null), default);
|
||||
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.CompanyResearch, "Funding history", "Series B in 2025.", null, null), default);
|
||||
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Technical, "System design", null, null, null), default);
|
||||
|
||||
var result = await prep.GetAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question },
|
||||
result!.Groups.Select(g => g.Category));
|
||||
Assert.Equal("Company research", result.Groups[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Marking_items_prepared_drives_the_progress_percentage()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
var a = await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(null, "One", null, null, null), default);
|
||||
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(null, "Two", null, null, null), default);
|
||||
|
||||
await prep.UpdateAsync("user-1", job.Id, a!.Id, new InterviewPrepInput(null, null, null, null, true), default);
|
||||
var result = await prep.GetAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal(2, result!.Total);
|
||||
Assert.Equal(1, result.Prepared);
|
||||
Assert.Equal(50, result.Percent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_accepted_ai_suggestion_is_recorded_as_ai_but_stays_editable()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var item = await prep.AddAsync("user-1", job.Id,
|
||||
new InterviewPrepInput(InterviewPrepCategories.Behavioural, "Tell me about a conflict", "Draft answer", InterviewPrepSources.Ai, null), default);
|
||||
|
||||
Assert.Equal(InterviewPrepSources.Ai, item!.Source);
|
||||
|
||||
var edited = await prep.UpdateAsync("user-1", job.Id, item.Id,
|
||||
new InterviewPrepInput(null, null, "My own answer", null, null), default);
|
||||
|
||||
Assert.Equal("My own answer", edited!.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generating_ai_history_does_not_create_prep_items()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
db.AiInteractions.Add(new AiInteraction
|
||||
{
|
||||
OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "interview",
|
||||
Title = "Interview prep", Provider = "p", ResultJson = """{"text":"Likely questions..."}""",
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await prep.GetAsync("user-1", job.Id, default);
|
||||
|
||||
// A suggestion is history until the user accepts it. Nothing appears in their prep by itself.
|
||||
Assert.Equal(0, result!.Total);
|
||||
Assert.Equal(1, result.AiSuggestionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prep_items_are_deletable_and_scoped_to_their_owner()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var mine = await SeedJobAsync(db, "user-1");
|
||||
var theirs = await SeedJobAsync(db, "user-2");
|
||||
var item = await prep.AddAsync("user-1", mine.Id, new InterviewPrepInput(null, "Mine", null, null, null), default);
|
||||
|
||||
Assert.Null(await prep.GetAsync("user-1", theirs.Id, default));
|
||||
Assert.Null(await prep.AddAsync("user-1", theirs.Id, new InterviewPrepInput(null, "Sneak", null, null, null), default));
|
||||
Assert.False(await prep.DeleteAsync("user-1", theirs.Id, item!.Id, default));
|
||||
|
||||
Assert.True(await prep.DeleteAsync("user-1", mine.Id, item.Id, default));
|
||||
Assert.Equal(0, (await prep.GetAsync("user-1", mine.Id, default))!.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prep_never_writes_to_the_career_profile()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
db.CareerProfiles.Add(new CareerProfile
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
Experiences = { new CareerExperience { OwnerUserId = "user-1", Title = "Dev", BulletsJson = """["Original"]""" } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var before = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync();
|
||||
|
||||
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Star, "A time I led", "Content", InterviewPrepSources.Ai, null), default);
|
||||
|
||||
var after = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync();
|
||||
Assert.Equal(before.Version, after.Version);
|
||||
Assert.Equal(before.Experiences[0].BulletsJson, after.Experiences[0].BulletsJson);
|
||||
}
|
||||
|
||||
// ---------- Part 4: follow-up ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Setting_a_follow_up_updates_the_existing_field_and_records_an_event()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
var due = DateTime.Today.AddDays(7);
|
||||
|
||||
var result = await prep.SetFollowUpAsync("user-1", job.Id, due, "Chase the recruiter", default);
|
||||
|
||||
Assert.Equal(due, result!.FollowUpAt);
|
||||
Assert.Equal("Chase the recruiter", result.NextAction);
|
||||
// The same field the reminder service and RulesEngine already read — not a parallel store.
|
||||
Assert.Equal(due, (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).FollowUpAt);
|
||||
Assert.Equal("FollowUpSet", (await db.JobEvents.AsNoTracking().SingleAsync()).Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Follow_up_counts_open_checklist_tasks_rather_than_owning_them()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
db.ApplicationChecklistItems.Add(new ApplicationChecklistItem
|
||||
{
|
||||
OwnerUserId = "user-1", JobApplicationId = job.Id, Title = "Chase",
|
||||
Category = ChecklistCategories.FollowUp, Status = ChecklistStatuses.Pending,
|
||||
});
|
||||
db.ApplicationChecklistItems.Add(new ApplicationChecklistItem
|
||||
{
|
||||
OwnerUserId = "user-1", JobApplicationId = job.Id, Title = "Done one",
|
||||
Category = ChecklistCategories.FollowUp, Status = ChecklistStatuses.Done,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await prep.GetFollowUpAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Equal(1, result!.OpenFollowUpTasks);
|
||||
// No follow-up table was created — the tasks live in the checklist.
|
||||
Assert.Equal(2, await db.ApplicationChecklistItems.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clearing_a_follow_up_is_recorded_too()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1", j => j.FollowUpAt = DateTime.Today.AddDays(3));
|
||||
|
||||
var result = await prep.SetFollowUpAsync("user-1", job.Id, null, null, default);
|
||||
|
||||
Assert.Null(result!.FollowUpAt);
|
||||
Assert.Equal(1, await db.JobEvents.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Follow_up_is_not_reachable_for_another_users_application()
|
||||
{
|
||||
var (db, prep, _) = New("user-1");
|
||||
await using var _d = db;
|
||||
var other = await SeedJobAsync(db, "user-2");
|
||||
|
||||
Assert.Null(await prep.GetFollowUpAsync("user-1", other.Id, default));
|
||||
Assert.Null(await prep.SetFollowUpAsync("user-1", other.Id, DateTime.Today, null, default));
|
||||
}
|
||||
|
||||
// ---------- Part 5: timeline integration ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Timeline_reads_the_new_lifecycle_events()
|
||||
{
|
||||
var (db, _, timeline) = New("user-1");
|
||||
await using var _d = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "InterviewScheduled", NewValue = "2026-08-01", At = DateTime.Now.AddDays(-2) });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "InterviewCompleted", At = DateTime.Now.AddDays(-1) });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "OfferReceived", At = DateTime.Now });
|
||||
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "FollowUpCompleted", At = DateTime.Now });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await timeline.GetAsync("user-1", job.Id, null, false, default);
|
||||
|
||||
var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList();
|
||||
Assert.Contains("Interview scheduled for 1 August 2026", summaries);
|
||||
Assert.Contains("Interview completed", summaries);
|
||||
Assert.Contains("Offer received", summaries);
|
||||
Assert.Contains("Follow-up completed", summaries);
|
||||
|
||||
// Interviews and offers are milestones; a completed follow-up is routine.
|
||||
Assert.Equal(3, result.Milestones.Count);
|
||||
Assert.DoesNotContain(result.Milestones, m => m.Type == "FollowUpCompleted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
// Phase 5.5 — Interview preparation and follow-up.
|
||||
//
|
||||
// Prep content is the user's; AI suggestions come from the existing /api/jobapplications/{id}/ai
|
||||
// routes and only reach here once the user accepts one (POST with source "ai"). Communication stays
|
||||
// entirely on the existing Correspondence routes — there is no messaging endpoint here.
|
||||
// docs/architecture/application-workspace.md.
|
||||
[ApiController]
|
||||
[Route("api/jobapplications/{jobId:int}")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class InterviewPrepController : ControllerBase
|
||||
{
|
||||
public sealed record SetFollowUpRequest(DateTime? FollowUpAt, string? NextAction);
|
||||
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly IInterviewPrepService _prep;
|
||||
|
||||
public InterviewPrepController(UserManager<ApplicationUser> users, IInterviewPrepService prep)
|
||||
{
|
||||
_users = users;
|
||||
_prep = prep;
|
||||
}
|
||||
|
||||
[HttpGet("interview-prep")]
|
||||
public async Task<ActionResult<InterviewPrepBoardDto>> Get(int jobId, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _prep.GetAsync(userId, jobId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("interview-prep")]
|
||||
public async Task<ActionResult<InterviewPrepItemDto>> Add(int jobId, [FromBody] InterviewPrepInput input, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(input?.Title)) return BadRequest("Title is required.");
|
||||
var created = await _prep.AddAsync(userId, jobId, input, ct);
|
||||
return created is null ? NotFound() : Ok(created);
|
||||
}
|
||||
|
||||
[HttpPatch("interview-prep/{itemId:int}")]
|
||||
public async Task<ActionResult<InterviewPrepItemDto>> Update(int jobId, int itemId, [FromBody] InterviewPrepInput input, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var updated = await _prep.UpdateAsync(userId, jobId, itemId, input, ct);
|
||||
return updated is null ? NotFound() : Ok(updated);
|
||||
}
|
||||
|
||||
[HttpDelete("interview-prep/{itemId:int}")]
|
||||
public async Task<IActionResult> Delete(int jobId, int itemId, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
return await _prep.DeleteAsync(userId, jobId, itemId, ct) ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("follow-up")]
|
||||
public async Task<ActionResult<FollowUpDto>> GetFollowUp(int jobId, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _prep.GetFollowUpAsync(userId, jobId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("follow-up")]
|
||||
public async Task<ActionResult<FollowUpDto>> SetFollowUp(int jobId, [FromBody] SetFollowUpRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _prep.SetFollowUpAsync(userId, jobId, request?.FollowUpAt, request?.NextAction, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
private async Task<string?> CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInterviewPrepItems : Migration
|
||||
{
|
||||
// Deliberately a no-op. Scaffolded against SQLite, so on MariaDB it would emit TEXT datetimes
|
||||
// and a PRIMARY KEY without AUTO_INCREMENT, and the composite index over those columns would
|
||||
// exceed MySQL's 3072-byte key limit.
|
||||
//
|
||||
// InterviewPrepItems is reconciler-owned and provisioned by StartupInitializationExtensions,
|
||||
// which carries correct DDL per provider and guards the create on JobApplications existing.
|
||||
// docs/infrastructure/database-ownership.md.
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1204,6 +1204,59 @@ namespace JobTrackerApi.Migrations
|
||||
b.ToTable("ImapConnections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPrepared")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("JobApplicationId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OwnerUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobApplicationId");
|
||||
|
||||
b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder");
|
||||
|
||||
b.ToTable("InterviewPrepItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2115,6 +2168,17 @@ namespace JobTrackerApi.Migrations
|
||||
b.Navigation("CvVariant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b =>
|
||||
{
|
||||
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
||||
.WithMany()
|
||||
.HasForeignKey("JobApplicationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("JobApplication");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b =>
|
||||
{
|
||||
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
||||
|
||||
@@ -46,6 +46,7 @@ builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistSer
|
||||
builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineService>();
|
||||
builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>();
|
||||
builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>();
|
||||
builder.Services.AddScoped<IInterviewPrepService, InterviewPrepService>();
|
||||
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||
|
||||
@@ -94,6 +94,13 @@ public sealed class ApplicationTimelineService : IApplicationTimelineService
|
||||
"Restored" => (CategoryLifecycle, "Application restored from trash", false),
|
||||
"Undo" => (CategoryLifecycle, "Change undone", false),
|
||||
"StatusChanged" => (CategoryStage, StatusSummary(e), IsMilestoneStatus(e.NewValue)),
|
||||
// Phase 5.5 lifecycle events. Interviews and offers are milestones; scheduling and
|
||||
// completing a follow-up is routine, so it stays out of the milestone spine.
|
||||
"InterviewScheduled" => (CategoryStage, InterviewSummary(e, "Interview scheduled"), true),
|
||||
"InterviewCompleted" => (CategoryStage, InterviewSummary(e, "Interview completed"), true),
|
||||
"OfferReceived" => (CategoryStage, "Offer received", true),
|
||||
"FollowUpCreated" => (CategoryFollowUp, FollowUpSummary(e), false),
|
||||
"FollowUpCompleted" => (CategoryFollowUp, "Follow-up completed", false),
|
||||
"FollowUpSet" => (CategoryFollowUp, FollowUpSummary(e), false),
|
||||
"ResponseUpdated" => (CategoryCommunication, ResponseSummary(e), false),
|
||||
"ReplyReceived" => (CategoryCommunication, "Reply received", true),
|
||||
@@ -115,6 +122,15 @@ public sealed class ApplicationTimelineService : IApplicationTimelineService
|
||||
return from is null ? $"Moved to {to}" : $"Moved from {from} to {to}";
|
||||
}
|
||||
|
||||
private static string InterviewSummary(JobEvent e, string prefix)
|
||||
{
|
||||
var detail = Clean(e.NewValue);
|
||||
if (detail is null) return prefix;
|
||||
return DateTime.TryParse(detail, out var parsed)
|
||||
? $"{prefix} for {parsed:d MMMM yyyy}"
|
||||
: $"{prefix} — {detail}";
|
||||
}
|
||||
|
||||
private static string FollowUpSummary(JobEvent e)
|
||||
{
|
||||
var to = Clean(e.NewValue);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
// Phase 5.5 — Interview preparation and follow-up.
|
||||
//
|
||||
// The prep content is the USER's. AI suggestions arrive through the existing AiWorkspaceService
|
||||
// "interview" module and only become prep items when the user accepts them, which is what approval
|
||||
// means here. Nothing in this service writes to the CareerProfile, a CvVariant, or the application's
|
||||
// own fields — except FollowUpAt, which is the one thing a follow-up genuinely is.
|
||||
//
|
||||
// Follow-ups reuse what already exists: JobApplication.FollowUpAt for the date (RulesEngine and the
|
||||
// reminder hosted service already act on it) and ApplicationChecklistItem for the task. No second
|
||||
// reminder system. docs/architecture/application-workspace.md.
|
||||
public sealed record InterviewPrepItemDto(
|
||||
int Id,
|
||||
string Category,
|
||||
string Title,
|
||||
string? Content,
|
||||
string Source,
|
||||
bool IsPrepared,
|
||||
int SortOrder,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
public sealed record InterviewPrepGroupDto(string Category, string Label, IReadOnlyList<InterviewPrepItemDto> Items);
|
||||
|
||||
public sealed record InterviewPrepBoardDto(
|
||||
IReadOnlyList<InterviewPrepGroupDto> Groups,
|
||||
int Total,
|
||||
int Prepared,
|
||||
int Percent,
|
||||
bool IsInterviewStage,
|
||||
int AiSuggestionCount);
|
||||
|
||||
public sealed record InterviewPrepInput(string? Category, string? Title, string? Content, string? Source, bool? IsPrepared);
|
||||
|
||||
public sealed record FollowUpDto(DateTime? FollowUpAt, string? NextAction, bool ResponseReceived, int OpenFollowUpTasks);
|
||||
|
||||
public interface IInterviewPrepService
|
||||
{
|
||||
Task<InterviewPrepBoardDto?> GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||||
Task<InterviewPrepItemDto?> AddAsync(string ownerUserId, int jobApplicationId, InterviewPrepInput input, CancellationToken ct);
|
||||
Task<InterviewPrepItemDto?> UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, InterviewPrepInput input, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct);
|
||||
|
||||
Task<FollowUpDto?> GetFollowUpAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||||
Task<FollowUpDto?> SetFollowUpAsync(string ownerUserId, int jobApplicationId, DateTime? followUpAt, string? nextAction, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class InterviewPrepService : IInterviewPrepService
|
||||
{
|
||||
private static readonly Dictionary<string, string> Labels = new(StringComparer.Ordinal)
|
||||
{
|
||||
[InterviewPrepCategories.CompanyResearch] = "Company research",
|
||||
[InterviewPrepCategories.Technical] = "Technical preparation",
|
||||
[InterviewPrepCategories.Behavioural] = "Behavioural questions",
|
||||
[InterviewPrepCategories.Star] = "STAR examples",
|
||||
[InterviewPrepCategories.Question] = "Questions to ask them",
|
||||
[InterviewPrepCategories.Note] = "Notes",
|
||||
};
|
||||
|
||||
private readonly JobTrackerContext _db;
|
||||
|
||||
public InterviewPrepService(JobTrackerContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<InterviewPrepBoardDto?> GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
||||
{
|
||||
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
||||
if (job is null) return null;
|
||||
|
||||
var items = await _db.InterviewPrepItems.AsNoTracking()
|
||||
.Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var aiCount = await _db.AiInteractions.AsNoTracking()
|
||||
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "interview", ct);
|
||||
|
||||
var groups = items
|
||||
.GroupBy(i => i.Category)
|
||||
.OrderBy(g => InterviewPrepCategories.Rank(g.Key))
|
||||
.Select(g => new InterviewPrepGroupDto(
|
||||
g.Key,
|
||||
Labels.TryGetValue(g.Key, out var label) ? label : g.Key,
|
||||
g.OrderBy(i => i.SortOrder).ThenBy(i => i.Id).Select(Project).ToList()))
|
||||
.ToList();
|
||||
|
||||
var prepared = items.Count(i => i.IsPrepared);
|
||||
|
||||
return new InterviewPrepBoardDto(
|
||||
groups,
|
||||
items.Count,
|
||||
prepared,
|
||||
items.Count == 0 ? 0 : (int)Math.Round(prepared * 100.0 / items.Count),
|
||||
IsInterviewStage(job.Status),
|
||||
aiCount);
|
||||
}
|
||||
|
||||
public async Task<InterviewPrepItemDto?> AddAsync(string ownerUserId, int jobApplicationId, InterviewPrepInput input, CancellationToken ct)
|
||||
{
|
||||
var title = (input.Title ?? string.Empty).Trim();
|
||||
if (title.Length == 0) return null;
|
||||
|
||||
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
||||
if (job is null) return null;
|
||||
|
||||
var maxSort = await _db.InterviewPrepItems
|
||||
.Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId)
|
||||
.Select(i => (int?)i.SortOrder)
|
||||
.MaxAsync(ct) ?? 0;
|
||||
|
||||
var item = new InterviewPrepItem
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
JobApplicationId = jobApplicationId,
|
||||
Category = InterviewPrepCategories.IsValid(input.Category) ? input.Category! : InterviewPrepCategories.Note,
|
||||
Title = title,
|
||||
Content = Blank(input.Content),
|
||||
// An accepted AI suggestion is recorded as such, but is fully the user's to edit after.
|
||||
Source = InterviewPrepSources.IsValid(input.Source) ? input.Source! : InterviewPrepSources.User,
|
||||
IsPrepared = input.IsPrepared ?? false,
|
||||
SortOrder = maxSort + 1,
|
||||
};
|
||||
|
||||
_db.InterviewPrepItems.Add(item);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Project(item);
|
||||
}
|
||||
|
||||
public async Task<InterviewPrepItemDto?> UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, InterviewPrepInput input, CancellationToken ct)
|
||||
{
|
||||
var item = await _db.InterviewPrepItems
|
||||
.FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct);
|
||||
if (item is null) return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.Title)) item.Title = input.Title!.Trim();
|
||||
if (input.Content is not null) item.Content = Blank(input.Content);
|
||||
if (InterviewPrepCategories.IsValid(input.Category)) item.Category = input.Category!;
|
||||
if (input.IsPrepared is not null) item.IsPrepared = input.IsPrepared.Value;
|
||||
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Project(item);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct)
|
||||
{
|
||||
var item = await _db.InterviewPrepItems
|
||||
.FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct);
|
||||
if (item is null) return false;
|
||||
|
||||
_db.InterviewPrepItems.Remove(item);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- follow-up ----------
|
||||
|
||||
public async Task<FollowUpDto?> GetFollowUpAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
||||
{
|
||||
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
||||
if (job is null) return null;
|
||||
return await BuildFollowUpAsync(ownerUserId, job, ct);
|
||||
}
|
||||
|
||||
// Sets the date the existing reminder machinery already reads, and records a JobEvent so the
|
||||
// timeline shows it. The follow-up TASK itself stays a checklist item — this does not invent a
|
||||
// second to-do list.
|
||||
public async Task<FollowUpDto?> SetFollowUpAsync(string ownerUserId, int jobApplicationId, DateTime? followUpAt, string? nextAction, CancellationToken ct)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
if (job is null) return null;
|
||||
|
||||
var previous = job.FollowUpAt;
|
||||
job.FollowUpAt = followUpAt;
|
||||
if (nextAction is not null) job.NextAction = Blank(nextAction);
|
||||
|
||||
// Same event type the rest of the app already emits, so the timeline reads it unchanged.
|
||||
_db.JobEvents.Add(new JobEvent
|
||||
{
|
||||
JobApplicationId = jobApplicationId,
|
||||
Type = "FollowUpSet",
|
||||
OldValue = previous?.ToString("yyyy-MM-dd"),
|
||||
NewValue = followUpAt?.ToString("yyyy-MM-dd"),
|
||||
At = DateTime.Now,
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await BuildFollowUpAsync(ownerUserId, job, ct);
|
||||
}
|
||||
|
||||
private async Task<FollowUpDto> BuildFollowUpAsync(string ownerUserId, JobApplication job, CancellationToken ct)
|
||||
{
|
||||
// The follow-up tasks are checklist items — counted here, owned there.
|
||||
var openTasks = await _db.ApplicationChecklistItems.AsNoTracking()
|
||||
.CountAsync(i => i.OwnerUserId == ownerUserId
|
||||
&& i.JobApplicationId == job.Id
|
||||
&& i.Category == ChecklistCategories.FollowUp
|
||||
&& i.Status == ChecklistStatuses.Pending, ct);
|
||||
|
||||
return new FollowUpDto(job.FollowUpAt, job.NextAction, job.ResponseReceived, openTasks);
|
||||
}
|
||||
|
||||
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
||||
_db.JobApplications.AsNoTracking()
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
|
||||
private static bool IsInterviewStage(string? status) =>
|
||||
(status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? Blank(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static InterviewPrepItemDto Project(InterviewPrepItem i) =>
|
||||
new(i.Id, i.Category, i.Title, i.Content, i.Source, i.IsPrepared, i.SortOrder, i.UpdatedAtUtc);
|
||||
}
|
||||
@@ -1063,6 +1063,28 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CoverLetterVersions_Owner_Job_Version" ON "CoverLetterVersions" ("OwnerUserId", "JobApplicationId", "Version");""");
|
||||
}
|
||||
|
||||
// Phase 5.5: user-owned interview preparation (not the AI cache InterviewPrepNote).
|
||||
static void EnsureInterviewPrepItemsTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "InterviewPrepItems" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepItems" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"Category" TEXT NOT NULL,
|
||||
"Title" TEXT NOT NULL,
|
||||
"Content" TEXT NULL,
|
||||
"Source" TEXT NOT NULL,
|
||||
"IsPrepared" INTEGER NOT NULL,
|
||||
"SortOrder" INTEGER NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
"UpdatedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_InterviewPrepItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_InterviewPrepItems_Owner_Job_Sort" ON "InterviewPrepItems" ("OwnerUserId", "JobApplicationId", "SortOrder");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
@@ -1077,6 +1099,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureAiInteractionsTable(conn);
|
||||
EnsureApplicationChecklistTable(conn);
|
||||
EnsureCoverLetterVersionsTable(conn);
|
||||
EnsureInterviewPrepItemsTable(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
// and at least one of the new columns already exists.
|
||||
@@ -1672,6 +1695,7 @@ public static class StartupInitializationExtensions
|
||||
DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime");
|
||||
DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime");
|
||||
DropMalformedMySqlTable(conn, "CoverLetterVersions", "CreatedAtUtc", "datetime");
|
||||
DropMalformedMySqlTable(conn, "InterviewPrepItems", "CreatedAtUtc", "datetime");
|
||||
|
||||
if (!HasMySqlTable(conn, "CvVariants") && HasMySqlTable(conn, "JobApplications"))
|
||||
{
|
||||
@@ -1773,6 +1797,30 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "InterviewPrepItems") && HasMySqlTable(conn, "JobApplications"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepItems` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`Category` varchar(32) NOT NULL,
|
||||
`Title` varchar(500) NOT NULL,
|
||||
`Content` longtext NULL,
|
||||
`Source` varchar(16) NOT NULL,
|
||||
`IsPrepared` tinyint(1) NOT NULL,
|
||||
`SortOrder` int NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_InterviewPrepItems_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepItems", "Id");
|
||||
EnsureMySqlIndex(conn, "InterviewPrepItems", "IX_InterviewPrepItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`");
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CoverLetterVersions", "Id");
|
||||
EnsureMySqlIndex(conn, "CoverLetterVersions", "IX_CoverLetterVersions_Owner_Job_Version", "`OwnerUserId`, `JobApplicationId`, `Version`");
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
// Phase 5.5 — Interview preparation the USER owns.
|
||||
//
|
||||
// Distinct from InterviewPrepNote and AiWorkspaceNote, which are AI caches: both are regenerated when
|
||||
// their context changes, so anything a user typed there would eventually be overwritten. This is the
|
||||
// durable side — company research, technical notes, behavioural answers, STAR examples and the user's
|
||||
// own questions — and nothing regenerates it.
|
||||
//
|
||||
// One table for every category rather than a table per category: they differ only by label, and a new
|
||||
// category must not need a migration. Reconciler-owned
|
||||
// (docs/infrastructure/database-ownership.md); its migration is a no-op.
|
||||
public sealed class InterviewPrepItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int JobApplicationId { get; set; }
|
||||
public JobApplication? JobApplication { get; set; }
|
||||
|
||||
// company-research | technical | behavioural | star | question | note
|
||||
public string Category { get; set; } = InterviewPrepCategories.Note;
|
||||
|
||||
// The prompt side: a question to answer, or the heading of a research note.
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
// The user's own words. Always theirs to edit — an AI suggestion only lands here once saved.
|
||||
public string? Content { get; set; }
|
||||
|
||||
// user | ai — whether the user wrote this or accepted it from a suggestion. Recorded for honesty
|
||||
// in the UI, not to restrict editing: an accepted suggestion is fully editable afterwards.
|
||||
public string Source { get; set; } = InterviewPrepSources.User;
|
||||
|
||||
// Practice tracking, so the section doubles as the preparation checklist.
|
||||
public bool IsPrepared { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public static class InterviewPrepCategories
|
||||
{
|
||||
public const string CompanyResearch = "company-research";
|
||||
public const string Technical = "technical";
|
||||
public const string Behavioural = "behavioural";
|
||||
public const string Star = "star";
|
||||
public const string Question = "question";
|
||||
public const string Note = "note";
|
||||
|
||||
// Display order in the workspace: understand the company, then the role, then yourself, then what
|
||||
// you want to ask them.
|
||||
public static readonly string[] Order =
|
||||
{
|
||||
CompanyResearch, Technical, Behavioural, Star, Question, Note,
|
||||
};
|
||||
|
||||
public static int Rank(string? category)
|
||||
{
|
||||
var i = Array.IndexOf(Order, category ?? Note);
|
||||
return i < 0 ? Order.Length : i;
|
||||
}
|
||||
|
||||
public static bool IsValid(string? value) => Array.IndexOf(Order, value ?? string.Empty) >= 0;
|
||||
}
|
||||
|
||||
public static class InterviewPrepSources
|
||||
{
|
||||
public const string User = "user";
|
||||
public const string Ai = "ai";
|
||||
|
||||
public static bool IsValid(string? value) => value is User or Ai;
|
||||
}
|
||||
@@ -297,6 +297,66 @@ storage, no duplicate upload path. Files stay private to the owning user.
|
||||
- **Multiple attached variants**: relax the one-per-application rule in `AttachVariantAsync`; the DTO
|
||||
already carries the full variant list.
|
||||
|
||||
## Interview and follow-up (Phase 5.5)
|
||||
|
||||
Completes the lifecycle after submission: prepare, communicate, chase.
|
||||
|
||||
### Interview preparation
|
||||
|
||||
There were already two per-application AI stores — `InterviewPrepNote` and `AiWorkspaceNote` — and
|
||||
**both are caches**: each regenerates when its context signature changes, so anything a user typed
|
||||
into them would eventually be overwritten. `InterviewPrepItem` is the durable, user-owned side.
|
||||
Nothing regenerates it.
|
||||
|
||||
One table covers every category (`company-research`, `technical`, `behavioural`, `star`, `question`,
|
||||
`note`) — they differ only by label, and adding a category must not need a migration. Each item
|
||||
carries the user's own `Content`, a `Source` (`user | ai`) recording whether they wrote it or accepted
|
||||
a suggestion, and `IsPrepared`, which makes the section double as the preparation checklist.
|
||||
|
||||
An accepted AI suggestion is marked `ai` for honesty, not to restrict editing — it is fully the
|
||||
user's afterwards.
|
||||
|
||||
### The AI boundary, restated
|
||||
|
||||
Generation stays in `AiWorkspaceService`'s existing `interview` module, reached from
|
||||
`AiWorkspacePanel`, authenticated and ownership-scoped like every other module, with each run appended
|
||||
to `AiInteraction`. **A suggestion is history until the user adds it as a prep item.** Opening the
|
||||
section generates nothing; `Generating_ai_history_does_not_create_prep_items` pins that.
|
||||
|
||||
### Communication
|
||||
|
||||
Unchanged. `Correspondence` already owns recruiter contacts, message history and notes, and the
|
||||
workspace already mounts that component. No second messaging or history system was added.
|
||||
|
||||
### Follow-up
|
||||
|
||||
Reuses what exists rather than adding a tracker:
|
||||
|
||||
- **The date** is `JobApplication.FollowUpAt` — the same field `RulesEngine` and
|
||||
`FollowUpReminderHostedService` already act on. Writing it here means reminders keep working with no
|
||||
new wiring.
|
||||
- **The task** is an `ApplicationChecklistItem` in the `follow-up` category. The follow-up section
|
||||
*counts* open tasks; it does not own them.
|
||||
- **The record** is a `FollowUpSet` `JobEvent` — the same type the rest of the app emits, so the
|
||||
timeline reads it unchanged.
|
||||
|
||||
### Timeline integration
|
||||
|
||||
`JobEvent` remains the source of history. The interpreter learned five more types:
|
||||
`InterviewScheduled`, `InterviewCompleted`, `OfferReceived` (milestones) and `FollowUpCreated`,
|
||||
`FollowUpCompleted` (routine, deliberately kept out of the milestone spine so it stays the "what
|
||||
actually happened" summary).
|
||||
|
||||
### Ownership
|
||||
|
||||
`InterviewPrepItems` is reconciler-owned with a no-op migration, guarded on `JobApplications`
|
||||
(`docs/infrastructure/database-ownership.md`). Verified on a fresh MariaDB 11: `int AUTO_INCREMENT`
|
||||
PK, `varchar(255)` owner, `varchar(500)` title, `tinyint(1)` flag, `datetime(6)`, composite index
|
||||
inside the key limit.
|
||||
|
||||
`InterviewPrepBoardDto` is named to avoid colliding with the pre-existing `InterviewPrepDto`, which
|
||||
belongs to the AI cache — a reminder that the two systems are genuinely different.
|
||||
|
||||
## Extension points
|
||||
|
||||
- **New section**: add to `WORKSPACE_SECTIONS` and render it; nav is data-driven.
|
||||
@@ -313,4 +373,6 @@ storage, no duplicate upload path. Files stay private to the owning user.
|
||||
(Phase 5.3, all three deterministic and read-only).
|
||||
4. ✅ Application assets — CV variant association, tailoring suggestions, cover letter workflow with
|
||||
version history, documents (Phase 5.4).
|
||||
5. ✅ Interview and follow-up — user-owned interview preparation, follow-up over the existing
|
||||
FollowUpAt and checklist, five more timeline event types (Phase 5.5).
|
||||
7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements.
|
||||
|
||||
@@ -75,8 +75,8 @@ Created by `StartupInitializationExtensions`, with a **no-op migration** holding
|
||||
`CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`,
|
||||
`CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`),
|
||||
`InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`,
|
||||
`ApplicationChecklistItems`, `CoverLetterVersions`, `TwoFactorRecoveryCodes`, `TrustedDevices`,
|
||||
`UserSessions`.
|
||||
`ApplicationChecklistItems`, `CoverLetterVersions`, `InterviewPrepItems`, `TwoFactorRecoveryCodes`,
|
||||
`TrustedDevices`, `UserSessions`.
|
||||
|
||||
No-op migrations, each with a comment explaining why:
|
||||
|
||||
@@ -88,6 +88,7 @@ No-op migrations, each with a comment explaining why:
|
||||
| `20260719085904_AddApplicationChecklistItems` | `ApplicationChecklistItems` |
|
||||
| `20260719094728_SyncCareerChildKeyLengths` | snapshot sync only |
|
||||
| `20260719120954_AddCoverLetterVersions` | `CoverLetterVersions` |
|
||||
| `20260719145044_AddInterviewPrepItems` | `InterviewPrepItems` |
|
||||
|
||||
### Dependency guards
|
||||
|
||||
@@ -96,7 +97,7 @@ skips it on a fresh database and pass 2 creates it:
|
||||
|
||||
| Table | Waits for |
|
||||
|---|---|
|
||||
| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems`, `CoverLetterVersions` | `JobApplications` (migration-owned) |
|
||||
| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems`, `CoverLetterVersions`, `InterviewPrepItems` | `JobApplications` (migration-owned) |
|
||||
| `CvVariantVersions` | `CvVariants` |
|
||||
| `CareerProfileVersions`, the six CareerProfile children | `CareerProfiles` |
|
||||
| `CvExtractionRuns` | `CvUploadArtifacts` |
|
||||
|
||||
@@ -234,6 +234,61 @@ export const applicationAssetsApi = {
|
||||
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come
|
||||
// from the existing /ai routes and only land here once accepted.
|
||||
export type InterviewPrepItem = {
|
||||
id: number;
|
||||
category: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
source: string;
|
||||
isPrepared: boolean;
|
||||
sortOrder: number;
|
||||
updatedAtUtc: string;
|
||||
};
|
||||
|
||||
export type InterviewPrepGroup = { category: string; label: string; items: InterviewPrepItem[] };
|
||||
|
||||
export type InterviewPrepBoard = {
|
||||
groups: InterviewPrepGroup[];
|
||||
total: number;
|
||||
prepared: number;
|
||||
percent: number;
|
||||
isInterviewStage: boolean;
|
||||
aiSuggestionCount: number;
|
||||
};
|
||||
|
||||
export type FollowUp = {
|
||||
followUpAt: string | null;
|
||||
nextAction: string | null;
|
||||
responseReceived: boolean;
|
||||
openFollowUpTasks: number;
|
||||
};
|
||||
|
||||
export const INTERVIEW_PREP_CATEGORIES: { key: string; label: string }[] = [
|
||||
{ key: "company-research", label: "Company research" },
|
||||
{ key: "technical", label: "Technical preparation" },
|
||||
{ key: "behavioural", label: "Behavioural questions" },
|
||||
{ key: "star", label: "STAR examples" },
|
||||
{ key: "question", label: "Questions to ask them" },
|
||||
{ key: "note", label: "Notes" },
|
||||
];
|
||||
|
||||
export const interviewPrepApi = {
|
||||
get: (jobId: number) =>
|
||||
api.get<InterviewPrepBoard>(`/jobapplications/${jobId}/interview-prep`).then((r) => r.data),
|
||||
add: (jobId: number, body: { category?: string; title: string; content?: string; source?: string }) =>
|
||||
api.post<InterviewPrepItem>(`/jobapplications/${jobId}/interview-prep`, body).then((r) => r.data),
|
||||
update: (jobId: number, itemId: number, body: Partial<Pick<InterviewPrepItem, "title" | "content" | "category" | "isPrepared">>) =>
|
||||
api.patch<InterviewPrepItem>(`/jobapplications/${jobId}/interview-prep/${itemId}`, body).then((r) => r.data),
|
||||
remove: (jobId: number, itemId: number) =>
|
||||
api.delete(`/jobapplications/${jobId}/interview-prep/${itemId}`).then(() => undefined),
|
||||
followUp: (jobId: number) =>
|
||||
api.get<FollowUp>(`/jobapplications/${jobId}/follow-up`).then((r) => r.data),
|
||||
setFollowUp: (jobId: number, followUpAt: string | null, nextAction?: string | null) =>
|
||||
api.put<FollowUp>(`/jobapplications/${jobId}/follow-up`, { followUpAt, nextAction }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const applicationChecklistApi = {
|
||||
get: (jobId: number) =>
|
||||
api.get<Checklist>(`/jobapplications/${jobId}/checklist`).then((r) => r.data),
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Checkbox, Chip, Divider, IconButton, LinearProgress, MenuItem, Paper,
|
||||
Skeleton, Stack, TextField, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import {
|
||||
FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi,
|
||||
} from "../applicationWorkspace";
|
||||
|
||||
// Phase 5.5 — Interview preparation and follow-up.
|
||||
//
|
||||
// The prep content is the user's: this component never generates anything. AI suggestions live in the
|
||||
// AI panel below and only become prep items when the user adds them.
|
||||
// docs/architecture/application-workspace.md.
|
||||
|
||||
function Shell({ title, subtitle, loading, error, children }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
|
||||
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
{loading ? (
|
||||
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
|
||||
) : error ? (
|
||||
<Alert severity="error">{error}</Alert>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
||||
const [board, setBoard] = useState<InterviewPrepBoard | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState(INTERVIEW_PREP_CATEGORIES[0].key);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setBoard(await interviewPrepApi.get(jobId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not load interview preparation."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const mutate = async (run: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await run();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not update interview preparation."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const add = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = title.trim();
|
||||
if (!value) return;
|
||||
setTitle("");
|
||||
return mutate(() => interviewPrepApi.add(jobId, { category, title: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Shell
|
||||
title="Interview preparation"
|
||||
subtitle="Your own research, answers and questions. Nothing here is generated or overwritten."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{board && !board.isInterviewStage && (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
This application has not reached an interview stage yet. Preparing early is fine.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{board && board.total > 0 && (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 0.75 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
||||
Preparation progress
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{board.prepared} of {board.total} ready
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={board.percent}
|
||||
aria-label="Interview preparation progress"
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{board && board.total === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nothing prepared yet. Add a question you expect, a company fact worth knowing, or a STAR
|
||||
example you want ready.
|
||||
</Typography>
|
||||
) : (
|
||||
(board?.groups ?? []).map((group) => (
|
||||
<Box key={group.category}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
|
||||
{group.label}
|
||||
</Typography>
|
||||
<Stack sx={{ mt: 0.5 }}>
|
||||
{group.items.map((item) => (
|
||||
<PrepRow
|
||||
key={item.id}
|
||||
jobId={jobId}
|
||||
item={item}
|
||||
busy={busy}
|
||||
onChanged={load}
|
||||
onError={setError}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={add}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Category"
|
||||
value={category}
|
||||
disabled={busy}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
sx={{ minWidth: { sm: 200 } }}
|
||||
>
|
||||
{INTERVIEW_PREP_CATEGORIES.map((c) => (
|
||||
<MenuItem key={c.key} value={c.key}>{c.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Add a question, topic or note"
|
||||
value={title}
|
||||
disabled={busy}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="contained" disabled={busy || !title.trim()}>Add</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Shell>
|
||||
|
||||
<ApplicationFollowUp jobId={jobId} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// One prep entry. The answer is a local draft until saved, so a background reload never eats typing.
|
||||
function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
||||
jobId: number;
|
||||
item: InterviewPrepItem;
|
||||
busy: boolean;
|
||||
onChanged: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const content = draft ?? item.content ?? "";
|
||||
const dirty = draft !== null && draft !== (item.content ?? "");
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fn();
|
||||
setDraft(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
onError(getApiErrorMessage(err, "Could not save this answer."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 1, borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
<Stack direction="row" alignItems="flex-start" spacing={1}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={item.isPrepared}
|
||||
disabled={busy || saving}
|
||||
inputProps={{ "aria-label": `Ready: ${item.title}` }}
|
||||
onChange={() => run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))}
|
||||
sx={{ mt: -0.5 }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
||||
{item.source === "ai" && (
|
||||
<Chip size="small" label="From AI" variant="outlined" />
|
||||
)}
|
||||
</Stack>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Your answer, in your own words."
|
||||
value={content}
|
||||
disabled={busy || saving}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
sx={{ mt: 0.75 }}
|
||||
/>
|
||||
{dirty && (
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 0.75 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={saving}
|
||||
onClick={() => run(() => interviewPrepApi.update(jobId, item.id, { content }))}
|
||||
>
|
||||
Save answer
|
||||
</Button>
|
||||
<Button size="small" disabled={saving} onClick={() => setDraft(null)}>Discard</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Tooltip title="Delete">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy || saving}
|
||||
aria-label={`Delete: ${item.title}`}
|
||||
onClick={() => run(() => interviewPrepApi.remove(jobId, item.id))}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
||||
const [data, setData] = useState<FollowUp | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [date, setDate] = useState<string>("");
|
||||
const [action, setAction] = useState<string>("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await interviewPrepApi.followUp(jobId);
|
||||
setData(result);
|
||||
setDate(result.followUpAt ? result.followUpAt.slice(0, 10) : "");
|
||||
setAction(result.nextAction ?? "");
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not load follow-up."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await interviewPrepApi.setFollowUp(jobId, date || null, action || null);
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not save the follow-up."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Follow-up"
|
||||
subtitle="The same date the reminder service already uses. Follow-up tasks live in the checklist."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{data && data.openFollowUpTasks > 0 && (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
{data.openFollowUpTasks} open follow-up {data.openFollowUpTasks === 1 ? "task" : "tasks"} on
|
||||
the checklist.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label="Follow up on"
|
||||
value={date}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Next action"
|
||||
value={action}
|
||||
disabled={busy}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
/>
|
||||
<Button variant="contained" disabled={busy} onClick={save}>Save</Button>
|
||||
</Stack>
|
||||
|
||||
{data && !data.followUpAt && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No follow-up scheduled. Applications without one go quiet.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ApplicationFollowUp, ApplicationInterviewPrep } from "./components/InterviewPrep";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const board = {
|
||||
groups: [
|
||||
{
|
||||
category: "company-research",
|
||||
label: "Company research",
|
||||
items: [
|
||||
{ id: 1, category: "company-research", title: "Funding history", content: "Series B in 2025.", source: "user", isPrepared: true, sortOrder: 1, updatedAtUtc: "2026-07-19T10:00:00Z" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "behavioural",
|
||||
label: "Behavioural questions",
|
||||
items: [
|
||||
{ id: 2, category: "behavioural", title: "Tell me about a conflict", content: null, source: "ai", isPrepared: false, sortOrder: 2, updatedAtUtc: "2026-07-19T10:00:00Z" },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
prepared: 1,
|
||||
percent: 50,
|
||||
isInterviewStage: true,
|
||||
aiSuggestionCount: 1,
|
||||
};
|
||||
|
||||
const followUp = { followUpAt: "2026-07-26T00:00:00", nextAction: "Chase recruiter", responseReceived: false, openFollowUpTasks: 1 };
|
||||
|
||||
function routeGet(overrides: Record<string, any> = {}) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url.endsWith("/follow-up")) return Promise.resolve({ data: overrides.followUp ?? followUp } as any);
|
||||
return Promise.resolve({ data: overrides.board ?? board } as any);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test("prep renders grouped items with progress and marks AI-sourced ones", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationInterviewPrep jobId={7} />);
|
||||
|
||||
// Each label appears twice: once as the group heading, once as a category option in the add form.
|
||||
expect((await screen.findAllByText("Company research")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Behavioural questions").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Funding history")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 of 2 ready")).toBeInTheDocument();
|
||||
expect(screen.getByText("From AI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adding a prep item posts the chosen category and title", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockResolvedValue({ data: board.groups[0].items[0] } as any);
|
||||
|
||||
render(<ApplicationInterviewPrep jobId={7} />);
|
||||
fireEvent.change(await screen.findByLabelText(/Add a question, topic or note/i), {
|
||||
target: { value: "What does success look like?" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/interview-prep",
|
||||
{ category: "company-research", title: "What does success look like?" },
|
||||
));
|
||||
});
|
||||
|
||||
test("marking an item ready patches it", async () => {
|
||||
routeGet();
|
||||
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);
|
||||
|
||||
render(<ApplicationInterviewPrep jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("checkbox", { name: "Ready: Tell me about a conflict" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/interview-prep/2", { isPrepared: true }));
|
||||
});
|
||||
|
||||
test("an answer is only saved when the user asks", async () => {
|
||||
routeGet();
|
||||
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);
|
||||
|
||||
render(<ApplicationInterviewPrep jobId={7} />);
|
||||
const boxes = await screen.findAllByPlaceholderText(/Your answer, in your own words/i);
|
||||
fireEvent.change(boxes[1], { target: { value: "My STAR answer" } });
|
||||
|
||||
// Typing alone must not persist anything.
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save answer/i }));
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/interview-prep/2", { content: "My STAR answer" }));
|
||||
});
|
||||
|
||||
test("deleting a prep item calls delete", async () => {
|
||||
routeGet();
|
||||
mockedApi.delete.mockResolvedValue({ data: undefined } as any);
|
||||
|
||||
render(<ApplicationInterviewPrep jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Delete: Funding history" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/interview-prep/1"));
|
||||
});
|
||||
|
||||
test("empty prep shows a useful empty state", async () => {
|
||||
routeGet({ board: { groups: [], total: 0, prepared: 0, percent: 0, isInterviewStage: false, aiSuggestionCount: 0 } });
|
||||
|
||||
render(<ApplicationInterviewPrep jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Nothing prepared yet/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/has not reached an interview stage/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("prep surfaces a load error", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("boom"));
|
||||
|
||||
render(<ApplicationInterviewPrep jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Could not load interview preparation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------- follow-up ----------
|
||||
|
||||
test("follow-up loads the existing date and open checklist tasks", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationFollowUp jobId={7} />);
|
||||
|
||||
expect(await screen.findByDisplayValue("2026-07-26")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Chase recruiter")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 open follow-up task on the checklist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("saving a follow-up sends the date and next action", async () => {
|
||||
routeGet();
|
||||
mockedApi.put.mockResolvedValue({ data: followUp } as any);
|
||||
|
||||
render(<ApplicationFollowUp jobId={7} />);
|
||||
fireEvent.change(await screen.findByLabelText(/Follow up on/i), { target: { value: "2026-08-01" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/follow-up",
|
||||
{ followUpAt: "2026-08-01", nextAction: "Chase recruiter" },
|
||||
));
|
||||
});
|
||||
|
||||
test("no follow-up shows an empty state", async () => {
|
||||
routeGet({ followUp: { followUpAt: null, nextAction: null, responseReceived: false, openFollowUpTasks: 0 } });
|
||||
|
||||
render(<ApplicationFollowUp jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/No follow-up scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
ApplicationCoverLetterSection, ApplicationCvSection,
|
||||
} from "../components/ApplicationAssets";
|
||||
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
|
||||
import {
|
||||
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
|
||||
} from "../applicationWorkspace";
|
||||
@@ -102,6 +103,7 @@ export default function ApplicationWorkspacePage() {
|
||||
{section === "analysis" && jobId > 0 && <ApplicationAnalysis jobId={jobId} />}
|
||||
{section === "match" && jobId > 0 && <ApplicationMatch jobId={jobId} />}
|
||||
{section === "timeline" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
|
||||
{section === "interview" && jobId > 0 && <ApplicationInterviewPrep jobId={jobId} />}
|
||||
{(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<AiWorkspacePanel jobId={jobId} />
|
||||
|
||||
Reference in New Issue
Block a user