feat: integrate Career Workspace foundation from feature/career-workspace

Recover the F1 Career Profile foundation + AI-workspace persistence from the
unmerged feature/career-workspace branch, so Phase 2 builds on the documented,
tested target state instead of re-deriving it. Foundation only — CV Builder
commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV
Builder yet". See docs/career-workspace-branch-assessment.md.

Squashed from 3 branch commits (235e291, 5916f09, 00a035e), resolved against
main + Phase 0:

- CareerProfile + CareerProfileVersion (append-only history), dual-written from
  every profile save path via CareerProfileService. ApplicationUser.
  ProfileCvStructureJson stays authoritative; the tables mirror it. Stable item
  IDs assigned to jobs/education/certifications/projects (the prerequisite for
  future variant lineage). CvDateNormalizer for free-text -> YYYY-MM.
- InterviewPrepNote + AiWorkspaceNote: cache AI interview prep / candidate fit /
  focus plan keyed by an attachment-context signature, so they stop regenerating
  (and re-spending the provider) on every open.

Conflict resolutions (union, favouring current code + Phase 0):
- JobTrackerContext / StartupInitializationExtensions: kept Phase 0's tables and
  reconciler blocks, added the career/interview/ai-note tables (both SQLite and
  MySQL dialects).
- ProfileCvController: dropped the branch's in-file DTO records (main defines them
  in ProfileCvDtos.cs) and the LayoutFamily/AtsRating template fields (deferred
  ATS-badge work), keeping main's 7-arg CvTemplateDescriptor.
- JobApplicationsController: kept the branch's cache-check, restored main's
  AsNoTracking on the read-only user load.

Tables ship empty (verified dev); nothing to migrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-17 19:09:42 +02:00
parent aedd6e32ad
commit 992f89e619
15 changed files with 1063 additions and 9 deletions
@@ -1428,7 +1428,7 @@ Canonical profile:
}
[HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.AsNoTracking()
@@ -1439,6 +1439,13 @@ Canonical profile:
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
if (!refresh)
{
var cached = await TryGetCachedAiNoteAsync<CandidateFitDto>(userId, id, "candidate-fit", attachmentSignature, cancellationToken);
if (cached is not null) return Ok(cached);
}
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
@@ -1535,7 +1542,7 @@ Candidate CV/profile:
"Close with a clear expression of interest and availability."
});
return Ok(new CandidateFitDto(
var dto = new CandidateFitDto(
MatchSummary: matchSummary,
FitLevel: fitLevel,
MatchScore: matchScore,
@@ -1549,11 +1556,14 @@ Candidate CV/profile:
TailoredPitch: tailoredPitch,
Guidance: guidance,
CoverLetterDraft: coverLetterDraft,
RecruiterMessageDraft: recruiterMessageDraft));
RecruiterMessageDraft: recruiterMessageDraft);
await SaveAiNoteAsync(userId, id, "candidate-fit", attachmentSignature, dto, cancellationToken);
return Ok(dto);
}
[HttpGet("{id:int}/focus-plan")]
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.AsNoTracking()
@@ -1564,6 +1574,13 @@ Candidate CV/profile:
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
if (!refresh)
{
var cached = await TryGetCachedAiNoteAsync<FocusPlanDto>(userId, id, "focus-plan", attachmentSignature, cancellationToken);
if (cached is not null) return Ok(cached);
}
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
@@ -1626,17 +1643,45 @@ Candidate master CV:
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
return Ok(new FocusPlanDto(
var dto = new FocusPlanDto(
ImmediatePriorities: immediatePriorities,
CvBulletIdeas: cvBulletIdeas,
ProofPointsToLeadWith: proofPointsToLeadWith,
CoverLetterAngles: coverLetterAngles,
FollowUpApproach: followUpApproach,
StrategicSummary: strategicSummary));
StrategicSummary: strategicSummary);
await SaveAiNoteAsync(userId, id, "focus-plan", attachmentSignature, dto, cancellationToken);
return Ok(dto);
}
private async Task<T?> TryGetCachedAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, CancellationToken cancellationToken) where T : class
{
var existing = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType && x.AttachmentContextSignature == attachmentSignature,
cancellationToken);
if (existing is null) return null;
return JsonSerializer.Deserialize<T>(existing.ResultJson);
}
private async Task SaveAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, T dto, CancellationToken cancellationToken)
{
var note = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType,
cancellationToken);
if (note is null)
{
note = new AiWorkspaceNote { OwnerUserId = userId, JobApplicationId = jobApplicationId, NoteType = noteType };
_db.AiWorkspaceNotes.Add(note);
}
note.AttachmentContextSignature = attachmentSignature;
note.ResultJson = JsonSerializer.Serialize(dto);
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
}
[HttpGet("{id:int}/interview-prep")]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.AsNoTracking()
@@ -1644,6 +1689,24 @@ Candidate master CV:
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
if (!refresh && userId is not null)
{
var existing = await _db.InterviewPrepNotes.FirstOrDefaultAsync(
x => x.OwnerUserId == userId && x.JobApplicationId == id && x.AttachmentContextSignature == attachmentSignature,
cancellationToken);
if (existing is not null)
{
return Ok(new InterviewPrepDto(
existing.Summary,
JsonSerializer.Deserialize<List<string>>(existing.TalkingPointsJson) ?? new List<string>(),
JsonSerializer.Deserialize<List<string>>(existing.LikelyQuestionsJson) ?? new List<string>(),
JsonSerializer.Deserialize<List<string>>(existing.WeakSpotsJson) ?? new List<string>()));
}
}
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, attachmentContext?.Context }
.Where(x => !string.IsNullOrWhiteSpace(x)));
@@ -1662,9 +1725,34 @@ Candidate master CV:
180,
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
if (userId is not null)
{
var note = await _db.InterviewPrepNotes.FirstOrDefaultAsync(x => x.OwnerUserId == userId && x.JobApplicationId == id, cancellationToken);
if (note is null)
{
note = new InterviewPrepNote { OwnerUserId = userId, JobApplicationId = id };
_db.InterviewPrepNotes.Add(note);
}
note.AttachmentContextSignature = attachmentSignature;
note.Summary = summary;
note.TalkingPointsJson = JsonSerializer.Serialize(talkingPoints);
note.LikelyQuestionsJson = JsonSerializer.Serialize(likelyQuestions);
note.WeakSpotsJson = JsonSerializer.Serialize(weakSpots);
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
}
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
}
private static string NormalizeAttachmentIdsSignature(string? attachmentIds)
{
if (string.IsNullOrWhiteSpace(attachmentIds)) return string.Empty;
var ids = attachmentIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.OrderBy(x => x, StringComparer.Ordinal);
return string.Join(",", ids);
}
[HttpGet("{id:int}/readiness")]
public async Task<ActionResult<ReadinessDto>> GetReadiness([FromRoute] int id, CancellationToken cancellationToken)
{
@@ -71,8 +71,9 @@ public sealed class ProfileCvController : ControllerBase
private readonly ICvPdfExporter _cvPdfExporter;
private readonly ICvProcessingQueue _cvProcessingQueue;
private readonly IAppEmailSender _emailSender;
private readonly ICareerProfileService _careerProfileService;
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null)
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null)
{
_users = users;
_aiService = aiService;
@@ -85,6 +86,7 @@ public sealed class ProfileCvController : ControllerBase
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
_emailSender = emailSender ?? NoOpEmailSender.Instance;
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
}
private sealed class NoOpEmailSender : IAppEmailSender
@@ -113,6 +115,11 @@ public sealed class ProfileCvController : ControllerBase
public string? Tone { get; set; }
public string? Language { get; set; }
}
// ParseCvRequest / CvTemplateDescriptor / ProfileCvPreviewDto / CvRewriteFailureDto are
// defined in ProfileCvDtos.cs on main (extracted after this branch forked). The branch's
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
// (Phase 4), not this foundation integration.
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
@@ -155,6 +162,7 @@ public sealed class ProfileCvController : ControllerBase
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.RawExtractedText = result.RawText;
@@ -553,6 +561,8 @@ public sealed class ProfileCvController : ControllerBase
private static IReadOnlyList<CvTemplateDescriptor> GetCvTemplateDescriptors()
{
// 7-arg shape matches CvTemplateDescriptor in ProfileCvDtos.cs. The LayoutFamily/AtsRating
// fields the branch added here are deferred with the rest of the ATS-badge work (Phase 4).
return new[]
{
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }),
@@ -825,6 +835,7 @@ public sealed class ProfileCvController : ControllerBase
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.StructuredProfileJson = structuredJson;
@@ -965,6 +976,7 @@ public sealed class ProfileCvController : ControllerBase
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.RawExtractedText = rawText;
+1
View File
@@ -37,6 +37,7 @@ builder.Services.AddSingleton<ICvProcessingQueue, CvProcessingQueue>();
builder.Services.AddTransient<ProfileCvController>();
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
@@ -0,0 +1,169 @@
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Career Workspace foundation (see docs/career-workspace-implementation-roadmap.md, Phase F1).
// Bounded to the profile/CV domain -- job tracking is untouched. Every existing read path still
// goes through ApplicationUser.ProfileCvStructureJson (dual-write window); this service is the
// single place stable item IDs and normalized dates get assigned, and where profile history is
// captured so it's never silently overwritten on the next rebuild/improve/upload.
public interface ICareerProfileService
{
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, and
// returns the same profile so the caller can go on to serialize it into the legacy column.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
}
public sealed class CareerProfileService : ICareerProfileService
{
private readonly JobTrackerContext _db;
public CareerProfileService(JobTrackerContext db)
{
_db = db;
}
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
{
AssignStableIds(profile);
NormalizeDates(profile);
var json = StructuredCvProfileJson.Serialize(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new CareerProfile
{
OwnerUserId = ownerUserId,
ProfileJson = json,
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
await _db.SaveChangesAsync(cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
return profile;
}
private static void AssignStableIds(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)
{
if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
}
foreach (var education in profile.Education)
{
if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
}
foreach (var certification in profile.Certifications)
{
if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
}
foreach (var project in profile.Projects)
{
if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
}
}
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
private static void NormalizeDates(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)
{
job.StartDate = CvDateNormalizer.TryParseYearMonth(job.Start);
job.EndDate = job.IsCurrent ? null : CvDateNormalizer.TryParseYearMonth(job.End);
}
foreach (var education in profile.Education)
{
education.StartDate = CvDateNormalizer.TryParseYearMonth(education.Start);
education.EndDate = CvDateNormalizer.TryParseYearMonth(education.End);
}
foreach (var certification in profile.Certifications)
{
certification.DateNormalized = CvDateNormalizer.TryParseYearMonth(certification.Date);
}
foreach (var project in profile.Projects)
{
project.StartDate = CvDateNormalizer.TryParseYearMonth(project.Start);
project.EndDate = CvDateNormalizer.TryParseYearMonth(project.End);
}
}
}
// Best-effort free-string -> "YYYY-MM" parser. Never throws, never loses data: the original
// free-string field is always kept alongside whatever this returns (null on anything it can't
// confidently parse -- callers must not treat null as "no date", only as "unparsed").
public static class CvDateNormalizer
{
private static readonly Dictionary<string, int> MonthNames = new(StringComparer.OrdinalIgnoreCase)
{
["jan"] = 1, ["january"] = 1,
["feb"] = 2, ["february"] = 2,
["mar"] = 3, ["march"] = 3,
["apr"] = 4, ["april"] = 4,
["may"] = 5,
["jun"] = 6, ["june"] = 6,
["jul"] = 7, ["july"] = 7,
["aug"] = 8, ["august"] = 8,
["sep"] = 9, ["sept"] = 9, ["september"] = 9,
["oct"] = 10, ["october"] = 10,
["nov"] = 11, ["november"] = 11,
["dec"] = 12, ["december"] = 12,
};
public static string? TryParseYearMonth(string? raw)
{
var value = (raw ?? string.Empty).Trim();
if (value.Length == 0) return null;
if (value.Equals("present", StringComparison.OrdinalIgnoreCase) || value.Equals("current", StringComparison.OrdinalIgnoreCase)) return null;
// "2020" -> January is an assumption we don't want to make silently; year-only stays unparsed.
var monthYear = Regex.Match(value, @"^(?<month>[A-Za-z]+)\.?\s+(?<year>\d{4})$");
if (monthYear.Success && MonthNames.TryGetValue(monthYear.Groups["month"].Value, out var month))
{
return $"{monthYear.Groups["year"].Value}-{month:D2}";
}
var slash = Regex.Match(value, @"^(?<month>\d{1,2})/(?<year>\d{4})$");
if (slash.Success)
{
var m = int.Parse(slash.Groups["month"].Value);
if (m is >= 1 and <= 12) return $"{slash.Groups["year"].Value}-{m:D2}";
}
var isoLike = Regex.Match(value, @"^(?<year>\d{4})-(?<month>\d{1,2})$");
if (isoLike.Success)
{
var m = int.Parse(isoLike.Groups["month"].Value);
if (m is >= 1 and <= 12) return $"{isoLike.Groups["year"].Value}-{m:D2}";
}
return null;
}
}
@@ -682,6 +682,84 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");""");
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
// authoritative column every existing read path uses; these mirror it so future
// Career Workspace features (variants, history UI) have a real table to build on.
static void EnsureCareerProfileTables(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ProfileJson" TEXT NOT NULL,
"Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"CareerProfileId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL,
"ProfileJson" TEXT NOT NULL,
"Source" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
}
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5):
// stop re-running the AI call on every tab open by persisting the last generated
// note per job, keyed by the attachment selection it was generated from.
static void EnsureInterviewPrepNotesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "InterviewPrepNotes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepNotes" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"AttachmentContextSignature" TEXT NOT NULL,
"Summary" TEXT NOT NULL,
"TalkingPointsJson" TEXT NOT NULL,
"LikelyQuestionsJson" TEXT NOT NULL,
"WeakSpotsJson" TEXT NOT NULL,
"GeneratedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_InterviewPrepNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");""");
}
// Generic AI workspace note persistence (candidate fit, focus plan) -- same
// rationale as EnsureInterviewPrepNotesTable, generalized for DTOs too irregular
// for per-field columns.
static void EnsureAiWorkspaceNotesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "AiWorkspaceNotes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiWorkspaceNotes" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"NoteType" TEXT NOT NULL,
"AttachmentContextSignature" TEXT NOT NULL,
"ResultJson" TEXT NOT NULL,
"GeneratedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_AiWorkspaceNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");""");
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
@@ -689,6 +767,9 @@ public static class StartupInitializationExtensions
EnsureTwoFactorRecoveryCodesTable(conn);
EnsureTrustedDevicesTable(conn);
EnsureUserSessionsTable(conn);
EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn);
EnsureAiWorkspaceNotesTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -825,6 +906,40 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
if (!MySqlIndexExists(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_InterviewPrepNotes_OwnerUserId_JobApplicationId` ON `InterviewPrepNotes` (`OwnerUserId`, `JobApplicationId`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");
if (!MySqlIndexExists(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType` ON `AiWorkspaceNotes` (`OwnerUserId`, `JobApplicationId`, `NoteType`);";
cmd.ExecuteNonQuery();
}
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
// Correspondences/Attachments) -- re-run once more after Migrate() below via
@@ -1053,6 +1168,93 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
// for every existing read path during the dual-write window.
if (!HasMySqlTable(conn, "CareerProfiles"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfiles` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`ProfileJson` longtext NOT NULL,
`Version` int NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CareerProfileVersions"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`CareerProfileId` int NOT NULL,
`Version` int NOT NULL,
`ProfileJson` longtext NOT NULL,
`Source` varchar(100) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
if (!HasMySqlTable(conn, "InterviewPrepNotes"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepNotes` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`AttachmentContextSignature` longtext NOT NULL,
`Summary` longtext NOT NULL,
`TalkingPointsJson` longtext NOT NULL,
`LikelyQuestionsJson` longtext NOT NULL,
`WeakSpotsJson` longtext NOT NULL,
`GeneratedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_InterviewPrepNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
// Generic AI workspace note persistence (candidate fit, focus plan).
if (!HasMySqlTable(conn, "AiWorkspaceNotes"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`NoteType` varchar(50) NOT NULL,
`AttachmentContextSignature` longtext NOT NULL,
`ResultJson` longtext NOT NULL,
`GeneratedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_AiWorkspaceNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
{
using var cmd = conn.CreateCommand();