Files
jobtrackingapp/JobTrackerApi/Services/ApplicationAssetsService.cs
T
cesnimda 02b38f7acb
CI and Deploy / test (push) Failing after 1m11s
CI and Deploy / deploy (push) Has been skipped
feat(workspace): application assets workflow
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.

The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.

CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.

Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.

Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.

Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.

CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.

360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:36:27 +02:00

300 lines
13 KiB
C#

using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
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/application-workspace.md.
public sealed record ApplicationCvDto(
int? AttachedVariantId,
string? AttachedVariantName,
string? AttachedThemeId,
int? AttachedVersion,
DateTimeOffset? AttachedUpdatedAtUtc,
bool AttachedIsPublic,
bool HasTailoredCvText,
IReadOnlyList<CvVariantSummary> AvailableVariants);
public sealed record TailoringSuggestionDto(string Kind, string Title, string? Detail, IReadOnlyList<string> Items);
public sealed record TailoringPlanDto(
bool HasJobDescription,
bool HasCareerProfile,
bool HasAttachedVariant,
int MatchScore,
IReadOnlyList<TailoringSuggestionDto> 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<CoverLetterVersionDto> Versions,
int AiSuggestionCount);
public interface IApplicationAssetsService
{
Task<ApplicationCvDto?> GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<ApplicationCvDto?> AttachVariantAsync(string ownerUserId, int jobApplicationId, int? variantId, CancellationToken ct);
Task<TailoringPlanDto?> GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CoverLetterDto?> GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct);
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct);
}
public sealed class ApplicationAssetsService : IApplicationAssetsService
{
private readonly JobTrackerContext _db;
private readonly ICvVariantService _variants;
private readonly IApplicationIntelligenceService _intelligence;
public ApplicationAssetsService(JobTrackerContext db, ICvVariantService variants, IApplicationIntelligenceService intelligence)
{
_db = db;
_variants = variants;
_intelligence = intelligence;
}
// ---------- Part 1: CV variant integration ----------
public async Task<ApplicationCvDto?> 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<ApplicationCvDto?> 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<ApplicationCvDto> BuildCvAsync(string ownerUserId, JobApplication job, CancellationToken ct)
{
var attached = await _db.CvVariants.AsNoTracking()
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == job.Id)
.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<TailoringPlanDto?> 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<TailoringSuggestionDto>();
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()));
}
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<CoverLetterDto?> 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);
}
// Every save snapshots the PREVIOUS text first, so an AI rewrite can always be undone. The user's
// text is what gets stored — an AI suggestion only becomes a version once the user saves it, which
// is what "requires approval" means here.
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct)
{
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;
// Nothing changed: do not spend a version on a no-op save (autosave calls this often).
if (string.Equals(next, current, StringComparison.Ordinal))
{
return await BuildCoverLetterAsync(ownerUserId, job, ct);
}
var version = await NextVersionAsync(ownerUserId, jobApplicationId, ct);
_db.CoverLetterVersions.Add(new CoverLetterVersion
{
OwnerUserId = ownerUserId,
JobApplicationId = jobApplicationId,
Version = version,
Text = next,
Source = CoverLetterSources.IsValid(source) ? source : CoverLetterSources.Manual,
AiAction = string.IsNullOrWhiteSpace(aiAction) ? null : aiAction.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
});
job.CoverLetterText = next.Length == 0 ? null : next;
// HasCoverLetter is derived from attachments elsewhere; a written draft counts too.
if (next.Length > 0) job.HasCoverLetter = true;
await _db.SaveChangesAsync(ct);
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<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, 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, ct);
}
private async Task<CoverLetterDto> 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);
}
private async Task<int> NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
{
var max = await _db.CoverLetterVersions.AsNoTracking()
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId)
.Select(v => (int?)v.Version)
.MaxAsync(ct);
return (max ?? 0) + 1;
}
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
_db.JobApplications.AsNoTracking()
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
}