feat(career): add factual evidence bank

This commit is contained in:
cesnimda
2026-08-31 17:03:09 +02:00
parent 5337ba3a5e
commit 78ad0c66f9
16 changed files with 362 additions and 5 deletions
@@ -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<ICurrentUserService>(); current.SetupGet(x => x.UserId).Returns("owner-1");
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().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<CareerEvidenceController.EvidenceDto>(Assert.IsType<OkObjectResult>((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<IEnumerable<CareerEvidenceController.EvidenceDto>>(Assert.IsType<OkObjectResult>((await controller.List(default)).Result).Value).ToList();
Assert.Single(listed);
Assert.Equal("Reduced incidents", listed[0].Title);
Assert.IsType<NoContentResult>(await controller.Delete(created.Id, default));
Assert.Empty(await db.CareerEvidence.Where(x => x.OwnerUserId == "owner-1").ToListAsync());
}
private static Mock<UserManager<ApplicationUser>> Users(string id)
{
var store = new Mock<IUserStore<ApplicationUser>>();
var manager = new Mock<UserManager<ApplicationUser>>(store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
manager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(new ApplicationUser { Id = id });
return manager;
}
}
@@ -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);
@@ -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<ApplicationUser> users) : ControllerBase
{
public sealed record EvidenceRequest(string? Category, string? Title, string? Statement, IReadOnlyList<string>? Tags, string? SourceType, string? SourceReference, bool? IsVerified);
public sealed record EvidenceDto(int Id, string Category, string Title, string Statement, IReadOnlyList<string> Tags, string SourceType, string? SourceReference, bool IsVerified, DateTimeOffset UpdatedAtUtc);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<EvidenceDto>>> List(CancellationToken ct)
{
var owner = await OwnerAsync();
if (owner is null) return Unauthorized();
var rows = await db.Set<CareerEvidence>().AsNoTracking().Where(x => x.OwnerUserId == owner).OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
return Ok(rows.Select(ToDto));
}
[HttpPost]
public async Task<ActionResult<EvidenceDto>> 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<ActionResult<EvidenceDto>> Update(int id, EvidenceRequest request, CancellationToken ct)
{
var owner = await OwnerAsync();
if (owner is null) return Unauthorized();
var row = await db.Set<CareerEvidence>().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<IActionResult> Delete(int id, CancellationToken ct)
{
var owner = await OwnerAsync();
if (owner is null) return Unauthorized();
var row = await db.Set<CareerEvidence>().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<string?> 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<List<string>>(row.TagsJson) ?? [], row.SourceType, row.SourceReference, row.IsVerified, row.UpdatedAtUtc);
}
+8
View File
@@ -51,6 +51,7 @@ namespace JobTrackerApi.Data
public DbSet<CareerProject> CareerProjects => Set<CareerProject>();
public DbSet<CareerCertification> CareerCertifications => Set<CareerCertification>();
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
public DbSet<CareerEvidence> CareerEvidence => Set<CareerEvidence>();
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
@@ -418,6 +419,13 @@ namespace JobTrackerApi.Data
ConfigureCareerChild<CareerProject>(modelBuilder);
ConfigureCareerChild<CareerCertification>(modelBuilder);
ConfigureCareerChild<CareerLanguage>(modelBuilder);
modelBuilder.Entity<CareerEvidence>().HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CareerEvidence>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<CareerEvidence>().Property(x => x.Category).HasMaxLength(32);
modelBuilder.Entity<CareerEvidence>().Property(x => x.Title).HasMaxLength(255);
modelBuilder.Entity<CareerEvidence>().Property(x => x.SourceType).HasMaxLength(32);
modelBuilder.Entity<CareerEvidence>().Property(x => x.SourceReference).HasMaxLength(500);
modelBuilder.Entity<CareerEvidence>().HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc });
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
modelBuilder.Entity<InterviewPrepNote>()
@@ -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`);
""";
}
@@ -987,6 +987,24 @@ namespace JobTrackerApi.Migrations
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerEvidence", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("Category").IsRequired().HasMaxLength(32).HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc").HasColumnType("TEXT");
b.Property<bool>("IsVerified").HasColumnType("INTEGER");
b.Property<string>("OwnerUserId").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<string>("SourceReference").HasMaxLength(500).HasColumnType("TEXT");
b.Property<string>("SourceType").IsRequired().HasMaxLength(32).HasColumnType("TEXT");
b.Property<string>("Statement").IsRequired().HasColumnType("TEXT");
b.Property<string>("TagsJson").IsRequired().HasColumnType("TEXT");
b.Property<string>("Title").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAtUtc").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerUserId", "UpdatedAtUtc");
b.ToTable("CareerEvidence");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
+17
View File
@@ -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;
}
@@ -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);
@@ -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);
@@ -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<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
_db.JobApplications.AsNoTracking()
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
private static IReadOnlyList<string> EvidenceTags(string? json)
{
try { return JsonSerializer.Deserialize<List<string>>(json ?? "[]") ?? []; }
catch (JsonException) { return []; }
}
}
@@ -25,6 +25,7 @@ internal static class StartupSchemaOwnership
"Attachments",
"CareerCertifications",
"CareerEducations",
"CareerEvidence",
"CareerExperiences",
"CareerLanguages",
"CareerProfiles",
+3 -1
View File
@@ -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.
@@ -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<typeof api>;
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(<I18nProvider><CareerEvidenceBank /></I18nProvider>);
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();
});
+52
View File
@@ -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.",
@@ -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() {
>
<MenuItem value="overview">{t("careerWorkspaceOverviewTab")}</MenuItem>
<MenuItem value="profile">{t("careerWorkspaceProfileTab")}</MenuItem>
<MenuItem value="evidence">{t("careerWorkspaceEvidenceTab")}</MenuItem>
<MenuItem value="import">{t("careerWorkspaceImportTab")}</MenuItem>
</Select>
</FormControl>
@@ -333,6 +335,7 @@ export default function CareerProfilePage() {
>
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
<Tab value="profile" label={t("careerWorkspaceProfileTab")} />
<Tab value="evidence" label={t("careerWorkspaceEvidenceTab")} />
<Tab value="import" label={t("careerWorkspaceImportTab")} />
</Tabs>
</Paper>
@@ -347,7 +350,9 @@ export default function CareerProfilePage() {
/>
) : null}
{workspaceSection !== "overview" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
{workspaceSection === "evidence" ? <CareerEvidenceBank /> : null}
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
{workspaceSection === "profile" ? <ProfileCompleteness
completeness={completeness}
versions={versions}
@@ -0,0 +1,38 @@
import React, { useEffect, useState } from "react";
import { Alert, Box, Button, Chip, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { api, getApiErrorMessage } from "../../api";
import { useConfirm } from "../../confirm";
import { useI18n } from "../../i18n/I18nProvider";
type Evidence = { id: number; category: string; title: string; statement: string; tags: string[]; sourceReference: string | null };
export default function CareerEvidenceBank() {
const { t } = useI18n(); const { confirm } = useConfirm();
const [items, setItems] = useState<Evidence[]>([]); 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<string | null>(null); const [busy, setBusy] = useState(false);
const load = () => api.get<Evidence[]>("/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<Evidence>("/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 <Stack spacing={2}>
<Box><Typography variant="h5" fontWeight={800}>{t("careerEvidenceTitle")}</Typography><Typography color="text.secondary">{t("careerEvidenceSubtitle")}</Typography></Box>
{error && <Alert severity="error" action={<Button color="inherit" onClick={() => void load()}>{t("retry")}</Button>}>{error}</Alert>}
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}><Stack spacing={1.5}>
<Typography variant="subtitle1" fontWeight={700}>{t("careerEvidenceAdd")}</Typography>
<TextField select size="small" label={t("careerEvidenceCategory")} value={category} onChange={e => setCategory(e.target.value)}>{categories.map(v => <MenuItem key={v} value={v}>{categoryLabel(v)}</MenuItem>)}</TextField>
<TextField label={t("careerEvidenceItemTitle")} value={title} onChange={e => setTitle(e.target.value)} />
<TextField label={t("careerEvidenceStatement")} value={statement} onChange={e => setStatement(e.target.value)} multiline minRows={3} helperText={t("careerEvidenceTruthHelp")} />
<TextField label={t("careerEvidenceTags")} value={tags} onChange={e => setTags(e.target.value)} helperText={t("careerEvidenceTagsHelp")} />
<TextField label={t("careerEvidenceSource")} value={source} onChange={e => setSource(e.target.value)} helperText={t("careerEvidenceSourceHelp")} />
<Button variant="contained" disabled={busy || !title.trim() || !statement.trim()} onClick={() => void add()} sx={{ alignSelf: "flex-start" }}>{t("careerEvidenceSave")}</Button>
</Stack></Paper>
{items.length === 0 ? <Alert severity="info">{t("careerEvidenceEmpty")}</Alert> : items.map(item => <Paper key={item.id} variant="outlined" sx={{ p: 2, borderRadius: 3 }}><Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" gap={1}><Box><Typography fontWeight={700}>{item.title}</Typography><Typography variant="caption" color="text.secondary">{categoryLabel(item.category)} · {item.sourceReference || t("careerEvidenceManualSource")}</Typography></Box><Button color="error" size="small" startIcon={<DeleteOutlineIcon />} onClick={() => void remove(item)}>{t("deleteAction")}</Button></Stack>
<Typography variant="body2">{item.statement}</Typography><Stack direction="row" flexWrap="wrap" gap={0.5}>{item.tags.map(tag => <Chip key={tag} size="small" label={tag} />)}</Stack>
</Stack></Paper>)}
</Stack>;
}