Add CV template preview and PDF export pipeline

This commit is contained in:
2026-03-29 00:43:54 +01:00
parent 2392b135c2
commit 839a2ed80d
15 changed files with 2288 additions and 97 deletions
+14
View File
@@ -24,6 +24,7 @@ namespace JobTrackerApi.Data
public DbSet<JobEvent> JobEvents => Set<JobEvent>(); public DbSet<JobEvent> JobEvents => Set<JobEvent>();
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>(); public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>(); public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -101,6 +102,19 @@ namespace JobTrackerApi.Data
.WithMany() .WithMany()
.HasForeignKey(x => x.ArtifactId) .HasForeignKey(x => x.ArtifactId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<TailoredCvDraft>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<TailoredCvDraft>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
.IsUnique();
modelBuilder.Entity<TailoredCvDraft>()
.HasOne(x => x.JobApplication)
.WithOne(j => j.TailoredCvDraft)
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
} }
} }
} }
@@ -234,9 +234,232 @@ public sealed class JobApplicationsApplicationPackageTests
Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext); 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<IAppEmailSender>(), CreateUserManager().Object, NullLogger<JobApplicationsController>.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<ISummarizerService>(), "user-1");
var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(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<ISummarizerService>();
summarizer
.Setup(service => service.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.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<OkObjectResult>(generateResult.Result);
var generated = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(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<string> { "Own backend delivery for critical APIs." },
new List<string> { ".NET", "SQL" },
generated.Experience,
generated.Education,
generated.CustomSections,
generated.RenderOptions,
"edited"), CancellationToken.None);
Assert.IsType<NoContentResult>(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<ISummarizerService>(), "user-1", renderer, exporter);
var request = new JobApplicationsController.TailoredCvRenderRequest(
"ats-minimal",
"Backend Engineer",
new List<string> { "Built APIs" },
new List<string> { ".NET" },
new List<TailoredCvExperienceItem>(),
new List<TailoredCvEducationItem>(),
new List<TailoredCvCustomSection>(),
new TailoredCvRenderOptions { ShowPhoto = true, AccentColor = "#123456" },
null,
true);
var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(previewResult.Result);
var preview = Assert.IsType<JobApplicationsController.TailoredCvPreviewDto>(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<FileContentResult>(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<string> { "Built and shipped product roadmaps." },
SelectedSkills = new List<string> { "Strategy", "Stakeholder management" },
Experience = new List<TailoredCvExperienceItem>
{
new() { Title = "Product Manager", Company = "Acme", Start = "2022", End = "2025", Bullets = new List<string> { "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&#39;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<IAppEmailSender>(),
CreateUserManager(user).Object,
NullLogger<JobApplicationsController>.Instance,
renderer ?? new TestCvTemplateRenderer(),
exporter ?? new TestCvPdfExporter());
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext HttpContext = new DefaultHttpContext
@@ -260,10 +483,10 @@ public sealed class JobApplicationsApplicationPackageTests
return new JobTrackerContext(options, currentUser.Object); return new JobTrackerContext(options, currentUser.Object);
} }
private static Mock<UserManager<ApplicationUser>> CreateUserManager() private static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? user = null)
{ {
var store = new Mock<IUserStore<ApplicationUser>>(); var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>( var manager = new Mock<UserManager<ApplicationUser>>(
store.Object, store.Object,
Options.Create(new IdentityOptions()), Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(), new PasswordHasher<ApplicationUser>(),
@@ -273,5 +496,29 @@ public sealed class JobApplicationsApplicationPackageTests
new IdentityErrorDescriber(), new IdentityErrorDescriber(),
null!, null!,
new NullLogger<UserManager<ApplicationUser>>()); new NullLogger<UserManager<ApplicationUser>>());
manager.Setup(x => x.FindByIdAsync(It.IsAny<string>())).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", $"<html><body>{candidateName}|{jobTitle}|{companyName}|{document?.Headline}|{photoDataUrl}</body></html>");
}
}
private sealed class TestCvPdfExporter : ICvPdfExporter
{
public TailoredCvRenderResult? LastRenderResult { get; private set; }
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
LastRenderResult = renderResult;
return Task.FromResult(new CvPdfArtifact("preview.pdf", "/tmp/preview.pdf", new byte[] { 1, 2, 3 }));
}
} }
} }
@@ -5,6 +5,7 @@ using JobTrackerApi.Models;
using JobTrackerApi.Services; using JobTrackerApi.Services;
using JobTrackerApi.Services.JobImport; using JobTrackerApi.Services.JobImport;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -20,14 +21,26 @@ namespace JobTrackerApi.Controllers
private readonly IAppEmailSender _email; private readonly IAppEmailSender _email;
private readonly UserManager<ApplicationUser> _users; private readonly UserManager<ApplicationUser> _users;
private readonly ILogger<JobApplicationsController> _logger; private readonly ILogger<JobApplicationsController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger) public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
{ {
_db = db; _db = db;
_summarizer = summarizer; _summarizer = summarizer;
_email = email; _email = email;
_users = users; _users = users;
_logger = logger; _logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
}
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
{
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
}
} }
private string? CurrentUserId => private string? CurrentUserId =>
@@ -153,6 +166,275 @@ namespace JobTrackerApi.Controllers
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}"; 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<string> 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<string> 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<TailoredCvCustomSection>()
: new List<TailoredCvCustomSection>
{
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<TailoredCvDraft?> 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<string>? Summary,
List<string>? SelectedSkills,
List<TailoredCvExperienceItem>? Experience,
List<TailoredCvEducationItem>? Education,
List<TailoredCvCustomSection>? CustomSections,
TailoredCvRenderOptions? RenderOptions,
string? PhotoDataUrl,
bool? UseProfileAvatar);
private async Task<TailoredCvDraft> 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<TailoredCvCustomSection>();
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<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix) private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
{ {
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70); var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
@@ -1729,6 +2011,33 @@ namespace JobTrackerApi.Controllers
string? CoverLetterDraft, string? CoverLetterDraft,
string? RecruiterMessageDraft); string? RecruiterMessageDraft);
public sealed record SaveTailoredCvRequest(string? TailoredCvText); public sealed record SaveTailoredCvRequest(string? TailoredCvText);
public sealed record TailoredCvDraftDto(
int? Id,
int? CanonicalProfileVersion,
string TemplateId,
string? Headline,
List<string> Summary,
List<string> SelectedSkills,
List<TailoredCvExperienceItem> Experience,
List<TailoredCvEducationItem> Education,
List<TailoredCvCustomSection> CustomSections,
TailoredCvRenderOptions RenderOptions,
string? GenerationContextHash,
DateTimeOffset? LastGeneratedAtUtc,
DateTimeOffset? LastEditedAtUtc,
string Status,
string RenderedText,
bool IsLegacyFallback);
public sealed record SaveTailoredCvDraftRequest(
string? TemplateId,
string? Headline,
List<string>? Summary,
List<string>? SelectedSkills,
List<TailoredCvExperienceItem>? Experience,
List<TailoredCvEducationItem>? Education,
List<TailoredCvCustomSection>? CustomSections,
TailoredCvRenderOptions? RenderOptions,
string? Status);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants); public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft); public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes); 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)); return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
} }
[HttpGet("{id:int}/tailored-cv-draft")]
public async Task<ActionResult<TailoredCvDraftDto>> 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<ActionResult<TailoredCvPreviewDto>> 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<IActionResult> 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<ActionResult<TailoredCvDraftDto>> 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<IActionResult> 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<string>(),
SelectedSkills = request.SelectedSkills ?? new List<string>(),
Experience = request.Experience ?? new List<TailoredCvExperienceItem>(),
Education = request.Education ?? new List<TailoredCvEducationItem>(),
CustomSections = request.CustomSections ?? new List<TailoredCvCustomSection>(),
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")] [HttpPut("{id:int}/tailored-cv")]
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken) public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
{ {
+1
View File
@@ -18,6 +18,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
</ItemGroup> </ItemGroup>
+67
View File
@@ -30,6 +30,8 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>(); builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>(); builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddSingleton<AppPaths>(); builder.Services.AddSingleton<AppPaths>();
@@ -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_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_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");"""); 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); EnsureGmailConnectionsTable(conn);
@@ -911,6 +938,32 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
cmd.ExecuteNonQuery(); 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")) if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{ {
using var cmd = conn.CreateCommand(); 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.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
cmd.ExecuteNonQuery(); 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();
}
} }
} }
+8
View File
@@ -8,6 +8,7 @@ namespace JobTrackerApi.Services
public string DataRoot { get; } public string DataRoot { get; }
public string AttachmentsRoot { get; } public string AttachmentsRoot { get; }
public string CvArtifactsRoot { get; } public string CvArtifactsRoot { get; }
public string CvExportsRoot { get; }
public AppPaths(IConfiguration cfg, IHostEnvironment env) public AppPaths(IConfiguration cfg, IHostEnvironment env)
{ {
@@ -31,6 +32,13 @@ namespace JobTrackerApi.Services
Directory.CreateDirectory(cvArtifactsRoot); Directory.CreateDirectory(cvArtifactsRoot);
CvArtifactsRoot = 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); public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
@@ -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 : $"<span>Company focus: {Encode(companyName)}</span>";
var photoMarkup = showPhoto ? $"<div class=\"photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — ATS Minimal</title>
<style>
:root {{ --accent:{accent}; --ink:#111827; --muted:#4b5563; --line:#d1d5db; --paper:#fff; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#eef2f7; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
.header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; border-bottom:2px solid var(--accent); padding-bottom:8mm; margin-bottom:7mm; }}
.name {{ margin:0; font-size:25pt; letter-spacing:.02em; }}
.headline {{ margin-top:2mm; color:var(--muted); font-size:11pt; }}
.meta {{ margin-top:3mm; display:flex; flex-wrap:wrap; gap:3mm; color:var(--muted); font-size:9pt; }}
.photo {{ width:28mm; height:36mm; border-radius:5mm; overflow:hidden; border:1px solid var(--line); }}
.photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
{BaseSectionCss(accent, "caps-rule")}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<header class=""header"">
<div>
<h1 class=""name"">{Encode(candidateName)}</h1>
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
<div class=""meta""><span>Target role: {Encode(jobTitle)}</span>{companyFocusMarkup}<span>Template: ATS Minimal</span></div>
</div>
{photoMarkup}
</header>
{body}
</main>
</body>
</html>";
}
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(" &nbsp;•&nbsp; ", new[]
{
string.IsNullOrWhiteSpace(companyName) ? null : $"Targeting {Encode(companyName)}",
Encode(jobTitle)
}.Where(x => !string.IsNullOrWhiteSpace(x)));
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — Harvard</title>
<style>
:root {{ --accent:{accent}; --ink:#111; --muted:#333; --line:#111; --paper:#fff; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#f5f5f5; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; padding:16mm 18mm; }}
.header {{ text-align:center; margin-bottom:6mm; }}
.name {{ margin:0; font-size:23pt; font-weight:700; }}
.headline {{ margin-top:2mm; font-size:10pt; font-style:italic; }}
.meta {{ margin-top:4mm; font-size:9pt; }}
{BaseSectionCss(accent, "harvard")}
.section-title {{ color:var(--ink); }}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<header class=""header"">
<h1 class=""name"">{Encode(candidateName)}</h1>
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
<div class=""meta"">{contactLine}</div>
</header>
{body}
</main>
</body>
</html>";
}
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 ? $"<div class=\"{photoClass}\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var heroClass = curvedHeader ? "hero curved" : "hero";
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — {Encode(templateLabel)}</title>
<style>
:root {{ --accent:{accent}; --ink:#1f2937; --muted:#4b5563; --line:#d1d5db; --sidebar:#f3f4f6; --paper:#fff; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#edf2f7; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; display:grid; grid-template-columns:34% 66%; }}
.sidebar {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 8%, white)); color:#fff; padding:12mm 8mm 12mm 10mm; }}
.hero {{ margin:-12mm -8mm 8mm -10mm; padding:10mm 10mm 8mm 10mm; background:var(--accent); }}
.hero.curved {{ border-bottom-right-radius:28mm; }}
.name {{ margin:0; font-size:18pt; letter-spacing:.08em; font-weight:700; }}
.headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
.photo {{ width:34mm; height:34mm; margin-top:6mm; border:2px solid rgba(255,255,255,.85); overflow:hidden; }}
.photo.round {{ border-radius:50%; }}
.photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
.sidebar-section {{ margin-top:7mm; }}
.sidebar-title {{ margin:0 0 3mm 0; font-size:9pt; text-transform:uppercase; letter-spacing:.16em; }}
.sidebar-item {{ margin:0 0 2.4mm 0; font-size:8.8pt; line-height:1.4; white-space:pre-line; }}
.content {{ padding:14mm 14mm 14mm 10mm; }}
{BaseSectionCss(accent, "sidebar")}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<aside class=""sidebar"">
<div class=""{heroClass}"">
<h1 class=""name"">{Encode(candidateName)}</h1>
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
{photoMarkup}
</div>
{sidebarSections}
</aside>
<section class=""content"">
{main}
</section>
</main>
</body>
</html>";
}
private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle)
{
var sectionOrder = document.RenderOptions.SectionOrder.Count == 0
? new List<string> { "summary", "skills", "experience", "education", "custom" }
: document.RenderOptions.SectionOrder;
var sections = new Dictionary<string, string>(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<string?> items)
{
var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"<p class=\"sidebar-item\">{item}</p>"));
if (string.IsNullOrWhiteSpace(content)) return string.Empty;
return $"<section class=\"sidebar-section\"><h2 class=\"sidebar-title\">{Encode(title)}</h2>{content}</section>";
}
private static string RenderListSection(string title, IReadOnlyCollection<string> items, bool bulletList)
{
if (items.Count == 0) return string.Empty;
var tag = bulletList ? "summary" : "custom-list";
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(title)}</h2><ul class=\"{tag}\">{string.Join(string.Empty, items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
}
private static string RenderSkillSection(IReadOnlyCollection<string> skills)
{
if (skills.Count == 0) return string.Empty;
return $"<section class=\"section\"><h2 class=\"section-title\">Skills</h2><ul class=\"skills\">{string.Join(string.Empty, skills.Select(skill => $"<li class=\"skill-pill\">{Encode(skill)}</li>"))}</ul></section>";
}
private static string RenderExperienceSection(IReadOnlyCollection<TailoredCvExperienceItem> 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("<article class=\"entry\">");
items.Append($"<div class=\"entry-header\"><div class=\"entry-title\">{Encode(entry.Title)}</div><div class=\"entry-meta\">{Encode(dateRange)}</div></div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Bullets.Count > 0) items.Append($"<ul class=\"experience-bullets\">{string.Join(string.Empty, entry.Bullets.Select(bullet => $"<li>{Encode(bullet)}</li>"))}</ul>");
items.Append("</article>");
}
return $"<section class=\"section\"><h2 class=\"section-title\">Professional Experience</h2>{items}</section>";
}
private static string RenderEducationSection(IReadOnlyCollection<TailoredCvEducationItem> 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("<article class=\"entry\">");
items.Append($"<div class=\"entry-title\">{Encode(entry.Qualification)}</div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
items.Append("</article>");
}
return $"<section class=\"section\"><h2 class=\"section-title\">Education</h2>{items}</section>";
}
private static string RenderCustomSection(TailoredCvCustomSection section)
{
if (section.Items.Count == 0) return string.Empty;
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(section.Title ?? "Additional Information")}</h2><ul class=\"custom-list\">{string.Join(string.Empty, section.Items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
}
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("'", "&#39;", 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('-');
}
}
@@ -0,0 +1,66 @@
using Microsoft.Playwright;
namespace JobTrackerApi.Services;
public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] Bytes);
public interface ICvPdfExporter
{
Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken);
}
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
{
private readonly AppPaths _paths;
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger)
{
_paths = paths;
_logger = logger;
}
public async Task<CvPdfArtifact> 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);
}
}
}
+1
View File
@@ -46,6 +46,7 @@ public class JobApplication
public DateTime? TailoredCvUpdatedAt { get; set; } public DateTime? TailoredCvUpdatedAt { get; set; }
public DateTime? LastReminderEmailSentAt { get; set; } public DateTime? LastReminderEmailSentAt { get; set; }
public TailoredCvDraft? TailoredCvDraft { get; set; }
public List<Correspondence> Messages { get; set; } = new(); public List<Correspondence> Messages { get; set; } = new();
public List<Attachment> Attachments { get; set; } = new(); public List<Attachment> Attachments { get; set; } = new();
public List<JobEvent> Events { get; set; } = new(); public List<JobEvent> Events { get; set; } = new();
+70
View File
@@ -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<string> Summary { get; set; } = new();
public List<string> SelectedSkills { get; set; } = new();
public List<TailoredCvExperienceItem> Experience { get; set; } = new();
public List<TailoredCvEducationItem> Education { get; set; } = new();
public List<TailoredCvCustomSection> 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<string> 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<string> Details { get; set; } = new();
}
public sealed class TailoredCvCustomSection
{
public string? Title { get; set; }
public List<string> 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<string> SectionOrder { get; set; } = new() { "summary", "skills", "experience", "education", "custom" };
public string BulletDensity { get; set; } = "balanced";
}
+253
View File
@@ -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<TailoredCvExperienceItem>(draft.ExperienceJson)
.Select(NormalizeExperience)
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || !string.IsNullOrWhiteSpace(item.Company) || item.Bullets.Count > 0)
.ToList(),
Education = DeserializeList<TailoredCvEducationItem>(draft.EducationJson)
.Select(NormalizeEducation)
.Where(item => !string.IsNullOrWhiteSpace(item.Qualification) || !string.IsNullOrWhiteSpace(item.Institution) || item.Details.Count > 0)
.ToList(),
CustomSections = DeserializeList<TailoredCvCustomSection>(draft.CustomSectionsJson)
.Select(NormalizeCustomSection)
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || item.Items.Count > 0)
.ToList(),
RenderOptions = DeserializeObject<TailoredCvRenderOptions>(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<TailoredCvExperienceItem>())
.Select(NormalizeExperience)
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || !string.IsNullOrWhiteSpace(item.Company) || item.Bullets.Count > 0)
.ToList();
document.Education = (document.Education ?? new List<TailoredCvEducationItem>())
.Select(NormalizeEducation)
.Where(item => !string.IsNullOrWhiteSpace(item.Qualification) || !string.IsNullOrWhiteSpace(item.Institution) || item.Details.Count > 0)
.ToList();
document.CustomSections = (document.CustomSections ?? new List<TailoredCvCustomSection>())
.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<string> { "summary", "skills", "experience", "education", "custom" };
}
return document;
}
public static string RenderPlainText(TailoredCvDocument? document)
{
var normalized = Normalize(document);
var lines = new List<string>();
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<string>();
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<string>();
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<string> DeserializeList(string? json)
{
return DeserializeList<string>(json)
.Select(value => value?.Trim() ?? string.Empty)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static List<T> DeserializeList<T>(string? json)
{
try
{
return string.IsNullOrWhiteSpace(json)
? new List<T>()
: JsonSerializer.Deserialize<List<T>>(json, SerializerOptions) ?? new List<T>();
}
catch
{
return new List<T>();
}
}
private static T? DeserializeObject<T>(string? json) where T : class
{
try
{
return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<T>(json, SerializerOptions);
}
catch
{
return null;
}
}
private static List<string> CleanList(IEnumerable<string>? values)
{
return (values ?? Array.Empty<string>())
.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<string> lines, string title, IEnumerable<string> 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<string> lines, string? value)
{
if (!string.IsNullOrWhiteSpace(value)) lines.Add(value.Trim());
}
}
@@ -19,9 +19,10 @@ import {
} from "@mui/material"; } from "@mui/material";
import { api, getApiErrorMessage } from "../api"; 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 { useToast } from "../toast";
import { useDialogActions } from "../dialogs"; import { useDialogActions } from "../dialogs";
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
import Correspondence from "./Correspondence"; import Correspondence from "./Correspondence";
import Attachments from "./Attachments"; import Attachments from "./Attachments";
@@ -47,6 +48,12 @@ type PackageWorkspaceState = {
recruiterMessage: string; recruiterMessage: string;
}; };
type TailoredCvPreviewResponse = {
templateId: string;
html: string;
suggestedFileName: string;
};
interface Props { interface Props {
open: boolean; open: boolean;
jobId: number | null; jobId: number | null;
@@ -129,6 +136,22 @@ function getWorkspaceStatus(currentValue: string, savedValue: string) {
return { label: "Empty", color: "default" as const }; 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) { export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
@@ -153,13 +176,22 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [loadingReadiness, setLoadingReadiness] = useState(false); const [loadingReadiness, setLoadingReadiness] = useState(false);
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]); const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]); const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false); const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
const [generatingPackage, setGeneratingPackage] = useState(false); const [generatingPackage, setGeneratingPackage] = useState(false);
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null); const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
const [generationMode, setGenerationMode] = useState<GenerationMode>("default"); const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced"); const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
const [tailoredCvText, setTailoredCvText] = useState(""); const [tailoredCvDraft, setTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
const [savedTailoredCvDraft, setSavedTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
const [loadingTailoredCvDraft, setLoadingTailoredCvDraft] = useState(false);
const [generatingTailoredCvDraft, setGeneratingTailoredCvDraft] = useState(false);
const [savingTailoredCvDraft, setSavingTailoredCvDraft] = useState(false);
const [tailoredCvPreview, setTailoredCvPreview] = useState<TailoredCvPreviewResponse | null>(null);
const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false);
const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false);
const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState<string | null>(null);
const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState<string | null>(null);
const [useProfilePhoto, setUseProfilePhoto] = useState(true);
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null); const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
@@ -182,11 +214,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setJobAttachments([]); setJobAttachments([]);
setSelectedAttachmentIds([]); setSelectedAttachmentIds([]);
setPackageGeneratedAt(null); setPackageGeneratedAt(null);
setTailoredCvDraft(emptyTailoredCvDraft());
setSavedTailoredCvDraft(emptyTailoredCvDraft());
setTailoredCvPreview(null);
setProfileAvatarImageDataUrl(null);
setCustomPhotoDataUrl(null);
setUseProfilePhoto(true);
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" }); setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => { api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
setJob(r.data); setJob(r.data);
setTailoredCvText(r.data.tailoredCvText ?? "");
const savedWorkspace = { const savedWorkspace = {
coverLetter: r.data.coverLetterText ?? "", coverLetter: r.data.coverLetterText ?? "",
applicationAnswer: extractApplicationAnswerDraft(r.data.notes), applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
@@ -206,10 +243,30 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
setJobAttachments([]); setJobAttachments([]);
setSelectedAttachmentIds([]); 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([])); api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
}, [open, jobId, initialTab, initialFollowUpMode]); }, [open, jobId, initialTab, initialFollowUpMode]);
useEffect(() => {
if (!open || !jobId || tab !== 3) return;
setLoadingTailoredCvDraft(true);
api.get<TailoredCvDraft>(`/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(() => { useEffect(() => {
if (!open || !jobId || tab !== 4) return; if (!open || !jobId || tab !== 4) return;
setLoadingDraft(true); setLoadingDraft(true);
@@ -303,51 +360,160 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box> </Box>
) : null; ) : null;
const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? ""); const tailoredCvDraftStatus = getWorkspaceStatus(tailoredCvDraft.renderedText, savedTailoredCvDraft.renderedText);
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter); const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer); const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage); const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
const hasUnsavedTailoredCvDraftChanges = serializeTailoredDraft(tailoredCvDraft) !== serializeTailoredDraft(savedTailoredCvDraft);
const hasUnsavedPackageChanges = [ const hasUnsavedPackageChanges = [
tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(),
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(), packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(), packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(), packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
].some(Boolean); ].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<TailoredCvDraft>(`/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<TailoredCvPreviewResponse>(`/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 () => { const savePackageWorkspace = async () => {
if (!jobId || !job) return; if (!jobId || !job) return;
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer); const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim();
const draftsChanged = const draftsChanged =
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() || packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() || packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(); packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
if (!tailoredCvChanged && !draftsChanged) { if (!draftsChanged) {
toast("No unsaved package changes.", "info"); toast("No unsaved package changes.", "info");
return; return;
} }
try { try {
if (tailoredCvChanged) { setSavingApplicationDrafts(true);
setSavingTailoredCv(true); await api.put(`/jobapplications/${jobId}/application-drafts`, {
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText }); coverLetterText: packageWorkspace.coverLetter,
} notes: nextNotes,
recruiterMessageDraft: packageWorkspace.recruiterMessage,
if (draftsChanged) { });
setSavingApplicationDrafts(true);
await api.put(`/jobapplications/${jobId}/application-drafts`, {
coverLetterText: packageWorkspace.coverLetter,
notes: nextNotes,
recruiterMessageDraft: packageWorkspace.recruiterMessage,
});
}
setJob((prev) => prev ? { setJob((prev) => prev ? {
...prev, ...prev,
tailoredCvText,
tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt,
coverLetterText: packageWorkspace.coverLetter, coverLetterText: packageWorkspace.coverLetter,
recruiterMessageDraft: packageWorkspace.recruiterMessage, recruiterMessageDraft: packageWorkspace.recruiterMessage,
notes: nextNotes, notes: nextNotes,
@@ -359,13 +525,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to save the application package."), "error"); toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
} finally { } finally {
setSavingTailoredCv(false);
setSavingApplicationDrafts(false); setSavingApplicationDrafts(false);
} }
}; };
const resetPackageWorkspaceToSaved = () => { const resetPackageWorkspaceToSaved = () => {
setTailoredCvText(job?.tailoredCvText ?? "");
setPackageWorkspace(savedPackageWorkspace); setPackageWorkspace(savedPackageWorkspace);
toast("Restored the last saved package.", "info"); toast("Restored the last saved package.", "info");
}; };
@@ -489,11 +653,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
{tab === 3 && ( {tab === 3 && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}> <Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedTailoredCvDraftChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}> <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box> <Box>
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography> <Typography variant="overline">Tailored CV draft</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Build the package here, then save the working copy back onto this job.</Typography> <Typography variant="body2" sx={{ color: "text.secondary" }}>This draft is job-scoped. It stays separate from your master CV and from the package drafts below.</Typography>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<FormControl size="small" sx={{ minWidth: 180 }}> <FormControl size="small" sx={{ minWidth: 180 }}>
@@ -506,6 +670,214 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem> <MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Template</InputLabel>
<Select value={tailoredCvDraft.templateId} label="Template" onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, templateId: e.target.value, status: "edited" }))}>
<MenuItem value="ats-minimal">ATS Minimal</MenuItem>
<MenuItem value="harvard">Harvard</MenuItem>
<MenuItem value="auckland">Auckland</MenuItem>
<MenuItem value="edinburgh">Edinburgh</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="Accent"
type="color"
value={tailoredCvDraft.renderOptions.accentColor?.startsWith("#") ? tailoredCvDraft.renderOptions.accentColor : "#334155"}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
...current,
renderOptions: { ...current.renderOptions, accentColor: e.target.value },
status: "edited",
}))}
sx={{ width: 110 }}
InputLabelProps={{ shrink: true }}
/>
<Button size="small" variant={tailoredCvDraft.renderOptions.showPhoto ? "contained" : "outlined"} onClick={() => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
...current,
renderOptions: { ...current.renderOptions, showPhoto: !current.renderOptions.showPhoto },
status: "edited",
}))}>{tailoredCvDraft.renderOptions.showPhoto ? "Photo on" : "Photo off"}</Button>
<Button size="small" variant={useProfilePhoto ? "contained" : "outlined"} onClick={() => setUseProfilePhoto((current) => !current)}>{useProfilePhoto ? "Using profile photo" : "Profile photo off"}</Button>
<Button size="small" variant="outlined" component="label">
Pick photo
<input hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setCustomPhotoDataUrl(typeof reader.result === "string" ? reader.result : null);
reader.readAsDataURL(file);
}} />
</Button>
{customPhotoDataUrl ? <Button size="small" variant="text" onClick={() => setCustomPhotoDataUrl(null)}>Clear custom photo</Button> : null}
<Button size="small" variant="outlined" disabled={loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : "Generate tailored draft"}</Button>
<Button size="small" variant="outlined" disabled={loadingTailoredCvPreview} onClick={refreshTailoredCvPreview}>{loadingTailoredCvPreview ? "Building preview..." : "Preview PDF layout"}</Button>
<Button size="small" variant="outlined" disabled={exportingTailoredCvPdf} onClick={exportTailoredCvPdf}>{exportingTailoredCvPdf ? "Exporting PDF..." : "Download PDF"}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedTailoredCvDraftChanges} onClick={resetTailoredCvDraftToSaved}>Reset to saved draft</Button>
<Button size="small" variant="contained" disabled={savingTailoredCvDraft || loadingTailoredCvDraft} onClick={saveTailoredCvDraft}>{savingTailoredCvDraft ? t("jobDetailsSaving") : "Save tailored draft"}</Button>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
<Chip size="small" label={`Tailored CV · ${tailoredCvDraftStatus.label}`} color={tailoredCvDraftStatus.color} />
<Chip size="small" variant="outlined" label={`Template · ${tailoredCvDraft.templateId}`} />
{tailoredCvDraft.isLegacyFallback ? <Chip size="small" color="warning" variant="outlined" label="Legacy text fallback" /> : null}
{tailoredCvDraft.lastGeneratedAtUtc ? <Chip size="small" variant="outlined" label={`Generated ${new Date(tailoredCvDraft.lastGeneratedAtUtc).toLocaleString()}`} /> : null}
{tailoredCvDraft.canonicalProfileVersion ? <Chip size="small" variant="outlined" label={`Profile v${tailoredCvDraft.canonicalProfileVersion}`} /> : null}
</Box>
{loadingTailoredCvDraft ? (
<Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.1fr 0.9fr" }, gap: 2 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
label="Headline"
value={tailoredCvDraft.headline ?? ""}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, headline: e.target.value, status: "edited" }))}
fullWidth
/>
<TextField
label="Summary bullets"
value={joinLines(tailoredCvDraft.summary)}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, summary: splitLines(e.target.value), status: "edited" }))}
multiline
minRows={5}
fullWidth
helperText="One bullet per line."
/>
<TextField
label="Selected skills"
value={joinLines(tailoredCvDraft.selectedSkills)}
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, selectedSkills: splitLines(e.target.value), status: "edited" }))}
multiline
minRows={4}
fullWidth
helperText="One skill per line."
/>
<TextField
label="Experience"
value={tailoredCvDraft.experience.map((item) => [
[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."
/>
<TextField
label="Education"
value={tailoredCvDraft.education.map((item) => [
[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."
/>
<TextField
label="Custom sections"
value={tailoredCvDraft.customSections.map((section) => `${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."
/>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="overline">Rendered CV snapshot</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>This plain-text snapshot stays deterministic and is what the job stores immediately after saving the draft.</Typography>
<TextField value={tailoredCvDraft.renderedText} multiline minRows={12} fullWidth InputProps={{ readOnly: true }} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="overline">PDF-style preview</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here.</Typography>
{tailoredCvPreview ? (
<iframe title="Tailored CV preview" srcDoc={tailoredCvPreview.html} style={{ width: "100%", minHeight: 780, border: "1px solid rgba(15,23,42,0.08)", borderRadius: 12, background: "white" }} />
) : (
<Typography sx={{ color: "text.secondary" }}>Build the PDF layout preview to inspect the ATS template before downloading.</Typography>
)}
</Box>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="overline">Saved job material</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Saving the tailored draft updates the job-scoped CV text without touching your master profile.</Typography>
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Master CV:</strong> Never overwritten here</Typography>
<Typography variant="body2"><strong>Photo source:</strong> {customPhotoDataUrl ? "Custom preview photo" : useProfilePhoto && profileAvatarImageDataUrl ? "Profile picture" : "No photo source selected"}</Typography>
</Box>
</Box>
</Box>
)}
</Box>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box>
<Typography variant="overline">Application package drafts</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>These drafts stay separate from the tailored CV draft. Save them when you want reusable role-specific copy on the job.</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<FormControl size="small" sx={{ minWidth: 190 }}> <FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel> <InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
<Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}> <Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}>
@@ -515,22 +887,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem> <MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<Button size="small" variant="outlined" onClick={async () => {
try {
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
setTailoredCvText(me.data?.profileCvText ?? "");
toast(t("jobDetailsLoadedMasterCv"), "success");
} catch {
toast(t("jobDetailsLoadMasterCvFailed"), "error");
}
}}>{t("jobDetailsStartFromMasterCv")}</Button>
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => { <Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
if (!jobId) return; if (!jobId) return;
setGeneratingPackage(true); setGeneratingPackage(true);
try { try {
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } }); const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
setApplicationPackage(res.data); setApplicationPackage(res.data);
setTailoredCvText(res.data.tailoredCvText ?? "");
setPackageWorkspace({ setPackageWorkspace({
coverLetter: res.data.coverLetterDraft ?? "", coverLetter: res.data.coverLetterDraft ?? "",
applicationAnswer: res.data.applicationAnswerDraft ?? "", applicationAnswer: res.data.applicationAnswerDraft ?? "",
@@ -545,58 +907,53 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
} }
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button> }}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button> <Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
<Button size="small" variant="contained" disabled={savingTailoredCv || savingApplicationDrafts} onClick={savePackageWorkspace}>{savingTailoredCv || savingApplicationDrafts ? t("jobDetailsSaving") : "Save package to job"}</Button> <Button size="small" variant="contained" disabled={savingApplicationDrafts} onClick={savePackageWorkspace}>{savingApplicationDrafts ? t("jobDetailsSaving") : "Save package drafts"}</Button>
</Box> </Box>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
<Chip size="small" label={`Tailored CV · ${tailoredCvStatus.label}`} color={tailoredCvStatus.color} />
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} /> <Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} /> <Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} /> <Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
<Chip size="small" variant="outlined" label="Saved package material feeds follow-up drafting" /> <Chip size="small" variant="outlined" label="Saved package material feeds follow-up drafting" />
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null} {packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
</Box> </Box>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<WorkspaceDraftCard <WorkspaceDraftCard
title={t("jobDetailsCoverLetterDraft")} title={t("jobDetailsCoverLetterDraft")}
value={packageWorkspace.coverLetter} value={packageWorkspace.coverLetter}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))} onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
statusLabel={coverLetterStatus.label} statusLabel={coverLetterStatus.label}
statusColor={coverLetterStatus.color} statusColor={coverLetterStatus.color}
/> />
<WorkspaceDraftCard <WorkspaceDraftCard
title={t("jobDetailsShortApplicationAnswer")} title={t("jobDetailsShortApplicationAnswer")}
value={packageWorkspace.applicationAnswer} value={packageWorkspace.applicationAnswer}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))} onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
statusLabel={applicationAnswerStatus.label} statusLabel={applicationAnswerStatus.label}
statusColor={applicationAnswerStatus.color} statusColor={applicationAnswerStatus.color}
/> />
<WorkspaceDraftCard <WorkspaceDraftCard
title={t("jobDetailsRecruiterMessageDraft")} title={t("jobDetailsRecruiterMessageDraft")}
value={packageWorkspace.recruiterMessage} value={packageWorkspace.recruiterMessage}
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))} onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
statusLabel={recruiterMessageStatus.label} statusLabel={recruiterMessageStatus.label}
statusColor={recruiterMessageStatus.color} statusColor={recruiterMessageStatus.color}
/> />
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}> <Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Typography variant="overline">Saved working material</Typography> <Typography variant="overline">Saved working material</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography> <Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography> <Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography> <Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography> <Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography> </Box>
</Box> </Box>
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
</Box> </Box>
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
</Box> </Box>
</Box> </Box>
)} )}
@@ -43,6 +43,10 @@ beforeEach(() => {
writeText: jest.fn().mockResolvedValue(undefined), writeText: jest.fn().mockResolvedValue(undefined),
}, },
}); });
Object.assign(URL, {
createObjectURL: jest.fn().mockReturnValue('blob:preview-pdf'),
revokeObjectURL: jest.fn(),
});
mockedApi.get.mockImplementation((url: string) => { mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') { if (url === '/jobapplications/42') {
@@ -61,7 +65,7 @@ beforeEach(() => {
} } as any); } } as any);
} }
if (url === '/auth/me') { if (url === '/auth/me') {
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any); return Promise.resolve({ data: { roles: [], avatarImageDataUrl: 'data:image/png;base64,avatar123' } } as any);
} }
if (url === '/jobapplications/42/history') { if (url === '/jobapplications/42/history') {
return Promise.resolve({ data: [] } as any); return Promise.resolve({ data: [] } as any);
@@ -69,6 +73,26 @@ beforeEach(() => {
if (url === '/attachments/42') { if (url === '/attachments/42') {
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any); return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
} }
if (url === '/jobapplications/42/tailored-cv-draft') {
return Promise.resolve({ data: {
id: 5,
canonicalProfileVersion: 3,
templateId: 'ats-minimal',
headline: 'Backend Engineer',
summary: ['Built APIs', 'Shipped backend work'],
selectedSkills: ['.NET', 'SQL'],
experience: [],
education: [],
customSections: [],
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
generationContextHash: 'abc123',
lastGeneratedAtUtc: new Date().toISOString(),
lastEditedAtUtc: null,
status: 'generated',
renderedText: 'Backend Engineer\n\nProfessional Summary\n- Built APIs\n- Shipped backend work',
isLegacyFallback: false,
} } as any);
}
if (url === '/jobapplications/42/candidate-fit') { if (url === '/jobapplications/42/candidate-fit') {
return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any); return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any);
} }
@@ -77,7 +101,44 @@ beforeEach(() => {
} }
return Promise.resolve({ data: [] } as any); return Promise.resolve({ data: [] } as any);
}); });
mockedApi.post.mockResolvedValue({ data: { tailoredCvText: 'Generated CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
mockedApi.post.mockImplementation((url: string, body?: any, config?: any) => {
if (url === '/jobapplications/42/generate-tailored-cv-draft') {
return Promise.resolve({ data: {
id: 5,
canonicalProfileVersion: 3,
templateId: 'ats-minimal',
headline: 'Senior Backend Engineer',
summary: ['Owned API delivery', 'Improved SQL workflows'],
selectedSkills: ['.NET', 'SQL', 'APIs'],
experience: [],
education: [],
customSections: [],
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
generationContextHash: 'def456',
lastGeneratedAtUtc: new Date().toISOString(),
lastEditedAtUtc: null,
status: 'generated',
renderedText: 'Senior Backend Engineer\n\nProfessional Summary\n- Owned API delivery\n- Improved SQL workflows',
isLegacyFallback: false,
} } as any);
}
if (url === '/jobapplications/42/tailored-cv-preview') {
return Promise.resolve({ data: {
templateId: body?.templateId ?? 'ats-minimal',
suggestedFileName: `${body?.templateId ?? 'ats-minimal'}.pdf`,
html: `<html><body data-template="${body?.templateId ?? 'ats-minimal'}" data-accent="${body?.renderOptions?.accentColor ?? ''}" data-photo="${body?.useProfileAvatar ? 'profile' : 'custom'}"></body></html>`,
} } as any);
}
if (url === '/jobapplications/42/export-tailored-cv-pdf') {
return Promise.resolve({ data: new Blob(['pdf'], { type: 'application/pdf' }) } as any);
}
if (url === '/jobapplications/42/generate-application-package') {
return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any); mockedApi.put.mockResolvedValue({ data: {} } as any);
}); });
@@ -85,20 +146,40 @@ afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('application package workspace reflects saved job material, generated drafts, and save state', async () => { test('tailored cv tab loads, regenerates, and saves the structured tailored draft', async () => {
renderDialog(); renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i })); fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument(); expect(await screen.findByDisplayValue('Backend Engineer')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument(); expect((await screen.findByLabelText('Summary bullets')) as HTMLInputElement).toHaveValue('Built APIs\nShipped backend work');
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /generate application package/i })); fireEvent.click(screen.getByRole('button', { name: /generate tailored draft/i }));
expect(await screen.findByDisplayValue('Senior Backend Engineer')).toBeInTheDocument();
const headline = screen.getByDisplayValue('Senior Backend Engineer');
fireEvent.change(headline, { target: { value: 'Principal Backend Engineer' } });
fireEvent.click(screen.getByRole('button', { name: /save tailored draft/i }));
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-draft', expect.objectContaining({
headline: 'Principal Backend Engineer',
summary: ['Owned API delivery', 'Improved SQL workflows'],
selectedSkills: ['.NET', 'SQL', 'APIs'],
status: 'edited',
}));
});
expect(mockedApi.put).not.toHaveBeenCalledWith('/jobapplications/42/tailored-cv', expect.anything());
});
test('application package drafts save separately from the tailored cv draft', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
fireEvent.click(await screen.findByRole('button', { name: /generate application package/i }));
expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument();
const coverLetter = await screen.findByDisplayValue('Draft letter'); const coverLetter = await screen.findByDisplayValue('Draft letter');
const applicationAnswer = await screen.findByDisplayValue('Draft answer'); const applicationAnswer = await screen.findByDisplayValue('Draft answer');
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello'); const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
@@ -107,18 +188,59 @@ test('application package workspace reflects saved job material, generated draft
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } }); fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } }); fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
fireEvent.click(screen.getByRole('button', { name: /save package to job/i })); fireEvent.click(screen.getByRole('button', { name: /save package drafts/i }));
await waitFor(() => { await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', { expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
coverLetterText: 'Edited cover letter', coverLetterText: 'Edited cover letter',
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>', notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
recruiterMessageDraft: 'Edited recruiter note', recruiterMessageDraft: 'Edited recruiter note',
}); });
}); });
});
expect(await screen.findAllByText(/saved to job/i)).not.toHaveLength(0); test('template switching refreshes preview and export uses the selected template payload', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
const comboboxes = await screen.findAllByRole('combobox');
fireEvent.mouseDown(comboboxes[1]);
fireEvent.click(await screen.findByRole('option', { name: 'Harvard' }));
const accent = screen.getByLabelText('Accent');
fireEvent.change(accent, { target: { value: '#123456' } });
fireEvent.click(screen.getByRole('button', { name: /preview pdf layout/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-preview', expect.objectContaining({
templateId: 'harvard',
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
useProfileAvatar: true,
}));
});
expect(await screen.findByTitle('Tailored CV preview')).toBeInTheDocument();
const appendChildSpy = jest.spyOn(document.body, 'appendChild');
const removeSpy = jest.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => {});
const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
fireEvent.click(screen.getByRole('button', { name: /download pdf/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/export-tailored-cv-pdf', expect.objectContaining({
templateId: 'harvard',
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
}), expect.objectContaining({ responseType: 'blob' }));
expect(URL.createObjectURL).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
});
appendChildSpy.mockRestore();
removeSpy.mockRestore();
clickSpy.mockRestore();
}); });
test('strategy snapshot can be generated from overview', async () => { test('strategy snapshot can be generated from overview', async () => {
+135
View File
@@ -0,0 +1,135 @@
import { joinLines, splitLines } from "./profileCv";
import { TailoredCvDraft } from "./types";
const DEFAULT_SECTION_ORDER = ["summary", "skills", "experience", "education", "custom"];
export function emptyTailoredCvDraft(): TailoredCvDraft {
return {
templateId: "ats-minimal",
headline: "",
summary: [],
selectedSkills: [],
experience: [],
education: [],
customSections: [],
renderOptions: {
showPhoto: false,
pageMode: "one-page",
accentColor: "slate",
sectionOrder: DEFAULT_SECTION_ORDER,
bulletDensity: "balanced",
},
status: "empty",
renderedText: "",
isLegacyFallback: false,
};
}
function formatDateRange(start?: string | null, end?: string | null, isCurrent?: boolean) {
const normalizedStart = start?.trim();
const normalizedEnd = end?.trim();
if (!normalizedStart && !normalizedEnd) return "";
if (!normalizedStart) return normalizedEnd ?? "";
return `${normalizedStart} - ${isCurrent ? "Present" : normalizedEnd || "Present"}`;
}
export function renderTailoredCvDraftText(source?: Partial<TailoredCvDraft> | null) {
const draft = emptyTailoredCvDraft();
const normalized = {
...draft,
...source,
summary: Array.isArray(source?.summary) ? source.summary.filter(Boolean) : [],
selectedSkills: Array.isArray(source?.selectedSkills) ? source.selectedSkills.filter(Boolean) : [],
experience: Array.isArray(source?.experience) ? source.experience.filter(Boolean) : [],
education: Array.isArray(source?.education) ? source.education.filter(Boolean) : [],
customSections: Array.isArray(source?.customSections) ? source.customSections.filter(Boolean) : [],
};
const sections: string[] = [];
if (normalized.headline?.trim()) {
sections.push(normalized.headline.trim());
}
if (normalized.summary.length) {
sections.push(`Professional Summary\n${normalized.summary.map((item) => `- ${item.trim()}`).join("\n")}`);
}
if (normalized.selectedSkills.length) {
sections.push(`Core Skills\n${normalized.selectedSkills.map((item) => item.trim()).join("\n")}`);
}
if (normalized.experience.length) {
const body = normalized.experience.map((item) => {
const header = [item.title, item.company, item.location, formatDateRange(item.start, item.end, item.isCurrent)]
.map((value) => value?.trim())
.filter(Boolean)
.join(" | ");
const bullets = (item.bullets ?? []).filter(Boolean).map((bullet) => `- ${bullet.trim()}`).join("\n");
return [header, bullets].filter(Boolean).join("\n");
}).filter(Boolean).join("\n\n");
if (body) sections.push(`Experience\n${body}`);
}
if (normalized.education.length) {
const body = normalized.education.map((item) => {
const header = [item.qualification, item.institution, item.location, formatDateRange(item.start, item.end, false)]
.map((value) => value?.trim())
.filter(Boolean)
.join(" | ");
const details = (item.details ?? []).filter(Boolean).map((detail) => `- ${detail.trim()}`).join("\n");
return [header, details].filter(Boolean).join("\n");
}).filter(Boolean).join("\n\n");
if (body) sections.push(`Education\n${body}`);
}
normalized.customSections.forEach((section) => {
const title = section.title?.trim() || "Additional Information";
const items = (section.items ?? []).filter(Boolean).map((item) => item.trim()).join("\n");
if (items) sections.push(`${title}\n${items}`);
});
return sections.join("\n\n").trim();
}
export function normalizeTailoredCvDraft(source?: Partial<TailoredCvDraft> | null): TailoredCvDraft {
const empty = emptyTailoredCvDraft();
const normalized: TailoredCvDraft = {
...empty,
...source,
templateId: source?.templateId?.trim() || empty.templateId,
headline: source?.headline ?? "",
summary: Array.isArray(source?.summary) ? source!.summary.filter(Boolean) : [],
selectedSkills: Array.isArray(source?.selectedSkills) ? source!.selectedSkills.filter(Boolean) : [],
experience: Array.isArray(source?.experience) ? source!.experience.map((item) => ({
title: item?.title ?? "",
company: item?.company ?? "",
location: item?.location ?? "",
start: item?.start ?? "",
end: item?.end ?? "",
isCurrent: Boolean(item?.isCurrent),
bullets: Array.isArray(item?.bullets) ? item!.bullets.filter(Boolean) : [],
})) : [],
education: Array.isArray(source?.education) ? source!.education.map((item) => ({
qualification: item?.qualification ?? "",
institution: item?.institution ?? "",
location: item?.location ?? "",
start: item?.start ?? "",
end: item?.end ?? "",
details: Array.isArray(item?.details) ? item!.details.filter(Boolean) : [],
})) : [],
customSections: Array.isArray(source?.customSections) ? source!.customSections.map((item) => ({
title: item?.title ?? "",
items: Array.isArray(item?.items) ? item!.items.filter(Boolean) : [],
})) : [],
renderOptions: {
...empty.renderOptions,
...source?.renderOptions,
sectionOrder: Array.isArray(source?.renderOptions?.sectionOrder) && source.renderOptions.sectionOrder.length > 0
? source.renderOptions.sectionOrder.filter(Boolean)
: DEFAULT_SECTION_ORDER,
},
status: source?.status?.trim() || empty.status,
renderedText: "",
isLegacyFallback: Boolean(source?.isLegacyFallback),
};
normalized.renderedText = renderTailoredCvDraftText(normalized);
return normalized;
}
export { joinLines, splitLines };
+51
View File
@@ -28,6 +28,57 @@ export interface WorkflowSignal {
hasInterviewPrepNotes: boolean; hasInterviewPrepNotes: boolean;
} }
export interface TailoredCvExperienceItem {
title?: string | null;
company?: string | null;
location?: string | null;
start?: string | null;
end?: string | null;
isCurrent?: boolean;
bullets: string[];
}
export interface TailoredCvEducationItem {
qualification?: string | null;
institution?: string | null;
location?: string | null;
start?: string | null;
end?: string | null;
details: string[];
}
export interface TailoredCvCustomSection {
title?: string | null;
items: string[];
}
export interface TailoredCvRenderOptions {
showPhoto: boolean;
pageMode: string;
accentColor: string;
sectionOrder: string[];
bulletDensity: string;
}
export interface TailoredCvDraft {
id?: number | null;
canonicalProfileVersion?: number | null;
templateId: string;
headline?: string | null;
summary: string[];
selectedSkills: string[];
experience: TailoredCvExperienceItem[];
education: TailoredCvEducationItem[];
customSections: TailoredCvCustomSection[];
renderOptions: TailoredCvRenderOptions;
generationContextHash?: string | null;
lastGeneratedAtUtc?: string | null;
lastEditedAtUtc?: string | null;
status: string;
renderedText: string;
isLegacyFallback: boolean;
}
export interface JobApplication { export interface JobApplication {
id: number; id: number;
jobTitle: string; jobTitle: string;