395 lines
20 KiB
C#
395 lines
20 KiB
C#
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<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,
|
|
int Revision = 1,
|
|
CoverLetterDocumentSettings? DocumentSettings = null);
|
|
|
|
public sealed class CoverLetterThemeAccessException() : Exception("This theme requires Pro.");
|
|
|
|
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, int? expectedVersion, CancellationToken ct, CoverLetterDocumentSettings? documentSettings = null);
|
|
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct);
|
|
Task<CoverLetterRenderDto?> PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct);
|
|
Task<CoverLetterRenderDto?> 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<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 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<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()));
|
|
}
|
|
|
|
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<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);
|
|
}
|
|
|
|
// Only approved text becomes a version; generating an AI suggestion does not save it.
|
|
public async Task<CoverLetterDto?> 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<CoverLetterDto?> 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<CoverLetterRenderDto?> PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct)
|
|
{
|
|
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
|
if (job is null) return null;
|
|
await RequireLetterThemeAsync(_db, job, settings, ct);
|
|
return _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
|
}
|
|
|
|
public async Task<CoverLetterRenderDto?> 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<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,
|
|
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)
|
|
{
|
|
await RequireLetterThemeAsync(db, job, settings, ct);
|
|
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 static async Task RequireLetterThemeAsync(JobTrackerContext db, JobApplication job, CoverLetterDocumentSettings? settings, CancellationToken ct)
|
|
{
|
|
if (settings is null) return;
|
|
var next = CoverLetterDocumentSettingsJson.Normalize(settings);
|
|
if (next.DesignVersion != 2 || CvThemeCatalog.CanUse(next.ThemeId, false)) return;
|
|
var current = CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson);
|
|
if (current.DesignVersion == 2 && current.ThemeId == next.ThemeId) return;
|
|
var roles = await (from userRole in db.UserRoles.AsNoTracking()
|
|
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
|
where userRole.UserId == job.OwnerUserId && role.Name != null
|
|
select role.Name!).ToListAsync(ct);
|
|
if (!CvThemeCatalog.CanUse(next.ThemeId, AccountPlans.ForRoles(roles).ProThemes))
|
|
throw new CoverLetterThemeAccessException();
|
|
}
|
|
|
|
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
|
_db.JobApplications.AsNoTracking()
|
|
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
|
|
|
private static IReadOnlyList<string> EvidenceTags(string? json)
|
|
{
|
|
try { return JsonSerializer.Deserialize<List<string>>(json ?? "[]") ?? []; }
|
|
catch (JsonException) { return []; }
|
|
}
|
|
}
|