From 3a906b881e9b8dcfd71e7e72871e7f05cd66fb9b Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 19 Jul 2026 11:15:46 +0200 Subject: [PATCH] feat(workspace): unified application checklist (Phase 5 milestone 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolve the existing readiness workflow into one persisted, user-controlled checklist rather than adding a second tracker. ApplicationChecklistItem records only completion state and user intent. Each default system item carries a stable SystemKey and an AutoSignal — the same signal /readiness already computed — and re-syncs on every read: a satisfied signal auto-completes the item, a reverted signal reopens it, and a manual tick always wins. Users can add, reorder, dismiss and delete. Readiness is refactored into a projection of the checklist (score = completion percentage, completed/missing = live items by status). Its DTO shape and the workflowSignal/reminders health view are unchanged, so no API contract breaks. The workspace's next recommended action now comes from the first pending checklist item in category priority order (preparation, submission, follow-up, interview, custom), replacing the parallel ruleset — so the overview can never recommend something already ticked off, and a user's own task can be next. The table follows the established MariaDB-safe path: the scaffolded migration is a no-op and the idempotent reconciler owns the DDL for both providers. Verified on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both indexes inside the key limit, cascade delete, unique system key per application, and NULL system keys not colliding for custom items. 329 backend tests, 94 frontend tests, type check, production build and both Docker builds pass locally. Co-Authored-By: Claude Opus 4.8 --- Data/JobTrackerContext.cs | 26 + .../ApplicationChecklistTests.cs | 274 ++ .../ApplicationWorkspaceTests.cs | 56 +- .../ApplicationChecklistController.cs | 72 + .../Controllers/JobApplicationsController.cs | 28 +- ...4_AddApplicationChecklistItems.Designer.cs | 2207 +++++++++++++++++ ...0719085904_AddApplicationChecklistItems.cs | 28 + .../JobTrackerContextModelSnapshot.cs | 88 + JobTrackerApi/Program.cs | 1 + .../Services/ApplicationChecklistService.cs | 393 +++ .../Services/ApplicationWorkspaceService.cs | 57 +- .../StartupInitializationExtensions.cs | 59 + Models/ApplicationChecklistItem.cs | 80 + docs/architecture/application-workspace.md | 118 +- .../src/application-checklist.test.tsx | 119 + job-tracker-ui/src/applicationWorkspace.ts | 45 +- .../src/components/ApplicationChecklist.tsx | 198 ++ .../src/views/ApplicationWorkspacePage.tsx | 10 +- 18 files changed, 3783 insertions(+), 76 deletions(-) create mode 100644 JobTrackerApi.Tests/ApplicationChecklistTests.cs create mode 100644 JobTrackerApi/Controllers/ApplicationChecklistController.cs create mode 100644 JobTrackerApi/Migrations/20260719085904_AddApplicationChecklistItems.Designer.cs create mode 100644 JobTrackerApi/Migrations/20260719085904_AddApplicationChecklistItems.cs create mode 100644 JobTrackerApi/Services/ApplicationChecklistService.cs create mode 100644 Models/ApplicationChecklistItem.cs create mode 100644 job-tracker-ui/src/application-checklist.test.tsx create mode 100644 job-tracker-ui/src/components/ApplicationChecklist.tsx diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 9c1830b..9e7c2a7 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -45,6 +45,7 @@ namespace JobTrackerApi.Data public DbSet CvVariants => Set(); public DbSet CvVariantVersions => Set(); public DbSet AiInteractions => Set(); + public DbSet ApplicationChecklistItems => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -340,6 +341,31 @@ namespace JobTrackerApi.Data .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + + // Phase 5 Milestone 2: the application checklist — a workflow guidance layer over the existing + // readiness signals, not a second store of truth. Same deny-on-null tenant filter; cascades with + // the application. docs/architecture/application-workspace.md. + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + // varchar (not longtext) for the indexed columns — see the CvVariant note above. + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.SystemKey).HasMaxLength(64); + modelBuilder.Entity().Property(x => x.AutoSignal).HasMaxLength(64); + modelBuilder.Entity().Property(x => x.Category).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.Section).HasMaxLength(64); + modelBuilder.Entity().Property(x => x.Title).HasMaxLength(255); + // Seeding is idempotent per (application, system key) — the unique index is what enforces it. + modelBuilder.Entity() + .HasIndex(x => new { x.JobApplicationId, x.SystemKey }) + .IsUnique(); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.SortOrder }); + modelBuilder.Entity() + .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 diff --git a/JobTrackerApi.Tests/ApplicationChecklistTests.cs b/JobTrackerApi.Tests/ApplicationChecklistTests.cs new file mode 100644 index 0000000..fe7e0d1 --- /dev/null +++ b/JobTrackerApi.Tests/ApplicationChecklistTests.cs @@ -0,0 +1,274 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Phase 5 Milestone 2. The point of these tests is that the checklist is ONE system: system items seed +// from the same signals readiness reads, auto-complete when those signals are satisfied, and stay under +// the user's control after that. +public sealed class ApplicationChecklistTests +{ + private static (JobTrackerContext db, ApplicationChecklistService svc) New(string userId) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var currentUser = new Mock(); + currentUser.SetupGet(s => s.UserId).Returns(userId); + var db = new JobTrackerContext(options, currentUser.Object); + return (db, new ApplicationChecklistService(db)); + } + + private static async Task SeedAsync(JobTrackerContext db, string owner, Action? 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 = "Backend Developer", + Status = "Applied", + Description = "Needs .NET and SQL.", + DateApplied = DateTime.UtcNow.AddDays(-3), + }; + tweak?.Invoke(job); + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static ChecklistItemDto Item(ChecklistDto checklist, string systemKey) => + checklist.Items.Single(i => i.SystemKey == systemKey); + + [Fact] + public async Task First_read_seeds_the_default_system_checklist() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + + var checklist = await svc.GetAsync("user-1", job.Id, default); + + Assert.NotNull(checklist); + Assert.All(checklist!.Items, i => Assert.True(i.IsSystemGenerated)); + Assert.Contains(checklist.Items, i => i.SystemKey == "prepare-cv"); + Assert.Contains(checklist.Items, i => i.SystemKey == "confirm-submitted"); + Assert.Contains(checklist.Items, i => i.SystemKey == "research-company"); + } + + [Fact] + public async Task Seeding_is_idempotent_across_reads() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + + var first = await svc.GetAsync("user-1", job.Id, default); + var second = await svc.GetAsync("user-1", job.Id, default); + + Assert.Equal(first!.Items.Count, second!.Items.Count); + Assert.Equal(first.Items.Count, await db.ApplicationChecklistItems.CountAsync()); + } + + [Fact] + public async Task System_items_auto_complete_from_the_readiness_signals() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + + var before = await svc.GetAsync("user-1", job.Id, default); + Assert.Equal(ChecklistStatuses.Done, Item(before!, "review-job-details").Status); // advert present + Assert.Equal(ChecklistStatuses.Pending, Item(before!, "prepare-cv").Status); + + job.TailoredCvText = "tailored"; + await db.SaveChangesAsync(); + + var after = await svc.GetAsync("user-1", job.Id, default); + var cv = Item(after!, "prepare-cv"); + Assert.Equal(ChecklistStatuses.Done, cv.Status); + Assert.True(cv.IsAutoCompleted); + } + + [Fact] + public async Task An_auto_completed_item_reopens_when_its_signal_goes_away() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1", j => j.CoverLetterText = "Dear team"); + + Assert.Equal(ChecklistStatuses.Done, Item((await svc.GetAsync("user-1", job.Id, default))!, "create-cover-letter").Status); + + job.CoverLetterText = null; + job.HasCoverLetter = false; + await db.SaveChangesAsync(); + + Assert.Equal(ChecklistStatuses.Pending, Item((await svc.GetAsync("user-1", job.Id, default))!, "create-cover-letter").Status); + } + + [Fact] + public async Task A_manual_tick_survives_the_signal_sync() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + + var portfolio = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio"); + await svc.UpdateAsync("user-1", job.Id, portfolio.Id, + new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); + + // The portfolio signal is still false, but the user said it is done — that must stick. + var after = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio"); + Assert.Equal(ChecklistStatuses.Done, after.Status); + Assert.False(after.IsAutoCompleted); + } + + [Fact] + public async Task Custom_items_can_be_added_and_deleted() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + await svc.GetAsync("user-1", job.Id, default); + + var created = await svc.AddAsync("user-1", job.Id, + new ChecklistItemInput("Ask Sara for a referral", "She worked there until 2025.", null, null, null), default); + + Assert.NotNull(created); + Assert.False(created!.IsSystemGenerated); + Assert.Equal(ChecklistCategories.Custom, created.Category); + + Assert.True(await svc.DeleteAsync("user-1", job.Id, created.Id, default)); + var after = await svc.GetAsync("user-1", job.Id, default); + Assert.DoesNotContain(after!.Items, i => i.Id == created.Id); + } + + [Fact] + public async Task Deleting_a_system_item_dismisses_it_instead_of_resurrecting_it() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + var portfolio = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio"); + + Assert.True(await svc.DeleteAsync("user-1", job.Id, portfolio.Id, default)); + + // A hard delete would be undone by the next seed, so removal means "dismissed" for system items. + var after = await svc.GetAsync("user-1", job.Id, default); + Assert.Equal(ChecklistStatuses.Dismissed, Item(after!, "attach-portfolio").Status); + } + + [Fact] + public async Task Dismissed_items_leave_the_progress_denominator() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + var before = await svc.GetAsync("user-1", job.Id, default); + var total = before!.Progress.Total; + + await svc.DeleteAsync("user-1", job.Id, Item(before, "attach-portfolio").Id, default); + + var after = await svc.GetAsync("user-1", job.Id, default); + Assert.Equal(total - 1, after!.Progress.Total); + Assert.Equal(1, after.Progress.Dismissed); + } + + [Fact] + public async Task Reordering_persists_the_new_order() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + var checklist = await svc.GetAsync("user-1", job.Id, default); + var preparation = checklist!.Items.Where(i => i.Category == ChecklistCategories.Preparation).ToList(); + var reversed = preparation.Select(i => i.Id).Reverse().ToList(); + + await svc.ReorderAsync("user-1", job.Id, reversed, default); + + var after = await svc.GetAsync("user-1", job.Id, default); + var afterPreparation = after!.Items.Where(i => i.Category == ChecklistCategories.Preparation).Select(i => i.Id).ToList(); + Assert.Equal(reversed, afterPreparation); + } + + [Fact] + public async Task Next_pending_follows_the_category_priority() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + var checklist = await svc.GetAsync("user-1", job.Id, default); + + var next = ApplicationChecklistService.NextPending(checklist!); + + // Preparation outranks submission, follow-up, interview and custom. + Assert.Equal(ChecklistCategories.Preparation, next!.Category); + } + + [Fact] + public async Task A_custom_item_can_become_the_next_action_once_the_system_items_are_done() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + var checklist = await svc.GetAsync("user-1", job.Id, default); + foreach (var item in checklist!.Items.Where(i => i.Status == ChecklistStatuses.Pending)) + { + await svc.UpdateAsync("user-1", job.Id, item.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); + } + var custom = await svc.AddAsync("user-1", job.Id, new ChecklistItemInput("Chase the recruiter", null, null, null, null), default); + + var next = ApplicationChecklistService.NextPending((await svc.GetAsync("user-1", job.Id, default))!); + + Assert.Equal(custom!.Id, next!.Id); + } + + [Fact] + public async Task Interview_prep_is_only_outstanding_at_the_interview_stage() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var applied = await SeedAsync(db, "user-1"); + var interviewing = await SeedAsync(db, "user-1", j => j.Status = "Interview"); + + Assert.Equal(ChecklistStatuses.Done, + Item((await svc.GetAsync("user-1", applied.Id, default))!, "prepare-interview-notes").Status); + Assert.Equal(ChecklistStatuses.Pending, + Item((await svc.GetAsync("user-1", interviewing.Id, default))!, "prepare-interview-notes").Status); + } + + [Fact] + public async Task Another_users_checklist_is_not_reachable() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var other = await SeedAsync(db, "user-2"); + + Assert.Null(await svc.GetAsync("user-1", other.Id, default)); + Assert.Null(await svc.AddAsync("user-1", other.Id, new ChecklistItemInput("Sneak", null, null, null, null), default)); + Assert.False(await svc.DeleteAsync("user-1", other.Id, 1, default)); + } + + [Fact] + public async Task An_items_owner_is_checked_before_it_can_be_updated() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var mine = await SeedAsync(db, "user-1"); + var theirs = await SeedAsync(db, "user-2"); + var theirItem = new ApplicationChecklistItem + { + OwnerUserId = "user-2", JobApplicationId = theirs.Id, Title = "Theirs", Category = ChecklistCategories.Custom, + }; + db.ApplicationChecklistItems.Add(theirItem); + await db.SaveChangesAsync(); + + Assert.Null(await svc.UpdateAsync("user-1", mine.Id, theirItem.Id, + new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default)); + } +} diff --git a/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs b/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs index 483a31c..be0ce5c 100644 --- a/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs +++ b/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs @@ -16,7 +16,29 @@ public sealed class ApplicationWorkspaceTests var currentUser = new Mock(); currentUser.SetupGet(s => s.UserId).Returns(userId); var db = new JobTrackerContext(options, currentUser.Object); - return (db, new ApplicationWorkspaceService(db)); + return (db, new ApplicationWorkspaceService(db, new ApplicationChecklistService(db))); + } + + // Milestone 2: the next action comes from the checklist, and every preparation item outranks the + // later categories. Seeding the parts a test is not asserting on keeps the assertion about one rule. + private static async Task CompletePreparationAsync(JobTrackerContext db, string owner, int jobId, params string[] except) + { + db.CareerProfiles.Add(new CareerProfile + { + OwnerUserId = owner, + Experiences = { new CareerExperience { OwnerUserId = owner, Title = "Dev", Company = "Acme" } }, + }); + await db.SaveChangesAsync(); + + var svc = new ApplicationChecklistService(db); + var checklist = await svc.GetAsync(owner, jobId, default); + foreach (var item in checklist!.Items.Where(i => + i.Category == ChecklistCategories.Preparation && + i.Status == ChecklistStatuses.Pending && + !except.Contains(i.SystemKey))) + { + await svc.UpdateAsync(owner, jobId, item.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); + } } private static async Task SeedAsync(JobTrackerContext db, string owner, Action? tweak = null) @@ -129,7 +151,8 @@ public sealed class ApplicationWorkspaceTests var o = await svc.GetOverviewAsync("user-1", job.Id, default); - Assert.Equal("add-job-details", o!.NextStep!.Key); + Assert.Equal("review-job-details", o!.NextStep!.Key); + Assert.Equal("job-details", o.NextStep.Section); } [Fact] @@ -138,20 +161,22 @@ public sealed class ApplicationWorkspaceTests var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); + await CompletePreparationAsync(db, "user-1", job.Id, + "prepare-cv", "create-cover-letter", "attach-supporting-documents"); Assert.Equal("prepare-cv", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); job.TailoredCvText = "tailored"; await db.SaveChangesAsync(); - Assert.Equal("write-cover-letter", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); + Assert.Equal("create-cover-letter", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); job.CoverLetterText = "Dear team"; await db.SaveChangesAsync(); - Assert.Equal("attach-documents", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); + Assert.Equal("attach-supporting-documents", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); } [Fact] - public async Task Next_step_prioritises_interview_prep_at_the_interview_stage() + public async Task Next_step_reaches_interview_prep_once_preparation_is_done() { var (db, svc) = New("user-1"); await using var _ = db; @@ -160,13 +185,32 @@ public sealed class ApplicationWorkspaceTests j.Status = "Interview"; j.TailoredCvText = "tailored"; j.CoverLetterText = "letter"; + j.FollowUpAt = DateTime.UtcNow.AddDays(3); + j.NextAction = "Confirm the interview slot"; }); db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "c.pdf", FilePath = "/tmp/c.pdf", FileType = "PDF" }); await db.SaveChangesAsync(); + await CompletePreparationAsync(db, "user-1", job.Id); var o = await svc.GetOverviewAsync("user-1", job.Id, default); - Assert.Equal("prepare-interview", o!.NextStep!.Key); + Assert.Equal("prepare-interview-notes", o!.NextStep!.Key); + Assert.Equal("interview", o.NextStep.Section); + } + + [Fact] + public async Task Overview_reports_checklist_progress() + { + var (db, svc) = New("user-1"); + await using var _ = db; + var job = await SeedAsync(db, "user-1"); + + var o = await svc.GetOverviewAsync("user-1", job.Id, default); + + Assert.NotNull(o!.ChecklistProgress); + Assert.True(o.ChecklistProgress!.Total > 0); + // The advert is present, so that item auto-completed from the same signal readiness uses. + Assert.True(o.ChecklistProgress.Completed > 0); } [Fact] diff --git a/JobTrackerApi/Controllers/ApplicationChecklistController.cs b/JobTrackerApi/Controllers/ApplicationChecklistController.cs new file mode 100644 index 0000000..1a24c1b --- /dev/null +++ b/JobTrackerApi/Controllers/ApplicationChecklistController.cs @@ -0,0 +1,72 @@ +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +// Phase 5 Milestone 2 — the application checklist. System items seed themselves on first read from the +// existing readiness signals; the user owns everything after that. +// docs/architecture/application-workspace.md. +[ApiController] +[Route("api/jobapplications/{jobId:int}/checklist")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class ApplicationChecklistController : ControllerBase +{ + private readonly UserManager _users; + private readonly IApplicationChecklistService _checklist; + + public ApplicationChecklistController(UserManager users, IApplicationChecklistService checklist) + { + _users = users; + _checklist = checklist; + } + + [HttpGet] + public async Task> Get(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _checklist.GetAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task> Add(int jobId, [FromBody] ChecklistItemInput 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 _checklist.AddAsync(userId, jobId, input, ct); + return created is null ? NotFound() : Ok(created); + } + + [HttpPatch("{itemId:int}")] + public async Task> Update(int jobId, int itemId, [FromBody] ChecklistItemInput input, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var updated = await _checklist.UpdateAsync(userId, jobId, itemId, input, ct); + return updated is null ? NotFound() : Ok(updated); + } + + [HttpDelete("{itemId:int}")] + public async Task Delete(int jobId, int itemId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + return await _checklist.DeleteAsync(userId, jobId, itemId, ct) ? NoContent() : NotFound(); + } + + [HttpPut("order")] + public async Task> Reorder(int jobId, [FromBody] List orderedIds, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _checklist.ReorderAsync(userId, jobId, orderedIds ?? new List(), ct); + return result is null ? NotFound() : Ok(result); + } + + private async Task CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id; +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 606330b..d98f5dc 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -28,9 +28,11 @@ namespace JobTrackerApi.Controllers private readonly AnalyticsService _analytics; private readonly IJobCvMatchService _matchService; private readonly IMemoryCache _cache; + private readonly IApplicationChecklistService _checklist; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null, IApplicationChecklistService? checklist = null) { + _checklist = checklist ?? new ApplicationChecklistService(db); _db = db; _summarizer = summarizer; _email = email; @@ -1771,20 +1773,22 @@ Candidate master CV: var followUpDecision = RulesEngine.Evaluate(settings, job, now, lastMessageAt); var workflowSignal = BuildWorkflowSignal(job, followUpDecision); - var completed = new List(); - var missing = new List(); + // Phase 5 Milestone 2: readiness no longer runs its own parallel checklist. The persisted + // application checklist is the one workflow surface; readiness projects it into the score / + // completed / missing / reminders health view the dialog and dashboard already consume, so + // the two can never disagree. The DTO shape is unchanged on purpose. + // docs/architecture/application-workspace.md. + var checklist = job.OwnerUserId is null + ? null + : await _checklist.GetAsync(job.OwnerUserId, id, cancellationToken); + var live = checklist?.Items.Where(i => i.Status != ChecklistStatuses.Dismissed).ToList() + ?? new List(); - if (workflowSignal.HasTailoredCv) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role"); - if (!string.IsNullOrWhiteSpace(job.CoverLetterText)) completed.Add("Cover letter draft ready"); else missing.Add("Create a cover letter draft"); - if (job.HasPortfolio) completed.Add("Portfolio attached"); else missing.Add("Consider adding a relevant portfolio example"); - if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) completed.Add("Recruiter contact available"); else missing.Add("Capture recruiter contact details if possible"); - if (!string.IsNullOrWhiteSpace(job.NextAction)) completed.Add("Next action captured"); else missing.Add("Write the next action so follow-up is clear"); - if (job.FollowUpAt is not null) completed.Add("Follow-up scheduled"); else missing.Add("Schedule a follow-up date"); - if (workflowSignal.HasSavedApplicationAnswerDraft) completed.Add("Saved application answers available"); else missing.Add("Save application answers for this role"); - if (workflowSignal.HasInterviewPrepNotes || !IsInterviewStage(job.Status)) completed.Add("Interview prep notes captured"); else missing.Add("Capture interview prep notes before the interview"); + var completed = live.Where(i => i.Status == ChecklistStatuses.Done).Select(i => i.Title).ToList(); + var missing = live.Where(i => i.Status == ChecklistStatuses.Pending).Select(i => i.Title).ToList(); var reminders = BuildReadinessReminders(job, workflowSignal); - var score = Math.Clamp(completed.Count * 12 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100); + var score = checklist?.Progress.Percent ?? 0; var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work"; return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal)); diff --git a/JobTrackerApi/Migrations/20260719085904_AddApplicationChecklistItems.Designer.cs b/JobTrackerApi/Migrations/20260719085904_AddApplicationChecklistItems.Designer.cs new file mode 100644 index 0000000..911309b --- /dev/null +++ b/JobTrackerApi/Migrations/20260719085904_AddApplicationChecklistItems.Designer.cs @@ -0,0 +1,2207 @@ +// +using System; +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + [DbContext(typeof(JobTrackerContext))] + [Migration("20260719085904_AddApplicationChecklistItems")] + partial class AddApplicationChecklistItems + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("TEXT"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc"); + + b.ToTable("AiInteractions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NoteType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "NoteType") + .IsUnique(); + + b.ToTable("AiWorkspaceNotes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoSignal") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAutoCompleted") + .HasColumnType("INTEGER"); + + b.Property("IsSystemGenerated") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Section") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SystemKey") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId", "SystemKey") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder"); + + b.ToTable("ApplicationChecklistItems"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AvatarImageDataUrl") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CurrentCvExtractionRunId") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvUploadArtifactId") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("GoogleEmail") + .HasColumnType("TEXT"); + + b.Property("GoogleLinkedAt") + .HasColumnType("TEXT"); + + b.Property("GoogleSubject") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MicrosoftEmail") + .HasColumnType("TEXT"); + + b.Property("MicrosoftLinkedAt") + .HasColumnType("TEXT"); + + b.Property("MicrosoftSubject") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("ProfileCvStructureJson") + .HasColumnType("TEXT"); + + b.Property("ProfileCvText") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TotpEnabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("TotpPendingSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TotpSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Purpose") + .HasColumnType("TEXT"); + + b.Property("UploadDate") + .HasColumnType("TEXT"); + + b.Property("UseForAi") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateNormalized") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Issuer") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerCertifications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Institution") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualification") + .HasColumnType("TEXT"); + + b.Property("QualificationLevel") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerEducations"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Company") + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("IsCurrent") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerExperiences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerLanguages"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LongTailJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId") + .IsUnique(); + + b.ToTable("CareerProfiles"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "Version"); + + b.ToTable("CareerProfileVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerProjects"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Proficiency") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerSkills"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastContactedAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextContactAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PipelineStage") + .HasColumnType("TEXT"); + + b.Property("RecruiterEmail") + .HasColumnType("TEXT"); + + b.Property("RecruiterLinkedIn") + .HasColumnType("TEXT"); + + b.Property("RecruiterName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentMetadataJson") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("ExternalFrom") + .HasColumnType("TEXT"); + + b.Property("ExternalLabelsJson") + .HasColumnType("TEXT"); + + b.Property("ExternalMessageId") + .HasColumnType("TEXT"); + + b.Property("ExternalThreadId") + .HasColumnType("TEXT"); + + b.Property("ExternalTo") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Correspondences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ArtifactId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LlmPromptVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NormalizedText") + .HasColumnType("TEXT"); + + b.Property("NormalizerVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParserVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RawExtractedText") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StructuredProfileJson") + .HasColumnType("TEXT"); + + b.Property("Trigger") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtifactId"); + + b.HasIndex("OwnerUserId", "StartedAtUtc"); + + b.ToTable("CvExtractionRuns"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvUploadArtifact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByteSize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sha256") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "UploadedAtUtc"); + + b.ToTable("CvUploadArtifacts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PublicSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("PublicSlug") + .IsUnique(); + + b.HasIndex("OwnerUserId", "UpdatedAtUtc"); + + b.ToTable("CvVariants"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CvVariantId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CvVariantId", "Version"); + + b.ToTable("CvVariantVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GmailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "GmailAddress") + .IsUnique(); + + b.ToTable("GmailConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailReviewDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ThreadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GmailReviewDecisions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ImapConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("UseSsl") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ImapConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LikelyQuestionsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TalkingPointsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WeakSpotsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("InterviewPrepNotes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CoverLetterText") + .HasColumnType("TEXT"); + + b.Property("DateApplied") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("FeedbackRequestedAt") + .HasColumnType("TEXT"); + + b.Property("FollowUpAt") + .HasColumnType("TEXT"); + + b.Property("HasCoverLetter") + .HasColumnType("INTEGER"); + + b.Property("HasOtherAttachment") + .HasColumnType("INTEGER"); + + b.Property("HasPortfolio") + .HasColumnType("INTEGER"); + + b.Property("HasResume") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("LastReminderEmailSentAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("NextAction") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("RecruiterMessageDraft") + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseReceived") + .HasColumnType("INTEGER"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TailoredCvText") + .HasColumnType("TEXT"); + + b.Property("TailoredCvUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("JobId"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "FollowUpAt"); + + b.HasIndex("OwnerUserId", "IsDeleted"); + + b.HasIndex("OwnerUserId", "IsDeleted", "Status"); + + b.ToTable("JobApplications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NewValue") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OldValue") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("JobEvents"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.MicrosoftGraphConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MicrosoftGraphConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("RuleSettings"); + + b.HasData( + new + { + Id = 1, + AppliedFollowUpDays = 14, + AppliedGhostDays = 30, + FeedbackFollowUpDays = 7, + FeedbackGhostDays = 14, + OfferFollowUpDays = 7, + OfferGhostDays = 14 + }); + }); + + modelBuilder.Entity("JobTrackerApi.Models.SystemEmailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("FromName") + .HasColumnType("TEXT"); + + b.Property("SmtpEnableSsl") + .HasColumnType("INTEGER"); + + b.Property("SmtpHost") + .HasColumnType("TEXT"); + + b.Property("SmtpPassword") + .HasColumnType("TEXT"); + + b.Property("SmtpPort") + .HasColumnType("INTEGER"); + + b.Property("SmtpTimeoutMs") + .HasColumnType("INTEGER"); + + b.Property("SmtpUser") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemEmailSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CustomSectionsJson") + .HasColumnType("TEXT"); + + b.Property("EducationJson") + .HasColumnType("TEXT"); + + b.Property("ExperienceJson") + .HasColumnType("TEXT"); + + b.Property("GenerationContextHash") + .HasColumnType("TEXT"); + + b.Property("Headline") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LastEditedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastGeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RenderOptionsJson") + .HasColumnType("TEXT"); + + b.Property("SelectedSkillsJson") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SummaryJson") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("TailoredCvDrafts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TrustedDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("TrustedDevices"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedAtUtc"); + + b.ToTable("TwoFactorRecoveryCodes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserRuleSettings", b => + { + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("OwnerUserId"); + + b.ToTable("UserRuleSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Attachments") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Certifications") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Education") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Experiences") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Languages") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany() + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Projects") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Skills") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Messages") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact") + .WithMany() + .HasForeignKey("ArtifactId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Artifact"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.HasOne("JobTrackerApi.Models.CvVariant", "CvVariant") + .WithMany() + .HasForeignKey("CvVariantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CvVariant"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany("Jobs") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.Job", "Job") + .WithMany("Applications") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Company"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Events") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithOne("TailoredCvDraft") + .HasForeignKey("JobTrackerApi.Models.TailoredCvDraft", "JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b => + { + b.Navigation("Certifications"); + + b.Navigation("Education"); + + b.Navigation("Experiences"); + + b.Navigation("Languages"); + + b.Navigation("Projects"); + + b.Navigation("Skills"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Navigation("Attachments"); + + b.Navigation("Events"); + + b.Navigation("Messages"); + + b.Navigation("TailoredCvDraft"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JobTrackerApi/Migrations/20260719085904_AddApplicationChecklistItems.cs b/JobTrackerApi/Migrations/20260719085904_AddApplicationChecklistItems.cs new file mode 100644 index 0000000..45d1b60 --- /dev/null +++ b/JobTrackerApi/Migrations/20260719085904_AddApplicationChecklistItems.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + public partial class AddApplicationChecklistItems : Migration + { + // Deliberately a no-op. Scaffolded against SQLite, so on MariaDB this would emit TEXT datetimes + // and a PRIMARY KEY without AUTO_INCREMENT, and the composite index over those columns then + // exceeds MySQL's 3072-byte key limit — exactly the failure that crashed prod startup for the + // Phase 4 CvVariants tables. + // + // ApplicationChecklistItems is provisioned instead by the idempotent reconciler in + // StartupInitializationExtensions, which carries correct DDL for both SQLite and MySQL. This + // migration exists only so the model snapshot stays in sync. + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index 505c5f7..1aab7dc 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -34,10 +34,12 @@ namespace JobTrackerApi.Migrations b.Property("Module") .IsRequired() + .HasMaxLength(64) .HasColumnType("TEXT"); b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("Provider") @@ -99,6 +101,78 @@ namespace JobTrackerApi.Migrations b.ToTable("AiWorkspaceNotes"); }); + modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoSignal") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAutoCompleted") + .HasColumnType("INTEGER"); + + b.Property("IsSystemGenerated") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Section") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SystemKey") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId", "SystemKey") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder"); + + b.ToTable("ApplicationChecklistItems"); + }); + modelBuilder.Entity("JobTrackerApi.Models.ApplicationUser", b => { b.Property("Id") @@ -860,10 +934,12 @@ namespace JobTrackerApi.Migrations b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("PublicSlug") .IsRequired() + .HasMaxLength(64) .HasColumnType("TEXT"); b.Property("SettingsJson") @@ -902,6 +978,7 @@ namespace JobTrackerApi.Migrations b.Property("OwnerUserId") .IsRequired() + .HasMaxLength(255) .HasColumnType("TEXT"); b.Property("SettingsJson") @@ -1832,6 +1909,17 @@ namespace JobTrackerApi.Migrations b.Navigation("JobApplication"); }); + modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => { b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 0e4b907..3ff81e8 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -42,6 +42,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/ApplicationChecklistService.cs b/JobTrackerApi/Services/ApplicationChecklistService.cs new file mode 100644 index 0000000..fbc9ad7 --- /dev/null +++ b/JobTrackerApi/Services/ApplicationChecklistService.cs @@ -0,0 +1,393 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +// Phase 5 Milestone 2 — the application checklist. +// +// One workflow surface, not a second tracker. The system items are seeded from the SAME signals the +// /readiness endpoint computes, and are re-synced on every read, so "readiness says X is missing" and +// "the checklist says X is pending" cannot drift apart. Readiness keeps its API contract and becomes +// the calculation; the checklist is what the user actually works from and edits. +// docs/architecture/application-workspace.md. +public sealed record ChecklistItemDto( + int Id, + string? SystemKey, + string Title, + string? Description, + string Category, + string Status, + string? Section, + int SortOrder, + bool IsSystemGenerated, + bool IsAutoCompleted, + DateTimeOffset? CompletedAt); + +public sealed record ChecklistProgressDto(int Total, int Completed, int Dismissed, int Percent); + +public sealed record ChecklistDto(IReadOnlyList Items, ChecklistProgressDto Progress); + +public sealed record ChecklistItemInput(string? Title, string? Description, string? Category, string? Status, string? Section); + +// The signals a checklist item can auto-complete from. Computed once per read. +public sealed record ChecklistSignals( + bool HasJobDescription, + bool HasCareerProfile, + bool HasCv, + bool HasCoverLetter, + bool HasPortfolio, + bool HasDocuments, + bool IsSubmitted, + bool HasFollowUp, + bool InterviewReady, + bool HasApplicationAnswers, + bool HasRecruiterContact, + bool HasNextAction) +{ + public bool IsSatisfied(string? signal) => signal switch + { + ChecklistSignalKeys.JobDescription => HasJobDescription, + ChecklistSignalKeys.CareerProfile => HasCareerProfile, + ChecklistSignalKeys.Cv => HasCv, + ChecklistSignalKeys.CoverLetter => HasCoverLetter, + ChecklistSignalKeys.Portfolio => HasPortfolio, + ChecklistSignalKeys.Documents => HasDocuments, + ChecklistSignalKeys.Submitted => IsSubmitted, + ChecklistSignalKeys.FollowUp => HasFollowUp, + ChecklistSignalKeys.InterviewNotes => InterviewReady, + ChecklistSignalKeys.ApplicationAnswers => HasApplicationAnswers, + ChecklistSignalKeys.RecruiterContact => HasRecruiterContact, + ChecklistSignalKeys.NextAction => HasNextAction, + _ => false, + }; +} + +public static class ChecklistSignalKeys +{ + public const string JobDescription = "job-description"; + public const string CareerProfile = "career-profile"; + public const string Cv = "cv"; + public const string CoverLetter = "cover-letter"; + public const string Portfolio = "portfolio"; + public const string Documents = "documents"; + public const string Submitted = "submitted"; + public const string FollowUp = "follow-up"; + public const string InterviewNotes = "interview-notes"; + public const string ApplicationAnswers = "application-answers"; + public const string RecruiterContact = "recruiter-contact"; + public const string NextAction = "next-action"; +} + +public interface IApplicationChecklistService +{ + Task GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + Task AddAsync(string ownerUserId, int jobApplicationId, ChecklistItemInput input, CancellationToken ct); + Task UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, ChecklistItemInput input, CancellationToken ct); + Task DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct); + Task ReorderAsync(string ownerUserId, int jobApplicationId, IReadOnlyList orderedIds, CancellationToken ct); +} + +public sealed class ApplicationChecklistService : IApplicationChecklistService +{ + // The default system checklist. Stable keys — renaming a title must never orphan a user's item. + private sealed record Template(string Key, string Title, string Description, string Category, string? Signal, string? Section); + + private static readonly Template[] Defaults = + { + new("review-job-details", "Review job details", "Save the advert text so analysis and matching have something to work with.", + ChecklistCategories.Preparation, ChecklistSignalKeys.JobDescription, "job-details"), + new("complete-career-profile", "Complete your career profile", "The master profile is what every CV variant is built from.", + ChecklistCategories.Preparation, ChecklistSignalKeys.CareerProfile, null), + new("prepare-cv", "Prepare a CV for this role", "Attach a CV variant tailored to this application.", + ChecklistCategories.Preparation, ChecklistSignalKeys.Cv, "cv"), + new("review-cv-match", "Review the CV match", "Check the CV actually answers the advert before sending it.", + ChecklistCategories.Preparation, null, "match"), + new("create-cover-letter", "Create a cover letter", "A tailored letter measurably lifts response rates.", + ChecklistCategories.Preparation, ChecklistSignalKeys.CoverLetter, "cover-letter"), + new("attach-portfolio", "Attach a portfolio example", "Relevant work samples where the role rewards them.", + ChecklistCategories.Preparation, ChecklistSignalKeys.Portfolio, "portfolio"), + new("attach-supporting-documents", "Attach supporting documents", "Certificates, references, transcripts.", + ChecklistCategories.Preparation, ChecklistSignalKeys.Documents, "documents"), + new("save-application-answers", "Save application answers for this role", "Reuse them in the form and in interview prep.", + ChecklistCategories.Preparation, ChecklistSignalKeys.ApplicationAnswers, "notes"), + new("capture-recruiter-contact", "Capture recruiter contact details", "A named contact is what makes a follow-up possible.", + ChecklistCategories.Preparation, ChecklistSignalKeys.RecruiterContact, "communication"), + new("confirm-submitted", "Confirm the application was submitted", "Move it out of the prospect stage and record the date applied.", + ChecklistCategories.Submission, ChecklistSignalKeys.Submitted, "overview"), + new("add-follow-up-reminder", "Add a follow-up reminder", "Applications without a follow-up date go quiet.", + ChecklistCategories.FollowUp, ChecklistSignalKeys.FollowUp, "overview"), + new("set-next-action", "Write the next action", "Keeps the application moving deliberately rather than drifting.", + ChecklistCategories.FollowUp, ChecklistSignalKeys.NextAction, "overview"), + new("prepare-interview-notes", "Prepare interview notes", "Talking points and likely questions before the interview.", + ChecklistCategories.Interview, ChecklistSignalKeys.InterviewNotes, "interview"), + new("research-company", "Research the company", "Product, people, recent news — enough to ask a good question.", + ChecklistCategories.Interview, null, "communication"), + }; + + private readonly JobTrackerContext _db; + + public ApplicationChecklistService(JobTrackerContext db) + { + _db = db; + } + + public async Task GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); + if (job is null) return null; + + var items = await LoadItemsAsync(ownerUserId, jobApplicationId, ct); + items = await SeedMissingAsync(ownerUserId, jobApplicationId, items, ct); + + var signals = await ComputeSignalsAsync(ownerUserId, job, ct); + await SyncAutoCompletionAsync(items, signals, ct); + + return Project(items); + } + + public async Task AddAsync(string ownerUserId, int jobApplicationId, ChecklistItemInput 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.ApplicationChecklistItems + .Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId) + .Select(i => (int?)i.SortOrder) + .MaxAsync(ct) ?? 0; + + var item = new ApplicationChecklistItem + { + OwnerUserId = ownerUserId, + JobApplicationId = jobApplicationId, + Title = title, + Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description!.Trim(), + Category = ChecklistCategories.IsValid(input.Category) ? input.Category! : ChecklistCategories.Custom, + Status = ChecklistStatuses.IsValid(input.Status) ? input.Status! : ChecklistStatuses.Pending, + Section = string.IsNullOrWhiteSpace(input.Section) ? null : input.Section, + SortOrder = maxSort + 1, + IsSystemGenerated = false, + }; + Stamp(item); + + _db.ApplicationChecklistItems.Add(item); + await _db.SaveChangesAsync(ct); + return Project(item); + } + + public async Task UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, ChecklistItemInput input, CancellationToken ct) + { + var item = await _db.ApplicationChecklistItems + .FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct); + if (item is null) return null; + + // System items keep their title/category — they are the shared vocabulary the next-action rules + // and the docs refer to. Everything else is the user's to change. + if (!item.IsSystemGenerated) + { + if (!string.IsNullOrWhiteSpace(input.Title)) item.Title = input.Title!.Trim(); + if (input.Description is not null) item.Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim(); + if (ChecklistCategories.IsValid(input.Category)) item.Category = input.Category!; + } + + if (ChecklistStatuses.IsValid(input.Status)) Stamp(item, input.Status!); + else Stamp(item); + + await _db.SaveChangesAsync(ct); + return Project(item); + } + + public async Task DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct) + { + var item = await _db.ApplicationChecklistItems + .FirstOrDefaultAsync(i => i.Id == itemId && i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId, ct); + if (item is null) return false; + + // A deleted system item would be re-seeded on the next read, so removing one means dismissing it. + if (item.IsSystemGenerated) Stamp(item, ChecklistStatuses.Dismissed); + else _db.ApplicationChecklistItems.Remove(item); + + await _db.SaveChangesAsync(ct); + return true; + } + + public async Task ReorderAsync(string ownerUserId, int jobApplicationId, IReadOnlyList orderedIds, CancellationToken ct) + { + var items = await LoadItemsAsync(ownerUserId, jobApplicationId, ct); + if (items.Count == 0) return null; + + var order = 0; + foreach (var id in orderedIds) + { + var item = items.FirstOrDefault(i => i.Id == id); + if (item is null) continue; + item.SortOrder = order++; + item.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + // Anything the client did not mention keeps its relative position, after the ordered ones. + foreach (var item in items.Where(i => !orderedIds.Contains(i.Id)).OrderBy(i => i.SortOrder)) + { + item.SortOrder = order++; + } + + await _db.SaveChangesAsync(ct); + return Project(items); + } + + // The next unfinished step, by category priority then the user's own ordering. This is what + // ApplicationWorkspaceService surfaces as "what do I do next" — one source, not a parallel ruleset. + public static ChecklistItemDto? NextPending(ChecklistDto checklist) => + checklist.Items + .Where(i => i.Status == ChecklistStatuses.Pending) + .OrderBy(i => ChecklistCategories.Rank(i.Category)) + .ThenBy(i => i.SortOrder) + .FirstOrDefault(); + + private Task LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => + _db.JobApplications.AsNoTracking().Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + + private Task> LoadItemsAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => + _db.ApplicationChecklistItems + .Where(i => i.OwnerUserId == ownerUserId && i.JobApplicationId == jobApplicationId) + .ToListAsync(ct); + + // Idempotent: seeds only the templates this application has never had. A dismissed item stays + // dismissed because its row still exists. + private async Task> SeedMissingAsync( + string ownerUserId, int jobApplicationId, List items, CancellationToken ct) + { + var existing = items.Where(i => i.SystemKey is not null).Select(i => i.SystemKey!).ToHashSet(StringComparer.Ordinal); + var missing = Defaults.Where(t => !existing.Contains(t.Key)).ToList(); + if (missing.Count == 0) return items; + + var order = 0; + foreach (var template in Defaults) + { + if (!existing.Contains(template.Key)) + { + var item = new ApplicationChecklistItem + { + OwnerUserId = ownerUserId, + JobApplicationId = jobApplicationId, + SystemKey = template.Key, + AutoSignal = template.Signal, + Title = template.Title, + Description = template.Description, + Category = template.Category, + Section = template.Section, + SortOrder = order, + IsSystemGenerated = true, + }; + _db.ApplicationChecklistItems.Add(item); + items.Add(item); + } + order++; + } + + await _db.SaveChangesAsync(ct); + return items; + } + + private async Task SyncAutoCompletionAsync(List items, ChecklistSignals signals, CancellationToken ct) + { + var changed = false; + foreach (var item in items) + { + if (item.AutoSignal is null || item.Status == ChecklistStatuses.Dismissed) continue; + var satisfied = signals.IsSatisfied(item.AutoSignal); + + if (satisfied && item.Status == ChecklistStatuses.Pending) + { + item.Status = ChecklistStatuses.Done; + item.IsAutoCompleted = true; + item.CompletedAt = DateTimeOffset.UtcNow; + item.UpdatedAtUtc = DateTimeOffset.UtcNow; + changed = true; + } + // Only reopen what the signal itself closed — a manual tick is the user's call and sticks. + else if (!satisfied && item.Status == ChecklistStatuses.Done && item.IsAutoCompleted) + { + item.Status = ChecklistStatuses.Pending; + item.IsAutoCompleted = false; + item.CompletedAt = null; + item.UpdatedAtUtc = DateTimeOffset.UtcNow; + changed = true; + } + } + + if (changed) await _db.SaveChangesAsync(ct); + } + + private async Task ComputeSignalsAsync(string ownerUserId, JobApplication job, CancellationToken ct) + { + var hasCv = !string.IsNullOrWhiteSpace(job.TailoredCvText) + || await _db.CvVariants.AsNoTracking() + .AnyAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == job.Id, ct); + + var hasDocuments = await _db.Attachments.AsNoTracking() + .AnyAsync(a => a.JobApplicationId == job.Id, ct); + + var hasProfile = await _db.CareerProfiles.AsNoTracking() + .AnyAsync(p => p.OwnerUserId == ownerUserId && p.Experiences.Any(), ct); + + var hasInterviewNotes = await _db.InterviewPrepNotes.AsNoTracking() + .AnyAsync(n => n.OwnerUserId == ownerUserId && n.JobApplicationId == job.Id, ct); + + return new ChecklistSignals( + HasJobDescription: !string.IsNullOrWhiteSpace(job.Description), + HasCareerProfile: hasProfile, + HasCv: hasCv, + HasCoverLetter: job.HasCoverLetter || !string.IsNullOrWhiteSpace(job.CoverLetterText), + HasPortfolio: job.HasPortfolio, + HasDocuments: hasDocuments, + IsSubmitted: job.DateApplied is not null && !JobPipeline.IsProspect(job.Status), + HasFollowUp: job.FollowUpAt is not null, + // Interview prep is only outstanding once the application actually reaches an interview. + InterviewReady: hasInterviewNotes + || JobApplicationHelpers.HasInterviewPrepNotes(job.Notes) + || !IsInterviewStage(job.Status), + // Same extractor the workflow signal uses, so the two readings cannot diverge. + HasApplicationAnswers: !string.IsNullOrWhiteSpace(JobApplicationHelpers.ExtractSavedApplicationAnswerDraft(job.Notes)), + HasRecruiterContact: !string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail), + HasNextAction: !string.IsNullOrWhiteSpace(job.NextAction)); + } + + private static bool IsInterviewStage(string? status) => + (status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase); + + private static void Stamp(ApplicationChecklistItem item, string? status = null) + { + if (status is not null && status != item.Status) + { + item.Status = status; + item.IsAutoCompleted = false; + item.CompletedAt = status == ChecklistStatuses.Done ? DateTimeOffset.UtcNow : null; + } + item.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + private static ChecklistItemDto Project(ApplicationChecklistItem i) => new( + i.Id, i.SystemKey, i.Title, i.Description, i.Category, i.Status, i.Section, i.SortOrder, + i.IsSystemGenerated, i.IsAutoCompleted, i.CompletedAt); + + private static ChecklistDto Project(List items) + { + var ordered = items + .OrderBy(i => ChecklistCategories.Rank(i.Category)) + .ThenBy(i => i.SortOrder) + .ThenBy(i => i.Id) + .Select(Project) + .ToList(); + + var dismissed = ordered.Count(i => i.Status == ChecklistStatuses.Dismissed); + var total = ordered.Count - dismissed; + var completed = ordered.Count(i => i.Status == ChecklistStatuses.Done); + var percent = total == 0 ? 100 : (int)Math.Round(completed * 100.0 / total); + + return new ChecklistDto(ordered, new ChecklistProgressDto(total, completed, dismissed, percent)); + } +} diff --git a/JobTrackerApi/Services/ApplicationWorkspaceService.cs b/JobTrackerApi/Services/ApplicationWorkspaceService.cs index 61636f5..ea683e2 100644 --- a/JobTrackerApi/Services/ApplicationWorkspaceService.cs +++ b/JobTrackerApi/Services/ApplicationWorkspaceService.cs @@ -36,7 +36,8 @@ public sealed record WorkspaceOverviewDto( int AiInteractionCount, DateTimeOffset? LastAiAtUtc, IReadOnlyList RecentActivity, - WorkspaceNextStepDto? NextStep); + WorkspaceNextStepDto? NextStep, + ChecklistProgressDto? ChecklistProgress); public interface IApplicationWorkspaceService { @@ -48,10 +49,12 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService private const int RecentActivityCount = 8; private readonly JobTrackerContext _db; + private readonly IApplicationChecklistService _checklist; - public ApplicationWorkspaceService(JobTrackerContext db) + public ApplicationWorkspaceService(JobTrackerContext db, IApplicationChecklistService checklist) { _db = db; + _checklist = checklist; } public async Task GetOverviewAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) @@ -90,6 +93,10 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService .Select(e => new WorkspaceActivityDto(e.Type, e.Note ?? e.NewValue, e.At)) .ToListAsync(ct); + // The checklist is the single workflow surface, so the overview's "next step" and progress both + // come from it rather than a parallel ruleset. Seeds itself on first read. + var checklist = await _checklist.GetAsync(ownerUserId, jobApplicationId, ct); + var stage = JobPipeline.Stages.FirstOrDefault(s => string.Equals(s.Key, JobPipeline.Normalize(job.Status), StringComparison.OrdinalIgnoreCase)); var hasCoverLetter = job.HasCoverLetter || !string.IsNullOrWhiteSpace(job.CoverLetterText); @@ -115,42 +122,20 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService aiCount, lastAi, activity, - NextStep(job, cv, hasCoverLetter, documentCount)); + NextStep(checklist), + checklist?.Progress); } - // "The user should never ask what to do next." First unmet rule in priority order wins. Ordered so - // the answer matches where the application actually is: understand the role, prepare the material, - // send it, then chase it. - private static WorkspaceNextStepDto? NextStep(JobApplication job, WorkspaceCvDto cv, bool hasCoverLetter, int documentCount) + // "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first + // pending item, in category priority order (preparation, submission, follow-up, interview, custom) + // then the user's own ordering. One workflow surface — the overview cannot recommend something the + // checklist has already been ticked off, and a user-added task can be the next action. + private static WorkspaceNextStepDto? NextStep(ChecklistDto? checklist) { - if (string.IsNullOrWhiteSpace(job.Description)) - return new("add-job-details", "Add the job advert", "Analysis and matching need the advert text.", "job-details"); - - if (JobPipeline.IsProspect(job.Status)) - { - if (cv.VariantId is null && !cv.HasTailoredCvText) - return new("prepare-cv", "Prepare a CV for this role", "No CV variant is attached yet.", "cv"); - if (!hasCoverLetter) - return new("write-cover-letter", "Write a cover letter", "A tailored letter measurably lifts response rates.", "cover-letter"); - return new("submit-application", "Submit the application", "The material is ready — move it out of the prospect stage.", "overview"); - } - - if (cv.VariantId is null && !cv.HasTailoredCvText) - return new("prepare-cv", "Prepare a CV for this role", "No CV variant is attached to this application.", "cv"); - if (!hasCoverLetter) - return new("write-cover-letter", "Write a cover letter", "No cover letter draft saved for this application.", "cover-letter"); - if (documentCount == 0) - return new("attach-documents", "Attach supporting documents", "Certificates or references strengthen the application.", "documents"); - if (IsInterviewStage(job.Status)) - return new("prepare-interview", "Prepare for the interview", "This application has reached the interview stage.", "interview"); - if (job.FollowUpAt is null && job.DateApplied is not null) - return new("schedule-follow-up", "Schedule a follow-up", "Applied with no follow-up date set.", "overview"); - if (string.IsNullOrWhiteSpace(job.NextAction)) - return new("set-next-action", "Write the next action", "Keeps the application moving deliberately.", "overview"); - - return null; + if (checklist is null) return null; + var next = ApplicationChecklistService.NextPending(checklist); + return next is null + ? null + : new WorkspaceNextStepDto(next.SystemKey ?? $"custom-{next.Id}", next.Title, next.Description ?? string.Empty, next.Section ?? "checklist"); } - - private static bool IsInterviewStage(string? status) => - (status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase); } diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 7b7e609..22bda28 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -858,6 +858,34 @@ public static class StartupInitializationExtensions Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_Owner_Job_Module_Created" ON "AiInteractions" ("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc");"""); } + // Phase 5 Milestone 2: the application checklist (workflow guidance over readiness signals). + static void EnsureApplicationChecklistTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "ApplicationChecklistItems" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ApplicationChecklistItems" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "JobApplicationId" INTEGER NOT NULL, + "SystemKey" TEXT NULL, + "AutoSignal" TEXT NULL, + "Title" TEXT NOT NULL, + "Description" TEXT NULL, + "Category" TEXT NOT NULL, + "Status" TEXT NOT NULL, + "Section" TEXT NULL, + "SortOrder" INTEGER NOT NULL, + "IsSystemGenerated" INTEGER NOT NULL, + "IsAutoCompleted" INTEGER NOT NULL, + "CompletedAt" TEXT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "UpdatedAtUtc" TEXT NOT NULL, + CONSTRAINT "FK_ApplicationChecklistItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE + ); + """); + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_ApplicationChecklistItems_JobApplicationId_SystemKey" ON "ApplicationChecklistItems" ("JobApplicationId", "SystemKey");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_ApplicationChecklistItems_Owner_Job_Sort" ON "ApplicationChecklistItems" ("OwnerUserId", "JobApplicationId", "SortOrder");"""); + } + EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); @@ -870,6 +898,7 @@ public static class StartupInitializationExtensions EnsureAiWorkspaceNotesTable(conn); EnsureCvBuilderTables(conn); EnsureAiInteractionsTable(conn); + EnsureApplicationChecklistTable(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -1351,6 +1380,7 @@ public static class StartupInitializationExtensions DropMalformedMySqlTable(conn, "CvVariantVersions", "CreatedAtUtc", "datetime"); DropMalformedMySqlTable(conn, "CvVariants", "UpdatedAtUtc", "datetime"); DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime"); + DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime"); if (!HasMySqlTable(conn, "CvVariants")) { @@ -1408,6 +1438,33 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + if (!HasMySqlTable(conn, "ApplicationChecklistItems")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `JobApplicationId` int NOT NULL, + `SystemKey` varchar(64) NULL, + `AutoSignal` varchar(64) NULL, + `Title` varchar(255) NOT NULL, + `Description` longtext NULL, + `Category` varchar(32) NOT NULL, + `Status` varchar(32) NOT NULL, + `Section` varchar(64) NULL, + `SortOrder` int NOT NULL, + `IsSystemGenerated` tinyint(1) NOT NULL, + `IsAutoCompleted` tinyint(1) NOT NULL, + `CompletedAt` datetime(6) NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `UpdatedAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_ApplicationChecklistItems_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE + );"; + cmd.ExecuteNonQuery(); + } + + EnsureMySqlAutoIncrementPrimaryKey(conn, "ApplicationChecklistItems", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "AiInteractions", "Id"); @@ -1420,6 +1477,8 @@ public static class StartupInitializationExtensions ("CvVariantVersions", "IX_CvVariantVersions_CvVariantId_Version", "`CvVariantId`, `Version`", false), ("AiInteractions", "IX_AiInteractions_JobApplicationId", "`JobApplicationId`", false), ("AiInteractions", "IX_AiInteractions_Owner_Job_Module_Created", "`OwnerUserId`, `JobApplicationId`, `Module`, `CreatedAtUtc`", false), + ("ApplicationChecklistItems", "IX_ApplicationChecklistItems_JobApplicationId_SystemKey", "`JobApplicationId`, `SystemKey`", true), + ("ApplicationChecklistItems", "IX_ApplicationChecklistItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`", false), }) { if (MySqlIndexExists(conn, ixTable, ixName)) continue; diff --git a/Models/ApplicationChecklistItem.cs b/Models/ApplicationChecklistItem.cs new file mode 100644 index 0000000..2585e9b --- /dev/null +++ b/Models/ApplicationChecklistItem.cs @@ -0,0 +1,80 @@ +namespace JobTrackerApi.Models; + +// Phase 5 Milestone 2 — the application checklist. +// +// A workflow GUIDANCE layer, not a new store of truth. It duplicates nothing: the CV lives in +// CvVariant, documents in Attachment, history in JobEvent, follow-up in JobApplication.FollowUpAt. +// A checklist item only records "is this step done, and does the user still want it". +// +// System items carry a stable SystemKey and (usually) an AutoSignal: the same readiness signal the +// /readiness endpoint already computes. When the signal is satisfied the item auto-completes, so the +// checklist and readiness can never disagree — readiness became the calculation, this is the surface. +// docs/architecture/application-workspace.md. +public sealed class ApplicationChecklistItem +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + + // Stable identifier for system items ("prepare-cv", ...). Null for user-created items, so re-seeding + // is idempotent and a system item can be recognised across renames. + public string? SystemKey { get; set; } + + // Which readiness signal completes this item automatically. Null = manual only. + public string? AutoSignal { get; set; } + + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + + // preparation | submission | follow-up | interview | custom. Drives grouping and next-action priority. + public string Category { get; set; } = ChecklistCategories.Custom; + + // pending | done | dismissed. "dismissed" is the user opting out — not every item fits every role. + public string Status { get; set; } = ChecklistStatuses.Pending; + + // Workspace section this step is done in, so the checklist can link straight to the work. + public string? Section { get; set; } + + public int SortOrder { get; set; } + public bool IsSystemGenerated { get; set; } + + // Set when the auto-signal completed the item, so the sync may reopen it if the signal reverts. + // A manual tick clears this and therefore sticks. + public bool IsAutoCompleted { get; set; } + + public DateTimeOffset? CompletedAt { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +public static class ChecklistStatuses +{ + public const string Pending = "pending"; + public const string Done = "done"; + public const string Dismissed = "dismissed"; + + public static bool IsValid(string? value) => + value is Pending or Done or Dismissed; +} + +public static class ChecklistCategories +{ + public const string Preparation = "preparation"; + public const string Submission = "submission"; + public const string FollowUp = "follow-up"; + public const string Interview = "interview"; + public const string Custom = "custom"; + + // Next-action priority: critical preparation, then submission, then chasing, then interview prep, + // then whatever the user added themselves. + public static readonly string[] Order = { Preparation, Submission, FollowUp, Interview, Custom }; + + public static int Rank(string? category) + { + var index = Array.IndexOf(Order, category ?? Custom); + return index < 0 ? Order.Length : index; + } + + public static bool IsValid(string? value) => Array.IndexOf(Order, value ?? string.Empty) >= 0; +} diff --git a/docs/architecture/application-workspace.md b/docs/architecture/application-workspace.md index 53cdefc..2846908 100644 --- a/docs/architecture/application-workspace.md +++ b/docs/architecture/application-workspace.md @@ -1,6 +1,6 @@ # Application Workspace (Phase 5) -> Phase 5, Milestone 1 (2026-07-18). The per-application workspace: what it is, what it deliberately +> Phase 5, Milestones 1–2 (2026-07-19). The per-application workspace: what it is, what it deliberately > is not, and how it composes existing systems. Companion to `cv-builder.md`, > `ai-career-assistant.md`, `career-profile-model.md`. @@ -18,6 +18,7 @@ The workspace **owns no data and duplicates none**. It is an aggregate read plus | Section | Backed by (existing system) | |---|---| +| Checklist | `ApplicationChecklistItem` — completion state only, seeded from the readiness signals | | CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile` | | Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history | | Documents | `Attachment` | @@ -42,19 +43,86 @@ Read-only and tenant-scoped (`OwnerUserId`), returning 404 for another user's ap ### Next recommended action -First unmet rule wins, ordered to match where the application actually is — understand the role, -prepare the material, send it, then chase it: +Milestone 2 moved this onto the checklist: **the first pending checklist item**, ordered by category +priority (`preparation` → `submission` → `follow-up` → `interview` → `custom`) then the user's own +ordering. `null` means nothing is outstanding. -1. `add-job-details` — no advert text (analysis and matching need it) -2. `prepare-cv` — no CV variant attached and no tailored CV text -3. `write-cover-letter` — no cover letter -4. `attach-documents` — nothing attached -5. `prepare-interview` — the application reached an interview stage -6. `schedule-follow-up` — applied with no follow-up date -7. `set-next-action` — no next action written +That means the overview cannot recommend something the user has already ticked off, a dismissed item +never comes back as a recommendation, and a task the user added themselves can legitimately be the +next action. There is no second ruleset to keep in sync. -Prospect-stage applications short-circuit to CV → cover letter → submit. `null` means nothing is -outstanding. +## The checklist + +`ApplicationChecklistItem` — a **workflow guidance layer**, not a store of truth. It records only "is +this step done, and does the user still want it". The CV still lives in `CvVariant`, documents in +`Attachment`, history in `JobEvent`, follow-up in `JobApplication.FollowUpAt`. + +### System items and auto-completion + +Each default item carries a stable `SystemKey` and usually an `AutoSignal` — the *same* signal +`/readiness` already computed. On every read the service re-syncs: + +- signal satisfied + item pending → **done**, `IsAutoCompleted = true` +- signal no longer satisfied + item was auto-completed → back to **pending** +- a **manual** tick clears `IsAutoCompleted` and therefore sticks, even against the signal + +So "readiness says the CV is missing" and "the checklist says Prepare a CV is pending" cannot drift +apart — they read the same state. The user always wins over the signal. + +| System key | Category | Signal | +|---|---|---| +| `review-job-details` | preparation | advert text present | +| `complete-career-profile` | preparation | career profile with at least one experience | +| `prepare-cv` | preparation | CV variant attached, or tailored CV text | +| `review-cv-match` | preparation | *manual* | +| `create-cover-letter` | preparation | cover letter present | +| `attach-portfolio` | preparation | `HasPortfolio` | +| `attach-supporting-documents` | preparation | at least one `Attachment` | +| `save-application-answers` | preparation | saved answer draft in `Notes` | +| `capture-recruiter-contact` | preparation | `Company.RecruiterEmail` | +| `confirm-submitted` | submission | applied date set and out of the prospect stage | +| `add-follow-up-reminder` | follow-up | `FollowUpAt` set | +| `set-next-action` | follow-up | `NextAction` written | +| `prepare-interview-notes` | interview | prep notes present, **or** not at an interview stage | +| `research-company` | interview | *manual* | + +Seeding is idempotent per `(JobApplicationId, SystemKey)` — enforced by a unique index, so a re-read +never duplicates. Custom items have a `NULL` `SystemKey`; both SQLite and MariaDB treat NULLs as +distinct in a unique index, so a user can add as many as they like. + +Deleting a **system** item dismisses it (a hard delete would be undone by the next seed); deleting a +**custom** item removes the row. Dismissed items leave the progress denominator entirely. + +### API + +`/api/jobapplications/{id}/checklist` — `GET` (seeds + syncs + returns items and progress), +`POST` (custom item), `PATCH /{itemId}`, `DELETE /{itemId}`, `PUT /order` (array of ids). +Tenant-scoped on `OwnerUserId`; another user's application is a 404. + +### Relationship to the other systems + +- **Not `JobEvent`** — the checklist is forward-looking intent; `JobEvent` is the append-only history. +- **Not follow-ups** — `FollowUpAt`, `RulesEngine` and the reminder hosted service still own + scheduling. The checklist only asks whether a follow-up exists. +- **Not `Attachment` / `CvVariant`** — it reads their presence as a signal and stores nothing of them. + +### Future AI suggestions + +An AI-suggested task is just a checklist row with `IsSystemGenerated = false` and no `AutoSignal`, +created after the user approves it. Nothing in the AI path may create, complete or delete an item +without approval — same rule as everywhere else (`ai-career-assistant.md`). + +### Schema provisioning + +`ApplicationChecklistItems` follows the established MariaDB-safe path: the scaffolded migration +(`20260719085904_AddApplicationChecklistItems`) is a **no-op**, and the table is created by the +idempotent reconciler in `StartupInitializationExtensions`, which has correct DDL per provider. A +SQLite-scaffolded migration would emit `TEXT` datetimes and a PK without `AUTO_INCREMENT` on MariaDB — +the failure that crashed prod startup for the Phase 4 tables. + +Verified on MariaDB 11: `int AUTO_INCREMENT` PK, `varchar`/`datetime(6)`/`tinyint(1)` columns, both +indexes inside the 3072-byte key limit, cascade delete from `JobApplications`, the unique index +rejecting a duplicate system key, and NULL system keys not colliding. ## Frontend @@ -66,18 +134,29 @@ The dialog passes an optional `onOpenWorkspace` callback rather than calling `us `JobDetailsDialog` must stay renderable without a `` (several suites mount it standalone), so router context belongs to the caller. -Implemented now: Overview, Job Details, and the sections that reuse an existing component +The Checklist section (`ApplicationChecklist`) groups items by category, shows a completion bar, and +supports tick/untick, add, remove and reorder. System items are labelled "Detected" when a signal +completed them, custom items "Yours". Every mutation re-reads, because only the backend's sync knows +the real post-mutation state. + +Implemented now: Overview, Checklist, Job Details, and the sections that reuse an existing component (Analysis/Match/Interview → `AiWorkspacePanel`, Documents → `Attachments`, Communication → `Correspondence`). Sections owned by later milestones state their milestone instead of faking functionality. ## Relationship to `/readiness` -`GET /{id}/readiness` already computes a polish-oriented checklist (score, completed, missing, -reminders) and still backs the dialog's Readiness tab. The workspace's next-action rules are -deliberately narrower and action-shaped. Milestone 2 introduces the persisted, user-editable checklist -and folds the readiness signals into it as defaults — at which point the overlap is resolved in one -place rather than two. +`GET /{id}/readiness` keeps its DTO shape (`score`, `level`, `completed`, `missing`, `reminders`, +`workflowSignal`) and still backs the dialog's Readiness tab — but as of Milestone 2 it no longer runs +its own parallel checklist. It **projects** the persisted checklist: + +- `completed` / `missing` — the live (non-dismissed) items by status +- `score` — the checklist completion percentage +- `level` — Ready ≥ 80, Needs polish ≥ 60, otherwise Needs work +- `reminders` / `workflowSignal` — unchanged; `BuildWorkflowSignal` remains the health/attention view + +So the division is: **the checklist is the workflow the user drives, readiness is the calculation and +health indicator derived from it.** One system, two projections. ## Extension points @@ -89,6 +168,7 @@ place rather than two. ## Milestones 1. ✅ Workspace foundation — route, nav shell, aggregate overview, next recommended action. -2. Checklist and progress tracking (persisted + custom items). +2. ✅ Checklist and progress tracking — persisted items, auto-completion from the readiness signals, + custom items, reordering, dismissal; readiness refactored into a projection of it. 3. Timeline and activity history. 4. Job analysis. 5. Career matching. 6. CV integration. 7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements. diff --git a/job-tracker-ui/src/application-checklist.test.tsx b/job-tracker-ui/src/application-checklist.test.tsx new file mode 100644 index 0000000..db86d15 --- /dev/null +++ b/job-tracker-ui/src/application-checklist.test.tsx @@ -0,0 +1,119 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import ApplicationChecklist from "./components/ApplicationChecklist"; +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; + +const item = (over: Partial = {}) => ({ + id: 1, + systemKey: "prepare-cv", + title: "Prepare a CV for this role", + description: "Attach a CV variant tailored to this application.", + category: "preparation", + status: "pending", + section: "cv", + sortOrder: 0, + isSystemGenerated: true, + isAutoCompleted: false, + completedAt: null, + ...over, +}); + +const checklist = (items: any[]) => ({ + items, + progress: { + total: items.filter((i) => i.status !== "dismissed").length, + completed: items.filter((i) => i.status === "done").length, + dismissed: items.filter((i) => i.status === "dismissed").length, + percent: 0, + }, +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockedApi.get.mockResolvedValue({ + data: checklist([ + item(), + item({ id: 2, systemKey: "create-cover-letter", title: "Create a cover letter", status: "done", isAutoCompleted: true }), + item({ id: 3, systemKey: null, title: "Ask Sara for a referral", category: "custom", isSystemGenerated: false, description: null }), + ]), + } as any); +}); + +test("renders the checklist grouped by category with progress", async () => { + render(); + + expect(await screen.findByText("Prepare a CV for this role")).toBeInTheDocument(); + expect(screen.getByText("Before applying")).toBeInTheDocument(); + expect(screen.getByText("Your own tasks")).toBeInTheDocument(); + expect(screen.getByText("1 of 3 done")).toBeInTheDocument(); + // Items already satisfied by an existing readiness signal are marked as detected, not hand-ticked. + expect(screen.getByText("Detected")).toBeInTheDocument(); +}); + +test("completing an item patches its status", async () => { + mockedApi.patch.mockResolvedValue({ data: item({ status: "done" }) } as any); + + render(); + fireEvent.click(await screen.findByRole("checkbox", { name: "Prepare a CV for this role" })); + + await waitFor(() => + expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/7/checklist/1", { status: "done" })); +}); + +test("un-ticking a completed item sends it back to pending", async () => { + mockedApi.patch.mockResolvedValue({ data: item({ id: 2, status: "pending" }) } as any); + + render(); + fireEvent.click(await screen.findByRole("checkbox", { name: "Create a cover letter" })); + + await waitFor(() => + expect(mockedApi.patch).toHaveBeenCalledWith("/jobapplications/7/checklist/2", { status: "pending" })); +}); + +test("adding a custom task posts the title", async () => { + mockedApi.post.mockResolvedValue({ data: item({ id: 9, systemKey: null, isSystemGenerated: false }) } as any); + + render(); + fireEvent.change(await screen.findByLabelText(/Add your own task/i), { target: { value: "Email the hiring manager" } }); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith( + "/jobapplications/7/checklist", + { title: "Email the hiring manager", description: undefined, category: undefined }, + )); +}); + +test("reordering sends the new id order", async () => { + mockedApi.put.mockResolvedValue({ data: checklist([]) } as any); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Move down: Prepare a CV for this role" })); + + await waitFor(() => + expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/checklist/order", [2, 1, 3])); +}); + +test("removing an item calls delete", async () => { + mockedApi.delete.mockResolvedValue({ data: undefined } as any); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "Remove: Ask Sara for a referral" })); + + await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/checklist/3")); +}); diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index 36af0ae..747b288 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -35,8 +35,38 @@ export type WorkspaceOverview = { lastAiAtUtc: string | null; recentActivity: WorkspaceActivity[]; nextStep: WorkspaceNextStep | null; + checklistProgress: ChecklistProgress | null; }; +// Milestone 2 — the application checklist. One workflow surface: system items seed from the same +// readiness signals the backend already computed, and the user owns everything after that. +export type ChecklistStatus = "pending" | "done" | "dismissed"; + +export type ChecklistItem = { + id: number; + systemKey: string | null; + title: string; + description: string | null; + category: string; + status: ChecklistStatus; + section: string | null; + sortOrder: number; + isSystemGenerated: boolean; + isAutoCompleted: boolean; + completedAt: string | null; +}; + +export type ChecklistProgress = { total: number; completed: number; dismissed: number; percent: number }; +export type Checklist = { items: ChecklistItem[]; progress: ChecklistProgress }; + +export const CHECKLIST_CATEGORIES: { key: string; label: string }[] = [ + { key: "preparation", label: "Before applying" }, + { key: "submission", label: "Submitting" }, + { key: "follow-up", label: "Follow-up" }, + { key: "interview", label: "Interview" }, + { key: "custom", label: "Your own tasks" }, +]; + // Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands // so the workspace is always usable rather than a shell of placeholders. export type WorkspaceSectionKey = @@ -48,7 +78,7 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; mile { key: "job-details", label: "Job Details" }, { key: "analysis", label: "Analysis" }, { key: "match", label: "Match" }, - { key: "checklist", label: "Checklist", milestone: 2 }, + { key: "checklist", label: "Checklist" }, { key: "cv", label: "CV", milestone: 6 }, { key: "cover-letter", label: "Cover Letter", milestone: 7 }, { key: "portfolio", label: "Portfolio", milestone: 8 }, @@ -63,3 +93,16 @@ export const applicationWorkspaceApi = { overview: (jobId: number) => api.get(`/jobapplications/${jobId}/workspace`).then((r) => r.data), }; + +export const applicationChecklistApi = { + get: (jobId: number) => + api.get(`/jobapplications/${jobId}/checklist`).then((r) => r.data), + add: (jobId: number, title: string, description?: string, category?: string) => + api.post(`/jobapplications/${jobId}/checklist`, { title, description, category }).then((r) => r.data), + update: (jobId: number, itemId: number, patch: Partial>) => + api.patch(`/jobapplications/${jobId}/checklist/${itemId}`, patch).then((r) => r.data), + remove: (jobId: number, itemId: number) => + api.delete(`/jobapplications/${jobId}/checklist/${itemId}`).then(() => undefined), + reorder: (jobId: number, orderedIds: number[]) => + api.put(`/jobapplications/${jobId}/checklist/order`, orderedIds).then((r) => r.data), +}; diff --git a/job-tracker-ui/src/components/ApplicationChecklist.tsx b/job-tracker-ui/src/components/ApplicationChecklist.tsx new file mode 100644 index 0000000..9be3887 --- /dev/null +++ b/job-tracker-ui/src/components/ApplicationChecklist.tsx @@ -0,0 +1,198 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import { + Alert, Box, Button, Checkbox, Chip, IconButton, LinearProgress, Paper, Skeleton, Stack, + TextField, Tooltip, Typography, +} from "@mui/material"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; + +import { getApiErrorMessage } from "../api"; +import { + CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi, +} from "../applicationWorkspace"; + +// Phase 5 Milestone 2 — the application checklist. +// +// One workflow surface, not a new tracker: the system items arrive already ticked wherever the +// existing readiness signals say the work is done (CV attached, cover letter written, follow-up +// scheduled...). Everything here is the user's to tick, add to, reorder or dismiss. +// docs/architecture/application-workspace.md. +export default function ApplicationChecklist({ jobId, onChanged }: { jobId: number; onChanged?: () => void }) { + const [checklist, setChecklist] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [draft, setDraft] = useState(""); + + const load = useCallback(async () => { + try { + setChecklist(await applicationChecklistApi.get(jobId)); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, "Could not load the checklist.")); + } + }, [jobId]); + + useEffect(() => { + load(); + }, [load]); + + // Every mutation re-reads: the backend re-syncs the auto-completed items on each read, so the + // response is the only thing that knows the real state. + const mutate = useCallback(async (run: () => Promise) => { + setBusy(true); + try { + await run(); + await load(); + onChanged?.(); + } catch (err) { + setError(getApiErrorMessage(err, "Could not update the checklist.")); + } finally { + setBusy(false); + } + }, [load, onChanged]); + + const toggle = (item: ChecklistItem) => + mutate(() => applicationChecklistApi.update(jobId, item.id, { + status: item.status === "done" ? "pending" : "done", + })); + + const remove = (item: ChecklistItem) => mutate(() => applicationChecklistApi.remove(jobId, item.id)); + + const move = (item: ChecklistItem, delta: -1 | 1) => { + if (!checklist) return; + const ids = checklist.items.map((i) => i.id); + const from = ids.indexOf(item.id); + const to = from + delta; + if (to < 0 || to >= ids.length) return; + [ids[from], ids[to]] = [ids[to], ids[from]]; + return mutate(() => applicationChecklistApi.reorder(jobId, ids)); + }; + + const add = (e: React.FormEvent) => { + e.preventDefault(); + const title = draft.trim(); + if (!title) return; + setDraft(""); + return mutate(() => applicationChecklistApi.add(jobId, title)); + }; + + const grouped = useMemo(() => { + const live = checklist?.items.filter((i) => i.status !== "dismissed") ?? []; + return CHECKLIST_CATEGORIES + .map((c) => ({ ...c, items: live.filter((i) => i.category === c.key) })) + .filter((c) => c.items.length > 0); + }, [checklist]); + + if (!checklist && !error) { + return {[0, 1, 2].map((i) => )}; + } + + const progress = checklist?.progress; + + return ( + + {error && setError(null)}>{error}} + + {progress && ( + + + Application checklist + + {progress.completed} of {progress.total} done + + + + + )} + + {grouped.map((group) => ( + + {group.label} + + {group.items.map((item) => ( + + toggle(item)} + inputProps={{ "aria-label": item.title }} + sx={{ mt: -0.25 }} + /> + + + + {item.title} + + {!item.isSystemGenerated && } + {item.isAutoCompleted && } + + {item.description && ( + {item.description} + )} + + + + + move(item, -1)}> + + + + + + + move(item, 1)}> + + + + + + + remove(item)}> + + + + + + + ))} + + + ))} + + + + setDraft(e.target.value)} + /> + + + + + ); +} diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index a1b03e9..3d98f93 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -12,11 +12,13 @@ import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; import MailOutlineIcon from "@mui/icons-material/MailOutline"; import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; +import ChecklistIcon from "@mui/icons-material/Checklist"; import { getApiErrorMessage } from "../api"; import Attachments from "../components/Attachments"; import Correspondence from "../components/Correspondence"; import AiWorkspacePanel from "../components/AiWorkspacePanel"; +import ApplicationChecklist from "../components/ApplicationChecklist"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, } from "../applicationWorkspace"; @@ -101,7 +103,10 @@ export default function ApplicationWorkspacePage() { {section === "communication" && jobId > 0 && ( )} - {["checklist", "cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && ( + {section === "checklist" && jobId > 0 && ( + + )} + {["cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && ( )} @@ -146,6 +151,7 @@ function OverviewSection({ overview, onGo, onReload }: { { icon: , label: "Cover letter", value: overview.hasCoverLetter ? "Ready" : "Not written", ok: overview.hasCoverLetter, go: "cover-letter" as const }, { icon: , label: "Documents", value: overview.documentCount ? `${overview.documentCount} attached` : "None", ok: overview.documentCount > 0, go: "documents" as const }, { icon: , label: "AI suggestions", value: overview.aiInteractionCount ? `${overview.aiInteractionCount} saved` : "None yet", ok: overview.aiInteractionCount > 0, go: "analysis" as const }, + { icon: , label: "Checklist", value: overview.checklistProgress ? `${overview.checklistProgress.completed}/${overview.checklistProgress.total} done` : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const }, ] : [], [overview]); if (!overview) { @@ -170,7 +176,7 @@ function OverviewSection({ overview, onGo, onReload }: { )} - + {stats.map((s) => ( onGo(s.go)}