using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; using System.Text.Json; namespace JobTrackerApi.Services; // Phase 5.4 — Application Assets. // // Connects the career outputs the user already has to one job application. It owns almost nothing: // CV variants stay in CvVariantService (a lens over the master CareerProfile), documents stay in // Attachment, AI narrative stays in AiWorkspaceService. The only new state is the append-only cover // letter history, because JobApplication.CoverLetterText had no way back from a bad rewrite. // // The flow is strictly one-directional — CareerProfile -> CvVariant -> application output. Nothing // here writes upward: no method touches CareerProfile or its children. // docs/architecture/overview.md. public sealed record ApplicationCvDto( int? AttachedVariantId, string? AttachedVariantName, string? AttachedThemeId, int? AttachedVersion, DateTimeOffset? AttachedUpdatedAtUtc, bool AttachedIsPublic, bool HasTailoredCvText, IReadOnlyList AvailableVariants); public sealed record TailoringSuggestionDto(string Kind, string Title, string? Detail, IReadOnlyList Items); public sealed record TailoringPlanDto( bool HasJobDescription, bool HasCareerProfile, bool HasAttachedVariant, int MatchScore, IReadOnlyList Suggestions, int AiSuggestionCount); public sealed record CoverLetterVersionDto(int Version, string Source, string? AiAction, int Length, DateTimeOffset CreatedAtUtc, bool IsCurrent); public sealed record CoverLetterDto( string? Text, int CurrentVersion, IReadOnlyList Versions, int AiSuggestionCount, int Revision = 1, CoverLetterDocumentSettings? DocumentSettings = null); public interface IApplicationAssetsService { Task GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task AttachVariantAsync(string ownerUserId, int jobApplicationId, int? variantId, CancellationToken ct); Task GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct, CoverLetterDocumentSettings? documentSettings = null); Task RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct); Task PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct); Task RenderSavedCoverLetterAsync(string ownerUserId, int jobApplicationId, int? expectedVersion, CancellationToken ct); } public sealed class ApplicationAssetsService : IApplicationAssetsService { private readonly JobTrackerContext _db; private readonly ICvVariantService _variants; private readonly IApplicationIntelligenceService _intelligence; private readonly CoverLetterDocumentRenderer _letters; public ApplicationAssetsService(JobTrackerContext db, ICvVariantService variants, IApplicationIntelligenceService intelligence, CoverLetterDocumentRenderer? letters = null) { _db = db; _variants = variants; _intelligence = intelligence; _letters = letters ?? new CoverLetterDocumentRenderer(); } // ---------- Part 1: CV variant integration ---------- public async Task GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); if (job is null) return null; return await BuildCvAsync(ownerUserId, job, ct); } // Attach an EXISTING variant to this application, or detach with null. Creating, duplicating, // editing, previewing and exporting all stay in CvVariantService — this only moves the pointer. public async Task AttachVariantAsync(string ownerUserId, int jobApplicationId, int? variantId, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); if (job is null) return null; // Detaching clears whatever this application currently points at. var currentlyAttached = await _db.CvVariants .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId) .ToListAsync(ct); if (variantId is null) { foreach (var v in currentlyAttached) v.JobApplicationId = null; await _db.SaveChangesAsync(ct); return await BuildCvAsync(ownerUserId, job, ct); } var target = await _db.CvVariants .FirstOrDefaultAsync(v => v.Id == variantId.Value && v.OwnerUserId == ownerUserId, ct); if (target is null) return null; // One attached variant per application: the workspace answers "which CV am I sending". foreach (var v in currentlyAttached.Where(v => v.Id != target.Id)) v.JobApplicationId = null; target.JobApplicationId = jobApplicationId; target.UpdatedAtUtc = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(ct); return await BuildCvAsync(ownerUserId, job, ct); } private async Task BuildCvAsync(string ownerUserId, JobApplication job, CancellationToken ct) { var attachedQuery = _db.CvVariants.AsNoTracking() .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == job.Id); var attached = _db.Database.IsSqlite() ? (await attachedQuery.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc) : await attachedQuery.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct); var available = await _variants.ListAsync(ownerUserId, ct); return new ApplicationCvDto( attached?.Id, attached?.Name, attached is null ? null : CvVariantSettingsJson.Deserialize(attached.SettingsJson).ThemeId, attached?.Version, attached?.UpdatedAtUtc, attached?.IsPublic ?? false, !string.IsNullOrWhiteSpace(job.TailoredCvText), available); } // ---------- Part 2: tailoring workflow ---------- // Deterministic suggestions built from the Phase 5.3 analysis and match. These are SUGGESTIONS: // the service returns what the user could emphasise and the user decides. Nothing here edits a // variant, and nothing writes to the CareerProfile. public async Task GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); if (job is null) return null; var analysis = await _intelligence.AnalyzeAsync(ownerUserId, jobApplicationId, ct); var match = await _intelligence.MatchAsync(ownerUserId, jobApplicationId, ct); if (analysis is null || match is null) return null; var attachedVariantId = await _db.CvVariants.AsNoTracking() .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId) .Select(v => (int?)v.Id) .FirstOrDefaultAsync(ct); var suggestions = new List(); if (match.MatchedSkills.Count > 0) { suggestions.Add(new TailoringSuggestionDto( "highlight-skills", "Skills to highlight", "The advert asks for these and your profile already has them — put them where they are seen first.", match.MatchedSkills)); } if (match.RelevantExperience.Count > 0) { suggestions.Add(new TailoringSuggestionDto( "prioritise-experience", "Experience to prioritise", "Ordered by how much of the advert each role actually covers.", match.RelevantExperience.Select(e => e.Subtitle is null ? e.Title : $"{e.Title} — {e.Subtitle}").ToList())); } if (match.RelevantProjects.Count > 0) { suggestions.Add(new TailoringSuggestionDto( "emphasise-projects", "Projects to emphasise", "These projects demonstrate what the advert is asking for.", match.RelevantProjects.Select(p => p.Subtitle is null ? p.Title : $"{p.Title} — {p.Subtitle}").ToList())); } var evidenceTerms = match.MatchedSkills.Concat(analysis.Keywords) .Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var evidenceQuery = _db.CareerEvidence.AsNoTracking() .Where(x => x.OwnerUserId == ownerUserId && x.IsVerified); var evidence = _db.Database.IsSqlite() ? (await evidenceQuery.ToListAsync(ct)).OrderByDescending(x => x.UpdatedAtUtc).ToList() : await evidenceQuery.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct); var relevantEvidence = evidence.Where(item => { var searchable = $"{item.Title} {item.Statement} {string.Join(' ', EvidenceTags(item.TagsJson))}"; return evidenceTerms.Any(term => searchable.Contains(term, StringComparison.OrdinalIgnoreCase)); }).Take(6).Select(item => $"{item.Title} — {item.Statement}").ToList(); if (relevantEvidence.Count > 0) { suggestions.Add(new TailoringSuggestionDto( "career-evidence", "Verified evidence to reuse", "Facts you previously approved. Adapt the wording, but keep the claim accurate.", relevantEvidence)); } if (analysis.Keywords.Count > 0) { suggestions.Add(new TailoringSuggestionDto( "include-keywords", "Keywords to include", "Vocabulary from the advert. Use the ones that are honestly true of you — never pad.", analysis.Keywords)); } if (match.MissingSkills.Count > 0) { suggestions.Add(new TailoringSuggestionDto( "gaps", "Gaps to address", "Asked for but not found in your profile. Add them if you have them; otherwise be ready to talk about them.", match.MissingSkills)); } return new TailoringPlanDto( analysis.HasJobDescription, match.HasCareerProfile, attachedVariantId is not null, match.Score, suggestions, analysis.AiSuggestionCount + match.AiSuggestionCount); } // ---------- Part 3: cover letter workflow ---------- public async Task GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); if (job is null) return null; return await BuildCoverLetterAsync(ownerUserId, job, ct); } // Only approved text becomes a version; generating an AI suggestion does not save it. public async Task SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct, CoverLetterDocumentSettings? documentSettings = null) { var job = await _db.JobApplications .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); if (job is null) return null; var next = text?.Trim() ?? string.Empty; var current = job.CoverLetterText?.Trim() ?? string.Empty; if (expectedVersion is null or < 1 || expectedVersion != job.Version) throw new DbUpdateConcurrencyException("The application changed after this draft was loaded."); // Nothing changed: do not spend a version on a no-op save (autosave calls this often). var sameSettings = documentSettings is null || CoverLetterDocumentSettingsJson.Serialize(documentSettings) == CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)); if (string.Equals(next, current, StringComparison.Ordinal) && sameSettings) { // A no-op still consumes the source revision so an overlapping write cannot be acknowledged as this draft. _db.Entry(job).Property(x => x.Version).IsModified = true; try { await _db.SaveChangesAsync(ct); } catch { _db.ChangeTracker.Clear(); throw; } return await BuildCoverLetterAsync(ownerUserId, job, ct); } await StageCoverLetterAsync(_db, job, next.Length == 0 ? null : next, source, aiAction, ct, documentSettings); // Retain dedicated-save draft readiness; generic/import/draft writers leave attachment flags alone. if (next.Length > 0) job.HasCoverLetter = true; try { await _db.SaveChangesAsync(ct); } catch { _db.ChangeTracker.Clear(); throw; } return await BuildCoverLetterAsync(ownerUserId, job, ct); } // Restore is non-destructive: the old text comes back as a NEW version, so the thing you restored // from is still in the history. public async Task RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct) { var job = await _db.JobApplications .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); if (job is null) return null; var snapshot = await _db.CoverLetterVersions.AsNoTracking() .FirstOrDefaultAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId && v.Version == version, ct); if (snapshot is null) return null; return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, expectedVersion, ct, CoverLetterDocumentSettingsJson.Deserialize(snapshot.DocumentSettingsJson)); } public async Task PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); return job is null ? null : _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)); } public async Task RenderSavedCoverLetterAsync(string ownerUserId, int jobApplicationId, int? expectedVersion, CancellationToken ct) { var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct); if (job is null) return null; if (expectedVersion is null or < 1 || expectedVersion != job.Version) throw new DbUpdateConcurrencyException("The application changed. Save and review the current letter before exporting."); return _letters.Render(job.CoverLetterText, CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)); } private async Task BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct) { var versions = await _db.CoverLetterVersions.AsNoTracking() .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == job.Id) .OrderByDescending(v => v.Version) .ToListAsync(ct); var current = versions.Count == 0 ? 0 : versions[0].Version; var aiCount = await _db.AiInteractions.AsNoTracking() .CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == job.Id && a.Module == "cover-letter", ct); return new CoverLetterDto( job.CoverLetterText, current, versions.Select(v => new CoverLetterVersionDto( v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(), aiCount, job.Version, CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)); } // Stage history and current text together; the caller owns the parent save and its revision check. internal static async Task StageCoverLetterAsync(JobTrackerContext db, JobApplication job, string? text, string source, string? aiAction, CancellationToken ct, CoverLetterDocumentSettings? settings = null) { var currentSettings = CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson)); var nextSettings = settings is null ? currentSettings : CoverLetterDocumentSettingsJson.Serialize(settings); if (string.Equals(job.CoverLetterText, text, StringComparison.Ordinal) && nextSettings == currentSettings) return; var latest = db.Entry(job).State == EntityState.Added ? null : await db.CoverLetterVersions.AsNoTracking() .Where(v => v.OwnerUserId == job.OwnerUserId && v.JobApplicationId == job.Id) .OrderByDescending(v => v.Version).FirstOrDefaultAsync(ct); var version = latest?.Version ?? 0; void Snapshot(string value, string snapshotSource, string? action, string? settingsJson) => db.CoverLetterVersions.Add(new CoverLetterVersion { OwnerUserId = job.OwnerUserId ?? string.Empty, JobApplication = job, Version = ++version, Text = value, Source = snapshotSource, AiAction = action, DocumentSettingsJson = settingsJson, CreatedAtUtc = DateTimeOffset.UtcNow, }); // Older generic/import writes could bypass history; preserve their current state before replacing it. if (latest is null ? !string.IsNullOrEmpty(job.CoverLetterText) || job.CoverLetterDocumentSettingsJson is not null : latest.Text != (job.CoverLetterText ?? string.Empty) || CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(latest.DocumentSettingsJson)) != currentSettings) Snapshot(job.CoverLetterText ?? string.Empty, CoverLetterSources.Legacy, null, job.CoverLetterDocumentSettingsJson); Snapshot(text ?? string.Empty, CoverLetterSources.IsValid(source) ? source : CoverLetterSources.Manual, string.IsNullOrWhiteSpace(aiAction) ? null : aiAction.Trim(), nextSettings); job.CoverLetterText = text; if (settings is not null) job.CoverLetterDocumentSettingsJson = nextSettings; } private Task LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => _db.JobApplications.AsNoTracking() .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); private static IReadOnlyList EvidenceTags(string? json) { try { return JsonSerializer.Deserialize>(json ?? "[]") ?? []; } catch (JsonException) { return []; } } }