diff --git a/JobTrackerApi.Tests/AvatarStorageTests.cs b/JobTrackerApi.Tests/AvatarStorageTests.cs new file mode 100644 index 0000000..683d212 --- /dev/null +++ b/JobTrackerApi.Tests/AvatarStorageTests.cs @@ -0,0 +1,26 @@ +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class AvatarStorageTests +{ + [Fact] + public async Task Store_resolve_delete_round_trip_keeps_image_out_of_database_value() + { + var root = Path.Combine(Path.GetTempPath(), "jobtracker-avatar-test-" + Guid.NewGuid().ToString("N")); + try + { + var stored = await AvatarStorage.StoreAsync(root, "user-1", new byte[] { 1, 2, 3 }, "image/png", default); + + Assert.StartsWith("file:", stored); + Assert.Equal("data:image/png;base64,AQID", AvatarStorage.Resolve(stored)); + AvatarStorage.Delete(stored); + Assert.Null(AvatarStorage.Resolve(stored)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } +} diff --git a/JobTrackerApi.Tests/CareerProfileServiceTests.cs b/JobTrackerApi.Tests/CareerProfileServiceTests.cs index 3e047eb..9c5baf2 100644 --- a/JobTrackerApi.Tests/CareerProfileServiceTests.cs +++ b/JobTrackerApi.Tests/CareerProfileServiceTests.cs @@ -106,6 +106,10 @@ public sealed class CareerProfileServiceTests Contact = { FullName = "Ada Lovelace", Email = "ada@example.com", Headline = "Engineer" }, Summary = { "First", "Second" }, Interests = { "Chess" }, + Awards = { "Engineering prize" }, + Publications = { "Reliable systems" }, + Organisations = { "ACM" }, + References = { "Available on request" }, Jobs = { new StructuredCvJob { Title = "Senior Eng", Company = "Acme", Start = "Jan 2020", End = "Present", IsCurrent = true, Bullets = { "Built X" }, Skills = { "C#" } }, @@ -130,6 +134,10 @@ public sealed class CareerProfileServiceTests Assert.Equal("Ada Lovelace", loaded.Contact.FullName); Assert.Equal(new[] { "First", "Second" }, loaded.Summary); Assert.Equal(new[] { "Chess" }, loaded.Interests); + Assert.Equal(new[] { "Engineering prize" }, loaded.Awards); + Assert.Equal(new[] { "Reliable systems" }, loaded.Publications); + Assert.Equal(new[] { "ACM" }, loaded.Organisations); + Assert.Equal(new[] { "Available on request" }, loaded.References); Assert.Equal(2, loaded.Jobs.Count); Assert.Equal("Senior Eng", loaded.Jobs[0].Title); // order preserved Assert.True(loaded.Jobs[0].IsCurrent); diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index e27aab0..0965ab9 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -24,8 +24,9 @@ public sealed class AuthController : ControllerBase private readonly ILogger _logger; private readonly ITwoFactorPendingTokenService _twoFactorPending; private readonly JobTrackerContext _db; + private readonly string _avatarDataRoot; - public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db) + public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null) { _cfg = cfg; _users = users; @@ -36,6 +37,7 @@ public sealed class AuthController : ControllerBase _logger = logger; _twoFactorPending = twoFactorPending; _db = db; + _avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim()); } [HttpGet("config")] @@ -575,8 +577,7 @@ public sealed class AuthController : ControllerBase return BadRequest("Only PNG, JPEG, or WebP images are supported."); } - var base64 = Convert.ToBase64String(bytes); - user.AvatarImageDataUrl = $"data:{detectedContentType};base64,{base64}"; + user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted); var result = await _users.UpdateAsync(user); if (!result.Succeeded) @@ -584,7 +585,7 @@ public sealed class AuthController : ControllerBase return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); } - return Ok(new { avatarImageDataUrl = user.AvatarImageDataUrl }); + return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) }); } [HttpDelete("avatar")] @@ -597,6 +598,7 @@ public sealed class AuthController : ControllerBase return Unauthorized(); } + var storedAvatar = user.AvatarImageDataUrl; user.AvatarImageDataUrl = null; var result = await _users.UpdateAsync(user); if (!result.Succeeded) @@ -604,6 +606,7 @@ public sealed class AuthController : ControllerBase return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); } + AvatarStorage.Delete(storedAvatar); return NoContent(); } @@ -876,7 +879,7 @@ public sealed class AuthController : ControllerBase DisplayName: user.DisplayName, ProfileCvText: user.ProfileCvText, ProfileCvStructureJson: user.ProfileCvStructureJson, - AvatarImageDataUrl: user.AvatarImageDataUrl, + AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl), Roles: roles, GoogleLink: new GoogleLinkDto( Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject), diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs index e63ce41..f2e9e3a 100644 --- a/JobTrackerApi/Controllers/CvVariantController.cs +++ b/JobTrackerApi/Controllers/CvVariantController.cs @@ -229,7 +229,7 @@ public sealed class CvVariantController : ControllerBase if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim(); if (string.IsNullOrWhiteSpace(name)) name = user.Email?.Trim(); if (string.IsNullOrWhiteSpace(name)) name = "Your Name"; - return new CvRenderPerson(name!, user.AvatarImageDataUrl); + return new CvRenderPerson(name!, AvatarStorage.Resolve(user.AvatarImageDataUrl)); } private static VariantDto ToDto(CvVariant v) => new( diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index d9c1737..c96a48c 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1821,7 +1821,7 @@ Candidate master CV: ? request!.PhotoDataUrl : request?.UseProfileAvatar == false ? null - : user.AvatarImageDataUrl; + : AvatarStorage.Resolve(user.AvatarImageDataUrl); var rendered = RenderTailoredCv(job, document, user, photoDataUrl); return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName)); } @@ -1852,7 +1852,7 @@ Candidate master CV: ? request!.PhotoDataUrl : request?.UseProfileAvatar == false ? null - : user.AvatarImageDataUrl; + : AvatarStorage.Resolve(user.AvatarImageDataUrl); var rendered = RenderTailoredCv(job, document, user, photoDataUrl); var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken); return File(artifact.Bytes, "application/pdf", artifact.FileName); diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 14ef259..a155b4e 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -53,9 +53,18 @@ public sealed class ProfileCvController : ControllerBase ["languages"] = "Languages", ["interests"] = "Interests", ["hobbies"] = "Interests", + ["awards"] = "Awards", + ["honours"] = "Awards", + ["honors"] = "Awards", + ["publications"] = "Publications", + ["organisations"] = "Organisations", + ["organizations"] = "Organisations", + ["memberships"] = "Organisations", + ["references"] = "References", }; private const long MaxFileSizeBytes = 5 * 1024 * 1024; + private const int ExtractionRunRetentionCount = 20; private const string ParserVersion = "m005-s01"; private const string NormalizerVersion = "m005-s01"; private const string LlmPromptVersion = "m005-s01"; @@ -168,6 +177,7 @@ public sealed class ProfileCvController : ControllerBase run.Status = "pending_review"; run.CompletedAtUtc = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(HttpContext.RequestAborted); + await PruneExtractionRunsAsync(user.Id, HttpContext.RequestAborted); return Ok(new { @@ -185,6 +195,7 @@ public sealed class ProfileCvController : ControllerBase run.ErrorMessage = ex.Message; run.CompletedAtUtc = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(HttpContext.RequestAborted); + await PruneExtractionRunsAsync(user.Id, HttpContext.RequestAborted); throw; } } @@ -625,7 +636,7 @@ public sealed class ProfileCvController : ControllerBase if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.UserName?.Trim(); if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.Email?.Trim(); if (string.IsNullOrWhiteSpace(candidateName)) candidateName = "Your Name"; - return _cvTemplateRenderer.Render(document, document.TemplateId, candidateName!, targetRole, companyName, user.AvatarImageDataUrl); + return _cvTemplateRenderer.Render(document, document.TemplateId, candidateName!, targetRole, companyName, AvatarStorage.Resolve(user.AvatarImageDataUrl)); } private static TailoredCvDocument BuildMasterCvDocument(StructuredCvProfile structuredCv, string templateId, string? targetRole, string? fallbackHeadline, string? companyName) @@ -903,6 +914,7 @@ public sealed class ProfileCvController : ControllerBase } await _db.SaveChangesAsync(cancellationToken); + await PruneExtractionRunsAsync(user.Id, cancellationToken); } private async Task CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken) @@ -1011,6 +1023,7 @@ public sealed class ProfileCvController : ControllerBase run.ErrorMessage = ex.Message; run.CompletedAtUtc = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(cancellationToken); + await PruneExtractionRunsAsync(user.Id, cancellationToken); await SendRunCompletionEmailAsync(user, run, false, cancellationToken); _logger.LogWarning(ex, "CV processing run {RunId} failed for user {UserId}", run.Id, user.Id); } @@ -1024,6 +1037,19 @@ public sealed class ProfileCvController : ControllerBase run.Status = "pending_review"; run.CompletedAtUtc = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(cancellationToken); + await PruneExtractionRunsAsync(run.OwnerUserId, cancellationToken); + } + + private async Task PruneExtractionRunsAsync(string ownerUserId, CancellationToken cancellationToken) + { + var expired = await _db.CvExtractionRuns + .Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running") + .OrderByDescending(x => x.StartedAtUtc) + .Skip(ExtractionRunRetentionCount) + .ToListAsync(cancellationToken); + if (expired.Count == 0) return; + _db.CvExtractionRuns.RemoveRange(expired); + await _db.SaveChangesAsync(cancellationToken); } private async Task SendRunCompletionEmailAsync(ApplicationUser user, CvExtractionRun run, bool success, CancellationToken cancellationToken) diff --git a/JobTrackerApi/Controllers/PublicCvController.cs b/JobTrackerApi/Controllers/PublicCvController.cs index 95955ac..20966d1 100644 --- a/JobTrackerApi/Controllers/PublicCvController.cs +++ b/JobTrackerApi/Controllers/PublicCvController.cs @@ -44,6 +44,6 @@ public sealed class PublicCvController : ControllerBase if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim(); if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim(); if (string.IsNullOrWhiteSpace(name)) name = "Candidate"; - return new CvRenderPerson(name!, user.AvatarImageDataUrl); + return new CvRenderPerson(name!, AvatarStorage.Resolve(user.AvatarImageDataUrl)); } } diff --git a/JobTrackerApi/Services/AvatarStorage.cs b/JobTrackerApi/Services/AvatarStorage.cs new file mode 100644 index 0000000..8d999a8 --- /dev/null +++ b/JobTrackerApi/Services/AvatarStorage.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; + +namespace JobTrackerApi.Services; + +public static class AvatarStorage +{ + private const string FilePrefix = "file:"; + + public static async Task StoreAsync(string dataRoot, string userId, byte[] bytes, string contentType, CancellationToken cancellationToken) + { + var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant(); + var folder = Path.Combine(dataRoot, "Avatars", userKey); + Directory.CreateDirectory(folder); + var extension = contentType switch { "image/png" => ".png", "image/webp" => ".webp", _ => ".jpg" }; + var path = Path.Combine(folder, "avatar" + extension); + foreach (var old in Directory.EnumerateFiles(folder, "avatar.*")) if (!string.Equals(old, path, StringComparison.OrdinalIgnoreCase)) File.Delete(old); + await File.WriteAllBytesAsync(path, bytes, cancellationToken); + return FilePrefix + path; + } + + public static string? Resolve(string? value) + { + if (string.IsNullOrWhiteSpace(value) || !value.StartsWith(FilePrefix, StringComparison.Ordinal)) return value; + var path = value[FilePrefix.Length..]; + if (!File.Exists(path)) return null; + var contentType = Path.GetExtension(path).ToLowerInvariant() switch { ".png" => "image/png", ".webp" => "image/webp", _ => "image/jpeg" }; + return $"data:{contentType};base64,{Convert.ToBase64String(File.ReadAllBytes(path))}"; + } + + public static void Delete(string? value) + { + if (value?.StartsWith(FilePrefix, StringComparison.Ordinal) != true) return; + var path = value[FilePrefix.Length..]; + if (File.Exists(path)) File.Delete(path); + } +} diff --git a/JobTrackerApi/Services/CareerProfileMapper.cs b/JobTrackerApi/Services/CareerProfileMapper.cs index 96d5740..0cf5593 100644 --- a/JobTrackerApi/Services/CareerProfileMapper.cs +++ b/JobTrackerApi/Services/CareerProfileMapper.cs @@ -23,6 +23,10 @@ public static class CareerProfileMapper public StructuredCvContact Contact { get; set; } = new(); public List Summary { get; set; } = new(); public List Interests { get; set; } = new(); + public List Awards { get; set; } = new(); + public List Publications { get; set; } = new(); + public List Organisations { get; set; } = new(); + public List References { get; set; } = new(); public List OtherSections { get; set; } = new(); public List Sections { get; set; } = new(); } @@ -35,6 +39,10 @@ public static class CareerProfileMapper Contact = p.Contact, Summary = p.Summary, Interests = p.Interests, + Awards = p.Awards, + Publications = p.Publications, + Organisations = p.Organisations, + References = p.References, OtherSections = p.OtherSections, Sections = p.Sections, }, JsonOptions); @@ -125,6 +133,10 @@ public static class CareerProfileMapper Contact = tail.Contact, Summary = tail.Summary, Interests = tail.Interests, + Awards = tail.Awards, + Publications = tail.Publications, + Organisations = tail.Organisations, + References = tail.References, OtherSections = tail.OtherSections, Sections = tail.Sections, Jobs = experiences.Select(x => new StructuredCvJob diff --git a/JobTrackerApi/Services/CvImportDiff.cs b/JobTrackerApi/Services/CvImportDiff.cs index de3f514..9b2e5ab 100644 --- a/JobTrackerApi/Services/CvImportDiff.cs +++ b/JobTrackerApi/Services/CvImportDiff.cs @@ -71,6 +71,10 @@ public sealed class CvProfileDiffService : ICvProfileDiffService DiffLanguages(current.Languages, extracted.Languages), DiffScalars("Skills", current.Skills, extracted.Skills), DiffScalars("Interests", current.Interests, extracted.Interests), + DiffScalars("Awards", current.Awards, extracted.Awards), + DiffScalars("Publications", current.Publications, extracted.Publications), + DiffScalars("Organisations", current.Organisations, extracted.Organisations), + DiffScalars("References", current.References, extracted.References), }.Where(c => c is not null).Select(c => c!).ToList(), }; } @@ -89,6 +93,10 @@ public sealed class CvProfileDiffService : ICvProfileDiffService MergeLanguages(merged.Languages, extracted.Languages); AppendUnique(merged.Skills, extracted.Skills); AppendUnique(merged.Interests, extracted.Interests); + AppendUnique(merged.Awards, extracted.Awards); + AppendUnique(merged.Publications, extracted.Publications); + AppendUnique(merged.Organisations, extracted.Organisations); + AppendUnique(merged.References, extracted.References); MergeList(merged.OtherSections, extracted.OtherSections, x => Norm(x.Title), (a, b) => AppendUnique(a.Items, b.Items)); if (extracted.Sections.Count > 0) merged.Sections = extracted.Sections; foreach (var field in extracted.Metadata.Fields) merged.Metadata.Fields[field.Key] = field.Value; diff --git a/JobTrackerApi/Services/CvRenderModel.cs b/JobTrackerApi/Services/CvRenderModel.cs index 4df0ab1..91160cb 100644 --- a/JobTrackerApi/Services/CvRenderModel.cs +++ b/JobTrackerApi/Services/CvRenderModel.cs @@ -79,6 +79,10 @@ public static class CvVariantResolver ["certifications"] = CertificationSection(profile, settings), ["languages"] = LanguageSection(profile), ["interests"] = TagSection("interests", "Interests", profile.Interests), + ["awards"] = BulletSection("awards", "Awards", profile.Awards), + ["publications"] = BulletSection("publications", "Publications", profile.Publications), + ["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations), + ["references"] = BulletSection("references", "References", profile.References), }; // OtherSections from the master profile become body sections keyed other:. diff --git a/Models/StructuredCvProfile.cs b/Models/StructuredCvProfile.cs index 53bcbce..6ed20f3 100644 --- a/Models/StructuredCvProfile.cs +++ b/Models/StructuredCvProfile.cs @@ -13,6 +13,10 @@ public sealed class StructuredCvProfile public List Skills { get; set; } = new(); public List Languages { get; set; } = new(); public List Interests { get; set; } = new(); + public List Awards { get; set; } = new(); + public List Publications { get; set; } = new(); + public List Organisations { get; set; } = new(); + public List References { get; set; } = new(); public List OtherSections { get; set; } = new(); public List Sections { get; set; } = new(); } diff --git a/Models/StructuredCvProfileJson.cs b/Models/StructuredCvProfileJson.cs index da0abb3..23d53f5 100644 --- a/Models/StructuredCvProfileJson.cs +++ b/Models/StructuredCvProfileJson.cs @@ -82,6 +82,10 @@ public static class StructuredCvProfileJson primary.Interests = primary.Interests.Count == 0 ? secondary.Interests : primary.Interests.Concat(secondary.Interests).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + MergeStrings(primary.Awards, secondary.Awards); + MergeStrings(primary.Publications, secondary.Publications); + MergeStrings(primary.Organisations, secondary.Organisations); + MergeStrings(primary.References, secondary.References); if (primary.OtherSections.Count == 0) primary.OtherSections = secondary.OtherSections; if (primary.Sections.Count == 0) primary.Sections = secondary.Sections; @@ -126,6 +130,22 @@ public static class StructuredCvProfileJson case "interests": profile.Interests = SplitList(section.Content); break; + case "awards": + case "honours": + case "honors": + profile.Awards = SplitList(section.Content); + break; + case "publications": + profile.Publications = SplitList(section.Content); + break; + case "organisations": + case "organizations": + case "memberships": + profile.Organisations = SplitList(section.Content); + break; + case "references": + profile.References = SplitList(section.Content); + break; case "work experience": case "experience": case "employment history": @@ -193,6 +213,10 @@ public static class StructuredCvProfileJson .Where(language => !string.IsNullOrWhiteSpace(language.Name)) .ToList(); profile.Interests = CleanList(profile.Interests); + profile.Awards = CleanList(profile.Awards); + profile.Publications = CleanList(profile.Publications); + profile.Organisations = CleanList(profile.Organisations); + profile.References = CleanList(profile.References); profile.OtherSections = (profile.OtherSections ?? new List()) .Select(section => new StructuredCvOtherSection { @@ -630,6 +654,11 @@ public static class StructuredCvProfileJson AddSectionIfAny(sections, "Interests", profile.Interests); + AddSectionIfAny(sections, "Awards", profile.Awards); + AddSectionIfAny(sections, "Publications", profile.Publications); + AddSectionIfAny(sections, "Organisations", profile.Organisations); + AddSectionIfAny(sections, "References", profile.References); + foreach (var other in profile.OtherSections) { AddSectionIfAny(sections, other.Title ?? "Other", other.Items); @@ -638,6 +667,12 @@ public static class StructuredCvProfileJson return NormalizeSections(sections); } + private static void MergeStrings(List primary, IEnumerable secondary) + { + foreach (var value in secondary) + if (!primary.Contains(value, StringComparer.OrdinalIgnoreCase)) primary.Add(value); + } + private static void AddSectionIfAny(List sections, string name, IEnumerable? lines) { var content = string.Join("\n", (lines ?? Array.Empty()).Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim(); diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index c36e777..88cfd06 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -75,20 +75,19 @@ Goal: one professional source of truth that can actually feed outputs. > **Foundation SHIPPED 2026-07-18** (commits `9c8644e`…`a1dd447`). The relational master career > profile is built, tested (23 backend + frontend tests), migrated (verified on the live DB), wired > to `/career`, with completeness overview + version-history restore. See -> `docs/architecture/career-profile-model.md` (Implementation status). Tasks 3.1, 3.3, 3.4 and the -> versioning/validation goals are delivered. Remaining below: 3.2 (long-tail-as-relational, deferred -> — currently JSON), 3.5 (CV variants — Phase 4), 3.6 (retention), plus a full section-by-section UX -> redesign of the editor (polish; the functional workspace exists). +> `docs/architecture/career-profile-model.md` (Implementation status). **Phase 3 completed 2026-07-30:** +> the workspace, Master CV separation, variants, long-tail sections, extraction retention, and +> file-backed avatars are delivered. | # | Task | Priority | Difficulty | Dependencies | Expected value | |---|---|---|---|---|---| | 3.1 | ✅ **DONE** — Experience/Education/Skills/Projects/Certifications/Languages are relational children of `CareerProfile`; long tail is JSON. Projection service keeps the blob as a derived read-model; lazy backfill from existing data. | **P1** | **L** | 1.9 | Delivered. Queryable structured career data is now the source of truth; the blob is derived. | -| 3.2 | **Add the missing profile sections** — Awards, Publications, Organisations, References | **P1** | **S** | 3.1 | The guide names them; `StructuredCvProfile` has no home for them beyond generic `OtherSections`. These are the blob half of 3.1. | -| 3.3 | **Real Career Workspace page** — replace the 36-line tab facade | **P1** | **M** | 2.1, 2.2 | Currently a wrapper around `ProfilePage`. | -| 3.4 | **Separate Career Profile from Master CV** | **P1** | **M** | 3.1 | The glossary is explicit — "The career profile is NOT a CV"; Master CV is a *generated representation*. Code has one blob. Getting this wrong makes Phase 4 impossible. | -| 3.5 | **CV variants** (Software Engineer CV / Management CV) | **P2** | **M** | 3.4 | In the glossary; no code. Distinct from per-application `TailoredCvDraft`, which works correctly and must not be disturbed. | -| 3.6 | **Retention policy for `CvExtractionRun`** | **P2** | **S** | none | Three copies of every CV (raw/normalized/structured), unbounded. | -| 3.7 | **Move avatars out of the DB column** | **P3** | **S** | none | Base64 blob on the `/auth/me` hot path. | +| 3.2 | ✅ **DONE (2026-07-30)** — added Awards, Publications, Organisations, and References across extraction, review/merge, JSON storage, editing, and rendering. | **P1** | **S** | 3.1 | The guide names them; `StructuredCvProfile` has no home for them beyond generic `OtherSections`. These are the blob half of 3.1. | +| 3.3 | ✅ **DONE (2026-07-30)** — dedicated Career Workspace page and focused section components. | **P1** | **M** | 2.1, 2.2 | Currently a wrapper around `ProfilePage`. | +| 3.4 | ✅ **DONE (2026-07-30)** — Career Profile is the source of truth; Master CV is a generated representation. | **P1** | **M** | 3.1 | The glossary is explicit — "The career profile is NOT a CV"; Master CV is a *generated representation*. Code has one blob. Getting this wrong makes Phase 4 impossible. | +| 3.5 | ✅ **DONE (2026-07-30)** — named CV variants with stable profile-item references. | **P2** | **M** | 3.4 | In the glossary; no code. Distinct from per-application `TailoredCvDraft`, which works correctly and must not be disturbed. | +| 3.6 | ✅ **DONE (2026-07-30)** — retain the newest 20 completed extraction runs per user; queued/running work is protected. | **P2** | **S** | none | Three copies of every CV (raw/normalized/structured), unbounded. | +| 3.7 | ✅ **DONE (2026-07-30)** — new avatars live in persistent file storage; the DB stores only an internal file reference, with legacy data-URL compatibility. | **P3** | **S** | none | Base64 blob on the `/auth/me` hot path. | **Do not disturb:** `TailoredCvDraft` correctly implements "the master CV must never be modified automatically" — the single most important documented invariant, and it already holds. diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 177dbc1..5f11d18 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -252,6 +252,10 @@ export const translations = { profileCvStructuredSummary: "Professional summary", profileCvStructuredSkills: "Skills", profileCvStructuredInterests: "Interests", + profileCvStructuredAwards: "Awards", + profileCvStructuredPublications: "Publications", + profileCvStructuredOrganisations: "Organisations", + profileCvStructuredReferences: "References", profileCvStructuredLanguages: "Languages", profileCvStructuredJobs: "Work experience", profileCvStructuredEducation: "Education", @@ -1325,6 +1329,10 @@ export const translations = { profileCvStructuredSummary: "Sammendrags-punkter", profileCvStructuredSkills: "Kjernekompetanse", profileCvStructuredInterests: "Interesser", + profileCvStructuredAwards: "Priser og utmerkelser", + profileCvStructuredPublications: "Publikasjoner", + profileCvStructuredOrganisations: "Organisasjoner", + profileCvStructuredReferences: "Referanser", profileCvStructuredLanguages: "Språk", profileCvStructuredJobs: "Arbeidserfaring", profileCvStructuredEducation: "Utdanning", diff --git a/job-tracker-ui/src/profileCv.ts b/job-tracker-ui/src/profileCv.ts index f2fa632..b6df830 100644 --- a/job-tracker-ui/src/profileCv.ts +++ b/job-tracker-ui/src/profileCv.ts @@ -93,6 +93,10 @@ export type StructuredCvProfile = { skills: string[]; languages: StructuredCvLanguage[]; interests: string[]; + awards: string[]; + publications: string[]; + organisations: string[]; + references: string[]; otherSections: StructuredCvOtherSection[]; sections: ParsedCvSection[]; }; @@ -121,6 +125,10 @@ export function emptyStructuredCv(): StructuredCvProfile { skills: [], languages: [], interests: [], + awards: [], + publications: [], + organisations: [], + references: [], otherSections: [], sections: [], }; @@ -183,6 +191,10 @@ function buildLegacyStructuredCv(sections: ParsedCvSection[]): StructuredCvProfi skills, languages, interests, + awards: linesFromSection(sections, ["awards", "honours", "honors"]), + publications: linesFromSection(sections, ["publications"]), + organisations: linesFromSection(sections, ["organisations", "organizations", "memberships"]), + references: linesFromSection(sections, ["references"]), sections, }; } @@ -274,6 +286,10 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile { })) : [], interests: normalizeList(source.interests), + awards: normalizeList(source.awards), + publications: normalizeList(source.publications), + organisations: normalizeList(source.organisations), + references: normalizeList(source.references), otherSections: Array.isArray(source.otherSections) ? source.otherSections.map((section: any) => ({ title: normalizeString(section?.title), diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 232b008..8567b93 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -18,6 +18,7 @@ import { EducationSection, InterestsSection, LanguagesSection, + LongTailSections, OtherSectionsSection, PersonalInformationSection, ProfessionalSummarySection, @@ -647,6 +648,8 @@ export default function CareerProfilePage() { setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} /> + setStructuredCv((prev) => ({ ...prev, [key]: next }))} /> + setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} /> setStructuredCv((prev) => ({ ...prev, jobs: next }))} /> diff --git a/job-tracker-ui/src/views/career/CareerProfileSections.tsx b/job-tracker-ui/src/views/career/CareerProfileSections.tsx index 9378d04..985a710 100644 --- a/job-tracker-ui/src/views/career/CareerProfileSections.tsx +++ b/job-tracker-ui/src/views/career/CareerProfileSections.tsx @@ -122,6 +122,12 @@ export function InterestsSection({ value, onChange, getMetadata }: { value: stri return ; } +export function LongTailSections({ values, onChange }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void }) { + const { t } = useI18n(); + const labels = { awards: t("profileCvStructuredAwards"), publications: t("profileCvStructuredPublications"), organisations: t("profileCvStructuredOrganisations"), references: t("profileCvStructuredReferences") }; + return {(Object.keys(values) as (keyof typeof values)[]).map((key) => onChange(key, next)} minRows={3} />)}; +} + export function LanguagesSection({ value, onChange, getMetadata }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup }) { const { t } = useI18n(); const update = (index: number, patch: Partial) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));