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
+19
View File
@@ -0,0 +1,19 @@
namespace JobTrackerApi.Models;
// Generic persistence for per-job AI workspace outputs whose shape is irregular/nested enough
// that per-field columns (as used by InterviewPrepNote) would be unreasonable -- candidate fit
// and focus plan each make several AI calls and return DTOs with 6-13 fields including nested
// objects. One row per (owner, job, note type); ResultJson is the serialized DTO. See
// career-workspace-implementation-roadmap.md Phase F5 -- "persist interview prep / fit outputs".
public sealed class AiWorkspaceNote
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int JobApplicationId { get; set; }
public JobApplication? JobApplication { get; set; }
// "candidate-fit" | "focus-plan"
public string NoteType { get; set; } = string.Empty;
public string AttachmentContextSignature { get; set; } = string.Empty;
public string ResultJson { get; set; } = string.Empty;
public DateTimeOffset GeneratedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
+29
View File
@@ -0,0 +1,29 @@
namespace JobTrackerApi.Models;
// The Career Workspace's durable source of truth. Bounded to one row per user for now
// (see career-workspace-implementation-roadmap.md Phase F1) -- ProfileJson mirrors
// ApplicationUser.ProfileCvStructureJson during the dual-write window and will become
// authoritative once every read path is migrated (F5).
public sealed class CareerProfile
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public string ProfileJson { get; set; } = string.Empty;
public int Version { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
// Append-only history: one row per save, so profile edits are never silently lost.
public sealed class CareerProfileVersion
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int CareerProfileId { get; set; }
public CareerProfile? CareerProfile { get; set; }
public int Version { get; set; }
public string ProfileJson { get; set; } = string.Empty;
// Where this version came from: "upload" | "rebuild" | "improve" | "reprocess" | "parse" | "manual".
public string Source { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
+22
View File
@@ -0,0 +1,22 @@
namespace JobTrackerApi.Models;
// Persists interview prep so it survives tab switches and page reloads instead of being an AI
// call re-run every time the tab opens (career-workspace-implementation-roadmap.md Phase F5 --
// "persist interview prep / fit outputs" -- flagged in the product teardown as work evaporating
// on every re-open). One row per job application; regenerated when the selected attachment
// context changes or the user explicitly requests a refresh.
public sealed class InterviewPrepNote
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int JobApplicationId { get; set; }
public JobApplication? JobApplication { get; set; }
// Fingerprint of the attachment selection this note was generated from, so a different
// attachment selection triggers regeneration without needing an explicit refresh.
public string AttachmentContextSignature { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string TalkingPointsJson { get; set; } = "[]";
public string LikelyQuestionsJson { get; set; } = "[]";
public string WeakSpotsJson { get; set; } = "[]";
public DateTimeOffset GeneratedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
+16
View File
@@ -49,11 +49,19 @@ public sealed class StructuredCvContact
public sealed class StructuredCvJob
{
// Stable item ID (assigned by CareerProfileService on first save). Required for CV variants
// to reference "this job" across profile edits, instead of by array position. Nullable/empty
// on freshly-parsed or legacy data until the first save assigns it.
public string? Id { get; set; }
public string? Title { get; set; }
public string? Company { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
// Best-effort "YYYY-MM" normalization of Start/End, computed alongside Id assignment.
// Null when Start/End can't be parsed; the free-string fields above remain the display source.
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public bool IsCurrent { get; set; }
public List<string> Bullets { get; set; } = new();
public List<string> Skills { get; set; } = new();
@@ -61,31 +69,39 @@ public sealed class StructuredCvJob
public sealed class StructuredCvEducation
{
public string? Id { get; set; }
public string? Qualification { get; set; }
public string? QualificationLevel { get; set; }
public string? Institution { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public List<string> Details { get; set; } = new();
}
public sealed class StructuredCvCertification
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Issuer { get; set; }
public string? Location { get; set; }
public string? Date { get; set; }
public string? DateNormalized { get; set; }
public List<string> Details { get; set; } = new();
}
public sealed class StructuredCvProject
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? Role { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? StartDate { get; set; }
public string? EndDate { get; set; }
public List<string> Bullets { get; set; } = new();
public List<string> Skills { get; set; } = new();
}