feat(workspace): unified application checklist (Phase 5 milestone 2)
CI and Deploy / test (push) Failing after 1m8s
CI and Deploy / deploy (push) Has been skipped

Evolve the existing readiness workflow into one persisted, user-controlled
checklist rather than adding a second tracker.

ApplicationChecklistItem records only completion state and user intent. Each
default system item carries a stable SystemKey and an AutoSignal — the same
signal /readiness already computed — and re-syncs on every read: a satisfied
signal auto-completes the item, a reverted signal reopens it, and a manual tick
always wins. Users can add, reorder, dismiss and delete.

Readiness is refactored into a projection of the checklist (score = completion
percentage, completed/missing = live items by status). Its DTO shape and the
workflowSignal/reminders health view are unchanged, so no API contract breaks.

The workspace's next recommended action now comes from the first pending
checklist item in category priority order (preparation, submission, follow-up,
interview, custom), replacing the parallel ruleset — so the overview can never
recommend something already ticked off, and a user's own task can be next.

The table follows the established MariaDB-safe path: the scaffolded migration is
a no-op and the idempotent reconciler owns the DDL for both providers. Verified
on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both
indexes inside the key limit, cascade delete, unique system key per application,
and NULL system keys not colliding for custom items.

329 backend tests, 94 frontend tests, type check, production build and both
Docker builds pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-19 11:15:46 +02:00
parent e55a6e86b7
commit 3a906b881e
18 changed files with 3783 additions and 76 deletions
@@ -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<ApplicationUser> _users;
private readonly IApplicationChecklistService _checklist;
public ApplicationChecklistController(UserManager<ApplicationUser> users, IApplicationChecklistService checklist)
{
_users = users;
_checklist = checklist;
}
[HttpGet]
public async Task<ActionResult<ChecklistDto>> 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<ActionResult<ChecklistItemDto>> 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<ActionResult<ChecklistItemDto>> 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<IActionResult> 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<ActionResult<ChecklistDto>> Reorder(int jobId, [FromBody] List<int> orderedIds, CancellationToken ct)
{
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
var result = await _checklist.ReorderAsync(userId, jobId, orderedIds ?? new List<int>(), ct);
return result is null ? NotFound() : Ok(result);
}
private async Task<string?> CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id;
}
@@ -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<ApplicationUser> users, ILogger<JobApplicationsController> 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<ApplicationUser> users, ILogger<JobApplicationsController> 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<string>();
var missing = new List<string>();
// 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<ChecklistItemDto>();
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));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
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.
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -34,10 +34,12 @@ namespace JobTrackerApi.Migrations
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Provider")
@@ -99,6 +101,78 @@ namespace JobTrackerApi.Migrations
b.ToTable("AiWorkspaceNotes");
});
modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AutoSignal")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAutoCompleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystemGenerated")
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Section")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("SystemKey")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("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<string>("Id")
@@ -860,10 +934,12 @@ namespace JobTrackerApi.Migrations
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("PublicSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("SettingsJson")
@@ -902,6 +978,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("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")
+1
View File
@@ -42,6 +42,7 @@ builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistService>();
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
@@ -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<ChecklistItemDto> 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<ChecklistDto?> GetAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<ChecklistItemDto?> AddAsync(string ownerUserId, int jobApplicationId, ChecklistItemInput input, CancellationToken ct);
Task<ChecklistItemDto?> UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, ChecklistItemInput input, CancellationToken ct);
Task<bool> DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct);
Task<ChecklistDto?> ReorderAsync(string ownerUserId, int jobApplicationId, IReadOnlyList<int> 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<ChecklistDto?> 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<ChecklistItemDto?> 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<ChecklistItemDto?> 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<bool> 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<ChecklistDto?> ReorderAsync(string ownerUserId, int jobApplicationId, IReadOnlyList<int> 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<JobApplication?> 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<List<ApplicationChecklistItem>> 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<List<ApplicationChecklistItem>> SeedMissingAsync(
string ownerUserId, int jobApplicationId, List<ApplicationChecklistItem> 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<ApplicationChecklistItem> 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<ChecklistSignals> 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<ApplicationChecklistItem> 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));
}
}
@@ -36,7 +36,8 @@ public sealed record WorkspaceOverviewDto(
int AiInteractionCount,
DateTimeOffset? LastAiAtUtc,
IReadOnlyList<WorkspaceActivityDto> 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<WorkspaceOverviewDto?> 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);
}
@@ -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;