diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index d55ef38..e1f8a9e 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -24,6 +24,7 @@ namespace JobTrackerApi.Data public DbSet JobEvents => Set(); public DbSet CvUploadArtifacts => Set(); public DbSet CvExtractionRuns => Set(); + public DbSet TailoredCvDrafts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -101,6 +102,19 @@ namespace JobTrackerApi.Data .WithMany() .HasForeignKey(x => x.ArtifactId) .OnDelete(DeleteBehavior.SetNull); + + 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) + .WithOne(j => j.TailoredCvDraft) + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index 8662c89..1ce6feb 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -234,9 +234,232 @@ public sealed class JobApplicationsApplicationPackageTests Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext); } - private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId) + [Fact] + public async Task Get_tailored_cv_draft_returns_legacy_fallback_when_no_structured_draft_exists() { - var controller = new JobApplicationsController(db, summarizer, Mock.Of(), CreateUserManager().Object, NullLogger.Instance); + await using var db = CreateDb(); + 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", + TailoredCvText = "Existing tailored CV text", + TailoredCvUpdatedAt = DateTime.UtcNow + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, Mock.Of(), "user-1"); + var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + Assert.True(payload.IsLegacyFallback); + Assert.Equal("legacy-text", payload.TemplateId); + Assert.Contains("Existing tailored CV text", payload.RenderedText); + } + + [Fact] + public async Task Generate_and_save_tailored_cv_draft_persists_job_scoped_document_without_mutating_master_cv() + { + await using var db = CreateDb(); + 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", + CurrentCvProfileVersion = 7, + ProfileCvText = "Built APIs and owned backend delivery.", + ProfileCvStructureJson = """ + { + "version": "1", + "contact": { + "fullName": "Demo User", + "headline": "Backend Developer" + }, + "summary": ["Backend-focused developer with API delivery experience."], + "jobs": [ + { + "title": "System Developer", + "company": "Acme Consulting", + "location": "Oslo", + "start": "2021", + "end": "2024", + "isCurrent": false, + "bullets": ["Owned .NET API delivery across multiple services."], + "skills": [".NET", "SQL", "APIs"] + } + ], + "education": [], + "skills": [".NET", "SQL", "APIs"], + "languages": [{ "name": "English", "level": "Native" }], + "interests": [], + "otherSections": [] + } + """ + }); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Description = "Need .NET API ownership and strong SQL skills." + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var summarizer = new Mock(); + summarizer + .Setup(service => service.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string instruction, string _, int __, int ___) => + { + if (instruction.Contains("headline", StringComparison.OrdinalIgnoreCase)) return "Senior Backend Engineer"; + if (instruction.Contains("summary bullets", StringComparison.OrdinalIgnoreCase)) return "Led backend API delivery.\nImproved SQL-backed workflows."; + return "Draft"; + }); + + var controller = CreateController(db, summarizer.Object, "user-1"); + var generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None); + var generateOk = Assert.IsType(generateResult.Result); + var generated = Assert.IsType(generateOk.Value); + + Assert.False(generated.IsLegacyFallback); + Assert.Equal(7, generated.CanonicalProfileVersion); + Assert.Equal("Senior Backend Engineer", generated.Headline); + Assert.Contains("Led backend API delivery.", generated.RenderedText); + + var saveResult = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest( + generated.TemplateId, + "Principal Backend Engineer", + new List { "Own backend delivery for critical APIs." }, + new List { ".NET", "SQL" }, + generated.Experience, + generated.Education, + generated.CustomSections, + generated.RenderOptions, + "edited"), CancellationToken.None); + + Assert.IsType(saveResult); + + var savedDraft = await db.TailoredCvDrafts.SingleAsync(); + var savedJob = await db.JobApplications.SingleAsync(); + var savedUser = await db.Users.SingleAsync(); + + Assert.Equal("edited", savedDraft.Status); + Assert.Equal(7, savedDraft.CanonicalProfileVersion); + Assert.Contains("Principal Backend Engineer", savedJob.TailoredCvText); + Assert.Equal("Built APIs and owned backend delivery.", savedUser.ProfileCvText); + } + + [Fact] + public async Task Preview_and_export_tailored_cv_use_same_renderer_contract_and_profile_avatar_default() + { + await using var db = CreateDb(); + 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", + AvatarImageDataUrl = "data:image/png;base64,abc123" + }); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + TailoredCvText = "Saved tailored CV" + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var renderer = new TestCvTemplateRenderer(); + var exporter = new TestCvPdfExporter(); + var controller = CreateController(db, Mock.Of(), "user-1", renderer, exporter); + var request = new JobApplicationsController.TailoredCvRenderRequest( + "ats-minimal", + "Backend Engineer", + new List { "Built APIs" }, + new List { ".NET" }, + new List(), + new List(), + new List(), + new TailoredCvRenderOptions { ShowPhoto = true, AccentColor = "#123456" }, + null, + true); + + var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None); + var ok = Assert.IsType(previewResult.Result); + var preview = Assert.IsType(ok.Value); + Assert.Equal("ats-minimal", preview.TemplateId); + Assert.Equal("preview.pdf", preview.SuggestedFileName); + Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl); + + var exportResult = await controller.ExportTailoredCvPdf(job.Id, request, CancellationToken.None); + var file = Assert.IsType(exportResult); + Assert.Equal("application/pdf", file.ContentType); + Assert.Equal("preview.pdf", file.FileDownloadName); + Assert.NotNull(exporter.LastRenderResult); + Assert.Equal(preview.Html, exporter.LastRenderResult!.Html); + } + + [Fact] + public void Template_renderer_supports_named_variants() + { + var renderer = new CvTemplateRenderer(); + var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument + { + TemplateId = "harvard", + Headline = "Product Manager", + Summary = new List { "Built and shipped product roadmaps." }, + SelectedSkills = new List { "Strategy", "Stakeholder management" }, + Experience = new List + { + new() { Title = "Product Manager", Company = "Acme", Start = "2022", End = "2025", Bullets = new List { "Launched new product line." } } + } + }); + + var harvard = renderer.Render(document, "harvard", "Andrew O'Sullivan", "Product Manager", "Acme", null); + var auckland = renderer.Render(document, "auckland", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc"); + var edinburgh = renderer.Render(document, "edinburgh", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc"); + + Assert.Equal("harvard", harvard.TemplateId); + Assert.Contains("Template: ATS Minimal", renderer.Render(document, "ats-minimal", "Andrew O'Sullivan", "Product Manager", "Acme", null).Html); + Assert.Contains("Andrew O'Sullivan", harvard.Html); + Assert.Contains("sidebar", auckland.Html, StringComparison.OrdinalIgnoreCase); + Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase); + } + + private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId, ICvTemplateRenderer? renderer = null, ICvPdfExporter? exporter = null) + { + var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId); + var controller = new JobApplicationsController( + db, + summarizer, + Mock.Of(), + CreateUserManager(user).Object, + NullLogger.Instance, + renderer ?? new TestCvTemplateRenderer(), + exporter ?? new TestCvPdfExporter()); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext @@ -260,10 +483,10 @@ public sealed class JobApplicationsApplicationPackageTests return new JobTrackerContext(options, currentUser.Object); } - private static Mock> CreateUserManager() + private static Mock> CreateUserManager(ApplicationUser? user = null) { var store = new Mock>(); - return new Mock>( + var manager = new Mock>( store.Object, Options.Create(new IdentityOptions()), new PasswordHasher(), @@ -273,5 +496,29 @@ public sealed class JobApplicationsApplicationPackageTests new IdentityErrorDescriber(), null!, new NullLogger>()); + manager.Setup(x => x.FindByIdAsync(It.IsAny())).ReturnsAsync((string id) => user is not null && user.Id == id ? user : null); + return manager; + } + + private sealed class TestCvTemplateRenderer : ICvTemplateRenderer + { + public string? LastPhotoDataUrl { get; private set; } + + public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null) + { + LastPhotoDataUrl = photoDataUrl; + return new TailoredCvRenderResult(templateId ?? "ats-minimal", "preview.pdf", $"{candidateName}|{jobTitle}|{companyName}|{document?.Headline}|{photoDataUrl}"); + } + } + + private sealed class TestCvPdfExporter : ICvPdfExporter + { + public TailoredCvRenderResult? LastRenderResult { get; private set; } + + public Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) + { + LastRenderResult = renderResult; + return Task.FromResult(new CvPdfArtifact("preview.pdf", "/tmp/preview.pdf", new byte[] { 1, 2, 3 })); + } } } diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 916061f..c334b1b 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -5,6 +5,7 @@ using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Services.JobImport; using System.Security.Claims; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Identity; @@ -20,14 +21,26 @@ namespace JobTrackerApi.Controllers private readonly IAppEmailSender _email; private readonly UserManager _users; private readonly ILogger _logger; + private readonly ICvTemplateRenderer _cvTemplateRenderer; + private readonly ICvPdfExporter _cvPdfExporter; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null) { _db = db; _summarizer = summarizer; _email = email; _users = users; _logger = logger; + _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); + _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); + } + + private sealed class ThrowingCvPdfExporter : ICvPdfExporter + { + public Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) + { + throw new InvalidOperationException("CV PDF export is not configured for this controller instance."); + } } private string? CurrentUserId => @@ -153,6 +166,275 @@ namespace JobTrackerApi.Controllers return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}"; } + private static string ComputeGenerationContextHash(string value) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable matchedTags) + { + var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) } + .Where(value => !string.IsNullOrWhiteSpace(value))) + .ToLowerInvariant(); + var score = 0; + foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag))) + { + if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4; + } + score += Math.Min(job.Bullets.Count, 4); + return score; + } + + private static List SelectTailoredSkills(StructuredCvProfile structured, string jobText) + { + var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var prioritized = structured.Skills + .Select(skill => new + { + Skill = skill, + Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0 + }) + .OrderByDescending(entry => entry.Score) + .ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase) + .Select(entry => entry.Skill) + .ToList(); + + if (prioritized.Count == 0) + { + prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + return prioritized.Take(10).ToList(); + } + + private static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job) + { + var text = (job.TailoredCvText ?? string.Empty).Trim(); + var document = new TailoredCvDocument + { + Headline = job.JobTitle, + CustomSections = string.IsNullOrWhiteSpace(text) + ? new List() + : new List + { + new TailoredCvCustomSection + { + Title = "Legacy draft text", + Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(), + } + } + }; + return TailoredCvDraftJson.Normalize(document); + } + + private static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft) + { + var document = TailoredCvDraftJson.FromDraft(draft); + return new TailoredCvDraftDto( + draft.Id, + draft.CanonicalProfileVersion, + draft.TemplateId, + document.Headline, + document.Summary, + document.SelectedSkills, + document.Experience, + document.Education, + document.CustomSections, + document.RenderOptions, + draft.GenerationContextHash, + draft.LastGeneratedAtUtc, + draft.LastEditedAtUtc, + draft.Status, + TailoredCvDraftJson.RenderPlainText(document), + false); + } + + private static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job) + { + var document = BuildLegacyTailoredCvFallback(job); + return new TailoredCvDraftDto( + null, + null, + "legacy-text", + document.Headline, + document.Summary, + document.SelectedSkills, + document.Experience, + document.Education, + document.CustomSections, + document.RenderOptions, + null, + null, + job.TailoredCvUpdatedAt, + string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import", + TailoredCvDraftJson.RenderPlainText(document), + true); + } + + private static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job) + { + var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job); + if (request is null) + { + return baseDocument; + } + + return TailoredCvDraftJson.Normalize(new TailoredCvDocument + { + TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal", + Headline = request.Headline ?? baseDocument.Headline, + Summary = request.Summary ?? baseDocument.Summary, + SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills, + Experience = request.Experience ?? baseDocument.Experience, + Education = request.Education ?? baseDocument.Education, + CustomSections = request.CustomSections ?? baseDocument.CustomSections, + RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions, + }); + } + + private async Task FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken) + { + return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken); + } + + private TailoredCvRenderResult RenderTailoredCv(JobApplication job, TailoredCvDocument document, ApplicationUser? user, string? photoDataUrl) + { + return _cvTemplateRenderer.Render( + document, + document.TemplateId, + GetPreferredDisplayName(user), + job.JobTitle, + job.Company?.Name, + photoDataUrl); + } + + public sealed record TailoredCvPreviewDto(string TemplateId, string Html, string SuggestedFileName); + public sealed record TailoredCvRenderRequest( + string? TemplateId, + string? Headline, + List? Summary, + List? SelectedSkills, + List? Experience, + List? Education, + List? CustomSections, + TailoredCvRenderOptions? RenderOptions, + string? PhotoDataUrl, + bool? UseProfileAvatar); + + private async Task UpsertGeneratedTailoredCvDraftAsync(JobApplication job, ApplicationUser user, string? mode, CancellationToken cancellationToken) + { + var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl } + .Where(value => !string.IsNullOrWhiteSpace(value))); + var structuredCvContext = BuildStructuredCvContext(user); + var generationContext = $@"Job title: {job.JobTitle} +Company: {job.Company?.Name} +Status: {job.Status} +Generation mode: {mode ?? "default"} + +Job context: +{jobText} + +Canonical profile: +{structuredCvContext} +"; + + var headline = await _summarizer.SummarizeSectionAsync( + "Write a short, role-specific CV headline for this candidate. Keep it factual, scannable, and under 12 words. Return headline text only.", + generationContext, + 48, + 24); + + var summary = await BuildListFromAiAsync( + $"Write 4 short CV summary bullets tailored to this job. Use only facts supported by the canonical profile. Keep each line tight and credible. {BuildPackageModeInstruction(mode)}", + generationContext, + cancellationToken, + fallbackPrefix: job.JobTitle); + + var selectedSkills = SelectTailoredSkills(structured, jobText); + var matchedTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var experience = structured.Jobs + .OrderByDescending(entry => ScoreTailoredExperience(entry, matchedTags)) + .ThenByDescending(entry => entry.IsCurrent) + .Take(4) + .Select(entry => new TailoredCvExperienceItem + { + Title = entry.Title, + Company = entry.Company, + Location = entry.Location, + Start = entry.Start, + End = entry.End, + IsCurrent = entry.IsCurrent, + Bullets = entry.Bullets.Take(4).ToList(), + }) + .ToList(); + + var education = structured.Education + .Take(3) + .Select(entry => new TailoredCvEducationItem + { + Qualification = entry.Qualification, + Institution = entry.Institution, + Location = entry.Location, + Start = entry.Start, + End = entry.End, + Details = entry.Details.Take(3).ToList(), + }) + .ToList(); + + var customSections = new List(); + if (structured.Languages.Count > 0) + { + customSections.Add(new TailoredCvCustomSection + { + Title = "Languages", + Items = structured.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(), + }); + } + customSections.AddRange(structured.OtherSections.Take(2).Select(section => new TailoredCvCustomSection + { + Title = section.Title, + Items = section.Items.Take(4).ToList(), + })); + + var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument + { + TemplateId = "ats-minimal", + Headline = string.IsNullOrWhiteSpace(headline) ? structured.Contact.Headline ?? job.JobTitle : headline.Trim(), + Summary = summary, + SelectedSkills = selectedSkills, + Experience = experience, + Education = education, + CustomSections = customSections, + RenderOptions = new TailoredCvRenderOptions(), + }); + + var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == job.Id, cancellationToken) + ?? new TailoredCvDraft + { + OwnerUserId = user.Id, + JobApplicationId = job.Id, + }; + + draft.OwnerUserId = user.Id; + draft.CanonicalProfileVersion = user.CurrentCvProfileVersion; + draft.GenerationContextHash = ComputeGenerationContextHash(generationContext); + draft.LastGeneratedAtUtc = DateTimeOffset.UtcNow; + draft.Status = "generated"; + TailoredCvDraftJson.ApplyToDraft(draft, document); + + if (draft.Id == 0) + { + _db.TailoredCvDrafts.Add(draft); + } + + job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document); + job.TailoredCvUpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + return draft; + } + private async Task> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix) { var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70); @@ -1729,6 +2011,33 @@ namespace JobTrackerApi.Controllers string? CoverLetterDraft, string? RecruiterMessageDraft); public sealed record SaveTailoredCvRequest(string? TailoredCvText); + public sealed record TailoredCvDraftDto( + int? Id, + int? CanonicalProfileVersion, + string TemplateId, + string? Headline, + List Summary, + List SelectedSkills, + List Experience, + List Education, + List CustomSections, + TailoredCvRenderOptions RenderOptions, + string? GenerationContextHash, + DateTimeOffset? LastGeneratedAtUtc, + DateTimeOffset? LastEditedAtUtc, + string Status, + string RenderedText, + bool IsLegacyFallback); + public sealed record SaveTailoredCvDraftRequest( + string? TemplateId, + string? Headline, + List? Summary, + List? SelectedSkills, + List? Experience, + List? Education, + List? CustomSections, + TailoredCvRenderOptions? RenderOptions, + string? Status); public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List KeyPoints, List AttachmentSignals, List AttachmentFilesUsed, List CoverLetterVariants, List RecruiterMessageVariants); public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft); private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes); @@ -2029,6 +2338,155 @@ Candidate master CV: return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal)); } + [HttpGet("{id:int}/tailored-cv-draft")] + public async Task> GetTailoredCvDraft([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .AsNoTracking() + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var draft = await _db.TailoredCvDrafts + .AsNoTracking() + .FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken); + + return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft)); + } + + [HttpPost("{id:int}/tailored-cv-preview")] + public async Task> PreviewTailoredCv([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var user = await GetCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(); + + var draft = await FindTailoredCvDraftAsync(id, cancellationToken); + var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest( + request.TemplateId, + request.Headline, + request.Summary, + request.SelectedSkills, + request.Experience, + request.Education, + request.CustomSections, + request.RenderOptions, + draft?.Status ?? "generated"), draft, job); + var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl) + ? request!.PhotoDataUrl + : request?.UseProfileAvatar == false + ? null + : user.AvatarImageDataUrl; + var rendered = RenderTailoredCv(job, document, user, photoDataUrl); + return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName)); + } + + [HttpPost("{id:int}/export-tailored-cv-pdf")] + public async Task ExportTailoredCvPdf([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var user = await GetCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(); + + var draft = await FindTailoredCvDraftAsync(id, cancellationToken); + var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest( + request.TemplateId, + request.Headline, + request.Summary, + request.SelectedSkills, + request.Experience, + request.Education, + request.CustomSections, + request.RenderOptions, + draft?.Status ?? "generated"), draft, job); + var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl) + ? request!.PhotoDataUrl + : request?.UseProfileAvatar == false + ? null + : user.AvatarImageDataUrl; + var rendered = RenderTailoredCv(job, document, user, photoDataUrl); + var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken); + return File(artifact.Bytes, "application/pdf", artifact.FileName); + } + + [HttpPost("{id:int}/generate-tailored-cv-draft")] + public async Task> GenerateTailoredCvDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var user = await GetCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(); + if (string.IsNullOrWhiteSpace(user.ProfileCvText)) + { + return BadRequest("Add your profile CV text on the Profile page before generating a tailored CV draft."); + } + + var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + if (structured.Summary.Count == 0 && structured.Jobs.Count == 0 && structured.Skills.Count == 0) + { + return BadRequest("Build and review your canonical structured CV on the Profile page before generating a tailored draft."); + } + + var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken); + return Ok(ToTailoredCvDraftDto(draft)); + } + + [HttpPut("{id:int}/tailored-cv-draft")] + public async Task SaveTailoredCvDraft([FromRoute] int id, [FromBody] SaveTailoredCvDraftRequest request, CancellationToken cancellationToken) + { + var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var user = await GetCurrentUserAsync(cancellationToken); + if (user is null) return Unauthorized(); + + var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken) + ?? new TailoredCvDraft + { + OwnerUserId = user.Id, + JobApplicationId = id, + CanonicalProfileVersion = user.CurrentCvProfileVersion, + }; + + var document = new TailoredCvDocument + { + TemplateId = request.TemplateId ?? draft.TemplateId, + Headline = request.Headline, + Summary = request.Summary ?? new List(), + SelectedSkills = request.SelectedSkills ?? new List(), + Experience = request.Experience ?? new List(), + Education = request.Education ?? new List(), + CustomSections = request.CustomSections ?? new List(), + RenderOptions = request.RenderOptions ?? new TailoredCvRenderOptions(), + }; + + draft.OwnerUserId = user.Id; + draft.CanonicalProfileVersion ??= user.CurrentCvProfileVersion; + draft.Status = string.IsNullOrWhiteSpace(request.Status) ? "edited" : request.Status.Trim(); + draft.LastEditedAtUtc = DateTimeOffset.UtcNow; + TailoredCvDraftJson.ApplyToDraft(draft, document); + + if (draft.Id == 0) + { + _db.TailoredCvDrafts.Add(draft); + } + + job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document); + job.TailoredCvUpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + [HttpPut("{id:int}/tailored-cv")] public async Task SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken) { diff --git a/JobTrackerApi/JobTrackerApi.csproj b/JobTrackerApi/JobTrackerApi.csproj index f4c38da..853e887 100644 --- a/JobTrackerApi/JobTrackerApi.csproj +++ b/JobTrackerApi/JobTrackerApi.csproj @@ -18,6 +18,7 @@ all + diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 5396a30..b18037c 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -30,6 +30,8 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -673,6 +675,31 @@ CREATE TABLE IF NOT EXISTS "CvExtractionRuns" ( Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");"""); + + Exec(c, """ +CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "JobApplicationId" INTEGER NOT NULL, + "CanonicalProfileVersion" INTEGER NULL, + "TemplateId" TEXT NOT NULL, + "Headline" TEXT NULL, + "SummaryJson" TEXT NULL, + "SelectedSkillsJson" TEXT NULL, + "ExperienceJson" TEXT NULL, + "EducationJson" TEXT NULL, + "CustomSectionsJson" TEXT NULL, + "RenderOptionsJson" TEXT NULL, + "GenerationContextHash" TEXT NULL, + "LastGeneratedAtUtc" TEXT NULL, + "LastEditedAtUtc" TEXT NULL, + "Status" TEXT NOT NULL, + CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE +); +"""); + + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");"""); } EnsureGmailConnectionsTable(conn); @@ -911,6 +938,32 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti cmd.ExecuteNonQuery(); } + if (!HasMySqlTable(conn, "TailoredCvDrafts")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` ( +`Id` int NOT NULL AUTO_INCREMENT, +`OwnerUserId` varchar(255) NOT NULL, +`JobApplicationId` int NOT NULL, +`CanonicalProfileVersion` int NULL, +`TemplateId` varchar(100) NOT NULL, +`Headline` longtext NULL, +`SummaryJson` longtext NULL, +`SelectedSkillsJson` longtext NULL, +`ExperienceJson` longtext NULL, +`EducationJson` longtext NULL, +`CustomSectionsJson` longtext NULL, +`RenderOptionsJson` longtext NULL, +`GenerationContextHash` longtext NULL, +`LastGeneratedAtUtc` datetime(6) NULL, +`LastEditedAtUtc` datetime(6) NULL, +`Status` varchar(100) NOT NULL, +PRIMARY KEY (`Id`), +CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE +);"; + cmd.ExecuteNonQuery(); + } + if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) { using var cmd = conn.CreateCommand(); @@ -945,6 +998,20 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);"; cmd.ExecuteNonQuery(); } + + if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);"; + cmd.ExecuteNonQuery(); + } } } diff --git a/JobTrackerApi/Services/AppPaths.cs b/JobTrackerApi/Services/AppPaths.cs index d39073c..ac83c3f 100644 --- a/JobTrackerApi/Services/AppPaths.cs +++ b/JobTrackerApi/Services/AppPaths.cs @@ -8,6 +8,7 @@ namespace JobTrackerApi.Services public string DataRoot { get; } public string AttachmentsRoot { get; } public string CvArtifactsRoot { get; } + public string CvExportsRoot { get; } public AppPaths(IConfiguration cfg, IHostEnvironment env) { @@ -31,6 +32,13 @@ namespace JobTrackerApi.Services Directory.CreateDirectory(cvArtifactsRoot); CvArtifactsRoot = cvArtifactsRoot; + + var cvExportsRoot = (cfg["Data:CvExportsRoot"] ?? "").Trim(); + if (string.IsNullOrWhiteSpace(cvExportsRoot)) cvExportsRoot = Path.Combine(DataRoot, "CvExports"); + if (!Path.IsPathRooted(cvExportsRoot)) cvExportsRoot = Path.Combine(env.ContentRootPath, cvExportsRoot); + + Directory.CreateDirectory(cvExportsRoot); + CvExportsRoot = cvExportsRoot; } public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName); diff --git a/JobTrackerApi/Services/CvTemplateRenderer.cs b/JobTrackerApi/Services/CvTemplateRenderer.cs new file mode 100644 index 0000000..7b5ff57 --- /dev/null +++ b/JobTrackerApi/Services/CvTemplateRenderer.cs @@ -0,0 +1,341 @@ +using System.Net; +using System.Text; +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services; + +public sealed record TailoredCvRenderResult(string TemplateId, string SuggestedFileName, string Html); + +public interface ICvTemplateRenderer +{ + TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null); +} + +public sealed class CvTemplateRenderer : ICvTemplateRenderer +{ + public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null) + { + var normalized = TailoredCvDraftJson.Normalize(document); + var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId); + normalized.TemplateId = effectiveTemplateId; + var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf"; + var html = effectiveTemplateId switch + { + "harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName), + "auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false), + "edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true), + _ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl) + }; + return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html); + } + + private static string NormalizeTemplateId(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "base" => "ats-minimal", + "legacy-text" => "ats-minimal", + "harvard" => "harvard", + "auckland" => "auckland", + "edinburgh" => "edinburgh", + _ => "ats-minimal" + }; + } + + private static string RenderAtsMinimal(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) + { + var accent = ResolveAccent(document.RenderOptions.AccentColor); + var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); + var body = RenderMainSections(document, accent, headingStyle: "caps-rule"); + var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"Company focus: {Encode(companyName)}"; + var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; + + return $@" + + + + {Encode(candidateName)} — ATS Minimal + + + +
+
+
+

{Encode(candidateName)}

+
{Encode(document.Headline ?? jobTitle)}
+
Target role: {Encode(jobTitle)}{companyFocusMarkup}Template: ATS Minimal
+
+ {photoMarkup} +
+ {body} +
+ +"; + } + + private static string RenderHarvard(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName) + { + var accent = ResolveAccent(document.RenderOptions.AccentColor); + var body = RenderMainSections(document, accent, headingStyle: "harvard"); + var contactLine = string.Join("  •  ", new[] + { + string.IsNullOrWhiteSpace(companyName) ? null : $"Targeting {Encode(companyName)}", + Encode(jobTitle) + }.Where(x => !string.IsNullOrWhiteSpace(x))); + + return $@" + + + + {Encode(candidateName)} — Harvard + + + +
+
+

{Encode(candidateName)}

+
{Encode(document.Headline ?? jobTitle)}
+
{contactLine}
+
+ {body} +
+ +"; + } + + private static string RenderSidebar(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl, string templateLabel, bool roundedPhoto, bool curvedHeader) + { + var accent = ResolveAccent(document.RenderOptions.AccentColor); + var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); + var sidebarSections = new StringBuilder(); + sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[] + { + $"Name\n{Encode(candidateName)}", + $"Target role\n{Encode(jobTitle)}", + string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{Encode(companyName)}" + })); + if (document.CustomSections.Count > 0) + { + foreach (var section in document.CustomSections.Take(2)) + { + sidebarSections.Append(RenderSidebarMetaSection(section.Title ?? "Additional", section.Items)); + } + } + if (document.SelectedSkills.Count > 0) + { + sidebarSections.Append(RenderSidebarMetaSection("Skills", document.SelectedSkills.Take(8))); + } + + var main = RenderMainSections(document, accent, headingStyle: "sidebar"); + var photoClass = roundedPhoto ? "photo round" : "photo"; + var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; + var heroClass = curvedHeader ? "hero curved" : "hero"; + + return $@" + + + + {Encode(candidateName)} — {Encode(templateLabel)} + + + +
+ +
+ {main} +
+
+ +"; + } + + private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle) + { + var sectionOrder = document.RenderOptions.SectionOrder.Count == 0 + ? new List { "summary", "skills", "experience", "education", "custom" } + : document.RenderOptions.SectionOrder; + + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["summary"] = RenderListSection("Profile", document.Summary, bulletList: true), + ["skills"] = RenderSkillSection(document.SelectedSkills), + ["experience"] = RenderExperienceSection(document.Experience), + ["education"] = RenderEducationSection(document.Education), + ["custom"] = string.Join(string.Empty, document.CustomSections.Select(RenderCustomSection)), + }; + + return string.Join(string.Empty, sectionOrder + .Select(key => sections.TryGetValue(key, out var section) ? section : string.Empty) + .Where(section => !string.IsNullOrWhiteSpace(section))); + } + + private static string BaseSectionCss(string accent, string headingStyle) + { + var headingCss = headingStyle switch + { + "harvard" => ".section-title{font-size:17pt;font-weight:700;border-bottom:1.5px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}", + "sidebar" => ".section-title{font-size:14pt;font-weight:700;letter-spacing:.02em;margin-bottom:3mm;}", + _ => ".section-title{font-size:9pt;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);border-bottom:1px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}" + }; + + return $@" + .section{{margin-top:6mm;}} + {headingCss} + .summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}} + .summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;}} + .skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}} + .skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;}} + .entry{{margin-bottom:4.8mm;}} + .entry-header{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;margin-bottom:1.2mm;}} + .entry-title{{font-weight:700;font-size:11pt;}} + .entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:nowrap;}} + .entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;}}"; + } + + private static string RenderSidebarMetaSection(string title, IEnumerable items) + { + var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"

{item}

")); + if (string.IsNullOrWhiteSpace(content)) return string.Empty; + return $"

{Encode(title)}

{content}
"; + } + + private static string RenderListSection(string title, IReadOnlyCollection items, bool bulletList) + { + if (items.Count == 0) return string.Empty; + var tag = bulletList ? "summary" : "custom-list"; + return $"

{Encode(title)}

    {string.Join(string.Empty, items.Select(item => $"
  • {Encode(item)}
  • "))}
"; + } + + private static string RenderSkillSection(IReadOnlyCollection skills) + { + if (skills.Count == 0) return string.Empty; + return $"

Skills

    {string.Join(string.Empty, skills.Select(skill => $"
  • {Encode(skill)}
  • "))}
"; + } + + private static string RenderExperienceSection(IReadOnlyCollection experience) + { + if (experience.Count == 0) return string.Empty; + var items = new StringBuilder(); + foreach (var entry in experience) + { + var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode)); + var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent); + items.Append("
"); + items.Append($"
{Encode(entry.Title)}
{Encode(dateRange)}
"); + if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"
{subtitle}
"); + if (entry.Bullets.Count > 0) items.Append($"
    {string.Join(string.Empty, entry.Bullets.Select(bullet => $"
  • {Encode(bullet)}
  • "))}
"); + items.Append("
"); + } + return $"

Professional Experience

{items}
"; + } + + private static string RenderEducationSection(IReadOnlyCollection education) + { + if (education.Count == 0) return string.Empty; + var items = new StringBuilder(); + foreach (var entry in education) + { + var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) } + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(Encode)); + items.Append("
"); + items.Append($"
{Encode(entry.Qualification)}
"); + if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"
{subtitle}
"); + if (entry.Details.Count > 0) items.Append($"
    {string.Join(string.Empty, entry.Details.Select(detail => $"
  • {Encode(detail)}
  • "))}
"); + items.Append("
"); + } + return $"

Education

{items}
"; + } + + private static string RenderCustomSection(TailoredCvCustomSection section) + { + if (section.Items.Count == 0) return string.Empty; + return $"

{Encode(section.Title ?? "Additional Information")}

    {string.Join(string.Empty, section.Items.Select(item => $"
  • {Encode(item)}
  • "))}
"; + } + + private static string FormatDateRange(string? start, string? end, bool isCurrent) + { + var normalizedStart = (start ?? string.Empty).Trim(); + var normalizedEnd = (end ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedStart) && string.IsNullOrWhiteSpace(normalizedEnd)) return string.Empty; + if (string.IsNullOrWhiteSpace(normalizedStart)) return normalizedEnd; + return $"{normalizedStart} - {(isCurrent ? "Present" : string.IsNullOrWhiteSpace(normalizedEnd) ? "Present" : normalizedEnd)}"; + } + + private static string ResolveAccent(string? accentColor) + { + var normalized = (accentColor ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + "slate" => "#334155", + "blue" => "#1d4ed8", + "emerald" => "#047857", + "plum" => "#7c3aed", + "brick" => "#b45309", + _ when normalized.StartsWith("#") => normalized, + _ => "#334155" + }; + } + + private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); + private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "'", StringComparison.Ordinal); + + private static string Slugify(string value) + { + var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray()); + while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal); + return cleaned.Trim('-'); + } +} diff --git a/JobTrackerApi/Services/PlaywrightCvPdfExporter.cs b/JobTrackerApi/Services/PlaywrightCvPdfExporter.cs new file mode 100644 index 0000000..740f158 --- /dev/null +++ b/JobTrackerApi/Services/PlaywrightCvPdfExporter.cs @@ -0,0 +1,66 @@ +using Microsoft.Playwright; + +namespace JobTrackerApi.Services; + +public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] Bytes); + +public interface ICvPdfExporter +{ + Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken); +} + +public sealed class PlaywrightCvPdfExporter : ICvPdfExporter +{ + private readonly AppPaths _paths; + private readonly ILogger _logger; + + public PlaywrightCvPdfExporter(AppPaths paths, ILogger logger) + { + _paths = paths; + _logger = logger; + } + + public async Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd")); + Directory.CreateDirectory(folder); + var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName) + ? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf" + : renderResult.SuggestedFileName; + var storagePath = Path.Combine(folder, fileName); + + try + { + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + }); + var page = await browser.NewPageAsync(); + await page.SetContentAsync(renderResult.Html, new PageSetContentOptions + { + WaitUntil = WaitUntilState.Load, + }); + var bytes = await page.PdfAsync(new PagePdfOptions + { + Format = "A4", + PrintBackground = true, + Margin = new() + { + Top = "0", + Right = "0", + Bottom = "0", + Left = "0", + } + }); + await File.WriteAllBytesAsync(storagePath, bytes, cancellationToken); + return new CvPdfArtifact(fileName, storagePath, bytes); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath); + throw new InvalidOperationException("CV PDF export is unavailable. Ensure Chromium is installed for Playwright on this machine.", ex); + } + } +} diff --git a/Models/JobApplication.cs b/Models/JobApplication.cs index 86de0e1..dc8d3b6 100644 --- a/Models/JobApplication.cs +++ b/Models/JobApplication.cs @@ -46,6 +46,7 @@ public class JobApplication public DateTime? TailoredCvUpdatedAt { get; set; } public DateTime? LastReminderEmailSentAt { get; set; } + public TailoredCvDraft? TailoredCvDraft { get; set; } public List Messages { get; set; } = new(); public List Attachments { get; set; } = new(); public List Events { get; set; } = new(); diff --git a/Models/TailoredCvDraft.cs b/Models/TailoredCvDraft.cs new file mode 100644 index 0000000..bd7cb95 --- /dev/null +++ b/Models/TailoredCvDraft.cs @@ -0,0 +1,70 @@ +namespace JobTrackerApi.Models; + +public sealed class TailoredCvDraft +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + public int? CanonicalProfileVersion { get; set; } + public string TemplateId { get; set; } = "ats-minimal"; + public string? Headline { get; set; } + public string? SummaryJson { get; set; } + public string? SelectedSkillsJson { get; set; } + public string? ExperienceJson { get; set; } + public string? EducationJson { get; set; } + public string? CustomSectionsJson { get; set; } + public string? RenderOptionsJson { get; set; } + public string? GenerationContextHash { get; set; } + public DateTimeOffset? LastGeneratedAtUtc { get; set; } + public DateTimeOffset? LastEditedAtUtc { get; set; } + public string Status { get; set; } = "generated"; +} + +public sealed class TailoredCvDocument +{ + public string TemplateId { get; set; } = "ats-minimal"; + public string? Headline { get; set; } + public List Summary { get; set; } = new(); + public List SelectedSkills { get; set; } = new(); + public List Experience { get; set; } = new(); + public List Education { get; set; } = new(); + public List CustomSections { get; set; } = new(); + public TailoredCvRenderOptions RenderOptions { get; set; } = new(); +} + +public sealed class TailoredCvExperienceItem +{ + 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; } + public bool IsCurrent { get; set; } + public List Bullets { get; set; } = new(); +} + +public sealed class TailoredCvEducationItem +{ + public string? Qualification { get; set; } + public string? Institution { get; set; } + public string? Location { get; set; } + public string? Start { get; set; } + public string? End { get; set; } + public List Details { get; set; } = new(); +} + +public sealed class TailoredCvCustomSection +{ + public string? Title { get; set; } + public List Items { get; set; } = new(); +} + +public sealed class TailoredCvRenderOptions +{ + public bool ShowPhoto { get; set; } + public string PageMode { get; set; } = "one-page"; + public string AccentColor { get; set; } = "slate"; + public List SectionOrder { get; set; } = new() { "summary", "skills", "experience", "education", "custom" }; + public string BulletDensity { get; set; } = "balanced"; +} diff --git a/Models/TailoredCvDraftJson.cs b/Models/TailoredCvDraftJson.cs new file mode 100644 index 0000000..6ecf027 --- /dev/null +++ b/Models/TailoredCvDraftJson.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JobTrackerApi.Models; + +public static class TailoredCvDraftJson +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static TailoredCvDocument Empty() => Normalize(new TailoredCvDocument()); + + public static TailoredCvDocument FromDraft(TailoredCvDraft? draft) + { + if (draft is null) return Empty(); + + var document = new TailoredCvDocument + { + TemplateId = string.IsNullOrWhiteSpace(draft.TemplateId) ? "base" : draft.TemplateId.Trim(), + Headline = TrimOrNull(draft.Headline), + Summary = DeserializeList(draft.SummaryJson), + SelectedSkills = DeserializeList(draft.SelectedSkillsJson), + Experience = DeserializeList(draft.ExperienceJson) + .Select(NormalizeExperience) + .Where(item => !string.IsNullOrWhiteSpace(item.Title) || !string.IsNullOrWhiteSpace(item.Company) || item.Bullets.Count > 0) + .ToList(), + Education = DeserializeList(draft.EducationJson) + .Select(NormalizeEducation) + .Where(item => !string.IsNullOrWhiteSpace(item.Qualification) || !string.IsNullOrWhiteSpace(item.Institution) || item.Details.Count > 0) + .ToList(), + CustomSections = DeserializeList(draft.CustomSectionsJson) + .Select(NormalizeCustomSection) + .Where(item => !string.IsNullOrWhiteSpace(item.Title) || item.Items.Count > 0) + .ToList(), + RenderOptions = DeserializeObject(draft.RenderOptionsJson) ?? new TailoredCvRenderOptions(), + }; + + return Normalize(document); + } + + public static void ApplyToDraft(TailoredCvDraft draft, TailoredCvDocument? document) + { + var normalized = Normalize(document); + draft.TemplateId = normalized.TemplateId; + draft.Headline = TrimOrNull(normalized.Headline); + draft.SummaryJson = JsonSerializer.Serialize(normalized.Summary, SerializerOptions); + draft.SelectedSkillsJson = JsonSerializer.Serialize(normalized.SelectedSkills, SerializerOptions); + draft.ExperienceJson = JsonSerializer.Serialize(normalized.Experience, SerializerOptions); + draft.EducationJson = JsonSerializer.Serialize(normalized.Education, SerializerOptions); + draft.CustomSectionsJson = JsonSerializer.Serialize(normalized.CustomSections, SerializerOptions); + draft.RenderOptionsJson = JsonSerializer.Serialize(normalized.RenderOptions, SerializerOptions); + } + + public static TailoredCvDocument Normalize(TailoredCvDocument? document) + { + document ??= new TailoredCvDocument(); + document.TemplateId = string.IsNullOrWhiteSpace(document.TemplateId) ? "ats-minimal" : document.TemplateId.Trim(); + document.Headline = TrimOrNull(document.Headline); + document.Summary = CleanList(document.Summary); + document.SelectedSkills = CleanList(document.SelectedSkills); + document.Experience = (document.Experience ?? new List()) + .Select(NormalizeExperience) + .Where(item => !string.IsNullOrWhiteSpace(item.Title) || !string.IsNullOrWhiteSpace(item.Company) || item.Bullets.Count > 0) + .ToList(); + document.Education = (document.Education ?? new List()) + .Select(NormalizeEducation) + .Where(item => !string.IsNullOrWhiteSpace(item.Qualification) || !string.IsNullOrWhiteSpace(item.Institution) || item.Details.Count > 0) + .ToList(); + document.CustomSections = (document.CustomSections ?? new List()) + .Select(NormalizeCustomSection) + .Where(item => !string.IsNullOrWhiteSpace(item.Title) || item.Items.Count > 0) + .ToList(); + document.RenderOptions ??= new TailoredCvRenderOptions(); + document.RenderOptions.PageMode = string.IsNullOrWhiteSpace(document.RenderOptions.PageMode) ? "one-page" : document.RenderOptions.PageMode.Trim(); + document.RenderOptions.AccentColor = string.IsNullOrWhiteSpace(document.RenderOptions.AccentColor) ? "slate" : document.RenderOptions.AccentColor.Trim(); + document.RenderOptions.BulletDensity = string.IsNullOrWhiteSpace(document.RenderOptions.BulletDensity) ? "balanced" : document.RenderOptions.BulletDensity.Trim(); + document.RenderOptions.SectionOrder = CleanList(document.RenderOptions.SectionOrder); + if (document.RenderOptions.SectionOrder.Count == 0) + { + document.RenderOptions.SectionOrder = new List { "summary", "skills", "experience", "education", "custom" }; + } + return document; + } + + public static string RenderPlainText(TailoredCvDocument? document) + { + var normalized = Normalize(document); + var lines = new List(); + + AddLine(lines, normalized.Headline); + if (normalized.Summary.Count > 0) + { + AddBlock(lines, "Professional Summary", normalized.Summary.Select(item => $"- {item}")); + } + + if (normalized.SelectedSkills.Count > 0) + { + AddBlock(lines, "Core Skills", normalized.SelectedSkills); + } + + if (normalized.Experience.Count > 0) + { + var block = new List(); + foreach (var item in normalized.Experience) + { + AddLine(block, item.Title); + var meta = string.Join(" | ", new[] + { + item.Company, + item.Location, + FormatDateRange(item.Start, item.End, item.IsCurrent) + }.Where(value => !string.IsNullOrWhiteSpace(value))); + AddLine(block, meta); + foreach (var bullet in item.Bullets) + { + AddLine(block, $"- {bullet}"); + } + AddLine(block, string.Empty); + } + AddBlock(lines, "Experience", block); + } + + if (normalized.Education.Count > 0) + { + var block = new List(); + foreach (var item in normalized.Education) + { + AddLine(block, item.Qualification); + var meta = string.Join(" | ", new[] + { + item.Institution, + item.Location, + FormatDateRange(item.Start, item.End, false) + }.Where(value => !string.IsNullOrWhiteSpace(value))); + AddLine(block, meta); + foreach (var detail in item.Details) + { + AddLine(block, $"- {detail}"); + } + AddLine(block, string.Empty); + } + AddBlock(lines, "Education", block); + } + + foreach (var section in normalized.CustomSections) + { + AddBlock(lines, section.Title ?? "Additional Information", section.Items); + } + + return string.Join("\n\n", lines.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim(); + } + + private static TailoredCvExperienceItem NormalizeExperience(TailoredCvExperienceItem? item) + { + item ??= new TailoredCvExperienceItem(); + item.Title = TrimOrNull(item.Title); + item.Company = TrimOrNull(item.Company); + item.Location = TrimOrNull(item.Location); + item.Start = TrimOrNull(item.Start); + item.End = TrimOrNull(item.End); + item.Bullets = CleanList(item.Bullets); + item.IsCurrent = item.IsCurrent || string.Equals(item.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(item.End, "current", StringComparison.OrdinalIgnoreCase); + return item; + } + + private static TailoredCvEducationItem NormalizeEducation(TailoredCvEducationItem? item) + { + item ??= new TailoredCvEducationItem(); + item.Qualification = TrimOrNull(item.Qualification); + item.Institution = TrimOrNull(item.Institution); + item.Location = TrimOrNull(item.Location); + item.Start = TrimOrNull(item.Start); + item.End = TrimOrNull(item.End); + item.Details = CleanList(item.Details); + return item; + } + + private static TailoredCvCustomSection NormalizeCustomSection(TailoredCvCustomSection? item) + { + item ??= new TailoredCvCustomSection(); + item.Title = TrimOrNull(item.Title); + item.Items = CleanList(item.Items); + return item; + } + + private static List DeserializeList(string? json) + { + return DeserializeList(json) + .Select(value => value?.Trim() ?? string.Empty) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static List DeserializeList(string? json) + { + try + { + return string.IsNullOrWhiteSpace(json) + ? new List() + : JsonSerializer.Deserialize>(json, SerializerOptions) ?? new List(); + } + catch + { + return new List(); + } + } + + private static T? DeserializeObject(string? json) where T : class + { + try + { + return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize(json, SerializerOptions); + } + catch + { + return null; + } + } + + private static List CleanList(IEnumerable? values) + { + return (values ?? Array.Empty()) + .Select(value => value?.Trim() ?? string.Empty) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string? TrimOrNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string? FormatDateRange(string? start, string? end, bool isCurrent) + { + if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null; + if (string.IsNullOrWhiteSpace(start)) return end; + return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}"; + } + + private static void AddBlock(List lines, string title, IEnumerable body) + { + var content = string.Join("\n", body.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim(); + if (string.IsNullOrWhiteSpace(content)) return; + lines.Add($"{title}\n{content}"); + } + + private static void AddLine(List lines, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) lines.Add(value.Trim()); + } +} diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 6850e32..4397b13 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -19,9 +19,10 @@ import { } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; -import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types"; +import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; +import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft"; import Correspondence from "./Correspondence"; import Attachments from "./Attachments"; @@ -47,6 +48,12 @@ type PackageWorkspaceState = { recruiterMessage: string; }; +type TailoredCvPreviewResponse = { + templateId: string; + html: string; + suggestedFileName: string; +}; + interface Props { open: boolean; jobId: number | null; @@ -129,6 +136,22 @@ function getWorkspaceStatus(currentValue: string, savedValue: string) { return { label: "Empty", color: "default" as const }; } +function serializeTailoredDraft(draft: TailoredCvDraft) { + const normalized = normalizeTailoredCvDraft(draft); + return JSON.stringify({ + templateId: normalized.templateId, + headline: normalized.headline ?? "", + summary: normalized.summary, + selectedSkills: normalized.selectedSkills, + experience: normalized.experience, + education: normalized.education, + customSections: normalized.customSections, + renderOptions: normalized.renderOptions, + status: normalized.status, + isLegacyFallback: normalized.isLegacyFallback, + }); +} + export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) { const { toast } = useToast(); const { t } = useI18n(); @@ -153,13 +176,22 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [loadingReadiness, setLoadingReadiness] = useState(false); const [jobAttachments, setJobAttachments] = useState([]); const [selectedAttachmentIds, setSelectedAttachmentIds] = useState([]); - const [savingTailoredCv, setSavingTailoredCv] = useState(false); const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false); const [generatingPackage, setGeneratingPackage] = useState(false); const [applicationPackage, setApplicationPackage] = useState(null); const [generationMode, setGenerationMode] = useState("default"); const [coverLetterStyle, setCoverLetterStyle] = useState("balanced"); - const [tailoredCvText, setTailoredCvText] = useState(""); + const [tailoredCvDraft, setTailoredCvDraft] = useState(emptyTailoredCvDraft()); + const [savedTailoredCvDraft, setSavedTailoredCvDraft] = useState(emptyTailoredCvDraft()); + const [loadingTailoredCvDraft, setLoadingTailoredCvDraft] = useState(false); + const [generatingTailoredCvDraft, setGeneratingTailoredCvDraft] = useState(false); + const [savingTailoredCvDraft, setSavingTailoredCvDraft] = useState(false); + const [tailoredCvPreview, setTailoredCvPreview] = useState(null); + const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false); + const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false); + const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState(null); + const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState(null); + const [useProfilePhoto, setUseProfilePhoto] = useState(true); const [packageWorkspace, setPackageWorkspace] = useState({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); const [savedPackageWorkspace, setSavedPackageWorkspace] = useState({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); const [packageGeneratedAt, setPackageGeneratedAt] = useState(null); @@ -182,11 +214,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, setJobAttachments([]); setSelectedAttachmentIds([]); setPackageGeneratedAt(null); + setTailoredCvDraft(emptyTailoredCvDraft()); + setSavedTailoredCvDraft(emptyTailoredCvDraft()); + setTailoredCvPreview(null); + setProfileAvatarImageDataUrl(null); + setCustomPhotoDataUrl(null); + setUseProfilePhoto(true); setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); api.get(`/jobapplications/${jobId}`).then((r) => { setJob(r.data); - setTailoredCvText(r.data.tailoredCvText ?? ""); const savedWorkspace = { coverLetter: r.data.coverLetterText ?? "", applicationAnswer: extractApplicationAnswerDraft(r.data.notes), @@ -206,10 +243,30 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, setJobAttachments([]); setSelectedAttachmentIds([]); }); - api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false)); + api.get(`/auth/me`).then((r) => { + setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); + setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null); + }).catch(() => { + setIsAdmin(false); + setProfileAvatarImageDataUrl(null); + }); api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([])); }, [open, jobId, initialTab, initialFollowUpMode]); + useEffect(() => { + if (!open || !jobId || tab !== 3) return; + setLoadingTailoredCvDraft(true); + api.get(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => { + const normalized = normalizeTailoredCvDraft(r.data); + setTailoredCvDraft(normalized); + setSavedTailoredCvDraft(normalized); + }).catch(() => { + const empty = emptyTailoredCvDraft(); + setTailoredCvDraft(empty); + setSavedTailoredCvDraft(empty); + }).finally(() => setLoadingTailoredCvDraft(false)); + }, [open, jobId, tab]); + useEffect(() => { if (!open || !jobId || tab !== 4) return; setLoadingDraft(true); @@ -303,51 +360,160 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, ) : null; - const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? ""); + const tailoredCvDraftStatus = getWorkspaceStatus(tailoredCvDraft.renderedText, savedTailoredCvDraft.renderedText); const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter); const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer); const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage); + const hasUnsavedTailoredCvDraftChanges = serializeTailoredDraft(tailoredCvDraft) !== serializeTailoredDraft(savedTailoredCvDraft); const hasUnsavedPackageChanges = [ - tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(), packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(), packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(), packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(), ].some(Boolean); + const saveTailoredCvDraft = async () => { + if (!jobId) return; + + const normalized = normalizeTailoredCvDraft({ + ...tailoredCvDraft, + status: tailoredCvDraft.status === "empty" ? "edited" : tailoredCvDraft.status, + }); + + try { + setSavingTailoredCvDraft(true); + await api.put(`/jobapplications/${jobId}/tailored-cv-draft`, { + templateId: normalized.templateId, + headline: normalized.headline, + summary: normalized.summary, + selectedSkills: normalized.selectedSkills, + experience: normalized.experience, + education: normalized.education, + customSections: normalized.customSections, + renderOptions: normalized.renderOptions, + status: normalized.status, + }); + setTailoredCvDraft(normalized); + setSavedTailoredCvDraft(normalized); + setJob((prev) => prev ? { + ...prev, + tailoredCvText: normalized.renderedText, + tailoredCvUpdatedAt: new Date().toISOString(), + } : prev); + setReadiness(null); + toast("Tailored CV draft saved.", "success"); + } catch (error: any) { + toast(getApiErrorMessage(error, "Failed to save the tailored CV draft."), "error"); + } finally { + setSavingTailoredCvDraft(false); + } + }; + + const generateTailoredCvDraft = async () => { + if (!jobId) return; + if (hasUnsavedTailoredCvDraftChanges) { + const confirmed = await confirmAction("Regenerating the tailored CV draft will replace your unsaved edits.", { + title: "Replace unsaved tailored CV edits?", + confirmLabel: "Regenerate draft", + }); + if (!confirmed) return; + } + + try { + setGeneratingTailoredCvDraft(true); + const res = await api.post(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } }); + const normalized = normalizeTailoredCvDraft(res.data); + setTailoredCvDraft(normalized); + setSavedTailoredCvDraft(normalized); + setJob((prev) => prev ? { + ...prev, + tailoredCvText: normalized.renderedText, + tailoredCvUpdatedAt: new Date().toISOString(), + } : prev); + toast("Tailored CV draft generated.", "success"); + } catch (error: any) { + toast(getApiErrorMessage(error, "Failed to generate a tailored CV draft."), "error"); + } finally { + setGeneratingTailoredCvDraft(false); + } + }; + + const resetTailoredCvDraftToSaved = () => { + setTailoredCvDraft(savedTailoredCvDraft); + toast("Restored the last saved tailored CV draft.", "info"); + }; + + const buildTailoredCvRenderPayload = () => ({ + templateId: tailoredCvDraft.templateId, + headline: tailoredCvDraft.headline, + summary: tailoredCvDraft.summary, + selectedSkills: tailoredCvDraft.selectedSkills, + experience: tailoredCvDraft.experience, + education: tailoredCvDraft.education, + customSections: tailoredCvDraft.customSections, + renderOptions: tailoredCvDraft.renderOptions, + photoDataUrl: customPhotoDataUrl, + useProfileAvatar: useProfilePhoto, + }); + + const refreshTailoredCvPreview = async () => { + if (!jobId) return; + try { + setLoadingTailoredCvPreview(true); + const res = await api.post(`/jobapplications/${jobId}/tailored-cv-preview`, buildTailoredCvRenderPayload()); + setTailoredCvPreview(res.data); + } catch (error: any) { + toast(getApiErrorMessage(error, "Failed to build the CV preview."), "error"); + } finally { + setLoadingTailoredCvPreview(false); + } + }; + + const exportTailoredCvPdf = async () => { + if (!jobId) return; + try { + setExportingTailoredCvPdf(true); + const response = await api.post(`/jobapplications/${jobId}/export-tailored-cv-pdf`, buildTailoredCvRenderPayload(), { responseType: "blob" }); + const blob = new Blob([response.data], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = tailoredCvPreview?.suggestedFileName || `${(job?.jobTitle ?? "tailored-cv").replace(/\s+/g, "-").toLowerCase()}.pdf`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + toast("Tailored CV PDF downloaded.", "success"); + } catch (error: any) { + toast(getApiErrorMessage(error, "Failed to export the CV PDF."), "error"); + } finally { + setExportingTailoredCvPdf(false); + } + }; + const savePackageWorkspace = async () => { if (!jobId || !job) return; const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer); - const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim(); const draftsChanged = packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() || packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() || packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(); - if (!tailoredCvChanged && !draftsChanged) { + if (!draftsChanged) { toast("No unsaved package changes.", "info"); return; } try { - if (tailoredCvChanged) { - setSavingTailoredCv(true); - await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText }); - } - - if (draftsChanged) { - setSavingApplicationDrafts(true); - await api.put(`/jobapplications/${jobId}/application-drafts`, { - coverLetterText: packageWorkspace.coverLetter, - notes: nextNotes, - recruiterMessageDraft: packageWorkspace.recruiterMessage, - }); - } + setSavingApplicationDrafts(true); + await api.put(`/jobapplications/${jobId}/application-drafts`, { + coverLetterText: packageWorkspace.coverLetter, + notes: nextNotes, + recruiterMessageDraft: packageWorkspace.recruiterMessage, + }); setJob((prev) => prev ? { ...prev, - tailoredCvText, - tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt, coverLetterText: packageWorkspace.coverLetter, recruiterMessageDraft: packageWorkspace.recruiterMessage, notes: nextNotes, @@ -359,13 +525,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, } catch (error: any) { toast(getApiErrorMessage(error, "Failed to save the application package."), "error"); } finally { - setSavingTailoredCv(false); setSavingApplicationDrafts(false); } }; const resetPackageWorkspaceToSaved = () => { - setTailoredCvText(job?.tailoredCvText ?? ""); setPackageWorkspace(savedPackageWorkspace); toast("Restored the last saved package.", "info"); }; @@ -489,11 +653,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {tab === 3 && ( - + - {t("jobDetailsTabTailoredCv")} - Build the package here, then save the working copy back onto this job. + Tailored CV draft + This draft is job-scoped. It stays separate from your master CV and from the package drafts below. @@ -506,6 +670,214 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {t("jobDetailsGenerationInterview")} + + Template + + + setTailoredCvDraft((current) => normalizeTailoredCvDraft({ + ...current, + renderOptions: { ...current.renderOptions, accentColor: e.target.value }, + status: "edited", + }))} + sx={{ width: 110 }} + InputLabelProps={{ shrink: true }} + /> + + + + {customPhotoDataUrl ? : null} + + + + + + + + + + + {tailoredCvDraft.isLegacyFallback ? : null} + {tailoredCvDraft.lastGeneratedAtUtc ? : null} + {tailoredCvDraft.canonicalProfileVersion ? : null} + + + {loadingTailoredCvDraft ? ( + + ) : ( + + + setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, headline: e.target.value, status: "edited" }))} + fullWidth + /> + setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, summary: splitLines(e.target.value), status: "edited" }))} + multiline + minRows={5} + fullWidth + helperText="One bullet per line." + /> + setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, selectedSkills: splitLines(e.target.value), status: "edited" }))} + multiline + minRows={4} + fullWidth + helperText="One skill per line." + /> + [ + [item.title, item.company].filter(Boolean).join(" — "), + [item.location, item.start, item.end].filter(Boolean).join(" | "), + ...(item.bullets ?? []).map((bullet) => `- ${bullet}`), + ].filter(Boolean).join("\n")).join("\n\n")} + onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ + ...current, + experience: e.target.value + .split(/\n\s*\n/) + .map((block) => block.trim()) + .filter(Boolean) + .map((block) => { + const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const [titleCompany = "", meta = "", ...bulletLines] = lines; + const [title = "", company = ""] = titleCompany.split("—").map((part) => part.trim()); + const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim()); + return { + title, + company, + location, + start, + end, + bullets: bulletLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean), + }; + }), + status: "edited", + }))} + multiline + minRows={10} + fullWidth + helperText="Separate entries with a blank line. First line: Title — Company. Second line: Location | Start | End." + /> + [ + [item.qualification, item.institution].filter(Boolean).join(" — "), + [item.location, item.start, item.end].filter(Boolean).join(" | "), + ...(item.details ?? []).map((detail) => `- ${detail}`), + ].filter(Boolean).join("\n")).join("\n\n")} + onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ + ...current, + education: e.target.value + .split(/\n\s*\n/) + .map((block) => block.trim()) + .filter(Boolean) + .map((block) => { + const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const [qualificationInstitution = "", meta = "", ...detailLines] = lines; + const [qualification = "", institution = ""] = qualificationInstitution.split("—").map((part) => part.trim()); + const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim()); + return { + qualification, + institution, + location, + start, + end, + details: detailLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean), + }; + }), + status: "edited", + }))} + multiline + minRows={8} + fullWidth + helperText="Separate entries with a blank line. First line: Qualification — Institution. Second line: Location | Start | End." + /> + `${section.title || "Additional Information"}\n${(section.items ?? []).join("\n")}`).join("\n\n")} + onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ + ...current, + customSections: e.target.value + .split(/\n\s*\n/) + .map((block) => block.trim()) + .filter(Boolean) + .map((block) => { + const [title = "", ...items] = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return { title, items }; + }), + status: "edited", + }))} + multiline + minRows={7} + fullWidth + helperText="Each block starts with the section title, followed by one item per line." + /> + + + + + Rendered CV snapshot + This plain-text snapshot stays deterministic and is what the job stores immediately after saving the draft. + + {t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })} + + + PDF-style preview + Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here. + {tailoredCvPreview ? ( +