feat(workspace): application assets workflow
CI and Deploy / test (push) Failing after 1m11s
CI and Deploy / deploy (push) Has been skipped

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:
cesnimda
2026-07-19 16:36:27 +02:00
parent a7cecce13d
commit 02b38f7acb
16 changed files with 3867 additions and 7 deletions
@@ -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;
}
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")
+1
View File
@@ -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");