feat: integrate Career Workspace foundation from feature/career-workspace
Recover the F1 Career Profile foundation + AI-workspace persistence from the unmerged feature/career-workspace branch, so Phase 2 builds on the documented, tested target state instead of re-deriving it. Foundation only — CV Builder commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV Builder yet". See docs/career-workspace-branch-assessment.md. Squashed from 3 branch commits (235e291,5916f09,00a035e), resolved against main + Phase 0: - CareerProfile + CareerProfileVersion (append-only history), dual-written from every profile save path via CareerProfileService. ApplicationUser. ProfileCvStructureJson stays authoritative; the tables mirror it. Stable item IDs assigned to jobs/education/certifications/projects (the prerequisite for future variant lineage). CvDateNormalizer for free-text -> YYYY-MM. - InterviewPrepNote + AiWorkspaceNote: cache AI interview prep / candidate fit / focus plan keyed by an attachment-context signature, so they stop regenerating (and re-spending the provider) on every open. Conflict resolutions (union, favouring current code + Phase 0): - JobTrackerContext / StartupInitializationExtensions: kept Phase 0's tables and reconciler blocks, added the career/interview/ai-note tables (both SQLite and MySQL dialects). - ProfileCvController: dropped the branch's in-file DTO records (main defines them in ProfileCvDtos.cs) and the LayoutFamily/AtsRating template fields (deferred ATS-badge work), keeping main's 7-arg CvTemplateDescriptor. - JobApplicationsController: kept the branch's cache-check, restored main's AsNoTracking on the read-only user load. Tables ship empty (verified dev); nothing to migrate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "job-tracker-ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"cwd": "job-tracker-ui",
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,6 +32,10 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<TwoFactorRecoveryCode> TwoFactorRecoveryCodes => Set<TwoFactorRecoveryCode>();
|
||||
public DbSet<TrustedDevice> TrustedDevices => Set<TrustedDevice>();
|
||||
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
||||
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
||||
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
||||
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
|
||||
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -216,6 +220,57 @@ namespace JobTrackerApi.Data
|
||||
|
||||
modelBuilder.Entity<UserSession>()
|
||||
.HasIndex(x => x.UserId);
|
||||
|
||||
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md Phase F1).
|
||||
// One CareerProfile per user for now -- see roadmap "Not now: multiple profiles per user".
|
||||
modelBuilder.Entity<CareerProfile>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<CareerProfile>()
|
||||
.HasIndex(x => x.OwnerUserId)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<CareerProfileVersion>()
|
||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<CareerProfileVersion>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.Version });
|
||||
|
||||
modelBuilder.Entity<CareerProfileVersion>()
|
||||
.HasOne(x => x.CareerProfile)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 CandidateFitDto GetDto(ActionResult<CandidateFitDto> result)
|
||||
=> (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,101 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CareerProfileServiceTests
|
||||
{
|
||||
private static JobTrackerContext NewContext(string userId)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns(userId);
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveVersionAsync_assigns_stable_ids_to_items_missing_one()
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = new StructuredCvProfile
|
||||
{
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveVersionAsync_preserves_existing_ids_across_saves()
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = new StructuredCvProfile
|
||||
{
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||
};
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
var firstId = profile.Jobs[0].Id;
|
||||
|
||||
await service.SaveVersionAsync("user-1", profile, "rebuild", default);
|
||||
|
||||
Assert.Equal(firstId, profile.Jobs[0].Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("January 2020", "2020-01")]
|
||||
[InlineData("Mar 2019", "2019-03")]
|
||||
[InlineData("03/2019", "2019-03")]
|
||||
[InlineData("2019-03", "2019-03")]
|
||||
[InlineData("Present", null)]
|
||||
[InlineData("2020", null)]
|
||||
[InlineData(null, null)]
|
||||
public void CvDateNormalizer_parses_common_formats_without_guessing(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, CvDateNormalizer.TryParseYearMonth(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveVersionAsync_persists_current_snapshot_and_append_only_history()
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = new StructuredCvProfile { Summary = { "First version" } };
|
||||
|
||||
await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
await service.SaveVersionAsync("user-1", profile, "improve", default);
|
||||
|
||||
var snapshots = await db.CareerProfiles.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||
var history = await db.CareerProfileVersions.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||
|
||||
Assert.Single(snapshots);
|
||||
Assert.Equal(2, snapshots[0].Version);
|
||||
Assert.Equal(2, history.Count);
|
||||
Assert.Equal(new[] { "upload", "improve" }, history.OrderBy(x => x.Version).Select(x => x.Source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveVersionAsync_does_not_normalize_end_date_when_job_is_current()
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = new StructuredCvProfile
|
||||
{
|
||||
Jobs = { new StructuredCvJob { Title = "Engineer", Start = "Jan 2020", End = "Present", IsCurrent = true } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||
|
||||
Assert.Equal("2020-01", saved.Jobs[0].StartDate);
|
||||
Assert.Null(saved.Jobs[0].EndDate);
|
||||
}
|
||||
}
|
||||
@@ -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 InterviewPrepDto GetDto(ActionResult<InterviewPrepDto> result)
|
||||
=> (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;
|
||||
}
|
||||
}
|
||||
@@ -1428,7 +1428,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
|
||||
.AsNoTracking()
|
||||
@@ -1439,6 +1439,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.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
var cvText = user?.ProfileCvText;
|
||||
if (string.IsNullOrWhiteSpace(cvText))
|
||||
@@ -1535,7 +1542,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,
|
||||
@@ -1549,11 +1556,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
|
||||
.AsNoTracking()
|
||||
@@ -1564,6 +1574,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.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
var cvText = user?.ProfileCvText;
|
||||
if (string.IsNullOrWhiteSpace(cvText))
|
||||
@@ -1626,17 +1643,45 @@ 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
|
||||
.AsNoTracking()
|
||||
@@ -1644,6 +1689,24 @@ Candidate master CV:
|
||||
.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)));
|
||||
@@ -1662,9 +1725,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)
|
||||
{
|
||||
|
||||
@@ -71,8 +71,9 @@ public sealed class ProfileCvController : ControllerBase
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
private readonly ICvProcessingQueue _cvProcessingQueue;
|
||||
private readonly IAppEmailSender _emailSender;
|
||||
private readonly ICareerProfileService _careerProfileService;
|
||||
|
||||
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null)
|
||||
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null)
|
||||
{
|
||||
_users = users;
|
||||
_aiService = aiService;
|
||||
@@ -85,6 +86,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
|
||||
_emailSender = emailSender ?? NoOpEmailSender.Instance;
|
||||
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
|
||||
}
|
||||
|
||||
private sealed class NoOpEmailSender : IAppEmailSender
|
||||
@@ -113,6 +115,11 @@ public sealed class ProfileCvController : ControllerBase
|
||||
public string? Tone { get; set; }
|
||||
public string? Language { get; set; }
|
||||
}
|
||||
// ParseCvRequest / CvTemplateDescriptor / ProfileCvPreviewDto / CvRewriteFailureDto are
|
||||
// defined in ProfileCvDtos.cs on main (extracted after this branch forked). The branch's
|
||||
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating
|
||||
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
|
||||
// (Phase 4), not this foundation integration.
|
||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
|
||||
@@ -155,6 +162,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
|
||||
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
|
||||
|
||||
run.RawExtractedText = result.RawText;
|
||||
@@ -553,6 +561,8 @@ public sealed class ProfileCvController : ControllerBase
|
||||
|
||||
private static IReadOnlyList<CvTemplateDescriptor> GetCvTemplateDescriptors()
|
||||
{
|
||||
// 7-arg shape matches CvTemplateDescriptor in ProfileCvDtos.cs. The LayoutFamily/AtsRating
|
||||
// fields the branch added here are deferred with the rest of the ATS-badge work (Phase 4).
|
||||
return new[]
|
||||
{
|
||||
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }),
|
||||
@@ -825,6 +835,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
|
||||
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||
run.StructuredProfileJson = structuredJson;
|
||||
|
||||
@@ -965,6 +976,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
|
||||
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||
|
||||
run.RawExtractedText = rawText;
|
||||
|
||||
@@ -37,6 +37,7 @@ builder.Services.AddSingleton<ICvProcessingQueue, CvProcessingQueue>();
|
||||
builder.Services.AddTransient<ProfileCvController>();
|
||||
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
|
||||
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
// Career Workspace foundation (see docs/career-workspace-implementation-roadmap.md, Phase F1).
|
||||
// Bounded to the profile/CV domain -- job tracking is untouched. Every existing read path still
|
||||
// goes through ApplicationUser.ProfileCvStructureJson (dual-write window); this service is the
|
||||
// single place stable item IDs and normalized dates get assigned, and where profile history is
|
||||
// captured so it's never silently overwritten on the next rebuild/improve/upload.
|
||||
public interface ICareerProfileService
|
||||
{
|
||||
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
|
||||
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, and
|
||||
// returns the same profile so the caller can go on to serialize it into the legacy column.
|
||||
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class CareerProfileService : ICareerProfileService
|
||||
{
|
||||
private readonly JobTrackerContext _db;
|
||||
|
||||
public CareerProfileService(JobTrackerContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
|
||||
{
|
||||
AssignStableIds(profile);
|
||||
NormalizeDates(profile);
|
||||
|
||||
var json = StructuredCvProfileJson.Serialize(profile);
|
||||
|
||||
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new CareerProfile
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
ProfileJson = json,
|
||||
Version = 1,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
UpdatedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.CareerProfiles.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ProfileJson = json;
|
||||
existing.Version += 1;
|
||||
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_db.CareerProfileVersions.Add(new CareerProfileVersion
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CareerProfileId = existing.Id,
|
||||
Version = existing.Version,
|
||||
ProfileJson = json,
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static void AssignStableIds(StructuredCvProfile profile)
|
||||
{
|
||||
foreach (var job in profile.Jobs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
|
||||
}
|
||||
foreach (var education in profile.Education)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
|
||||
}
|
||||
foreach (var certification in profile.Certifications)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
|
||||
}
|
||||
foreach (var project in profile.Projects)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
private static void NormalizeDates(StructuredCvProfile profile)
|
||||
{
|
||||
foreach (var job in profile.Jobs)
|
||||
{
|
||||
job.StartDate = CvDateNormalizer.TryParseYearMonth(job.Start);
|
||||
job.EndDate = job.IsCurrent ? null : CvDateNormalizer.TryParseYearMonth(job.End);
|
||||
}
|
||||
foreach (var education in profile.Education)
|
||||
{
|
||||
education.StartDate = CvDateNormalizer.TryParseYearMonth(education.Start);
|
||||
education.EndDate = CvDateNormalizer.TryParseYearMonth(education.End);
|
||||
}
|
||||
foreach (var certification in profile.Certifications)
|
||||
{
|
||||
certification.DateNormalized = CvDateNormalizer.TryParseYearMonth(certification.Date);
|
||||
}
|
||||
foreach (var project in profile.Projects)
|
||||
{
|
||||
project.StartDate = CvDateNormalizer.TryParseYearMonth(project.Start);
|
||||
project.EndDate = CvDateNormalizer.TryParseYearMonth(project.End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort free-string -> "YYYY-MM" parser. Never throws, never loses data: the original
|
||||
// free-string field is always kept alongside whatever this returns (null on anything it can't
|
||||
// confidently parse -- callers must not treat null as "no date", only as "unparsed").
|
||||
public static class CvDateNormalizer
|
||||
{
|
||||
private static readonly Dictionary<string, int> MonthNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["jan"] = 1, ["january"] = 1,
|
||||
["feb"] = 2, ["february"] = 2,
|
||||
["mar"] = 3, ["march"] = 3,
|
||||
["apr"] = 4, ["april"] = 4,
|
||||
["may"] = 5,
|
||||
["jun"] = 6, ["june"] = 6,
|
||||
["jul"] = 7, ["july"] = 7,
|
||||
["aug"] = 8, ["august"] = 8,
|
||||
["sep"] = 9, ["sept"] = 9, ["september"] = 9,
|
||||
["oct"] = 10, ["october"] = 10,
|
||||
["nov"] = 11, ["november"] = 11,
|
||||
["dec"] = 12, ["december"] = 12,
|
||||
};
|
||||
|
||||
public static string? TryParseYearMonth(string? raw)
|
||||
{
|
||||
var value = (raw ?? string.Empty).Trim();
|
||||
if (value.Length == 0) return null;
|
||||
if (value.Equals("present", StringComparison.OrdinalIgnoreCase) || value.Equals("current", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
// "2020" -> January is an assumption we don't want to make silently; year-only stays unparsed.
|
||||
var monthYear = Regex.Match(value, @"^(?<month>[A-Za-z]+)\.?\s+(?<year>\d{4})$");
|
||||
if (monthYear.Success && MonthNames.TryGetValue(monthYear.Groups["month"].Value, out var month))
|
||||
{
|
||||
return $"{monthYear.Groups["year"].Value}-{month:D2}";
|
||||
}
|
||||
|
||||
var slash = Regex.Match(value, @"^(?<month>\d{1,2})/(?<year>\d{4})$");
|
||||
if (slash.Success)
|
||||
{
|
||||
var m = int.Parse(slash.Groups["month"].Value);
|
||||
if (m is >= 1 and <= 12) return $"{slash.Groups["year"].Value}-{m:D2}";
|
||||
}
|
||||
|
||||
var isoLike = Regex.Match(value, @"^(?<year>\d{4})-(?<month>\d{1,2})$");
|
||||
if (isoLike.Success)
|
||||
{
|
||||
var m = int.Parse(isoLike.Groups["month"].Value);
|
||||
if (m is >= 1 and <= 12) return $"{isoLike.Groups["year"].Value}-{m:D2}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -682,6 +682,84 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");""");
|
||||
}
|
||||
|
||||
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
|
||||
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
|
||||
// authoritative column every existing read path uses; these mirror it so future
|
||||
// Career Workspace features (variants, history UI) have a real table to build on.
|
||||
static void EnsureCareerProfileTables(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"ProfileJson" TEXT NOT NULL,
|
||||
"Version" INTEGER NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
"UpdatedAtUtc" TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"CareerProfileId" INTEGER NOT NULL,
|
||||
"Version" INTEGER NOT NULL,
|
||||
"ProfileJson" TEXT NOT NULL,
|
||||
"Source" TEXT NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
|
||||
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");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
@@ -689,6 +767,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureTwoFactorRecoveryCodesTable(conn);
|
||||
EnsureTrustedDevicesTable(conn);
|
||||
EnsureUserSessionsTable(conn);
|
||||
EnsureCareerProfileTables(conn);
|
||||
EnsureInterviewPrepNotesTable(conn);
|
||||
EnsureAiWorkspaceNotesTable(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
// and at least one of the new columns already exists.
|
||||
@@ -825,6 +906,40 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
|
||||
|
||||
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
|
||||
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();
|
||||
}
|
||||
|
||||
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||||
@@ -1053,6 +1168,93 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
|
||||
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
|
||||
// for every existing read path during the dual-write window.
|
||||
if (!HasMySqlTable(conn, "CareerProfiles"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfiles` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`ProfileJson` longtext NOT NULL,
|
||||
`Version` int NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "CareerProfileVersions"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`CareerProfileId` int NOT NULL,
|
||||
`Version` int NOT NULL,
|
||||
`ProfileJson` longtext NOT NULL,
|
||||
`Source` varchar(100) NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
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();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
|
||||
{
|
||||
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,29 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
// The Career Workspace's durable source of truth. Bounded to one row per user for now
|
||||
// (see career-workspace-implementation-roadmap.md Phase F1) -- ProfileJson mirrors
|
||||
// ApplicationUser.ProfileCvStructureJson during the dual-write window and will become
|
||||
// authoritative once every read path is migrated (F5).
|
||||
public sealed class CareerProfile
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public string ProfileJson { get; set; } = string.Empty;
|
||||
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 save, so profile edits are never silently lost.
|
||||
public sealed class CareerProfileVersion
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int CareerProfileId { get; set; }
|
||||
public CareerProfile? CareerProfile { get; set; }
|
||||
public int Version { get; set; }
|
||||
public string ProfileJson { get; set; } = string.Empty;
|
||||
// Where this version came from: "upload" | "rebuild" | "improve" | "reprocess" | "parse" | "manual".
|
||||
public string Source { get; set; } = string.Empty;
|
||||
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;
|
||||
}
|
||||
@@ -49,11 +49,19 @@ public sealed class StructuredCvContact
|
||||
|
||||
public sealed class StructuredCvJob
|
||||
{
|
||||
// Stable item ID (assigned by CareerProfileService on first save). Required for CV variants
|
||||
// to reference "this job" across profile edits, instead of by array position. Nullable/empty
|
||||
// on freshly-parsed or legacy data until the first save assigns it.
|
||||
public string? Id { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Company { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
public string? End { get; set; }
|
||||
// Best-effort "YYYY-MM" normalization of Start/End, computed alongside Id assignment.
|
||||
// Null when Start/End can't be parsed; the free-string fields above remain the display source.
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public bool IsCurrent { get; set; }
|
||||
public List<string> Bullets { get; set; } = new();
|
||||
public List<string> Skills { get; set; } = new();
|
||||
@@ -61,31 +69,39 @@ public sealed class StructuredCvJob
|
||||
|
||||
public sealed class StructuredCvEducation
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Qualification { get; set; }
|
||||
public string? QualificationLevel { get; set; }
|
||||
public string? Institution { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
public string? End { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public List<string> Details { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvCertification
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Issuer { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Date { get; set; }
|
||||
public string? DateNormalized { get; set; }
|
||||
public List<string> Details { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvProject
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
public string? End { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public List<string> Bullets { get; set; } = new();
|
||||
public List<string> Skills { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -290,6 +290,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(() => {
|
||||
@@ -349,6 +361,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"}`;
|
||||
@@ -365,6 +387,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);
|
||||
@@ -1146,6 +1181,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>
|
||||
@@ -1174,6 +1214,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} />
|
||||
@@ -1187,6 +1232,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} />
|
||||
|
||||
Reference in New Issue
Block a user