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)}