Files
jobtrackingapp/JobTrackerApi/Services/ApplicationChecklistService.cs
T
cesnimda 432e1fd667
CI and Deploy / test (push) Failing after 1m3s
CI and Deploy / deploy (push) Has been skipped
feat(timeline): emit application lifecycle events
The timeline could interpret InterviewScheduled, InterviewCompleted,
OfferReceived and FollowUpCompleted, but only StatusChanged and FollowUpSet were
ever written, so those branches never rendered.

Events are now derived from the status TRANSITION in one shared emitter rather
than at each call site, so the two status-change boundaries in
JobApplicationsController cannot drift apart and a third would get the behaviour
for free. Both boundaries now call it instead of hand-writing the StatusChanged
block.

Deriving from the transition rather than the resulting state is what prevents
duplicates: one user action produces at most one lifecycle event, re-saving an
unchanged status produces none, and reaching an offer twice records it once.
Moving an application backwards is treated as a correction, not a completed
interview, so only a forward move out of an interview stage counts. An
Interview to Offer move reports the offer, which is the thing the user cares
about.

Completing a follow-up checklist item emits FollowUpCompleted, guarded on the
same transition rule so re-saving a done item stays silent. The task itself
remains a checklist item — this only records that it happened.

No new history store: every event is a JobEvent row, which stays the single
source of application history.

393 backend tests pass, including timeline rendering of the emitted events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:30:40 +02:00

403 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!;
}
var wasDone = item.Status == ChecklistStatuses.Done;
if (ChecklistStatuses.IsValid(input.Status)) Stamp(item, input.Status!);
else Stamp(item);
// Ticking off a follow-up task is a real lifecycle moment, so the timeline records it. Guarded
// on the transition, so re-saving an already-done item does not emit a second event.
if (!wasDone && item.Status == ChecklistStatuses.Done && item.Category == ChecklistCategories.FollowUp)
{
JobLifecycleEvents.RecordFollowUpCompleted(_db, jobApplicationId, item.Title);
}
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));
}
}