Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597191f384 | |||
| 47d05ba946 | |||
| 00c7e0b6ca | |||
| 00a035ea20 | |||
| 5916f09852 |
@@ -30,6 +30,11 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
||||
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
||||
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
||||
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
||||
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
|
||||
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
|
||||
public DbSet<CvVersion> CvVersions => Set<CvVersion>();
|
||||
public DbSet<TailoredApplication> TailoredApplications => Set<TailoredApplication>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -164,6 +169,82 @@ namespace JobTrackerApi.Data
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CareerProfileId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
|
||||
modelBuilder.Entity<InterviewPrepNote>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<InterviewPrepNote>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<InterviewPrepNote>()
|
||||
.HasOne(x => x.JobApplication)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Generic AI workspace note persistence (candidate fit, focus plan) -- same rationale as
|
||||
// InterviewPrepNote above, generalized because these DTOs are irregular/nested enough that
|
||||
// per-field columns would be unreasonable.
|
||||
modelBuilder.Entity<AiWorkspaceNote>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<AiWorkspaceNote>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.NoteType })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<AiWorkspaceNote>()
|
||||
.HasOne(x => x.JobApplication)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// CV variants (career-workspace-implementation-roadmap.md Phase F2). A variant is not
|
||||
// owned by a job -- it survives job deletion and can be reused across applications; only
|
||||
// TailoredApplication (the reference) is job-scoped.
|
||||
modelBuilder.Entity<CvVariant>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<CvVariant>()
|
||||
.HasIndex(x => x.OwnerUserId);
|
||||
|
||||
modelBuilder.Entity<CvVariant>()
|
||||
.HasOne(x => x.CareerProfile)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CareerProfileId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
modelBuilder.Entity<CvVersion>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<CvVersion>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.CvVariantId, x.Version });
|
||||
|
||||
modelBuilder.Entity<CvVersion>()
|
||||
.HasOne(x => x.CvVariant)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CvVariantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<TailoredApplication>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<TailoredApplication>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<TailoredApplication>()
|
||||
.HasOne(x => x.CvVariant)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CvVariantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<TailoredApplication>()
|
||||
.HasOne(x => x.JobApplication)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Candidate fit and focus plan share AiWorkspaceNote persistence with the same rules as
|
||||
// InterviewPrepNote: reuse across calls, regenerate on refresh, regenerate when the attachment
|
||||
// selection changes (career-workspace-implementation-roadmap.md Phase F5).
|
||||
public sealed class AiWorkspaceNotePersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetCandidateFit_persists_and_reuses_the_generated_note()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobWithCvAsync(db);
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
var callCount = 0;
|
||||
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; });
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
|
||||
var first = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
|
||||
var callsAfterFirst = callCount;
|
||||
|
||||
var second = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
|
||||
|
||||
Assert.Equal(GetDto(first).MatchSummary, GetDto(second).MatchSummary);
|
||||
Assert.Equal(callsAfterFirst, callCount);
|
||||
|
||||
var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "candidate-fit"));
|
||||
Assert.NotEmpty(stored.ResultJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCandidateFit_regenerates_when_refresh_is_requested()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobWithCvAsync(db);
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
var callCount = 0;
|
||||
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; });
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
|
||||
await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
|
||||
var callsAfterFirst = callCount;
|
||||
|
||||
var refreshed = await controller.GetCandidateFit(job.Id, null, true, CancellationToken.None);
|
||||
|
||||
Assert.True(callCount > callsAfterFirst);
|
||||
Assert.NotNull(GetDto(refreshed).MatchSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFocusPlan_persists_and_reuses_the_generated_note()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobWithCvAsync(db);
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
var callCount = 0;
|
||||
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(() => { callCount++; return $"Text {callCount}"; });
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
|
||||
await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None);
|
||||
var callsAfterFirst = callCount;
|
||||
|
||||
await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None);
|
||||
|
||||
Assert.Equal(callsAfterFirst, callCount);
|
||||
|
||||
var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "focus-plan"));
|
||||
Assert.NotEmpty(stored.ResultJson);
|
||||
}
|
||||
|
||||
private static JobApplicationsController.CandidateFitDto GetDto(ActionResult<JobApplicationsController.CandidateFitDto> result)
|
||||
=> (JobApplicationsController.CandidateFitDto)Assert.IsType<OkObjectResult>(result.Result).Value!;
|
||||
|
||||
private static async Task<JobApplication> SeedJobWithCvAsync(JobTrackerContext db)
|
||||
{
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
UserName = "user@example.test",
|
||||
Email = "user@example.test",
|
||||
ProfileCvText = "Built .NET APIs and led backend delivery with SQL and Docker.",
|
||||
ProfileCvStructureJson = "[]",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
Description = "Needs .NET, SQL, and Docker experience.",
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
|
||||
{
|
||||
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
|
||||
var controller = new JobApplicationsController(
|
||||
db,
|
||||
summarizer,
|
||||
Mock.Of<IAppEmailSender>(),
|
||||
TestHostFactory.CreateUserManager(user).Object,
|
||||
NullLogger<JobApplicationsController>.Instance,
|
||||
Mock.Of<ICvTemplateRenderer>(),
|
||||
Mock.Of<ICvPdfExporter>());
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md). Every
|
||||
// TailoredCvDraft save should dual-write a CvVariant + CvVersion + TailoredApplication without
|
||||
// changing TailoredCvDraft's own behavior -- these tests lock in that seam.
|
||||
public sealed class CvVariantSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveTailoredCvDraft_creates_a_variant_version_and_application_link()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobAsync(db);
|
||||
var controller = CreateController(db, "user-1");
|
||||
|
||||
var result = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest(
|
||||
"ats-minimal", "Backend Engineer", new List<string> { "Built things." }, new List<string> { "C#" },
|
||||
new List<TailoredCvExperienceItem>(), new List<TailoredCvEducationItem>(), new List<TailoredCvCustomSection>(), null, "edited"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
|
||||
var variant = Assert.Single(db.CvVariants.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1"));
|
||||
Assert.Equal(1, variant.Version);
|
||||
Assert.Contains("Backend Developer", variant.Name);
|
||||
|
||||
var link = Assert.Single(db.TailoredApplications.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id));
|
||||
Assert.Equal(variant.Id, link.CvVariantId);
|
||||
|
||||
var version = Assert.Single(db.CvVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == variant.Id));
|
||||
Assert.Equal(1, version.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveTailoredCvDraft_reuses_the_same_variant_on_subsequent_saves()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobAsync(db);
|
||||
var controller = CreateController(db, "user-1");
|
||||
|
||||
var request = new JobApplicationsController.SaveTailoredCvDraftRequest(
|
||||
"ats-minimal", "Backend Engineer", new List<string> { "Built things." }, new List<string> { "C#" },
|
||||
new List<TailoredCvExperienceItem>(), new List<TailoredCvEducationItem>(), new List<TailoredCvCustomSection>(), null, "edited");
|
||||
|
||||
await controller.SaveTailoredCvDraft(job.Id, request, CancellationToken.None);
|
||||
await controller.SaveTailoredCvDraft(job.Id, request with { Headline = "Updated headline" }, CancellationToken.None);
|
||||
|
||||
var variant = Assert.Single(db.CvVariants.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1"));
|
||||
Assert.Equal(2, variant.Version);
|
||||
|
||||
var links = db.TailoredApplications.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id).ToList();
|
||||
Assert.Single(links);
|
||||
|
||||
var versions = db.CvVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == variant.Id).ToList();
|
||||
Assert.Equal(2, versions.Count);
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db)
|
||||
{
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "user@example.test", Email = "user@example.test" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
|
||||
{
|
||||
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
|
||||
var controller = new JobApplicationsController(
|
||||
db,
|
||||
Mock.Of<ISummarizerService>(),
|
||||
Mock.Of<IAppEmailSender>(),
|
||||
TestHostFactory.CreateUserManager(user).Object,
|
||||
NullLogger<JobApplicationsController>.Instance,
|
||||
Mock.Of<ICvTemplateRenderer>(),
|
||||
Mock.Of<ICvPdfExporter>());
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5): the AI call
|
||||
// used to re-run on every tab open. These tests lock in that a note is generated once, reused on
|
||||
// subsequent reads, and only regenerated when the attachment context changes or a refresh is
|
||||
// explicitly requested.
|
||||
public sealed class InterviewPrepPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetInterviewPrep_persists_and_reuses_the_generated_note()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobAsync(db);
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync("First generated brief.")
|
||||
.ReturnsAsync("Second generated brief.");
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
|
||||
var first = await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
|
||||
Assert.Equal("First generated brief.", GetDto(first).Summary);
|
||||
|
||||
var second = await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
|
||||
Assert.Equal("First generated brief.", GetDto(second).Summary);
|
||||
|
||||
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Once);
|
||||
|
||||
var stored = Assert.Single(db.InterviewPrepNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id));
|
||||
Assert.Equal("First generated brief.", stored.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInterviewPrep_regenerates_when_refresh_is_requested()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobAsync(db);
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync("First generated brief.")
|
||||
.ReturnsAsync("Refreshed brief.");
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
|
||||
await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
|
||||
var refreshed = await controller.GetInterviewPrep(job.Id, null, true, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Refreshed brief.", GetDto(refreshed).Summary);
|
||||
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(2));
|
||||
|
||||
var stored = Assert.Single(db.InterviewPrepNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id));
|
||||
Assert.Equal("Refreshed brief.", stored.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInterviewPrep_regenerates_when_attachment_selection_changes()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var job = await SeedJobAsync(db);
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync("No-attachment brief.")
|
||||
.ReturnsAsync("With-attachment brief.");
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
|
||||
await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
|
||||
var withAttachment = await controller.GetInterviewPrep(job.Id, "7", false, CancellationToken.None);
|
||||
|
||||
Assert.Equal("With-attachment brief.", GetDto(withAttachment).Summary);
|
||||
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
private static JobApplicationsController.InterviewPrepDto GetDto(ActionResult<JobApplicationsController.InterviewPrepDto> result)
|
||||
=> (JobApplicationsController.InterviewPrepDto)Assert.IsType<OkObjectResult>(result.Result).Value!;
|
||||
|
||||
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db)
|
||||
{
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
Description = "Needs .NET and SQL experience.",
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
|
||||
{
|
||||
var controller = new JobApplicationsController(
|
||||
db,
|
||||
summarizer,
|
||||
Mock.Of<IAppEmailSender>(),
|
||||
TestHostFactory.CreateUserManager(null).Object,
|
||||
NullLogger<JobApplicationsController>.Instance,
|
||||
Mock.Of<ICvTemplateRenderer>(),
|
||||
Mock.Of<ICvPdfExporter>());
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
@@ -436,9 +436,62 @@ Canonical profile:
|
||||
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await SyncCvVariantFromDraftAsync(user.Id, job, document, cancellationToken);
|
||||
return draft;
|
||||
}
|
||||
|
||||
// Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md).
|
||||
// Dual-writes a CvVariant + CvVersion + TailoredApplication whenever a TailoredCvDraft is
|
||||
// saved, so the new tables are populated from real usage without needing new UI yet.
|
||||
// TailoredCvDraft remains authoritative; this never blocks or fails draft saves.
|
||||
private async Task SyncCvVariantFromDraftAsync(string ownerUserId, JobApplication job, TailoredCvDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
var existingLink = await _db.TailoredApplications
|
||||
.Include(x => x.CvVariant)
|
||||
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == job.Id, cancellationToken);
|
||||
|
||||
var variant = existingLink?.CvVariant;
|
||||
if (variant is null)
|
||||
{
|
||||
var companyName = job.Company?.Name ?? (await _db.Companies.FirstOrDefaultAsync(c => c.Id == job.CompanyId, cancellationToken))?.Name;
|
||||
var careerProfileId = await _db.CareerProfiles.Where(x => x.OwnerUserId == ownerUserId).Select(x => (int?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||
variant = new CvVariant
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CareerProfileId = careerProfileId,
|
||||
Name = string.Join(" @ ", new[] { job.JobTitle, companyName }.Where(x => !string.IsNullOrWhiteSpace(x))),
|
||||
};
|
||||
_db.CvVariants.Add(variant);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
variant.ContentJson = JsonSerializer.Serialize(document);
|
||||
variant.ThemeId = string.IsNullOrWhiteSpace(document.TemplateId) ? "ats-minimal" : document.TemplateId;
|
||||
variant.Version += 1;
|
||||
variant.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_db.CvVersions.Add(new CvVersion
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CvVariantId = variant.Id,
|
||||
Version = variant.Version,
|
||||
ContentJson = variant.ContentJson,
|
||||
});
|
||||
|
||||
if (existingLink is null)
|
||||
{
|
||||
_db.TailoredApplications.Add(new TailoredApplication
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CvVariantId = variant.Id,
|
||||
JobApplicationId = job.Id,
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
|
||||
{
|
||||
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
||||
@@ -2192,7 +2245,7 @@ Canonical profile:
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/candidate-fit")]
|
||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
@@ -2202,6 +2255,13 @@ Canonical profile:
|
||||
var userId = CurrentUserId;
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
|
||||
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
||||
if (!refresh)
|
||||
{
|
||||
var cached = await TryGetCachedAiNoteAsync<CandidateFitDto>(userId, id, "candidate-fit", attachmentSignature, cancellationToken);
|
||||
if (cached is not null) return Ok(cached);
|
||||
}
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
var cvText = user?.ProfileCvText;
|
||||
if (string.IsNullOrWhiteSpace(cvText))
|
||||
@@ -2298,7 +2358,7 @@ Candidate CV/profile:
|
||||
"Close with a clear expression of interest and availability."
|
||||
});
|
||||
|
||||
return Ok(new CandidateFitDto(
|
||||
var dto = new CandidateFitDto(
|
||||
MatchSummary: matchSummary,
|
||||
FitLevel: fitLevel,
|
||||
MatchScore: matchScore,
|
||||
@@ -2312,11 +2372,14 @@ Candidate CV/profile:
|
||||
TailoredPitch: tailoredPitch,
|
||||
Guidance: guidance,
|
||||
CoverLetterDraft: coverLetterDraft,
|
||||
RecruiterMessageDraft: recruiterMessageDraft));
|
||||
RecruiterMessageDraft: recruiterMessageDraft);
|
||||
|
||||
await SaveAiNoteAsync(userId, id, "candidate-fit", attachmentSignature, dto, cancellationToken);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/focus-plan")]
|
||||
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
@@ -2326,6 +2389,13 @@ Candidate CV/profile:
|
||||
var userId = CurrentUserId;
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
|
||||
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
||||
if (!refresh)
|
||||
{
|
||||
var cached = await TryGetCachedAiNoteAsync<FocusPlanDto>(userId, id, "focus-plan", attachmentSignature, cancellationToken);
|
||||
if (cached is not null) return Ok(cached);
|
||||
}
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
var cvText = user?.ProfileCvText;
|
||||
if (string.IsNullOrWhiteSpace(cvText))
|
||||
@@ -2388,23 +2458,69 @@ Candidate master CV:
|
||||
|
||||
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
|
||||
|
||||
return Ok(new FocusPlanDto(
|
||||
var dto = new FocusPlanDto(
|
||||
ImmediatePriorities: immediatePriorities,
|
||||
CvBulletIdeas: cvBulletIdeas,
|
||||
ProofPointsToLeadWith: proofPointsToLeadWith,
|
||||
CoverLetterAngles: coverLetterAngles,
|
||||
FollowUpApproach: followUpApproach,
|
||||
StrategicSummary: strategicSummary));
|
||||
StrategicSummary: strategicSummary);
|
||||
|
||||
await SaveAiNoteAsync(userId, id, "focus-plan", attachmentSignature, dto, cancellationToken);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
private async Task<T?> TryGetCachedAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, CancellationToken cancellationToken) where T : class
|
||||
{
|
||||
var existing = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
|
||||
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType && x.AttachmentContextSignature == attachmentSignature,
|
||||
cancellationToken);
|
||||
if (existing is null) return null;
|
||||
return JsonSerializer.Deserialize<T>(existing.ResultJson);
|
||||
}
|
||||
|
||||
private async Task SaveAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, T dto, CancellationToken cancellationToken)
|
||||
{
|
||||
var note = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
|
||||
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType,
|
||||
cancellationToken);
|
||||
if (note is null)
|
||||
{
|
||||
note = new AiWorkspaceNote { OwnerUserId = userId, JobApplicationId = jobApplicationId, NoteType = noteType };
|
||||
_db.AiWorkspaceNotes.Add(note);
|
||||
}
|
||||
note.AttachmentContextSignature = attachmentSignature;
|
||||
note.ResultJson = JsonSerializer.Serialize(dto);
|
||||
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/interview-prep")]
|
||||
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var userId = CurrentUserId;
|
||||
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
||||
|
||||
if (!refresh && userId is not null)
|
||||
{
|
||||
var existing = await _db.InterviewPrepNotes.FirstOrDefaultAsync(
|
||||
x => x.OwnerUserId == userId && x.JobApplicationId == id && x.AttachmentContextSignature == attachmentSignature,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Ok(new InterviewPrepDto(
|
||||
existing.Summary,
|
||||
JsonSerializer.Deserialize<List<string>>(existing.TalkingPointsJson) ?? new List<string>(),
|
||||
JsonSerializer.Deserialize<List<string>>(existing.LikelyQuestionsJson) ?? new List<string>(),
|
||||
JsonSerializer.Deserialize<List<string>>(existing.WeakSpotsJson) ?? new List<string>()));
|
||||
}
|
||||
}
|
||||
|
||||
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
||||
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, attachmentContext?.Context }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
@@ -2423,9 +2539,34 @@ Candidate master CV:
|
||||
180,
|
||||
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
var note = await _db.InterviewPrepNotes.FirstOrDefaultAsync(x => x.OwnerUserId == userId && x.JobApplicationId == id, cancellationToken);
|
||||
if (note is null)
|
||||
{
|
||||
note = new InterviewPrepNote { OwnerUserId = userId, JobApplicationId = id };
|
||||
_db.InterviewPrepNotes.Add(note);
|
||||
}
|
||||
note.AttachmentContextSignature = attachmentSignature;
|
||||
note.Summary = summary;
|
||||
note.TalkingPointsJson = JsonSerializer.Serialize(talkingPoints);
|
||||
note.LikelyQuestionsJson = JsonSerializer.Serialize(likelyQuestions);
|
||||
note.WeakSpotsJson = JsonSerializer.Serialize(weakSpots);
|
||||
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
|
||||
}
|
||||
|
||||
private static string NormalizeAttachmentIdsSignature(string? attachmentIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachmentIds)) return string.Empty;
|
||||
var ids = attachmentIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.OrderBy(x => x, StringComparer.Ordinal);
|
||||
return string.Join(",", ids);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/readiness")]
|
||||
public async Task<ActionResult<ReadinessDto>> GetReadiness([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -2608,6 +2749,7 @@ Candidate master CV:
|
||||
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await SyncCvVariantFromDraftAsync(user.Id, job, document, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -657,11 +657,106 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
|
||||
}
|
||||
|
||||
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5):
|
||||
// stop re-running the AI call on every tab open by persisting the last generated
|
||||
// note per job, keyed by the attachment selection it was generated from.
|
||||
static void EnsureInterviewPrepNotesTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "InterviewPrepNotes" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepNotes" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"AttachmentContextSignature" TEXT NOT NULL,
|
||||
"Summary" TEXT NOT NULL,
|
||||
"TalkingPointsJson" TEXT NOT NULL,
|
||||
"LikelyQuestionsJson" TEXT NOT NULL,
|
||||
"WeakSpotsJson" TEXT NOT NULL,
|
||||
"GeneratedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_InterviewPrepNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");""");
|
||||
}
|
||||
|
||||
// Generic AI workspace note persistence (candidate fit, focus plan) -- same
|
||||
// rationale as EnsureInterviewPrepNotesTable, generalized for DTOs too irregular
|
||||
// for per-field columns.
|
||||
static void EnsureAiWorkspaceNotesTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AiWorkspaceNotes" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiWorkspaceNotes" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"NoteType" TEXT NOT NULL,
|
||||
"AttachmentContextSignature" TEXT NOT NULL,
|
||||
"ResultJson" TEXT NOT NULL,
|
||||
"GeneratedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_AiWorkspaceNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");""");
|
||||
}
|
||||
|
||||
// CV variants (career-workspace-implementation-roadmap.md Phase F2). Dual-written
|
||||
// from the existing TailoredCvDraft save paths; TailoredCvDraft stays authoritative.
|
||||
static void EnsureCvVariantTables(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "CvVariants" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariants" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"CareerProfileId" INTEGER NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"ContentJson" TEXT NOT NULL,
|
||||
"ThemeId" TEXT NOT NULL,
|
||||
"Version" INTEGER NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
"UpdatedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_CvVariants_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE SET NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "CvVersions" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvVersions" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"CvVariantId" INTEGER NOT NULL,
|
||||
"Version" INTEGER NOT NULL,
|
||||
"ContentJson" TEXT NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_CvVersions_CvVariants_CvVariantId" FOREIGN KEY ("CvVariantId") REFERENCES "CvVariants" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "TailoredApplications" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredApplications" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"CvVariantId" INTEGER NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_TailoredApplications_CvVariants_CvVariantId" FOREIGN KEY ("CvVariantId") REFERENCES "CvVariants" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_TailoredApplications_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariants_OwnerUserId" ON "CvVariants" ("OwnerUserId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVersions_OwnerUserId_CvVariantId_Version" ON "CvVersions" ("OwnerUserId", "CvVariantId", "Version");""");
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredApplications_OwnerUserId_JobApplicationId" ON "TailoredApplications" ("OwnerUserId", "JobApplicationId");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
EnsureCvTables(conn);
|
||||
EnsureCareerProfileTables(conn);
|
||||
EnsureInterviewPrepNotesTable(conn);
|
||||
EnsureAiWorkspaceNotesTable(conn);
|
||||
EnsureCvVariantTables(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
// and at least one of the new columns already exists.
|
||||
@@ -804,6 +899,49 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
|
||||
|
||||
if (!MySqlIndexExists(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_InterviewPrepNotes_OwnerUserId_JobApplicationId` ON `InterviewPrepNotes` (`OwnerUserId`, `JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");
|
||||
|
||||
if (!MySqlIndexExists(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType` ON `AiWorkspaceNotes` (`OwnerUserId`, `JobApplicationId`, `NoteType`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVersions", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredApplications", "Id");
|
||||
|
||||
if (!MySqlIndexExists(conn, "CvVariants", "IX_CvVariants_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_CvVariants_OwnerUserId` ON `CvVariants` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CvVersions", "IX_CvVersions_OwnerUserId_CvVariantId_Version"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_CvVersions_OwnerUserId_CvVariantId_Version` ON `CvVersions` (`OwnerUserId`, `CvVariantId`, `Version`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredApplications", "IX_TailoredApplications_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredApplications_OwnerUserId_JobApplicationId` ON `TailoredApplications` (`OwnerUserId`, `JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||||
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
|
||||
@@ -1063,6 +1201,98 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
|
||||
if (!HasMySqlTable(conn, "InterviewPrepNotes"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepNotes` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`AttachmentContextSignature` longtext NOT NULL,
|
||||
`Summary` longtext NOT NULL,
|
||||
`TalkingPointsJson` longtext NOT NULL,
|
||||
`LikelyQuestionsJson` longtext NOT NULL,
|
||||
`WeakSpotsJson` longtext NOT NULL,
|
||||
`GeneratedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_InterviewPrepNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Generic AI workspace note persistence (candidate fit, focus plan).
|
||||
if (!HasMySqlTable(conn, "AiWorkspaceNotes"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`NoteType` varchar(50) NOT NULL,
|
||||
`AttachmentContextSignature` longtext NOT NULL,
|
||||
`ResultJson` longtext NOT NULL,
|
||||
`GeneratedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_AiWorkspaceNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// CV variants (career-workspace-implementation-roadmap.md Phase F2). Order
|
||||
// matters: CvVariants before CvVersions/TailoredApplications (FK dependency),
|
||||
// and CareerProfiles (created above) before CvVariants.
|
||||
if (!HasMySqlTable(conn, "CvVariants"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariants` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`CareerProfileId` int NULL,
|
||||
`Name` longtext NOT NULL,
|
||||
`ContentJson` longtext NOT NULL,
|
||||
`ThemeId` varchar(100) NOT NULL,
|
||||
`Version` int NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_CvVariants_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE SET NULL
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "CvVersions"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVersions` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`CvVariantId` int NOT NULL,
|
||||
`Version` int NOT NULL,
|
||||
`ContentJson` longtext NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_CvVersions_CvVariants_CvVariantId` FOREIGN KEY (`CvVariantId`) REFERENCES `CvVariants` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TailoredApplications"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredApplications` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`CvVariantId` int NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_TailoredApplications_CvVariants_CvVariantId` FOREIGN KEY (`CvVariantId`) REFERENCES `CvVariants` (`Id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `FK_TailoredApplications_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
// Generic persistence for per-job AI workspace outputs whose shape is irregular/nested enough
|
||||
// that per-field columns (as used by InterviewPrepNote) would be unreasonable -- candidate fit
|
||||
// and focus plan each make several AI calls and return DTOs with 6-13 fields including nested
|
||||
// objects. One row per (owner, job, note type); ResultJson is the serialized DTO. See
|
||||
// career-workspace-implementation-roadmap.md Phase F5 -- "persist interview prep / fit outputs".
|
||||
public sealed class AiWorkspaceNote
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int JobApplicationId { get; set; }
|
||||
public JobApplication? JobApplication { get; set; }
|
||||
// "candidate-fit" | "focus-plan"
|
||||
public string NoteType { get; set; } = string.Empty;
|
||||
public string AttachmentContextSignature { get; set; } = string.Empty;
|
||||
public string ResultJson { get; set; } = string.Empty;
|
||||
public DateTimeOffset GeneratedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
// Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md). A CvVariant
|
||||
// is a durable, named lens on the career profile -- unlike TailoredCvDraft, it is not owned by a
|
||||
// job; TailoredApplication is the reference that links a variant to a job, so the same variant can
|
||||
// be reused across applications and a job can be deleted without losing the variant.
|
||||
//
|
||||
// This phase dual-writes from the existing TailoredCvDraft save paths (same pattern as
|
||||
// CareerProfile in Phase F1): every draft save also upserts the job's CvVariant, appends a
|
||||
// CvVersion snapshot, and ensures a TailoredApplication link. TailoredCvDraft remains the
|
||||
// authoritative row every existing read path uses.
|
||||
public sealed class CvVariant
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int? CareerProfileId { get; set; }
|
||||
public CareerProfile? CareerProfile { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
// Serialized TailoredCvDocument -- reuses the existing document shape rather than inventing a
|
||||
// new one, so this phase carries zero data-shape risk.
|
||||
public string ContentJson { get; set; } = string.Empty;
|
||||
public string ThemeId { get; set; } = "ats-minimal";
|
||||
public int Version { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// Append-only history: one row per CvVariant save.
|
||||
public sealed class CvVersion
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int CvVariantId { get; set; }
|
||||
public CvVariant? CvVariant { get; set; }
|
||||
public int Version { get; set; }
|
||||
public string ContentJson { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// The integration seam per the product boundary: a job application REFERENCES a tailored output,
|
||||
// it does not own it. Deleting a job does not delete the variant; the variant can outlive the job
|
||||
// or be reused by a future one.
|
||||
public sealed class TailoredApplication
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int CvVariantId { get; set; }
|
||||
public CvVariant? CvVariant { get; set; }
|
||||
public int JobApplicationId { get; set; }
|
||||
public JobApplication? JobApplication { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
// Persists interview prep so it survives tab switches and page reloads instead of being an AI
|
||||
// call re-run every time the tab opens (career-workspace-implementation-roadmap.md Phase F5 --
|
||||
// "persist interview prep / fit outputs" -- flagged in the product teardown as work evaporating
|
||||
// on every re-open). One row per job application; regenerated when the selected attachment
|
||||
// context changes or the user explicitly requests a refresh.
|
||||
public sealed class InterviewPrepNote
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int JobApplicationId { get; set; }
|
||||
public JobApplication? JobApplication { get; set; }
|
||||
// Fingerprint of the attachment selection this note was generated from, so a different
|
||||
// attachment selection triggers regeneration without needing an explicit refresh.
|
||||
public string AttachmentContextSignature { get; set; } = string.Empty;
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public string TalkingPointsJson { get; set; } = "[]";
|
||||
public string LikelyQuestionsJson { get; set; } = "[]";
|
||||
public string WeakSpotsJson { get; set; } = "[]";
|
||||
public DateTimeOffset GeneratedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Career Workspace — Implementation Roadmap (ADR + sequencing)
|
||||
|
||||
**Date:** 2026-07-12
|
||||
**Status:** Active. This is the execution plan that turns the three strategy docs into code, incrementally, without breaking the live app.
|
||||
**Status:** Active. F0–F2 shipped; F3 and F5 partially shipped (see phase sections below for exactly what landed vs. what's still open). This is the execution plan that turns the three strategy docs into code, incrementally, without breaking the live app.
|
||||
**Source of truth:** `cv-builder-competitor-deep-research.md`, `cv-builder-product-teardown.md`, `career-workspace-product-strategy.md`.
|
||||
|
||||
---
|
||||
@@ -59,33 +59,36 @@ The tracker references a career output by id; deleting a job never deletes profi
|
||||
|
||||
## Phased sequence (each phase ships independently, app stays green)
|
||||
|
||||
### Phase F0 — Immediate fixes (no architecture) ✅ shipped this session
|
||||
### Phase F0 — Immediate fixes (no architecture) ✅ SHIPPED
|
||||
- **OAuth CV lockout fixed** (`ProfilePage.tsx`): CV controls gated on a new `canEditCv` (any authenticated user) instead of `isLocal`. Identity/password fields remain local-only. Unblocks every Google/Microsoft user.
|
||||
- Commit: `fix: unlock CV builder for Google/Microsoft-authenticated users`
|
||||
|
||||
### Phase F1 — Career Profile as first-class data (backwards-compatible seam)
|
||||
1. Add `CareerProfile` + `CareerProfileVersion` tables to the reconciler (both dialects), `DbSet`s, query filters, indexes.
|
||||
2. Introduce `ICareerProfileService` — the single accessor for the user's structured profile. Initially **dual-writes**: persists to the new `CareerProfiles` table **and** keeps `ApplicationUser.ProfileCvStructureJson` in sync (so nothing that still reads the column breaks).
|
||||
3. Assign stable item IDs + normalize dates when materializing a profile into the new table (one-time backfill on first read/write per user).
|
||||
4. Point `ProfileCvController` read/write paths at the service (behavior identical).
|
||||
5. Test: round-trip a profile through the service; assert IDs stable across saves, dates normalized, legacy column still mirrored.
|
||||
### Phase F1 — Career Profile as first-class data (backwards-compatible seam) ✅ SHIPPED
|
||||
1. ✅ `CareerProfile` + `CareerProfileVersion` tables in the reconciler (both dialects), `DbSet`s, query filters, indexes.
|
||||
2. ✅ `ICareerProfileService` — dual-writes: persists to `CareerProfiles`/`CareerProfileVersions` on every structured-profile save (upload/rebuild/improve/reprocess/parse) while `ApplicationUser.ProfileCvStructureJson` stays the column every existing read path uses.
|
||||
3. ✅ Stable item IDs (jobs/education/certifications/projects) + normalized `YYYY-MM` dates (`CvDateNormalizer`) assigned on save.
|
||||
4. **Not done:** full cutover of `ProfileCvController` read paths to the service (still reads `user.ProfileCvStructureJson` directly). The service is invoked at every write site but reads haven't moved yet — deliberate: F1's own exit criteria says "new table is authoritative; column is a mirror" implies read cutover is a *later* step once the table's been proven, not this pass.
|
||||
5. ✅ 11 tests (stable IDs, date normalization incl. `IsCurrent` guard, version history, dual-write). Verified against the real dev DB.
|
||||
- Commit: `feat: add career profile foundation with versioned history`
|
||||
|
||||
**Exit:** new table is authoritative; column is a mirror. Zero user-visible change.
|
||||
### Phase F2 — Variants + versions (additive, opt-in) ✅ SHIPPED
|
||||
1. ✅ `CvVariant` + `CvVersion` tables. `CvVariant.CareerProfileId` links to the user's current `CareerProfile` (nullable, `SetNull` on delete — not owned, not cascaded).
|
||||
2. ✅ `TailoredApplication` table: `(CvVariantId, JobApplicationId)`, unique per `(OwnerUserId, JobApplicationId)`. Job references the tailored output; does not own the variant. Both FKs cascade (the link is meaningless without either side).
|
||||
3. ✅ Dual-write (not a one-time backfill): both `TailoredCvDraft` save paths (`SaveTailoredCvDraft`, `UpsertGeneratedTailoredCvDraftAsync`) now also upsert the variant, bump its version, append a `CvVersion` snapshot, and ensure the `TailoredApplication` link — via `SyncCvVariantFromDraftAsync`. Reuses `TailoredCvDocument` as `ContentJson` (zero new data shape). `TailoredCvDrafts` remains authoritative for every existing read path.
|
||||
4. **Not done:** a one-time backfill of *pre-existing* `TailoredCvDraft` rows that predate this change (only rows saved *after* this ships get synced). **Not done:** `/career/*` endpoints — nothing reads `CvVariant`/`CvVersion` yet; this phase is pure write-side foundation, same "populate before UI" strategy as F1.
|
||||
5. ✅ 2 tests (variant/version/link created on first save; same variant reused + version incremented on resave, not duplicated). Verified against the real dev DB — FK dependency order (`CareerProfiles` → `CvVariants` → `CvVersions`/`TailoredApplications`) holds in both dialects.
|
||||
- Commit: `feat: introduce CV variant schema, dual-written from tailored CV saves`
|
||||
|
||||
### Phase F2 — Variants + versions (additive, opt-in)
|
||||
1. `CvVariant` + `CvVersion` tables. A variant references a `CareerProfile` and holds selection/override JSON keyed by item ID.
|
||||
2. `TailoredApplication` table: `(CvVariantId, JobApplicationId)` — the reference seam. Job references the tailored output; does not own the variant.
|
||||
3. Backfill: each existing `TailoredCvDraft` → one `CvVariant` (job-linked) + its render options extracted toward a theme ref, wrapped in a `TailoredApplication`. Legacy `TailoredCvDrafts` retained (dual-read) until proven.
|
||||
4. Endpoints under `/career/*` grow beside legacy `/profile-cv/*` and the job-scoped tailored routes.
|
||||
**Next actions on this phase (not started):** (a) backfill script for pre-existing `TailoredCvDraft` rows if the table shouldn't have a "before my change" gap; (b) a read endpoint exposing `CvVariant` list — the actual precondition for F4's "reuse a variant across jobs" UI to mean anything.
|
||||
|
||||
**Exit:** variants exist; regeneration writes a new `CvVersion` instead of overwriting.
|
||||
### Phase F3 — Rendering as data (theme catalog) — PARTIAL
|
||||
1. ✅ `CvTemplateDescriptor` (backend, `ProfileCvController.GetCvTemplateDescriptors`) extended with `LayoutFamily` + `AtsRating`; surfaced as a badge in the frontend template picker (`ProfilePage.tsx`).
|
||||
2. ✅ 14-test regression suite (`CvTemplateRendererTests`) locking in current renderer output — the prerequisite for a safe future extraction — landed *before* touching the renderer, per the golden-test discipline this phase calls for.
|
||||
3. **Not done:** the actual extraction (renderer consumes `(document, theme)` as data; layout shells as a fixed set; theme = shell + tokens). The six `RenderXxx` HTML-string methods in `CvTemplateRenderer` are unchanged. Real work, real PDF-regression risk, correctly *not* attempted in the same pass as unrelated feature work.
|
||||
4. **Known gap surfaced this pass:** the frontend never calls `GET /profile-cv/templates` — it duplicates the template catalog in a hardcoded `REWRITE_TEMPLATES` array in `ProfilePage.tsx`. Two sources of truth for template metadata. Worth fixing *as part of* the F3 extraction (single source becomes the natural output), not before.
|
||||
5. **Deferred:** external template engine (Scriban) + user/marketplace themes — only when a marketplace is real (strategy §9).
|
||||
|
||||
### Phase F3 — Rendering as data (theme catalog)
|
||||
1. Extract the `CvTemplateRenderer` template catalog into a `CvTheme` descriptor set (id, label, layout shell, font stack, palette, heading style, default accent, ATS rating). Content pipeline (`RenderMainSections` + section renderers) already theme-agnostic — formalize the boundary: **renderer consumes `(document, theme)`; theme carries no CV logic.**
|
||||
2. Layout **shells** stay a small fixed set (single-column, sidebar, rail, bordered); a theme selects a shell + tokens. Adding a theme that reuses a shell = a data entry, no code.
|
||||
3. Golden test: render each existing template id before/after; assert byte-identical output (pure refactor).
|
||||
4. **Deferred:** external template engine (Scriban) + user/marketplace themes — only when a marketplace is real (strategy §9). Do not add the dependency now.
|
||||
|
||||
**Exit:** adding a theme on an existing layout is data-only; PDF output unchanged.
|
||||
**Next action on this phase:** the extraction itself (item 3) — budget a dedicated pass; the regression suite (item 2) is what makes it safe to attempt.
|
||||
|
||||
### Phase F4 — CV Builder UX (structured editor + tailoring workspace)
|
||||
- Structured profile editor route (`/career/profile`): section forms, per-bullet reorder, provenance-flagged review queue for low-confidence fields.
|
||||
@@ -93,11 +96,14 @@ The tracker references a career output by id; deleting a job never deletes profi
|
||||
- Tailoring workspace route (`/jobs/:id/tailor`): JD gap chips ↔ variant editor ↔ live themed preview ↔ rescore. Retire the modal editor. **Reached from a job** (integration), full page.
|
||||
- **Career** becomes a top-level nav pillar (Profile · Variants); "CV" ceases to be a nav noun. Tracker nav untouched.
|
||||
|
||||
### Phase F5 — AI depth + integration
|
||||
- Diff / accept-reject on every AI mutation (rewrite, improve, generation). Trust primitive; also kills silent hallucination.
|
||||
- Retarget `JobCvMatchService` to read from structured profile (not raw text) — removes the dual-truth divergence; validates F1's model with an existing consumer.
|
||||
- Fact-constraint validator (novel named-entity/number flagging) on generation.
|
||||
- Persist interview-prep / fit outputs (stop regenerating).
|
||||
### Phase F5 — AI depth + integration — PARTIAL
|
||||
- ✅ **Diff view for AI rewrites**: `TextDiff` component (word-level `diffWords`), wired into the master-CV rewrite preview behind a "Show changes" toggle (default off — an existing test proved diff-by-default breaks the plain-text read). Scoped to the master-CV rewrite surface only; the tailored-CV draft regenerate flow already had a confirm+reset safety net and wasn't a good fit for the same treatment (would mean diffing structured fields, which is F4 tailoring-workspace scope).
|
||||
- Commit: `feat: show diff view for AI CV rewrites`
|
||||
- ✅ **Persist interview prep** (`InterviewPrepNote`, one table, per-field columns — the DTO is flat) and **candidate fit + focus plan** (`AiWorkspaceNote`, one generic table keyed by `NoteType` — those DTOs are irregular/nested, so per-field columns would've been unreasonable; introduced the generalization on the 2nd/3rd occurrence, not the 1st). All three: reuse across tab-opens, regenerate on attachment-context change, explicit "Regenerate" button as the escape hatch. Collectively these three tabs fired 9 AI calls on every single re-open before this; now 0 unless something changed.
|
||||
- Commits: `feat: persist interview prep instead of regenerating on every open`, `feat: persist candidate fit and focus plan, stop re-running on every open`
|
||||
- ✅ **ATS-safety badge** on the template picker (folded into the F3 entry above — same commit touched both, since `AtsRating` is a field on the template descriptor).
|
||||
- **Not done:** `JobCvMatchService` retarget — checked the live code this pass and found `BuildCvSearchCorpus` already reads structured profile *and* raw text as a hybrid (better than the teardown assumed); no change needed. Closing this line item as **resolved, not deferred**.
|
||||
- **Not done:** fact-constraint validator (novel named-entity/number flagging on AI generation). Real remaining trust gap — the diff view lets a user *see* a fabrication, but nothing stops the model from producing one. Good next F5 slice.
|
||||
|
||||
### Phase F6+ — Career Workspace horizons (architecture-ready, not built now)
|
||||
Cover letters (profile+JD+thread) · ATS plain-text view · skills-gap analytics · public profile (theme over live profile) · DOCX adapter · portfolio/LinkedIn adapters. Each ≈ one `IOutputAdapter` + optional theme. Keep the adapter boundary swap-clean (Reactive Resume abandoned server-Chromium for cost — our Playwright PDF adapter must stay replaceable without touching themes).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -289,6 +289,18 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||
|
||||
// Persisted server-side like interview prep (career-workspace-implementation-roadmap.md Phase
|
||||
// F5); Regenerate is the explicit escape hatch when the job has changed since it was written.
|
||||
const regenerateCandidateFit = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
setLoadingCandidateFit(true);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, r.data);
|
||||
setCandidateFit(r.data);
|
||||
toast("Candidate fit regenerated.", "success");
|
||||
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate candidate fit."), "error")).finally(() => setLoadingCandidateFit(false));
|
||||
}, [jobId, selectedAttachmentCsv, candidateFitCache, toast]);
|
||||
|
||||
// Match score is deterministic and cheap: load it on the Candidate Fit tab
|
||||
// independently of the slow AI narrative so users see the number instantly.
|
||||
useEffect(() => {
|
||||
@@ -348,6 +360,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
||||
|
||||
const regenerateFocusPlan = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
setLoadingFocusPlan(true);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, r.data);
|
||||
setFocusPlan(r.data);
|
||||
toast("Focus plan regenerated.", "success");
|
||||
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate focus plan."), "error")).finally(() => setLoadingFocusPlan(false));
|
||||
}, [jobId, selectedAttachmentCsv, focusPlanCache, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 7 || interviewPrep) return;
|
||||
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
|
||||
@@ -364,6 +386,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
|
||||
|
||||
// Interview prep is now persisted server-side (career-workspace-implementation-roadmap.md
|
||||
// Phase F5) so it survives tab switches without re-running the AI call. Regenerate is the
|
||||
// explicit escape hatch for when the underlying job/notes have changed since it was written.
|
||||
const regenerateInterviewPrep = useCallback(() => {
|
||||
if (!jobId) return;
|
||||
setLoadingInterviewPrep(true);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined, refresh: true } }).then((r) => {
|
||||
interviewPrepCache.setCached(`${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`, r.data);
|
||||
setInterviewPrep(r.data);
|
||||
toast("Interview prep regenerated.", "success");
|
||||
}).catch((error: any) => toast(getApiErrorMessage(error, "Failed to regenerate interview prep."), "error")).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [jobId, selectedAttachmentCsv, interviewPrepCache, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
@@ -1130,6 +1165,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
|
||||
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
||||
@@ -1158,6 +1198,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 6 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
|
||||
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
|
||||
@@ -1171,6 +1216,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 7 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
|
||||
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
|
||||
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
|
||||
</Button>
|
||||
</Box>
|
||||
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
|
||||
|
||||
@@ -71,6 +71,12 @@ type RewriteTemplateOption = {
|
||||
sampleHeading: string;
|
||||
sampleMeta: string;
|
||||
sampleBullets: string[];
|
||||
// Mirrors JobTrackerApi's CvTemplateDescriptor.AtsRating (career-workspace-implementation-roadmap.md
|
||||
// Phase F3): single-column layouts read top-to-bottom with no ATS parsing risk; sidebar/grid
|
||||
// layouts carry the same risk pattern the competitor research flagged in Canva's floating-box
|
||||
// designs, so they're rated Medium even though structured-data rendering keeps them far safer
|
||||
// than a canvas tool's undefined reading order.
|
||||
atsRating: "High" | "Medium";
|
||||
};
|
||||
|
||||
type CvBuilderPreview = {
|
||||
@@ -136,7 +142,8 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
blurb: "Compact, direct, and easy for screening systems to parse.",
|
||||
sampleHeading: "Senior Backend Engineer",
|
||||
sampleMeta: "Acme Systems · Oslo · 2021 - Present",
|
||||
sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."]
|
||||
sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."],
|
||||
atsRating: "High"
|
||||
},
|
||||
{
|
||||
id: "harvard",
|
||||
@@ -146,7 +153,8 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
blurb: "Formal hierarchy and restrained tone for conservative hiring flows.",
|
||||
sampleHeading: "Professional Summary",
|
||||
sampleMeta: "Clear structure · precise dates · credible language",
|
||||
sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."]
|
||||
sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."],
|
||||
atsRating: "High"
|
||||
},
|
||||
{
|
||||
id: "auckland",
|
||||
@@ -156,7 +164,8 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
blurb: "Sharper highlights with a more contemporary, design-forward rhythm.",
|
||||
sampleHeading: "Selected Impact",
|
||||
sampleMeta: "Focused strengths · compact highlights",
|
||||
sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."]
|
||||
sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."],
|
||||
atsRating: "Medium"
|
||||
},
|
||||
{
|
||||
id: "edinburgh",
|
||||
@@ -166,7 +175,8 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
blurb: "More personality and stronger section contrast without losing clarity.",
|
||||
sampleHeading: "Experience Highlights",
|
||||
sampleMeta: "Premium spacing · stronger visual voice",
|
||||
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
|
||||
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."],
|
||||
atsRating: "Medium"
|
||||
},
|
||||
{
|
||||
id: "monarch",
|
||||
@@ -176,7 +186,8 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
blurb: "High-contrast premium presentation for leadership-heavy applications.",
|
||||
sampleHeading: "Executive Profile",
|
||||
sampleMeta: "Leadership clarity · premium hierarchy",
|
||||
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
|
||||
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."],
|
||||
atsRating: "High"
|
||||
},
|
||||
{
|
||||
id: "fjord",
|
||||
@@ -186,7 +197,8 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.",
|
||||
sampleHeading: "Projects & Systems",
|
||||
sampleMeta: "Technical depth · practical readability",
|
||||
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."]
|
||||
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."],
|
||||
atsRating: "Medium"
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1059,7 +1071,15 @@ export default function ProfilePage() {
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, lineHeight: 1.4 }}>{option.blurb}</Typography>
|
||||
</Box>
|
||||
{selected ? <Chip size="small" color="primary" label="Selected" /> : null}
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5, alignItems: "flex-end" }}>
|
||||
{selected ? <Chip size="small" color="primary" label="Selected" /> : null}
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={option.atsRating === "High" ? "success" : "default"}
|
||||
label={`ATS: ${option.atsRating}`}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user