From 78ad0c66f9be6769a2e60a6ce66bf56c02677ec2 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 31 Aug 2026 17:03:09 +0200 Subject: [PATCH] feat(career): add factual evidence bank --- .../CareerEvidenceControllerTests.cs | 43 ++++++++++ JobTrackerApi.Tests/MigrationChainTests.cs | 1 + .../Controllers/CareerEvidenceController.cs | 84 +++++++++++++++++++ JobTrackerApi/Data/JobTrackerContext.cs | 8 ++ ...0260830136000_AdoptCareerEvidenceSchema.cs | 35 ++++++++ .../JobTrackerContextModelSnapshot.cs | 18 ++++ JobTrackerApi/Models/CareerEvidence.cs | 17 ++++ .../Services/AccountDataExportService.cs | 4 +- .../Services/AccountDeletionService.cs | 1 + .../Services/ApplicationAssetsService.cs | 27 ++++++ .../Services/StartupSchemaOwnership.cs | 1 + docs/work-programmes/master-progress.md | 4 +- job-tracker-ui/src/career-evidence.test.tsx | 23 +++++ job-tracker-ui/src/i18n/translations.ts | 52 ++++++++++++ .../src/views/CareerProfilePage.tsx | 11 ++- .../src/views/career/CareerEvidenceBank.tsx | 38 +++++++++ 16 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 JobTrackerApi.Tests/CareerEvidenceControllerTests.cs create mode 100644 JobTrackerApi/Controllers/CareerEvidenceController.cs create mode 100644 JobTrackerApi/Migrations/20260830136000_AdoptCareerEvidenceSchema.cs create mode 100644 JobTrackerApi/Models/CareerEvidence.cs create mode 100644 job-tracker-ui/src/career-evidence.test.tsx create mode 100644 job-tracker-ui/src/views/career/CareerEvidenceBank.tsx diff --git a/JobTrackerApi.Tests/CareerEvidenceControllerTests.cs b/JobTrackerApi.Tests/CareerEvidenceControllerTests.cs new file mode 100644 index 0000000..003e18a --- /dev/null +++ b/JobTrackerApi.Tests/CareerEvidenceControllerTests.cs @@ -0,0 +1,43 @@ +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class CareerEvidenceControllerTests +{ + [Fact] + public async Task Evidence_is_user_approved_tenant_scoped_and_deletable() + { + var current = new Mock(); current.SetupGet(x => x.UserId).Returns("owner-1"); + await using var db = new JobTrackerContext(new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object); + var controller = new CareerEvidenceController(db, Users("owner-1").Object); + var request = new CareerEvidenceController.EvidenceRequest("achievement", "Reduced incidents", "Introduced tested deployment rollback.", ["deployment", "reliability"], "manual", "production retrospective", true); + + var created = Assert.IsType(Assert.IsType((await controller.Create(request, default)).Result).Value); + Assert.True(created.IsVerified); + Assert.Equal(["deployment", "reliability"], created.Tags); + db.CareerEvidence.Add(new CareerEvidence { OwnerUserId = "owner-2", Title = "Private", Statement = "Other tenant" }); + await db.SaveChangesAsync(); + + var listed = Assert.IsAssignableFrom>(Assert.IsType((await controller.List(default)).Result).Value).ToList(); + Assert.Single(listed); + Assert.Equal("Reduced incidents", listed[0].Title); + Assert.IsType(await controller.Delete(created.Id, default)); + Assert.Empty(await db.CareerEvidence.Where(x => x.OwnerUserId == "owner-1").ToListAsync()); + } + + private static Mock> Users(string id) + { + var store = new Mock>(); + var manager = new Mock>(store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + manager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(new ApplicationUser { Id = id }); + return manager; + } +} diff --git a/JobTrackerApi.Tests/MigrationChainTests.cs b/JobTrackerApi.Tests/MigrationChainTests.cs index 2d5a14a..16b986d 100644 --- a/JobTrackerApi.Tests/MigrationChainTests.cs +++ b/JobTrackerApi.Tests/MigrationChainTests.cs @@ -132,6 +132,7 @@ public sealed class MigrationChainTests Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProjects`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerCertifications`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerLanguages`", script, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerEvidence`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetRoles`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserClaims`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserLogins`", script, StringComparison.Ordinal); diff --git a/JobTrackerApi/Controllers/CareerEvidenceController.cs b/JobTrackerApi/Controllers/CareerEvidenceController.cs new file mode 100644 index 0000000..441e33c --- /dev/null +++ b/JobTrackerApi/Controllers/CareerEvidenceController.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/career/evidence")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class CareerEvidenceController(JobTrackerContext db, UserManager users) : ControllerBase +{ + public sealed record EvidenceRequest(string? Category, string? Title, string? Statement, IReadOnlyList? Tags, string? SourceType, string? SourceReference, bool? IsVerified); + public sealed record EvidenceDto(int Id, string Category, string Title, string Statement, IReadOnlyList Tags, string SourceType, string? SourceReference, bool IsVerified, DateTimeOffset UpdatedAtUtc); + + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var owner = await OwnerAsync(); + if (owner is null) return Unauthorized(); + var rows = await db.Set().AsNoTracking().Where(x => x.OwnerUserId == owner).OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct); + return Ok(rows.Select(ToDto)); + } + + [HttpPost] + public async Task> Create(EvidenceRequest request, CancellationToken ct) + { + var owner = await OwnerAsync(); + if (owner is null) return Unauthorized(); + var error = Validate(request); + if (error is not null) return BadRequest(error); + var row = new CareerEvidence { OwnerUserId = owner }; + Apply(row, request); + db.Add(row); + await db.SaveChangesAsync(ct); + return Ok(ToDto(row)); + } + + [HttpPut("{id:int}")] + public async Task> Update(int id, EvidenceRequest request, CancellationToken ct) + { + var owner = await OwnerAsync(); + if (owner is null) return Unauthorized(); + var row = await db.Set().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == owner, ct); + if (row is null) return NotFound(); + var error = Validate(request); + if (error is not null) return BadRequest(error); + Apply(row, request); + await db.SaveChangesAsync(ct); + return Ok(ToDto(row)); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id, CancellationToken ct) + { + var owner = await OwnerAsync(); + if (owner is null) return Unauthorized(); + var row = await db.Set().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == owner, ct); + if (row is null) return NotFound(); + db.Remove(row); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + private async Task OwnerAsync() => (await users.GetUserAsync(User))?.Id; + private static string? Validate(EvidenceRequest value) => + string.IsNullOrWhiteSpace(value.Title) ? "Title is required." : string.IsNullOrWhiteSpace(value.Statement) ? "Evidence is required." : null; + private static void Apply(CareerEvidence row, EvidenceRequest value) + { + row.Category = Clean(value.Category, "achievement", 32); + row.Title = Clean(value.Title, string.Empty, 255); + row.Statement = value.Statement!.Trim(); + row.TagsJson = JsonSerializer.Serialize((value.Tags ?? []).Select(x => x.Trim()).Where(x => x.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).Take(30)); + row.SourceType = Clean(value.SourceType, "manual", 32); + row.SourceReference = string.IsNullOrWhiteSpace(value.SourceReference) ? null : value.SourceReference.Trim()[..Math.Min(value.SourceReference.Trim().Length, 500)]; + row.IsVerified = value.IsVerified ?? true; + row.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + private static string Clean(string? value, string fallback, int max) { var text = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); return text[..Math.Min(text.Length, max)]; } + private static EvidenceDto ToDto(CareerEvidence row) => new(row.Id, row.Category, row.Title, row.Statement, JsonSerializer.Deserialize>(row.TagsJson) ?? [], row.SourceType, row.SourceReference, row.IsVerified, row.UpdatedAtUtc); +} diff --git a/JobTrackerApi/Data/JobTrackerContext.cs b/JobTrackerApi/Data/JobTrackerContext.cs index 239383b..4716928 100644 --- a/JobTrackerApi/Data/JobTrackerContext.cs +++ b/JobTrackerApi/Data/JobTrackerContext.cs @@ -51,6 +51,7 @@ namespace JobTrackerApi.Data public DbSet CareerProjects => Set(); public DbSet CareerCertifications => Set(); public DbSet CareerLanguages => Set(); + public DbSet CareerEvidence => Set(); public DbSet InterviewPrepNotes => Set(); public DbSet AiWorkspaceNotes => Set(); public DbSet CvVariants => Set(); @@ -418,6 +419,13 @@ namespace JobTrackerApi.Data ConfigureCareerChild(modelBuilder); ConfigureCareerChild(modelBuilder); ConfigureCareerChild(modelBuilder); + modelBuilder.Entity().HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.Category).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.Title).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.SourceType).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.SourceReference).HasMaxLength(500); + modelBuilder.Entity().HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc }); // Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5). modelBuilder.Entity() diff --git a/JobTrackerApi/Migrations/20260830136000_AdoptCareerEvidenceSchema.cs b/JobTrackerApi/Migrations/20260830136000_AdoptCareerEvidenceSchema.cs new file mode 100644 index 0000000..17cb22c --- /dev/null +++ b/JobTrackerApi/Migrations/20260830136000_AdoptCareerEvidenceSchema.cs @@ -0,0 +1,35 @@ +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable +namespace JobTrackerApi.Migrations; + +[DbContext(typeof(JobTrackerContext))] +[Migration("20260830136000_AdoptCareerEvidenceSchema")] +public sealed class AdoptCareerEvidenceSchema : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.Sql( + ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? MySqlDdl : SqliteDdl); + protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.Sql( + ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "DROP TABLE IF EXISTS `CareerEvidence`;" : "DROP TABLE IF EXISTS \"CareerEvidence\";"); + private const string SqliteDdl = """ + CREATE TABLE IF NOT EXISTS "CareerEvidence" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CareerEvidence" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, "Category" TEXT NOT NULL, "Title" TEXT NOT NULL, + "Statement" TEXT NOT NULL, "TagsJson" TEXT NOT NULL, "SourceType" TEXT NOT NULL, + "SourceReference" TEXT NULL, "IsVerified" INTEGER NOT NULL, + "CreatedAtUtc" TEXT NOT NULL, "UpdatedAtUtc" TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS "IX_CareerEvidence_Owner_Updated" ON "CareerEvidence" ("OwnerUserId", "UpdatedAtUtc"); + """; + private const string MySqlDdl = """ + CREATE TABLE IF NOT EXISTS `CareerEvidence` ( + `Id` int NOT NULL AUTO_INCREMENT, `OwnerUserId` varchar(255) NOT NULL, + `Category` varchar(32) NOT NULL, `Title` varchar(255) NOT NULL, + `Statement` longtext NOT NULL, `TagsJson` longtext NOT NULL, + `SourceType` varchar(32) NOT NULL, `SourceReference` varchar(500) NULL, + `IsVerified` tinyint(1) NOT NULL, `CreatedAtUtc` datetime(6) NOT NULL, + `UpdatedAtUtc` datetime(6) NOT NULL, PRIMARY KEY (`Id`)) CHARACTER SET=utf8mb4; + CREATE INDEX IF NOT EXISTS `IX_CareerEvidence_Owner_Updated` ON `CareerEvidence` (`OwnerUserId`, `UpdatedAtUtc`); + """; +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index 3c0c28e..22e943b 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -987,6 +987,24 @@ namespace JobTrackerApi.Migrations b.ToTable("Companies"); }); + modelBuilder.Entity("JobTrackerApi.Models.CareerEvidence", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER"); + b.Property("Category").IsRequired().HasMaxLength(32).HasColumnType("TEXT"); + b.Property("CreatedAtUtc").HasColumnType("TEXT"); + b.Property("IsVerified").HasColumnType("INTEGER"); + b.Property("OwnerUserId").IsRequired().HasMaxLength(255).HasColumnType("TEXT"); + b.Property("SourceReference").HasMaxLength(500).HasColumnType("TEXT"); + b.Property("SourceType").IsRequired().HasMaxLength(32).HasColumnType("TEXT"); + b.Property("Statement").IsRequired().HasColumnType("TEXT"); + b.Property("TagsJson").IsRequired().HasColumnType("TEXT"); + b.Property("Title").IsRequired().HasMaxLength(255).HasColumnType("TEXT"); + b.Property("UpdatedAtUtc").HasColumnType("TEXT"); + b.HasKey("Id"); + b.HasIndex("OwnerUserId", "UpdatedAtUtc"); + b.ToTable("CareerEvidence"); + }); + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => { b.Property("Id") diff --git a/JobTrackerApi/Models/CareerEvidence.cs b/JobTrackerApi/Models/CareerEvidence.cs new file mode 100644 index 0000000..3663ec6 --- /dev/null +++ b/JobTrackerApi/Models/CareerEvidence.cs @@ -0,0 +1,17 @@ +namespace JobTrackerApi.Models; + +// User-approved factual evidence. AI may recommend these records but never writes them. +public sealed class CareerEvidence +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public string Category { get; set; } = "achievement"; + public string Title { get; set; } = string.Empty; + public string Statement { get; set; } = string.Empty; + public string TagsJson { get; set; } = "[]"; + public string SourceType { get; set; } = "manual"; + public string? SourceReference { get; set; } + public bool IsVerified { get; set; } = true; + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/JobTrackerApi/Services/AccountDataExportService.cs b/JobTrackerApi/Services/AccountDataExportService.cs index e2d4f0f..f708f3a 100644 --- a/JobTrackerApi/Services/AccountDataExportService.cs +++ b/JobTrackerApi/Services/AccountDataExportService.cs @@ -138,6 +138,7 @@ public sealed class AccountDataExportService( var careerProjects = await db.CareerProjects.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken); var careerCertifications = await db.CareerCertifications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken); var careerLanguages = await db.CareerLanguages.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken); + var careerEvidence = await db.CareerEvidence.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken); await AddJsonAsync("data/career.json", new { Profile = careerProfile, @@ -148,7 +149,8 @@ public sealed class AccountDataExportService( Projects = careerProjects, Certifications = careerCertifications, Languages = careerLanguages, - }, (careerProfileId is null ? 0 : 1) + careerVersions.Count + careerExperiences.Count + careerEducation.Count + careerSkills.Count + careerProjects.Count + careerCertifications.Count + careerLanguages.Count); + Evidence = careerEvidence, + }, (careerProfileId is null ? 0 : 1) + careerVersions.Count + careerExperiences.Count + careerEducation.Count + careerSkills.Count + careerProjects.Count + careerCertifications.Count + careerLanguages.Count + careerEvidence.Count); var variants = await db.CvVariants.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken); var variantVersions = await db.CvVariantVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken); diff --git a/JobTrackerApi/Services/AccountDeletionService.cs b/JobTrackerApi/Services/AccountDeletionService.cs index 8b11a52..6deecf2 100644 --- a/JobTrackerApi/Services/AccountDeletionService.cs +++ b/JobTrackerApi/Services/AccountDeletionService.cs @@ -248,6 +248,7 @@ public sealed class AccountDeletionService( deleted += await db.CareerProjects.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerCertifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerLanguages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); + deleted += await db.CareerEvidence.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerProfileVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerProfiles.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CvExtractionRuns.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); diff --git a/JobTrackerApi/Services/ApplicationAssetsService.cs b/JobTrackerApi/Services/ApplicationAssetsService.cs index 33f41fe..fa75580 100644 --- a/JobTrackerApi/Services/ApplicationAssetsService.cs +++ b/JobTrackerApi/Services/ApplicationAssetsService.cs @@ -1,6 +1,7 @@ using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace JobTrackerApi.Services; @@ -176,6 +177,26 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService match.RelevantProjects.Select(p => p.Subtitle is null ? p.Title : $"{p.Title} — {p.Subtitle}").ToList())); } + var evidenceTerms = match.MatchedSkills.Concat(analysis.Keywords) + .Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var evidence = await _db.CareerEvidence.AsNoTracking() + .Where(x => x.OwnerUserId == ownerUserId && x.IsVerified) + .OrderByDescending(x => x.UpdatedAtUtc) + .ToListAsync(ct); + var relevantEvidence = evidence.Where(item => + { + var searchable = $"{item.Title} {item.Statement} {string.Join(' ', EvidenceTags(item.TagsJson))}"; + return evidenceTerms.Any(term => searchable.Contains(term, StringComparison.OrdinalIgnoreCase)); + }).Take(6).Select(item => $"{item.Title} — {item.Statement}").ToList(); + if (relevantEvidence.Count > 0) + { + suggestions.Add(new TailoringSuggestionDto( + "career-evidence", + "Verified evidence to reuse", + "Facts you previously approved. Adapt the wording, but keep the claim accurate.", + relevantEvidence)); + } + if (analysis.Keywords.Count > 0) { suggestions.Add(new TailoringSuggestionDto( @@ -297,4 +318,10 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService private Task LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => _db.JobApplications.AsNoTracking() .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + + private static IReadOnlyList EvidenceTags(string? json) + { + try { return JsonSerializer.Deserialize>(json ?? "[]") ?? []; } + catch (JsonException) { return []; } + } } diff --git a/JobTrackerApi/Services/StartupSchemaOwnership.cs b/JobTrackerApi/Services/StartupSchemaOwnership.cs index 1a04544..6212859 100644 --- a/JobTrackerApi/Services/StartupSchemaOwnership.cs +++ b/JobTrackerApi/Services/StartupSchemaOwnership.cs @@ -25,6 +25,7 @@ internal static class StartupSchemaOwnership "Attachments", "CareerCertifications", "CareerEducations", + "CareerEvidence", "CareerExperiences", "CareerLanguages", "CareerProfiles", diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 3ef2fbc..ea44934 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -69,10 +69,11 @@ Updated: 2026-08-31 - Moved the complete Career Profile aggregate into a provider-aware migration; canonical and long-tail JSON, version history, all six relational child types, stable ordering, indexes, and cascades are preserved. - Completed JT-019 by moving all seven ASP.NET Identity tables into provider-aware migration ownership; accounts, credentials, 2FA state, preferences, roles, claims, external logins, tokens, indexes, and cascades are preserved. Startup schema code is now repair-only. - Added immutable submitted-application packages to the Job Workspace. Each append-only snapshot freezes the career-profile/CV source, rendered CV, cover letter, application material and SHA-256-identified copies of all attachments; history can be reviewed and preserved files downloaded without following mutable live references. Tenant filtering, account export/deletion, storage reconciliation, SQLite/MariaDB migrations and EN/NB UI are included. +- Added a factual Career Evidence Bank with categories, keywords and provenance. Only user-approved records are stored; AI has no write route. Verified evidence is recommended by the existing deterministic application-tailoring flow when its tags or content match the advert, and the feature includes tenant isolation, account lifecycle handling, provider-safe migration ownership and EN/NB UI. ### In progress -- Continue the prioritized product-value roadmap with the career evidence bank after the submitted-package batch completes its full regression gate. SEC-006/SEC-007 still await a running Linux Docker daemon for image/runtime proof; production activation remains separately gated. +- Complete the career evidence bank regression gate, then continue the prioritized product-value roadmap with saved job searches. SEC-006/SEC-007 still await a running Linux Docker daemon for image/runtime proof; production activation remains separately gated. ### Remaining @@ -107,6 +108,7 @@ Updated: 2026-08-31 - Prebuilt-export browser path: focused Playwright passed 1/1 while serving the existing optimized export without invoking another Next build. - Submitted-package focused verification: storage/snapshot plus provider migration tests passed 6/6; the complete migration chain passed 18/18; application-assets UI passed 16/16; ESLint and the optimized Next build/TypeScript passed. - Submitted-package complete regression: backend 740/740 and frontend 60 suites with 261/261 tests passed. The full run also exposed and fixed a date-sensitive reminder fixture that mixed a fixed worker clock with `DateTime.Now` seed data. +- Career-evidence focused verification: backend/controller/schema gates passed 21/21, the evidence UI regression passed 1/1, ESLint passed with zero warnings, and the optimized Next build/TypeScript passed. Complete regression then passed backend 741/741 and frontend 61 suites with 262/262 tests. - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed. diff --git a/job-tracker-ui/src/career-evidence.test.tsx b/job-tracker-ui/src/career-evidence.test.tsx new file mode 100644 index 0000000..17a6271 --- /dev/null +++ b/job-tracker-ui/src/career-evidence.test.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import CareerEvidenceBank from "./views/career/CareerEvidenceBank"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { api } from "./api"; + +jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() }, getApiErrorMessage: (_e: unknown, fallback: string) => fallback })); +jest.mock("./confirm", () => ({ useConfirm: () => ({ confirm: jest.fn().mockResolvedValue(true) }) })); +const mocked = api as jest.Mocked; + +test("saves factual evidence with provenance and tags", async () => { + mocked.get.mockResolvedValue({ data: [] } as any); + mocked.post.mockResolvedValue({ data: { id: 1, category: "achievement", title: "Reduced incidents", statement: "Added rollback checks", tags: ["reliability"], sourceReference: "retrospective" } } as any); + render(); + fireEvent.change(await screen.findByLabelText("Short title"), { target: { value: "Reduced incidents" } }); + fireEvent.change(screen.getByLabelText("Evidence statement"), { target: { value: "Added rollback checks" } }); + fireEvent.change(screen.getByLabelText("Skills and keywords"), { target: { value: "reliability" } }); + fireEvent.change(screen.getByLabelText("Source or provenance"), { target: { value: "retrospective" } }); + fireEvent.click(screen.getByRole("button", { name: "Save evidence" })); + await waitFor(() => expect(mocked.post).toHaveBeenCalledWith("/career/evidence", expect.objectContaining({ title: "Reduced incidents", isVerified: true, sourceType: "manual" }))); + expect(await screen.findByText("Added rollback checks")).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 8c927bb..0845297 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -736,6 +736,32 @@ export const translations = { careerOverviewTitle: "Career Workspace", careerWorkspaceNavigation: "Career workspace section", careerWorkspaceOverviewTab: "Overview", + careerWorkspaceEvidenceTab: "Evidence bank", + careerEvidenceTitle: "Career evidence bank", + careerEvidenceSubtitle: "Save factual achievements and examples once, then reuse them when tailoring applications.", + careerEvidenceAdd: "Add factual evidence", + careerEvidenceCategory: "Category", + careerEvidenceCategory_achievement: "Achievement", + careerEvidenceCategory_metric: "Metric", + careerEvidenceCategory_project: "Project", + careerEvidenceCategory_technology: "Technology", + careerEvidenceCategory_responsibility: "Responsibility", + careerEvidenceCategory_example: "Example", + careerEvidenceItemTitle: "Short title", + careerEvidenceStatement: "Evidence statement", + careerEvidenceTruthHelp: "Only save claims you can support. AI suggestions never enter this bank automatically.", + careerEvidenceTags: "Skills and keywords", + careerEvidenceTagsHelp: "Comma-separated; used to recommend relevant evidence for a job.", + careerEvidenceSource: "Source or provenance", + careerEvidenceSourceHelp: "For example: Warwickshire role, project retrospective, certificate.", + careerEvidenceSave: "Save evidence", + careerEvidenceEmpty: "No evidence saved yet. Start with one concrete achievement or project outcome.", + careerEvidenceManualSource: "Added by you", + careerEvidenceLoadFailed: "Could not load career evidence.", + careerEvidenceSaveFailed: "Could not save career evidence.", + careerEvidenceDeleteFailed: "Could not delete career evidence.", + careerEvidenceDeleteTitle: "Delete evidence?", + careerEvidenceDeleteMessage: "Delete “{title}”? This removes it from future tailoring suggestions.", careerWorkspaceProfileTab: "Career profile", careerWorkspaceImportTab: "Import review", careerOverviewSubtitle: "Choose what you want to work on next.", @@ -3068,6 +3094,32 @@ export const translations = { careerOverviewTitle: "Karriereområde", careerWorkspaceNavigation: "Område i karrierearbeidsområdet", careerWorkspaceOverviewTab: "Oversikt", + careerWorkspaceEvidenceTab: "Kunnskapsbank", + careerEvidenceTitle: "Kunnskapsbank for karrieren", + careerEvidenceSubtitle: "Lagre faktiske prestasjoner og eksempler én gang, og gjenbruk dem når du tilpasser søknader.", + careerEvidenceAdd: "Legg til faktisk dokumentasjon", + careerEvidenceCategory: "Kategori", + careerEvidenceCategory_achievement: "Prestasjon", + careerEvidenceCategory_metric: "Målt resultat", + careerEvidenceCategory_project: "Prosjekt", + careerEvidenceCategory_technology: "Teknologi", + careerEvidenceCategory_responsibility: "Ansvar", + careerEvidenceCategory_example: "Eksempel", + careerEvidenceItemTitle: "Kort tittel", + careerEvidenceStatement: "Dokumentert påstand", + careerEvidenceTruthHelp: "Lagre bare påstander du kan dokumentere. AI-forslag legges aldri til automatisk.", + careerEvidenceTags: "Ferdigheter og nøkkelord", + careerEvidenceTagsHelp: "Kommaseparert; brukes til å anbefale relevant dokumentasjon for en stilling.", + careerEvidenceSource: "Kilde eller opphav", + careerEvidenceSourceHelp: "For eksempel: rolle i Warwickshire, prosjektretrospektiv, sertifikat.", + careerEvidenceSave: "Lagre dokumentasjon", + careerEvidenceEmpty: "Ingen dokumentasjon er lagret ennå. Start med én konkret prestasjon eller et prosjektresultat.", + careerEvidenceManualSource: "Lagt til av deg", + careerEvidenceLoadFailed: "Kunne ikke laste karrieredokumentasjon.", + careerEvidenceSaveFailed: "Kunne ikke lagre karrieredokumentasjon.", + careerEvidenceDeleteFailed: "Kunne ikke slette karrieredokumentasjon.", + careerEvidenceDeleteTitle: "Slette dokumentasjon?", + careerEvidenceDeleteMessage: "Slette «{title}»? Dette fjerner den fra fremtidige tilpasningsforslag.", careerWorkspaceProfileTab: "Karriereprofil", careerWorkspaceImportTab: "Importgjennomgang", careerOverviewSubtitle: "Velg hva du vil arbeide med videre.", diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx index 5bc3444..76edff9 100644 --- a/job-tracker-ui/src/views/CareerProfilePage.tsx +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -10,6 +10,7 @@ import { api, getApiErrorMessage } from "../api"; import CropImageDialog from "../components/CropImageDialog"; import ProfileCompleteness from "./career/ProfileCompleteness"; import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview"; +import CareerEvidenceBank from "./career/CareerEvidenceBank"; import { EducationSection, InterestsSection, @@ -128,12 +129,12 @@ type CareerSectionStatus = { key: string; label: string; complete: boolean; coun type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] }; type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null }; type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean }; -type CareerWorkspaceSection = "overview" | "profile" | "import"; +type CareerWorkspaceSection = "overview" | "profile" | "evidence" | "import"; function initialWorkspaceSection(): CareerWorkspaceSection { if (typeof window === "undefined") return "overview"; const section = new URLSearchParams(window.location.search).get("section"); - return section === "profile" || section === "import" ? section : "overview"; + return section === "profile" || section === "evidence" || section === "import" ? section : "overview"; } // CareerProfilePage backs /career: the master career profile — the single editable source of @@ -322,6 +323,7 @@ export default function CareerProfilePage() { > {t("careerWorkspaceOverviewTab")} {t("careerWorkspaceProfileTab")} + {t("careerWorkspaceEvidenceTab")} {t("careerWorkspaceImportTab")} @@ -333,6 +335,7 @@ export default function CareerProfilePage() { > + @@ -347,7 +350,9 @@ export default function CareerProfilePage() { /> ) : null} - {workspaceSection !== "overview" ? + {workspaceSection === "evidence" ? : null} + + {workspaceSection === "profile" || workspaceSection === "import" ? {workspaceSection === "profile" ? ([]); const [title, setTitle] = useState(""); const [statement, setStatement] = useState(""); + const [category, setCategory] = useState("achievement"); const [tags, setTags] = useState(""); const [source, setSource] = useState(""); + const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const load = () => api.get("/career/evidence").then(r => { setItems(r.data); setError(null); }).catch(e => setError(getApiErrorMessage(e, t("careerEvidenceLoadFailed")))); + useEffect(() => { void load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + const add = async () => { setBusy(true); try { const r = await api.post("/career/evidence", { category, title, statement, tags: tags.split(","), sourceType: "manual", sourceReference: source, isVerified: true }); setItems(v => [r.data, ...v]); setTitle(""); setStatement(""); setTags(""); setSource(""); setError(null); } catch (e) { setError(getApiErrorMessage(e, t("careerEvidenceSaveFailed"))); } finally { setBusy(false); } }; + const remove = async (item: Evidence) => { if (!(await confirm({ title: t("careerEvidenceDeleteTitle"), message: t("careerEvidenceDeleteMessage", { title: item.title }), confirmLabel: t("deleteAction"), destructive: true }))) return; try { await api.delete(`/career/evidence/${item.id}`); setItems(v => v.filter(x => x.id !== item.id)); } catch (e) { setError(getApiErrorMessage(e, t("careerEvidenceDeleteFailed"))); } }; + const categories = ["achievement", "metric", "project", "technology", "responsibility", "example"]; + const categoryLabel = (value: string) => t(`careerEvidenceCategory_${value}` as "careerEvidenceCategory_achievement"); + return + {t("careerEvidenceTitle")}{t("careerEvidenceSubtitle")} + {error && void load()}>{t("retry")}}>{error}} + + {t("careerEvidenceAdd")} + setCategory(e.target.value)}>{categories.map(v => {categoryLabel(v)})} + setTitle(e.target.value)} /> + setStatement(e.target.value)} multiline minRows={3} helperText={t("careerEvidenceTruthHelp")} /> + setTags(e.target.value)} helperText={t("careerEvidenceTagsHelp")} /> + setSource(e.target.value)} helperText={t("careerEvidenceSourceHelp")} /> + + + {items.length === 0 ? {t("careerEvidenceEmpty")} : items.map(item => + {item.title}{categoryLabel(item.category)} · {item.sourceReference || t("careerEvidenceManualSource")} + {item.statement}{item.tags.map(tag => )} + )} + ; +}