3a906b881e
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>
394 lines
19 KiB
C#
394 lines
19 KiB
C#
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));
|
|
}
|
|
}
|