feat(career): add factual evidence bank
This commit is contained in:
@@ -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 `CareerProjects`", script, StringComparison.Ordinal);
|
||||||
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerCertifications`", 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 `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 `AspNetRoles`", script, StringComparison.Ordinal);
|
||||||
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserClaims`", 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);
|
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);
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ namespace JobTrackerApi.Data
|
|||||||
public DbSet<CareerProject> CareerProjects => Set<CareerProject>();
|
public DbSet<CareerProject> CareerProjects => Set<CareerProject>();
|
||||||
public DbSet<CareerCertification> CareerCertifications => Set<CareerCertification>();
|
public DbSet<CareerCertification> CareerCertifications => Set<CareerCertification>();
|
||||||
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
|
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
|
||||||
|
public DbSet<CareerEvidence> CareerEvidence => Set<CareerEvidence>();
|
||||||
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
||||||
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
|
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
|
||||||
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
|
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
|
||||||
@@ -418,6 +419,13 @@ namespace JobTrackerApi.Data
|
|||||||
ConfigureCareerChild<CareerProject>(modelBuilder);
|
ConfigureCareerChild<CareerProject>(modelBuilder);
|
||||||
ConfigureCareerChild<CareerCertification>(modelBuilder);
|
ConfigureCareerChild<CareerCertification>(modelBuilder);
|
||||||
ConfigureCareerChild<CareerLanguage>(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).
|
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
|
||||||
modelBuilder.Entity<InterviewPrepNote>()
|
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");
|
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 =>
|
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -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 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 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 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
|
await AddJsonAsync("data/career.json", new
|
||||||
{
|
{
|
||||||
Profile = careerProfile,
|
Profile = careerProfile,
|
||||||
@@ -148,7 +149,8 @@ public sealed class AccountDataExportService(
|
|||||||
Projects = careerProjects,
|
Projects = careerProjects,
|
||||||
Certifications = careerCertifications,
|
Certifications = careerCertifications,
|
||||||
Languages = careerLanguages,
|
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 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);
|
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.CareerProjects.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||||
deleted += await db.CareerCertifications.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.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.CareerProfileVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||||
deleted += await db.CareerProfiles.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);
|
deleted += await db.CvExtractionRuns.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace JobTrackerApi.Services;
|
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()));
|
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)
|
if (analysis.Keywords.Count > 0)
|
||||||
{
|
{
|
||||||
suggestions.Add(new TailoringSuggestionDto(
|
suggestions.Add(new TailoringSuggestionDto(
|
||||||
@@ -297,4 +318,10 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
|||||||
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
||||||
_db.JobApplications.AsNoTracking()
|
_db.JobApplications.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
.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",
|
"Attachments",
|
||||||
"CareerCertifications",
|
"CareerCertifications",
|
||||||
"CareerEducations",
|
"CareerEducations",
|
||||||
|
"CareerEvidence",
|
||||||
"CareerExperiences",
|
"CareerExperiences",
|
||||||
"CareerLanguages",
|
"CareerLanguages",
|
||||||
"CareerProfiles",
|
"CareerProfiles",
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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 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
|
### 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
|
### 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.
|
- 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 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.
|
- 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.
|
- Focused frontend: 2 suites, 6 tests passed.
|
||||||
- Full frontend: 64 suites, 272 tests passed.
|
- Full frontend: 64 suites, 272 tests passed.
|
||||||
- Next production build and TypeScript: 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();
|
||||||
|
});
|
||||||
@@ -736,6 +736,32 @@ export const translations = {
|
|||||||
careerOverviewTitle: "Career Workspace",
|
careerOverviewTitle: "Career Workspace",
|
||||||
careerWorkspaceNavigation: "Career workspace section",
|
careerWorkspaceNavigation: "Career workspace section",
|
||||||
careerWorkspaceOverviewTab: "Overview",
|
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",
|
careerWorkspaceProfileTab: "Career profile",
|
||||||
careerWorkspaceImportTab: "Import review",
|
careerWorkspaceImportTab: "Import review",
|
||||||
careerOverviewSubtitle: "Choose what you want to work on next.",
|
careerOverviewSubtitle: "Choose what you want to work on next.",
|
||||||
@@ -3068,6 +3094,32 @@ export const translations = {
|
|||||||
careerOverviewTitle: "Karriereområde",
|
careerOverviewTitle: "Karriereområde",
|
||||||
careerWorkspaceNavigation: "Område i karrierearbeidsområdet",
|
careerWorkspaceNavigation: "Område i karrierearbeidsområdet",
|
||||||
careerWorkspaceOverviewTab: "Oversikt",
|
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",
|
careerWorkspaceProfileTab: "Karriereprofil",
|
||||||
careerWorkspaceImportTab: "Importgjennomgang",
|
careerWorkspaceImportTab: "Importgjennomgang",
|
||||||
careerOverviewSubtitle: "Velg hva du vil arbeide med videre.",
|
careerOverviewSubtitle: "Velg hva du vil arbeide med videre.",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { api, getApiErrorMessage } from "../api";
|
|||||||
import CropImageDialog from "../components/CropImageDialog";
|
import CropImageDialog from "../components/CropImageDialog";
|
||||||
import ProfileCompleteness from "./career/ProfileCompleteness";
|
import ProfileCompleteness from "./career/ProfileCompleteness";
|
||||||
import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview";
|
import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview";
|
||||||
|
import CareerEvidenceBank from "./career/CareerEvidenceBank";
|
||||||
import {
|
import {
|
||||||
EducationSection,
|
EducationSection,
|
||||||
InterestsSection,
|
InterestsSection,
|
||||||
@@ -128,12 +129,12 @@ type CareerSectionStatus = { key: string; label: string; complete: boolean; coun
|
|||||||
type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] };
|
type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] };
|
||||||
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null };
|
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null };
|
||||||
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
|
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
|
||||||
type CareerWorkspaceSection = "overview" | "profile" | "import";
|
type CareerWorkspaceSection = "overview" | "profile" | "evidence" | "import";
|
||||||
|
|
||||||
function initialWorkspaceSection(): CareerWorkspaceSection {
|
function initialWorkspaceSection(): CareerWorkspaceSection {
|
||||||
if (typeof window === "undefined") return "overview";
|
if (typeof window === "undefined") return "overview";
|
||||||
const section = new URLSearchParams(window.location.search).get("section");
|
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
|
// 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="overview">{t("careerWorkspaceOverviewTab")}</MenuItem>
|
||||||
<MenuItem value="profile">{t("careerWorkspaceProfileTab")}</MenuItem>
|
<MenuItem value="profile">{t("careerWorkspaceProfileTab")}</MenuItem>
|
||||||
|
<MenuItem value="evidence">{t("careerWorkspaceEvidenceTab")}</MenuItem>
|
||||||
<MenuItem value="import">{t("careerWorkspaceImportTab")}</MenuItem>
|
<MenuItem value="import">{t("careerWorkspaceImportTab")}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -333,6 +335,7 @@ export default function CareerProfilePage() {
|
|||||||
>
|
>
|
||||||
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
|
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
|
||||||
<Tab value="profile" label={t("careerWorkspaceProfileTab")} />
|
<Tab value="profile" label={t("careerWorkspaceProfileTab")} />
|
||||||
|
<Tab value="evidence" label={t("careerWorkspaceEvidenceTab")} />
|
||||||
<Tab value="import" label={t("careerWorkspaceImportTab")} />
|
<Tab value="import" label={t("careerWorkspaceImportTab")} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -347,7 +350,9 @@ export default function CareerProfilePage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
{workspaceSection === "profile" ? <ProfileCompleteness
|
||||||
completeness={completeness}
|
completeness={completeness}
|
||||||
versions={versions}
|
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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user