using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; using System.Text; 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 LearningRecommendationDto(int Id, string Keyword, string Status); 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); Task> SyncLearningRecommendationsAsync( string ownerUserId, int jobApplicationId, IReadOnlyList missingKeywords, CancellationToken ct); } public sealed class ApplicationChecklistService : IApplicationChecklistService { private const string LearningKeyPrefix = "learning:"; // 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!; } 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 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); } public async Task> SyncLearningRecommendationsAsync( string ownerUserId, int jobApplicationId, IReadOnlyList missingKeywords, CancellationToken ct) { if (!await _db.JobApplications.AsNoTracking() .AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct)) { return []; } var keywords = missingKeywords .Where(keyword => !string.IsNullOrWhiteSpace(keyword)) .Select(keyword => keyword.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var items = await _db.ApplicationChecklistItems .Where(item => item.OwnerUserId == ownerUserId && item.JobApplicationId == jobApplicationId && item.SystemKey != null && item.SystemKey.StartsWith(LearningKeyPrefix)) .ToListAsync(ct); var byKey = items.ToDictionary(item => item.SystemKey!, StringComparer.OrdinalIgnoreCase); var activeKeys = keywords.Select(LearningKey).ToHashSet(StringComparer.OrdinalIgnoreCase); var changed = false; foreach (var keyword in keywords) { var key = LearningKey(keyword); if (!byKey.TryGetValue(key, out var item)) { item = new ApplicationChecklistItem { OwnerUserId = ownerUserId, JobApplicationId = jobApplicationId, SystemKey = key, Title = keyword, Description = $"Build or verify evidence for {keyword} before claiming it in an application.", Category = ChecklistCategories.Custom, Status = ChecklistStatuses.Pending, Section = "match", SortOrder = items.Count, IsSystemGenerated = true, }; _db.ApplicationChecklistItems.Add(item); items.Add(item); byKey[key] = item; changed = true; } else if (item.Status == ChecklistStatuses.Done && item.IsAutoCompleted) { item.Status = ChecklistStatuses.Pending; item.IsAutoCompleted = false; item.CompletedAt = null; item.UpdatedAtUtc = DateTimeOffset.UtcNow; changed = true; } } foreach (var item in items.Where(item => !activeKeys.Contains(item.SystemKey!) && item.Status == ChecklistStatuses.Pending)) { item.Status = ChecklistStatuses.Done; item.IsAutoCompleted = true; item.CompletedAt = DateTimeOffset.UtcNow; item.UpdatedAtUtc = DateTimeOffset.UtcNow; changed = true; } if (changed) await _db.SaveChangesAsync(ct); return keywords.Select(keyword => byKey[LearningKey(keyword)]) .Select(item => new LearningRecommendationDto(item.Id, item.Title, item.Status)) .ToList(); } private static string LearningKey(string keyword) => LearningKeyPrefix + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(keyword.Trim().ToLowerInvariant())))[..40]; // 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)); } }