diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..f906f2e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "job-tracker-ui", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "job-tracker-ui", + "port": 3000 + } + ] +} diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 661d931..09b08f3 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -32,6 +32,10 @@ namespace JobTrackerApi.Data public DbSet TwoFactorRecoveryCodes => Set(); public DbSet TrustedDevices => Set(); public DbSet UserSessions => Set(); + public DbSet CareerProfiles => Set(); + public DbSet CareerProfileVersions => Set(); + public DbSet InterviewPrepNotes => Set(); + public DbSet AiWorkspaceNotes => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -216,6 +220,57 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .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() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => x.OwnerUserId) + .IsUnique(); + + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.Version }); + + modelBuilder.Entity() + .HasOne(x => x.CareerProfile) + .WithMany() + .HasForeignKey(x => x.CareerProfileId) + .OnDelete(DeleteBehavior.Cascade); + + // Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5). + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId }) + .IsUnique(); + + modelBuilder.Entity() + .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() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.NoteType }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.JobApplication) + .WithMany() + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs b/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs new file mode 100644 index 0000000..9324521 --- /dev/null +++ b/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs @@ -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(); + var callCount = 0; + summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(); + var callCount = 0; + summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(); + var callCount = 0; + summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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 result) + => (CandidateFitDto)Assert.IsType(result.Result).Value!; + + private static async Task 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(), + TestHostFactory.CreateUserManager(user).Object, + NullLogger.Instance, + Mock.Of(), + Mock.Of()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + }; + return controller; + } +} diff --git a/JobTrackerApi.Tests/CareerProfileServiceTests.cs b/JobTrackerApi.Tests/CareerProfileServiceTests.cs new file mode 100644 index 0000000..e018fec --- /dev/null +++ b/JobTrackerApi.Tests/CareerProfileServiceTests.cs @@ -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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var currentUser = new Mock(); + 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); + } +} diff --git a/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs b/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs new file mode 100644 index 0000000..8c33bee --- /dev/null +++ b/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs @@ -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(); + summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny(), It.IsAny()), 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(); + summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny(), It.IsAny()), 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(); + summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + private static InterviewPrepDto GetDto(ActionResult result) + => (InterviewPrepDto)Assert.IsType(result.Result).Value!; + + private static async Task 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(), + TestHostFactory.CreateUserManager(null).Object, + NullLogger.Instance, + Mock.Of(), + Mock.Of()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + }; + return controller; + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index cbc93cb..606330b 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1428,7 +1428,7 @@ Canonical profile: } [HttpGet("{id:int}/candidate-fit")] - public async Task> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) + public async Task> 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(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> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) + public async Task> 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(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 TryGetCachedAiNoteAsync(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(existing.ResultJson); + } + + private async Task SaveAiNoteAsync(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> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) + public async Task> 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>(existing.TalkingPointsJson) ?? new List(), + JsonSerializer.Deserialize>(existing.LikelyQuestionsJson) ?? new List(), + JsonSerializer.Deserialize>(existing.WeakSpotsJson) ?? new List())); + } + } + 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> GetReadiness([FromRoute] int id, CancellationToken cancellationToken) { diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 8f87075..febd897 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -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 users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null) + public ProfileCvController(UserManager users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger? 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 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 { "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; diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 88329fa..b2af0a5 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -37,6 +37,7 @@ builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs new file mode 100644 index 0000000..46ce17e --- /dev/null +++ b/JobTrackerApi/Services/CareerProfileService.cs @@ -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 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 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 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, @"^(?[A-Za-z]+)\.?\s+(?\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, @"^(?\d{1,2})/(?\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, @"^(?\d{4})-(?\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; + } +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 9741d4e..22b4bf3 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -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(); diff --git a/Models/AiWorkspaceNote.cs b/Models/AiWorkspaceNote.cs new file mode 100644 index 0000000..084324e --- /dev/null +++ b/Models/AiWorkspaceNote.cs @@ -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; +} diff --git a/Models/CareerProfile.cs b/Models/CareerProfile.cs new file mode 100644 index 0000000..826c045 --- /dev/null +++ b/Models/CareerProfile.cs @@ -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; +} diff --git a/Models/InterviewPrepNote.cs b/Models/InterviewPrepNote.cs new file mode 100644 index 0000000..af16663 --- /dev/null +++ b/Models/InterviewPrepNote.cs @@ -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; +} diff --git a/Models/StructuredCvProfile.cs b/Models/StructuredCvProfile.cs index 6a4b628..53bcbce 100644 --- a/Models/StructuredCvProfile.cs +++ b/Models/StructuredCvProfile.cs @@ -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 Bullets { get; set; } = new(); public List 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 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 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 Bullets { get; set; } = new(); public List Skills { get; set; } = new(); } diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index e9806af..5dcfdf2 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -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(`/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(`/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(`/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 && ( + + + {loadingCandidateFit ? : candidateFit ? ( {t("jobDetailsAiFitHint")} @@ -1174,6 +1214,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {tab === 6 && ( + + + {loadingFocusPlan ? : focusPlan ? ( @@ -1187,6 +1232,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {tab === 7 && ( + + + {loadingInterviewPrep ? : interviewPrep ? (