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>
This commit is contained in:
@@ -46,6 +46,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<CvVariantVersion> CvVariantVersions => Set<CvVariantVersion>();
|
||||
public DbSet<AiInteraction> AiInteractions => Set<AiInteraction>();
|
||||
public DbSet<ApplicationChecklistItem> ApplicationChecklistItems => Set<ApplicationChecklistItem>();
|
||||
public DbSet<CoverLetterVersion> CoverLetterVersions => Set<CoverLetterVersion>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -366,6 +367,23 @@ namespace JobTrackerApi.Data
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Phase 5.4: append-only cover letter history. JobApplication.CoverLetterText stays the
|
||||
// current text; this makes every previous state recoverable.
|
||||
// docs/architecture/application-workspace.md.
|
||||
modelBuilder.Entity<CoverLetterVersion>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
// varchar (not longtext) for the indexed columns — see the CvVariant note above.
|
||||
modelBuilder.Entity<CoverLetterVersion>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<CoverLetterVersion>().Property(x => x.Source).HasMaxLength(32);
|
||||
modelBuilder.Entity<CoverLetterVersion>().Property(x => x.AiAction).HasMaxLength(32);
|
||||
modelBuilder.Entity<CoverLetterVersion>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Version });
|
||||
modelBuilder.Entity<CoverLetterVersion>()
|
||||
.HasOne(x => x.JobApplication)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
// Common config for CareerProfile's relational children. The 1:many FK + cascade delete is
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Phase 5.4 — Application Assets. The properties under test are the architecture rules: the flow runs
|
||||
// CareerProfile -> CvVariant -> application output and never back, versions are never destroyed, and
|
||||
// nothing is reachable across tenants.
|
||||
public sealed class ApplicationAssetsTests
|
||||
{
|
||||
private static (JobTrackerContext db, ApplicationAssetsService assets) New(string userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(s => s.UserId).Returns(userId);
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
|
||||
// Only ListAsync is used here — attaching, creating, previewing and exporting all stay in
|
||||
// CvVariantService, which has its own tests. Mocking it keeps these tests about association.
|
||||
var variants = new Mock<ICvVariantService>();
|
||||
variants
|
||||
.Setup(s => s.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string owner, CancellationToken _) => db.CvVariants
|
||||
.Where(v => v.OwnerUserId == owner)
|
||||
.Select(v => new CvVariantSummary(v.Id, v.Name, "nordic", v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc))
|
||||
.ToList());
|
||||
|
||||
var intelligence = new ApplicationIntelligenceService(db, new JobCvMatchService());
|
||||
return (db, new ApplicationAssetsService(db, variants.Object, intelligence));
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner)
|
||||
{
|
||||
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = owner,
|
||||
CompanyId = company.Id,
|
||||
JobTitle = "Senior Backend Developer",
|
||||
Status = "Applied",
|
||||
Description = "We need strong C# and .NET experience, plus SQL and Docker. You will build REST APIs.",
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static async Task<CvVariant> SeedVariantAsync(JobTrackerContext db, string owner, string name, int? jobId = null)
|
||||
{
|
||||
var variant = new CvVariant
|
||||
{
|
||||
OwnerUserId = owner,
|
||||
Name = name,
|
||||
JobApplicationId = jobId,
|
||||
PublicSlug = Guid.NewGuid().ToString("N"),
|
||||
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic" }),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.CvVariants.Add(variant);
|
||||
await db.SaveChangesAsync();
|
||||
return variant;
|
||||
}
|
||||
|
||||
// ---------- Part 1: CV variant association ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Attaching_a_variant_points_the_application_at_it()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
var variant = await SeedVariantAsync(db, "user-1", "Backend CV");
|
||||
|
||||
var cv = await assets.AttachVariantAsync("user-1", job.Id, variant.Id, default);
|
||||
|
||||
Assert.Equal(variant.Id, cv!.AttachedVariantId);
|
||||
Assert.Equal("Backend CV", cv.AttachedVariantName);
|
||||
Assert.Equal("nordic", cv.AttachedThemeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attaching_a_second_variant_replaces_the_first_rather_than_stacking()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
var first = await SeedVariantAsync(db, "user-1", "First");
|
||||
var second = await SeedVariantAsync(db, "user-1", "Second");
|
||||
|
||||
await assets.AttachVariantAsync("user-1", job.Id, first.Id, default);
|
||||
var cv = await assets.AttachVariantAsync("user-1", job.Id, second.Id, default);
|
||||
|
||||
Assert.Equal(second.Id, cv!.AttachedVariantId);
|
||||
// The replaced variant is detached, not deleted — it is still the user's to reuse.
|
||||
var stillThere = await db.CvVariants.FirstAsync(v => v.Id == first.Id);
|
||||
Assert.Null(stillThere.JobApplicationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detaching_leaves_the_variant_intact()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
var variant = await SeedVariantAsync(db, "user-1", "Backend CV", null);
|
||||
await assets.AttachVariantAsync("user-1", job.Id, variant.Id, default);
|
||||
|
||||
var cv = await assets.AttachVariantAsync("user-1", job.Id, null, default);
|
||||
|
||||
Assert.Null(cv!.AttachedVariantId);
|
||||
Assert.Equal(1, await db.CvVariants.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_users_variant_cannot_be_attached()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
var theirs = await SeedVariantAsync(db, "user-2", "Not yours");
|
||||
|
||||
Assert.Null(await assets.AttachVariantAsync("user-1", job.Id, theirs.Id, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cv_section_is_not_readable_for_another_users_application()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var other = await SeedJobAsync(db, "user-2");
|
||||
|
||||
Assert.Null(await assets.GetCvAsync("user-1", other.Id, default));
|
||||
}
|
||||
|
||||
// ---------- Part 2: tailoring ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Tailoring_suggests_keywords_even_without_a_career_profile()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var plan = await assets.GetTailoringPlanAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.NotNull(plan);
|
||||
Assert.False(plan!.HasCareerProfile);
|
||||
Assert.Contains(plan.Suggestions, s => s.Kind == "include-keywords");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tailoring_never_writes_to_the_career_profile_or_the_variant()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
var variant = await SeedVariantAsync(db, "user-1", "Backend CV", job.Id);
|
||||
db.CareerProfiles.Add(new CareerProfile
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
Experiences = { new CareerExperience { OwnerUserId = "user-1", Title = "Dev", Company = "Initech", BulletsJson = """["C# and .NET"]""" } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var settingsBefore = variant.SettingsJson;
|
||||
var versionBefore = variant.Version;
|
||||
var profileVersionBefore = (await db.CareerProfiles.FirstAsync()).Version;
|
||||
|
||||
var plan = await assets.GetTailoringPlanAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.NotEmpty(plan!.Suggestions);
|
||||
var variantAfter = await db.CvVariants.AsNoTracking().FirstAsync(v => v.Id == variant.Id);
|
||||
Assert.Equal(settingsBefore, variantAfter.SettingsJson);
|
||||
Assert.Equal(versionBefore, variantAfter.Version);
|
||||
Assert.Equal(profileVersionBefore, (await db.CareerProfiles.AsNoTracking().FirstAsync()).Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tailoring_is_not_readable_for_another_users_application()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var other = await SeedJobAsync(db, "user-2");
|
||||
|
||||
Assert.Null(await assets.GetTailoringPlanAsync("user-1", other.Id, default));
|
||||
}
|
||||
|
||||
// ---------- Part 3: cover letter ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Saving_a_cover_letter_stores_a_version_and_sets_the_current_text()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Dear team", CoverLetterSources.Manual, null, default);
|
||||
|
||||
Assert.Equal("Dear team", result!.Text);
|
||||
Assert.Equal(1, result.CurrentVersion);
|
||||
Assert.Single(result.Versions);
|
||||
Assert.True(result.Versions[0].IsCurrent);
|
||||
Assert.Equal("Dear team", (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).CoverLetterText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_edit_adds_a_version_and_none_are_lost()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "First draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Second draft", CoverLetterSources.Ai, "improve", default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Third draft", CoverLetterSources.Manual, null, default);
|
||||
|
||||
Assert.Equal(3, result!.CurrentVersion);
|
||||
Assert.Equal(3, result.Versions.Count);
|
||||
Assert.Equal("improve", result.Versions.Single(v => v.Version == 2).AiAction);
|
||||
Assert.Equal(CoverLetterSources.Ai, result.Versions.Single(v => v.Version == 2).Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unchanged_save_does_not_burn_a_version()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default);
|
||||
var result = await assets.SaveCoverLetterAsync("user-1", job.Id, "Same text", CoverLetterSources.Manual, null, default);
|
||||
|
||||
Assert.Single(result!.Versions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restoring_brings_back_old_text_without_destroying_the_newer_version()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The good draft", CoverLetterSources.Manual, null, default);
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "The AI ruined it", CoverLetterSources.Ai, "shorten", default);
|
||||
|
||||
var result = await assets.RestoreCoverLetterAsync("user-1", job.Id, 1, default);
|
||||
|
||||
Assert.Equal("The good draft", result!.Text);
|
||||
// Restore is additive: v1 and v2 both survive, and the restore is v3.
|
||||
Assert.Equal(3, result.CurrentVersion);
|
||||
Assert.Equal(3, result.Versions.Count);
|
||||
Assert.Equal(CoverLetterSources.Restore, result.Versions.Single(v => v.Version == 3).Source);
|
||||
Assert.Equal("The AI ruined it", (await db.CoverLetterVersions.AsNoTracking().FirstAsync(v => v.Version == 2)).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restoring_a_version_that_does_not_exist_is_a_miss()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await assets.SaveCoverLetterAsync("user-1", job.Id, "Only draft", CoverLetterSources.Manual, null, default);
|
||||
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", job.Id, 99, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cover_letter_history_is_scoped_to_its_owner()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var mine = await SeedJobAsync(db, "user-1");
|
||||
var theirs = await SeedJobAsync(db, "user-2");
|
||||
await assets.SaveCoverLetterAsync("user-1", mine.Id, "Mine", CoverLetterSources.Manual, null, default);
|
||||
|
||||
Assert.Null(await assets.GetCoverLetterAsync("user-1", theirs.Id, default));
|
||||
Assert.Null(await assets.SaveCoverLetterAsync("user-1", theirs.Id, "Sneak", CoverLetterSources.Manual, null, default));
|
||||
Assert.Null(await assets.RestoreCoverLetterAsync("user-1", theirs.Id, 1, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_ai_draft_only_becomes_a_version_when_the_user_saves_it()
|
||||
{
|
||||
var (db, assets) = New("user-1");
|
||||
await using var _ = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
// An AI generation on its own is an AiInteraction — history, not the user's cover letter.
|
||||
db.AiInteractions.Add(new AiInteraction
|
||||
{
|
||||
OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "cover-letter",
|
||||
Title = "Cover letter", Provider = "p", ResultJson = """{"text":"AI suggestion"}""",
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var before = await assets.GetCoverLetterAsync("user-1", job.Id, default);
|
||||
|
||||
Assert.Null(before!.Text);
|
||||
Assert.Empty(before.Versions);
|
||||
Assert.Equal(1, before.AiSuggestionCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
// Phase 5.4 — Application Assets. Connects existing career outputs to one application.
|
||||
//
|
||||
// Deliberately thin: CV variant CRUD, preview, PDF export, themes and version history all stay on
|
||||
// /api/cv (CvVariantController), and documents stay on /api/attachments. These routes only cover what
|
||||
// is genuinely application-scoped — which variant this application uses, what to tailor, and the
|
||||
// cover letter with its history. Every route is tenant-scoped and returns 404 for another user's
|
||||
// application. docs/architecture/application-workspace.md.
|
||||
[ApiController]
|
||||
[Route("api/jobapplications/{jobId:int}")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class ApplicationAssetsController : ControllerBase
|
||||
{
|
||||
public sealed record AttachVariantRequest(int? VariantId);
|
||||
public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction);
|
||||
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly IApplicationAssetsService _assets;
|
||||
|
||||
public ApplicationAssetsController(UserManager<ApplicationUser> users, IApplicationAssetsService assets)
|
||||
{
|
||||
_users = users;
|
||||
_assets = assets;
|
||||
}
|
||||
|
||||
[HttpGet("cv")]
|
||||
public async Task<ActionResult<ApplicationCvDto>> GetCv(int jobId, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.GetCvAsync(userId, jobId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("cv")]
|
||||
public async Task<ActionResult<ApplicationCvDto>> AttachVariant(int jobId, [FromBody] AttachVariantRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.AttachVariantAsync(userId, jobId, request?.VariantId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("tailoring")]
|
||||
public async Task<ActionResult<TailoringPlanDto>> GetTailoring(int jobId, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.GetTailoringPlanAsync(userId, jobId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("cover-letter")]
|
||||
public async Task<ActionResult<CoverLetterDto>> GetCoverLetter(int jobId, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.GetCoverLetterAsync(userId, jobId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("cover-letter")]
|
||||
public async Task<ActionResult<CoverLetterDto>> SaveCoverLetter(int jobId, [FromBody] SaveCoverLetterRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.SaveCoverLetterAsync(
|
||||
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("cover-letter/versions/{version:int}/restore")]
|
||||
public async Task<ActionResult<CoverLetterDto>> RestoreCoverLetter(int jobId, int version, CancellationToken ct)
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
private async Task<string?> CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id;
|
||||
}
|
||||
+2272
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCoverLetterVersions : Migration
|
||||
{
|
||||
// Deliberately a no-op. Scaffolded against SQLite, so on MariaDB it would emit a TEXT
|
||||
// CreatedAtUtc and a PRIMARY KEY without AUTO_INCREMENT, and the composite index over those
|
||||
// columns would exceed MySQL's 3072-byte key limit.
|
||||
//
|
||||
// CoverLetterVersions is reconciler-owned and provisioned by StartupInitializationExtensions,
|
||||
// which carries correct DDL per provider and guards the create on JobApplications existing.
|
||||
// This migration exists only to keep the model snapshot in sync.
|
||||
// docs/infrastructure/database-ownership.md.
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,6 +819,48 @@ namespace JobTrackerApi.Migrations
|
||||
b.ToTable("Correspondences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.CoverLetterVersion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AiAction")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("JobApplicationId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OwnerUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JobApplicationId");
|
||||
|
||||
b.HasIndex("OwnerUserId", "JobApplicationId", "Version");
|
||||
|
||||
b.ToTable("CoverLetterVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2031,6 +2073,17 @@ namespace JobTrackerApi.Migrations
|
||||
b.Navigation("JobApplication");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.CoverLetterVersion", b =>
|
||||
{
|
||||
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
|
||||
.WithMany()
|
||||
.HasForeignKey("JobApplicationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("JobApplication");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b =>
|
||||
{
|
||||
b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact")
|
||||
|
||||
@@ -45,6 +45,7 @@ builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceSer
|
||||
builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistService>();
|
||||
builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineService>();
|
||||
builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>();
|
||||
builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>();
|
||||
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
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);
|
||||
}
|
||||
@@ -1044,6 +1044,25 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_ApplicationChecklistItems_Owner_Job_Sort" ON "ApplicationChecklistItems" ("OwnerUserId", "JobApplicationId", "SortOrder");""");
|
||||
}
|
||||
|
||||
// Phase 5.4: append-only cover letter history.
|
||||
static void EnsureCoverLetterVersionsTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "CoverLetterVersions" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CoverLetterVersions" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"Version" INTEGER NOT NULL,
|
||||
"Text" TEXT NOT NULL,
|
||||
"Source" TEXT NOT NULL,
|
||||
"AiAction" TEXT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_CoverLetterVersions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CoverLetterVersions_Owner_Job_Version" ON "CoverLetterVersions" ("OwnerUserId", "JobApplicationId", "Version");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
@@ -1057,6 +1076,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureCvBuilderTables(conn);
|
||||
EnsureAiInteractionsTable(conn);
|
||||
EnsureApplicationChecklistTable(conn);
|
||||
EnsureCoverLetterVersionsTable(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
// and at least one of the new columns already exists.
|
||||
@@ -1651,6 +1671,7 @@ public static class StartupInitializationExtensions
|
||||
DropMalformedMySqlTable(conn, "CvVariants", "UpdatedAtUtc", "datetime");
|
||||
DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime");
|
||||
DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime");
|
||||
DropMalformedMySqlTable(conn, "CoverLetterVersions", "CreatedAtUtc", "datetime");
|
||||
|
||||
if (!HasMySqlTable(conn, "CvVariants") && HasMySqlTable(conn, "JobApplications"))
|
||||
{
|
||||
@@ -1734,6 +1755,27 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "CoverLetterVersions") && HasMySqlTable(conn, "JobApplications"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CoverLetterVersions` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`Version` int NOT NULL,
|
||||
`Text` longtext NOT NULL,
|
||||
`Source` varchar(32) NOT NULL,
|
||||
`AiAction` varchar(32) NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_CoverLetterVersions_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CoverLetterVersions", "Id");
|
||||
EnsureMySqlIndex(conn, "CoverLetterVersions", "IX_CoverLetterVersions_Owner_Job_Version", "`OwnerUserId`, `JobApplicationId`, `Version`");
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "ApplicationChecklistItems", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id");
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
// Phase 5.4 — Application Assets.
|
||||
//
|
||||
// Append-only history for a job application's cover letter. JobApplication.CoverLetterText stays the
|
||||
// CURRENT text and the existing API contract around it is unchanged; this only records what it used
|
||||
// to be, so an AI rewrite or a bad edit is never destructive. Mirrors CvVariantVersion: restore
|
||||
// re-saves an old snapshot as a new version rather than rewinding.
|
||||
//
|
||||
// Reconciler-owned (docs/infrastructure/database-ownership.md) — its migration is a no-op.
|
||||
public sealed class CoverLetterVersion
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int JobApplicationId { get; set; }
|
||||
public JobApplication? JobApplication { get; set; }
|
||||
|
||||
public int Version { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
// manual | ai | template | restore — how this text came to exist, so the history list can show
|
||||
// whether the user wrote it or approved it from a suggestion.
|
||||
public string Source { get; set; } = "manual";
|
||||
|
||||
// For an AI-sourced version: which action produced it (generate | improve | shorten | expand |
|
||||
// tone | tailor). Null for anything the user typed.
|
||||
public string? AiAction { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public static class CoverLetterSources
|
||||
{
|
||||
public const string Manual = "manual";
|
||||
public const string Ai = "ai";
|
||||
public const string Template = "template";
|
||||
public const string Restore = "restore";
|
||||
|
||||
public static bool IsValid(string? value) =>
|
||||
value is Manual or Ai or Template or Restore;
|
||||
}
|
||||
@@ -21,7 +21,8 @@ The workspace **owns no data and duplicates none**. It is an aggregate read plus
|
||||
| Timeline | `JobEvent` — interpreted, never replaced |
|
||||
| Analysis / Match | the advert and the master `CareerProfile`, read deterministically |
|
||||
| Checklist | `ApplicationChecklistItem` — completion state only, seeded from the readiness signals |
|
||||
| CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile` |
|
||||
| CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile`; the application only points at one |
|
||||
| Cover Letter | `JobApplication.CoverLetterText` + `CoverLetterVersions` history |
|
||||
| Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history |
|
||||
| Documents | `Attachment` |
|
||||
| Communication | `Correspondence` |
|
||||
@@ -222,6 +223,80 @@ Suggestions describe what the user could change. They never change it.
|
||||
|
||||
With no profile it returns score 0 and asks the user to build one, rather than implying a bad match.
|
||||
|
||||
## Application assets (Phase 5.4)
|
||||
|
||||
The workspace becomes the place an application is prepared. The rule is one-directional:
|
||||
|
||||
```
|
||||
CareerProfile → CvVariant → application-specific output
|
||||
```
|
||||
|
||||
Nothing flows back up. No code path in this phase writes to `CareerProfile` or its children —
|
||||
`Tailoring_never_writes_to_the_career_profile_or_the_variant` pins it.
|
||||
|
||||
### What is owned where
|
||||
|
||||
| Asset | Owned by | This phase adds |
|
||||
|---|---|---|
|
||||
| CV content | `CareerProfile` (master) | nothing |
|
||||
| CV variant, preview, PDF, themes, version history | `CvVariantService` / `/api/cv` | nothing |
|
||||
| Which variant an application uses | `CvVariant.JobApplicationId` | the attach/detach route |
|
||||
| Documents | `Attachment` / `/api/attachments` | nothing |
|
||||
| Cover letter text | `JobApplication.CoverLetterText` | version history |
|
||||
| AI narrative | `AiInteraction` | nothing |
|
||||
|
||||
The only new table is `CoverLetterVersions` — reconciler-owned, no-op migration, guarded on
|
||||
`JobApplications` (`docs/infrastructure/database-ownership.md`). Verified on MariaDB 11:
|
||||
`int AUTO_INCREMENT` PK, `varchar(255)` owner, `datetime(6)`, composite index inside the key limit.
|
||||
|
||||
### CV integration
|
||||
|
||||
`GET/PUT /{id}/cv` reads and re-points. Attaching sets `CvVariant.JobApplicationId`; **one variant per
|
||||
application**, so the workspace can answer "which CV am I sending". Replacing detaches the previous
|
||||
variant rather than deleting it — it is still the user's to reuse. Everything else (create, duplicate,
|
||||
edit, theme, preview, export PDF, versions, restore) is a link into the existing CV builder. There is
|
||||
deliberately no second CV system.
|
||||
|
||||
### Tailoring
|
||||
|
||||
`GET /{id}/tailoring` composes the Phase 5.3 analysis and match into five suggestion kinds: skills to
|
||||
highlight, experience to prioritise, projects to emphasise, keywords to include, gaps to address.
|
||||
|
||||
Deterministic and advisory. It returns what the user *could* emphasise; the user edits the variant in
|
||||
the builder. Nothing auto-applies, and no suggestion mutates a variant or the profile.
|
||||
|
||||
### Cover letter
|
||||
|
||||
`JobApplication.CoverLetterText` stays the current text and its existing API contract is unchanged.
|
||||
`CoverLetterVersions` records what it used to be, so an AI rewrite is never destructive.
|
||||
|
||||
- Every save that changes the text snapshots a new version; an unchanged save is a no-op, so autosave
|
||||
never burns history.
|
||||
- **Restore is additive** — the old text returns as a *new* version, so what you restored from is
|
||||
still there.
|
||||
- `Source` (`manual | ai | template | restore`) and `AiAction` record how each version came about, so
|
||||
the history shows what the user wrote versus what they approved from a suggestion.
|
||||
- An AI generation on its own is only an `AiInteraction`. It becomes a version when the user saves it
|
||||
— that is what "requires approval" means here.
|
||||
|
||||
Creation methods: write it, start from the built-in template, or generate from the AI panel below the
|
||||
editor. The editor is always the user's; generation is never triggered by opening the page.
|
||||
|
||||
### Documents
|
||||
|
||||
Unchanged. The existing `Attachments` component and `/api/attachments` already handle CV, cover
|
||||
letter, certificates, portfolio and other files with a `Purpose` field, and `JobApplication.HasResume`
|
||||
/ `HasCoverLetter` / `HasPortfolio` are derived from it. The workspace mounts that component; no new
|
||||
storage, no duplicate upload path. Files stay private to the owning user.
|
||||
|
||||
### Future extension points
|
||||
|
||||
- **Another asset type**: add a section, compose the service that already owns it — do not add storage.
|
||||
- **AI cover-letter actions** (improve, shorten, expand, tone, tailor): already modelled by
|
||||
`CoverLetterVersion.AiAction`; wire a new mode in `AiWorkspaceService` and save the approved result.
|
||||
- **Multiple attached variants**: relax the one-per-application rule in `AttachVariantAsync`; the DTO
|
||||
already carries the full variant list.
|
||||
|
||||
## Extension points
|
||||
|
||||
- **New section**: add to `WORKSPACE_SECTIONS` and render it; nav is data-driven.
|
||||
@@ -236,5 +311,6 @@ With no profile it returns score 0 and asks the user to build one, rather than i
|
||||
custom items, reordering, dismissal; readiness refactored into a projection of it.
|
||||
3. ✅ Application intelligence — timeline interpretation, structured job analysis, career matching
|
||||
(Phase 5.3, all three deterministic and read-only).
|
||||
4. CV integration.
|
||||
4. ✅ Application assets — CV variant association, tailoring suggestions, cover letter workflow with
|
||||
version history, documents (Phase 5.4).
|
||||
7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements.
|
||||
|
||||
@@ -75,7 +75,8 @@ Created by `StartupInitializationExtensions`, with a **no-op migration** holding
|
||||
`CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`,
|
||||
`CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`),
|
||||
`InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`,
|
||||
`ApplicationChecklistItems`, `TwoFactorRecoveryCodes`, `TrustedDevices`, `UserSessions`.
|
||||
`ApplicationChecklistItems`, `CoverLetterVersions`, `TwoFactorRecoveryCodes`, `TrustedDevices`,
|
||||
`UserSessions`.
|
||||
|
||||
No-op migrations, each with a comment explaining why:
|
||||
|
||||
@@ -86,6 +87,7 @@ No-op migrations, each with a comment explaining why:
|
||||
| `20260718131138_AddAiInteractions` | `AiInteractions` |
|
||||
| `20260719085904_AddApplicationChecklistItems` | `ApplicationChecklistItems` |
|
||||
| `20260719094728_SyncCareerChildKeyLengths` | snapshot sync only |
|
||||
| `20260719120954_AddCoverLetterVersions` | `CoverLetterVersions` |
|
||||
|
||||
### Dependency guards
|
||||
|
||||
@@ -94,7 +96,7 @@ skips it on a fresh database and pass 2 creates it:
|
||||
|
||||
| Table | Waits for |
|
||||
|---|---|
|
||||
| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems` | `JobApplications` (migration-owned) |
|
||||
| `TailoredCvDrafts`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `AiInteractions`, `ApplicationChecklistItems`, `CoverLetterVersions` | `JobApplications` (migration-owned) |
|
||||
| `CvVariantVersions` | `CvVariants` |
|
||||
| `CareerProfileVersions`, the six CareerProfile children | `CareerProfiles` |
|
||||
| `CvExtractionRuns` | `CvUploadArtifacts` |
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
ApplicationCoverLetterSection, ApplicationCvSection,
|
||||
} from "./components/ApplicationAssets";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
put: jest.fn(),
|
||||
post: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const cv = {
|
||||
attachedVariantId: 3,
|
||||
attachedVariantName: "Backend CV",
|
||||
attachedThemeId: "nordic",
|
||||
attachedVersion: 4,
|
||||
attachedUpdatedAtUtc: "2026-07-19T10:00:00Z",
|
||||
attachedIsPublic: false,
|
||||
hasTailoredCvText: false,
|
||||
availableVariants: [
|
||||
{ id: 3, name: "Backend CV", themeId: "nordic", publicSlug: "abc", isPublic: false, version: 4, jobApplicationId: 7, updatedAtUtc: "2026-07-19T10:00:00Z" },
|
||||
{ id: 5, name: "Generalist CV", themeId: "modern", publicSlug: "def", isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: "2026-07-18T10:00:00Z" },
|
||||
],
|
||||
};
|
||||
|
||||
const tailoring = {
|
||||
hasJobDescription: true,
|
||||
hasCareerProfile: true,
|
||||
hasAttachedVariant: true,
|
||||
matchScore: 72,
|
||||
suggestions: [
|
||||
{ kind: "highlight-skills", title: "Skills to highlight", detail: "The advert asks for these.", items: ["C#", "SQL"] },
|
||||
{ kind: "gaps", title: "Gaps to address", detail: null, items: ["Kubernetes"] },
|
||||
],
|
||||
aiSuggestionCount: 0,
|
||||
};
|
||||
|
||||
const coverLetter = {
|
||||
text: "Dear team",
|
||||
currentVersion: 2,
|
||||
versions: [
|
||||
{ version: 2, source: "ai", aiAction: "improve", length: 9, createdAtUtc: "2026-07-19T10:00:00Z", isCurrent: true },
|
||||
{ version: 1, source: "manual", aiAction: null, length: 40, createdAtUtc: "2026-07-19T09:00:00Z", isCurrent: false },
|
||||
],
|
||||
aiSuggestionCount: 1,
|
||||
};
|
||||
|
||||
function routeGet(overrides: Record<string, any> = {}) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any);
|
||||
if (url.endsWith("/cover-letter")) return Promise.resolve({ data: overrides.coverLetter ?? coverLetter } as any);
|
||||
return Promise.resolve({ data: overrides.cv ?? cv } as any);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ---------- CV ----------
|
||||
|
||||
test("cv section shows the attached variant and the ones available to attach", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Theme nordic · version 4/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute(
|
||||
"href", "/cv-builder?variant=3");
|
||||
});
|
||||
|
||||
test("attaching a different variant only re-points the application", async () => {
|
||||
routeGet();
|
||||
mockedApi.put.mockResolvedValue({ data: { ...cv, attachedVariantId: 5, attachedVariantName: "Generalist CV" } } as any);
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /Attached CV variant/i }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cv", { variantId: 5 }));
|
||||
});
|
||||
|
||||
test("cv section points at the builder when there are no variants", async () => {
|
||||
routeGet({ cv: { ...cv, attachedVariantId: null, attachedVariantName: null, availableVariants: [] } });
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/No CV variants yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("tailoring renders suggestions grouped by kind", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText("Skills to highlight")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gaps to address")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kubernetes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("tailoring asks for a career profile when there is none", async () => {
|
||||
routeGet({ tailoring: { ...tailoring, hasCareerProfile: false, suggestions: [] } });
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Build your career profile/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------- Cover letter ----------
|
||||
|
||||
test("cover letter loads the current text and its history", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument();
|
||||
expect(screen.getByText("v2")).toBeInTheDocument();
|
||||
expect(screen.getByText("ai · improve")).toBeInTheDocument();
|
||||
expect(screen.getByText("Current")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("editing marks the draft dirty and saving sends the new text", async () => {
|
||||
routeGet();
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Dear hiring team" } } as any);
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "Dear hiring team" } });
|
||||
|
||||
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/cover-letter",
|
||||
{ text: "Dear hiring team", source: "manual", aiAction: undefined },
|
||||
));
|
||||
});
|
||||
|
||||
test("discarding returns to the saved text", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "scratch" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Discard changes/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("restoring an old version calls the restore endpoint", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockResolvedValue({ data: coverLetter } as any);
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Restore version 1/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/cover-letter/versions/1/restore"));
|
||||
});
|
||||
|
||||
test("an empty cover letter offers the template and an empty history", async () => {
|
||||
routeGet({ coverLetter: { text: null, currentVersion: 0, versions: [], aiSuggestionCount: 0 } });
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/No versions yet/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Start from template/i }));
|
||||
expect((screen.getByLabelText("Cover letter") as HTMLTextAreaElement).value)
|
||||
.toContain("Dear Hiring Manager");
|
||||
});
|
||||
|
||||
test("a failed load surfaces an error", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("boom"));
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -79,8 +79,8 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; mile
|
||||
{ key: "analysis", label: "Analysis" },
|
||||
{ key: "match", label: "Match" },
|
||||
{ key: "checklist", label: "Checklist" },
|
||||
{ key: "cv", label: "CV", milestone: 6 },
|
||||
{ key: "cover-letter", label: "Cover Letter", milestone: 7 },
|
||||
{ key: "cv", label: "CV" },
|
||||
{ key: "cover-letter", label: "Cover Letter" },
|
||||
{ key: "portfolio", label: "Portfolio", milestone: 8 },
|
||||
{ key: "documents", label: "Documents" },
|
||||
{ key: "interview", label: "Interview Prep" },
|
||||
@@ -169,6 +169,71 @@ export const applicationIntelligenceApi = {
|
||||
match: (jobId: number) => api.get<CareerMatch>(`/jobapplications/${jobId}/match`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Phase 5.4 — Application Assets. CV variant CRUD, preview, PDF export and version history stay on
|
||||
// /api/cv (the existing CV builder). These types cover only what is application-scoped.
|
||||
export type CvVariantSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
themeId: string;
|
||||
publicSlug: string;
|
||||
isPublic: boolean;
|
||||
version: number;
|
||||
jobApplicationId: number | null;
|
||||
updatedAtUtc: string;
|
||||
};
|
||||
|
||||
export type ApplicationCv = {
|
||||
attachedVariantId: number | null;
|
||||
attachedVariantName: string | null;
|
||||
attachedThemeId: string | null;
|
||||
attachedVersion: number | null;
|
||||
attachedUpdatedAtUtc: string | null;
|
||||
attachedIsPublic: boolean;
|
||||
hasTailoredCvText: boolean;
|
||||
availableVariants: CvVariantSummary[];
|
||||
};
|
||||
|
||||
export type TailoringSuggestion = { kind: string; title: string; detail: string | null; items: string[] };
|
||||
|
||||
export type TailoringPlan = {
|
||||
hasJobDescription: boolean;
|
||||
hasCareerProfile: boolean;
|
||||
hasAttachedVariant: boolean;
|
||||
matchScore: number;
|
||||
suggestions: TailoringSuggestion[];
|
||||
aiSuggestionCount: number;
|
||||
};
|
||||
|
||||
export type CoverLetterVersion = {
|
||||
version: number;
|
||||
source: string;
|
||||
aiAction: string | null;
|
||||
length: number;
|
||||
createdAtUtc: string;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export type CoverLetter = {
|
||||
text: string | null;
|
||||
currentVersion: number;
|
||||
versions: CoverLetterVersion[];
|
||||
aiSuggestionCount: number;
|
||||
};
|
||||
|
||||
export const applicationAssetsApi = {
|
||||
cv: (jobId: number) => api.get<ApplicationCv>(`/jobapplications/${jobId}/cv`).then((r) => r.data),
|
||||
attachVariant: (jobId: number, variantId: number | null) =>
|
||||
api.put<ApplicationCv>(`/jobapplications/${jobId}/cv`, { variantId }).then((r) => r.data),
|
||||
tailoring: (jobId: number) =>
|
||||
api.get<TailoringPlan>(`/jobapplications/${jobId}/tailoring`).then((r) => r.data),
|
||||
coverLetter: (jobId: number) =>
|
||||
api.get<CoverLetter>(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data),
|
||||
saveCoverLetter: (jobId: number, text: string, source = "manual", aiAction?: string) =>
|
||||
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
|
||||
restoreCoverLetter: (jobId: number, version: number) =>
|
||||
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const applicationChecklistApi = {
|
||||
get: (jobId: number) =>
|
||||
api.get<Checklist>(`/jobapplications/${jobId}/checklist`).then((r) => r.data),
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
|
||||
Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import RestoreIcon from "@mui/icons-material/Restore";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import {
|
||||
ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi,
|
||||
} from "../applicationWorkspace";
|
||||
|
||||
// Phase 5.4 — Application Assets sections for the workspace.
|
||||
//
|
||||
// These compose systems that already exist. CV editing, preview, PDF export, themes and version
|
||||
// history all live in the CV builder at /cv-builder — this section only chooses WHICH variant the
|
||||
// application uses and links out. The cover letter is the one thing genuinely owned here, because it
|
||||
// is application-specific by nature. docs/architecture/application-workspace.md.
|
||||
|
||||
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const run = useCallback(load, deps);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
run()
|
||||
.then((r) => {
|
||||
if (!cancelled) {
|
||||
setData(r);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section."));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [run]);
|
||||
|
||||
useEffect(() => reload(), [reload]);
|
||||
|
||||
return { data, error, loading, setData, setError, reload };
|
||||
}
|
||||
|
||||
function Shell({ title, subtitle, loading, error, children }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
|
||||
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
{loading ? (
|
||||
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
|
||||
) : error ? (
|
||||
<Alert severity="error">{error}</Alert>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- CV ----------
|
||||
|
||||
export function ApplicationCvSection({ jobId }: { jobId: number }) {
|
||||
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
|
||||
() => applicationAssetsApi.cv(jobId),
|
||||
[jobId],
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const attach = async (variantId: number | null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await applicationAssetsApi.attachVariant(jobId, variantId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not change the attached CV."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const attached = data?.attachedVariantId ?? "";
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Shell
|
||||
title="CV"
|
||||
subtitle="Which CV variant this application uses. Variants are lenses over your master career profile."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{(data?.availableVariants.length ?? 0) === 0 ? (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
No CV variants yet. Build one in the CV builder — it starts from your master career
|
||||
profile, so you never retype your history.
|
||||
</Alert>
|
||||
) : (
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
label="Attached CV variant"
|
||||
value={attached}
|
||||
disabled={busy}
|
||||
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
|
||||
helperText="Changing this only re-points the application. The variant itself is untouched."
|
||||
>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{(data?.availableVariants ?? []).map((v) => (
|
||||
<MenuItem key={v.id} value={v.id}>
|
||||
{v.name} · {v.themeId} · v{v.version}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
{data?.attachedVariantId ? (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{data.attachedVariantName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Theme {data.attachedThemeId} · version {data.attachedVersion}
|
||||
{data.attachedIsPublic ? " · public" : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
endIcon={<OpenInNewIcon fontSize="small" />}
|
||||
href={`/cv-builder?variant=${data.attachedVariantId}`}
|
||||
>
|
||||
Edit, preview and export
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No CV attached to this application yet.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{data?.hasTailoredCvText && (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
This application also has legacy tailored CV text saved on it. A CV variant supersedes it.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Shell>
|
||||
|
||||
<ApplicationTailoringSection jobId={jobId} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Tailoring ----------
|
||||
|
||||
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
|
||||
const { data, error, loading } = useAsset<TailoringPlan>(
|
||||
() => applicationAssetsApi.tailoring(jobId),
|
||||
[jobId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Tailoring"
|
||||
subtitle="What to emphasise for this advert. Suggestions only — nothing here edits your profile or your CV."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{data && !data.hasCareerProfile && (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
Build your career profile to get experience and project suggestions.
|
||||
</Alert>
|
||||
)}
|
||||
{data && !data.hasJobDescription && (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
Paste the advert text to get keyword and requirement suggestions.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(data?.suggestions.length ?? 0) === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nothing to suggest yet.
|
||||
</Typography>
|
||||
) : (
|
||||
(data?.suggestions ?? []).map((s) => (
|
||||
<Box key={s.kind}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{s.title}</Typography>
|
||||
{s.detail && (
|
||||
<Typography variant="caption" color="text.secondary">{s.detail}</Typography>
|
||||
)}
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.75 }}>
|
||||
{s.items.map((item) => (
|
||||
<Chip key={item} size="small" label={item} variant="outlined" />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Cover letter ----------
|
||||
|
||||
const TEMPLATE = `Dear Hiring Manager,
|
||||
|
||||
I am writing to apply for the [role] position at [company]. [One sentence on why this company, specifically.]
|
||||
|
||||
In my current role I [the most relevant thing you have done, with a concrete outcome]. [A second example that matches what the advert asks for.]
|
||||
|
||||
[Why you want this job, in your own words.]
|
||||
|
||||
I would welcome the chance to talk it through.
|
||||
|
||||
Kind regards,
|
||||
[Your name]`;
|
||||
|
||||
export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
|
||||
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
|
||||
() => applicationAssetsApi.coverLetter(jobId),
|
||||
[jobId],
|
||||
);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// The textarea is only seeded from the server until the user starts typing, so a reload never
|
||||
// clobbers unsaved edits.
|
||||
const text = draft ?? data?.text ?? "";
|
||||
const dirty = draft !== null && draft !== (data?.text ?? "");
|
||||
|
||||
const save = async (value: string, source = "manual") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source));
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not save the cover letter."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (version: number) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not restore that version."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Shell
|
||||
title="Cover letter"
|
||||
subtitle="Every save keeps the previous text, so nothing you write is ever lost."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={12}
|
||||
fullWidth
|
||||
label="Cover letter"
|
||||
value={text}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
|
||||
Discard changes
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || text.trim().length > 0}
|
||||
onClick={() => setDraft(TEMPLATE)}
|
||||
>
|
||||
Start from template
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Chip size="small" color="warning" variant="outlined" label="Unsaved changes" />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Shell>
|
||||
|
||||
<Shell title="Version history" loading={loading} error={null}>
|
||||
{(data?.versions.length ?? 0) === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No versions yet. The first save starts the history.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={0.5}>
|
||||
{(data?.versions ?? []).map((v) => (
|
||||
<Stack
|
||||
key={v.version}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ py: 0.5 }}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography>
|
||||
<Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} />
|
||||
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label="Current" />}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(v.createdAtUtc).toLocaleString()} · {v.length} characters
|
||||
</Typography>
|
||||
</Box>
|
||||
{!v.isCurrent && (
|
||||
<Tooltip title="Restore this version">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy}
|
||||
aria-label={`Restore version ${v.version}`}
|
||||
onClick={() => restore(v.version)}
|
||||
>
|
||||
<RestoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Shell>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||
import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
} from "../components/ApplicationIntelligence";
|
||||
import {
|
||||
ApplicationCoverLetterSection, ApplicationCvSection,
|
||||
} from "../components/ApplicationAssets";
|
||||
import {
|
||||
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
|
||||
} from "../applicationWorkspace";
|
||||
@@ -113,7 +116,15 @@ export default function ApplicationWorkspacePage() {
|
||||
{section === "checklist" && jobId > 0 && (
|
||||
<ApplicationChecklist jobId={jobId} onChanged={load} />
|
||||
)}
|
||||
{["cv", "cover-letter", "portfolio", "notes"].includes(section) && (
|
||||
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
|
||||
{section === "cover-letter" && jobId > 0 && (
|
||||
<>
|
||||
<ApplicationCoverLetterSection jobId={jobId} />
|
||||
{/* Generation stays an explicit user action, below the editor the user owns. */}
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}><AiWorkspacePanel jobId={jobId} /></Paper>
|
||||
</>
|
||||
)}
|
||||
{["portfolio", "notes"].includes(section) && (
|
||||
<ComingInMilestone section={section} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user