Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 839a2ed80d |
@@ -24,6 +24,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
|
||||
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
|
||||
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
|
||||
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -101,6 +102,19 @@ namespace JobTrackerApi.Data
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ArtifactId)
|
||||
.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);
|
||||
}
|
||||
|
||||
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'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
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
@@ -260,10 +483,10 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
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>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
var manager = new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(),
|
||||
@@ -273,5 +496,29 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
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.JobImport;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -20,14 +21,26 @@ namespace JobTrackerApi.Controllers
|
||||
private readonly IAppEmailSender _email;
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
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;
|
||||
_summarizer = summarizer;
|
||||
_email = email;
|
||||
_users = users;
|
||||
_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 =>
|
||||
@@ -153,6 +166,275 @@ namespace JobTrackerApi.Controllers
|
||||
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
||||
}
|
||||
|
||||
private static string ComputeGenerationContextHash(string value)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable<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)
|
||||
{
|
||||
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
||||
@@ -1729,6 +2011,33 @@ namespace JobTrackerApi.Controllers
|
||||
string? CoverLetterDraft,
|
||||
string? RecruiterMessageDraft);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
public sealed record TailoredCvDraftDto(
|
||||
int? Id,
|
||||
int? CanonicalProfileVersion,
|
||||
string TemplateId,
|
||||
string? Headline,
|
||||
List<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 SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||
@@ -2029,6 +2338,155 @@ Candidate master CV:
|
||||
return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/tailored-cv-draft")]
|
||||
public async Task<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")]
|
||||
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
||||
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||
|
||||
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_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"CanonicalProfileVersion" INTEGER NULL,
|
||||
"TemplateId" TEXT NOT NULL,
|
||||
"Headline" TEXT NULL,
|
||||
"SummaryJson" TEXT NULL,
|
||||
"SelectedSkillsJson" TEXT NULL,
|
||||
"ExperienceJson" TEXT NULL,
|
||||
"EducationJson" TEXT NULL,
|
||||
"CustomSectionsJson" TEXT NULL,
|
||||
"RenderOptionsJson" TEXT NULL,
|
||||
"GenerationContextHash" TEXT NULL,
|
||||
"LastGeneratedAtUtc" TEXT NULL,
|
||||
"LastEditedAtUtc" TEXT NULL,
|
||||
"Status" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
@@ -911,6 +938,32 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`CanonicalProfileVersion` int NULL,
|
||||
`TemplateId` varchar(100) NOT NULL,
|
||||
`Headline` longtext NULL,
|
||||
`SummaryJson` longtext NULL,
|
||||
`SelectedSkillsJson` longtext NULL,
|
||||
`ExperienceJson` longtext NULL,
|
||||
`EducationJson` longtext NULL,
|
||||
`CustomSectionsJson` longtext NULL,
|
||||
`RenderOptionsJson` longtext NULL,
|
||||
`GenerationContextHash` longtext NULL,
|
||||
`LastGeneratedAtUtc` datetime(6) NULL,
|
||||
`LastEditedAtUtc` datetime(6) NULL,
|
||||
`Status` varchar(100) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -945,6 +998,20 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
||||
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace JobTrackerApi.Services
|
||||
public string DataRoot { get; }
|
||||
public string AttachmentsRoot { get; }
|
||||
public string CvArtifactsRoot { get; }
|
||||
public string CvExportsRoot { get; }
|
||||
|
||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||
{
|
||||
@@ -31,6 +32,13 @@ namespace JobTrackerApi.Services
|
||||
|
||||
Directory.CreateDirectory(cvArtifactsRoot);
|
||||
CvArtifactsRoot = cvArtifactsRoot;
|
||||
|
||||
var cvExportsRoot = (cfg["Data:CvExportsRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(cvExportsRoot)) cvExportsRoot = Path.Combine(DataRoot, "CvExports");
|
||||
if (!Path.IsPathRooted(cvExportsRoot)) cvExportsRoot = Path.Combine(env.ContentRootPath, cvExportsRoot);
|
||||
|
||||
Directory.CreateDirectory(cvExportsRoot);
|
||||
CvExportsRoot = cvExportsRoot;
|
||||
}
|
||||
|
||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||
|
||||
@@ -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(" • ", 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("'", "'", 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ public class JobApplication
|
||||
public DateTime? TailoredCvUpdatedAt { get; set; }
|
||||
public DateTime? LastReminderEmailSentAt { get; set; }
|
||||
|
||||
public TailoredCvDraft? TailoredCvDraft { get; set; }
|
||||
public List<Correspondence> Messages { get; set; } = new();
|
||||
public List<Attachment> Attachments { get; set; } = new();
|
||||
public List<JobEvent> Events { get; set; } = new();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||
|
||||
import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
@@ -47,6 +48,12 @@ type PackageWorkspaceState = {
|
||||
recruiterMessage: string;
|
||||
};
|
||||
|
||||
type TailoredCvPreviewResponse = {
|
||||
templateId: string;
|
||||
html: string;
|
||||
suggestedFileName: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
jobId: number | null;
|
||||
@@ -129,6 +136,22 @@ function getWorkspaceStatus(currentValue: string, savedValue: string) {
|
||||
return { label: "Empty", color: "default" as const };
|
||||
}
|
||||
|
||||
function serializeTailoredDraft(draft: TailoredCvDraft) {
|
||||
const normalized = normalizeTailoredCvDraft(draft);
|
||||
return JSON.stringify({
|
||||
templateId: normalized.templateId,
|
||||
headline: normalized.headline ?? "",
|
||||
summary: normalized.summary,
|
||||
selectedSkills: normalized.selectedSkills,
|
||||
experience: normalized.experience,
|
||||
education: normalized.education,
|
||||
customSections: normalized.customSections,
|
||||
renderOptions: normalized.renderOptions,
|
||||
status: normalized.status,
|
||||
isLegacyFallback: normalized.isLegacyFallback,
|
||||
});
|
||||
}
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
@@ -153,13 +176,22 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
||||
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
|
||||
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
|
||||
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
|
||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
||||
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 [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
||||
@@ -182,11 +214,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
setPackageGeneratedAt(null);
|
||||
setTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setSavedTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setTailoredCvPreview(null);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
setCustomPhotoDataUrl(null);
|
||||
setUseProfilePhoto(true);
|
||||
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
setTailoredCvText(r.data.tailoredCvText ?? "");
|
||||
const savedWorkspace = {
|
||||
coverLetter: r.data.coverLetterText ?? "",
|
||||
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
||||
@@ -206,10 +243,30 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
});
|
||||
api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false));
|
||||
api.get(`/auth/me`).then((r) => {
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null);
|
||||
}).catch(() => {
|
||||
setIsAdmin(false);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
});
|
||||
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||
}, [open, jobId, initialTab, initialFollowUpMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 3) return;
|
||||
setLoadingTailoredCvDraft(true);
|
||||
api.get<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(() => {
|
||||
if (!open || !jobId || tab !== 4) return;
|
||||
setLoadingDraft(true);
|
||||
@@ -303,51 +360,160 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? "");
|
||||
const tailoredCvDraftStatus = getWorkspaceStatus(tailoredCvDraft.renderedText, savedTailoredCvDraft.renderedText);
|
||||
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
|
||||
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
|
||||
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
|
||||
const hasUnsavedTailoredCvDraftChanges = serializeTailoredDraft(tailoredCvDraft) !== serializeTailoredDraft(savedTailoredCvDraft);
|
||||
const hasUnsavedPackageChanges = [
|
||||
tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(),
|
||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
|
||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
|
||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
|
||||
].some(Boolean);
|
||||
|
||||
const saveTailoredCvDraft = async () => {
|
||||
if (!jobId) return;
|
||||
|
||||
const normalized = normalizeTailoredCvDraft({
|
||||
...tailoredCvDraft,
|
||||
status: tailoredCvDraft.status === "empty" ? "edited" : tailoredCvDraft.status,
|
||||
});
|
||||
|
||||
try {
|
||||
setSavingTailoredCvDraft(true);
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv-draft`, {
|
||||
templateId: normalized.templateId,
|
||||
headline: normalized.headline,
|
||||
summary: normalized.summary,
|
||||
selectedSkills: normalized.selectedSkills,
|
||||
experience: normalized.experience,
|
||||
education: normalized.education,
|
||||
customSections: normalized.customSections,
|
||||
renderOptions: normalized.renderOptions,
|
||||
status: normalized.status,
|
||||
});
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText: normalized.renderedText,
|
||||
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||
} : prev);
|
||||
setReadiness(null);
|
||||
toast("Tailored CV draft saved.", "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to save the tailored CV draft."), "error");
|
||||
} finally {
|
||||
setSavingTailoredCvDraft(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateTailoredCvDraft = async () => {
|
||||
if (!jobId) return;
|
||||
if (hasUnsavedTailoredCvDraftChanges) {
|
||||
const confirmed = await confirmAction("Regenerating the tailored CV draft will replace your unsaved edits.", {
|
||||
title: "Replace unsaved tailored CV edits?",
|
||||
confirmLabel: "Regenerate draft",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGeneratingTailoredCvDraft(true);
|
||||
const res = await api.post<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 () => {
|
||||
if (!jobId || !job) return;
|
||||
|
||||
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
|
||||
const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim();
|
||||
const draftsChanged =
|
||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
|
||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
|
||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
|
||||
|
||||
if (!tailoredCvChanged && !draftsChanged) {
|
||||
if (!draftsChanged) {
|
||||
toast("No unsaved package changes.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (tailoredCvChanged) {
|
||||
setSavingTailoredCv(true);
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
|
||||
}
|
||||
|
||||
if (draftsChanged) {
|
||||
setSavingApplicationDrafts(true);
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
notes: nextNotes,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
});
|
||||
}
|
||||
setSavingApplicationDrafts(true);
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
notes: nextNotes,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
});
|
||||
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText,
|
||||
tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt,
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
notes: nextNotes,
|
||||
@@ -359,13 +525,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
|
||||
} finally {
|
||||
setSavingTailoredCv(false);
|
||||
setSavingApplicationDrafts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPackageWorkspaceToSaved = () => {
|
||||
setTailoredCvText(job?.tailoredCvText ?? "");
|
||||
setPackageWorkspace(savedPackageWorkspace);
|
||||
toast("Restored the last saved package.", "info");
|
||||
};
|
||||
@@ -489,11 +653,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 3 && (
|
||||
<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>
|
||||
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Build the package here, then save the working copy back onto this job.</Typography>
|
||||
<Typography variant="overline">Tailored CV draft</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 sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<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>
|
||||
</Select>
|
||||
</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 }}>
|
||||
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
|
||||
<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>
|
||||
</Select>
|
||||
</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 () => {
|
||||
if (!jobId) return;
|
||||
setGeneratingPackage(true);
|
||||
try {
|
||||
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
|
||||
setApplicationPackage(res.data);
|
||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||
setPackageWorkspace({
|
||||
coverLetter: res.data.coverLetterDraft ?? "",
|
||||
applicationAnswer: res.data.applicationAnswerDraft ?? "",
|
||||
@@ -545,58 +907,53 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}
|
||||
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</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 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={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.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" />
|
||||
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
|
||||
</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 }}>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsCoverLetterDraft")}
|
||||
value={packageWorkspace.coverLetter}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
|
||||
statusLabel={coverLetterStatus.label}
|
||||
statusColor={coverLetterStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsShortApplicationAnswer")}
|
||||
value={packageWorkspace.applicationAnswer}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
|
||||
statusLabel={applicationAnswerStatus.label}
|
||||
statusColor={applicationAnswerStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsRecruiterMessageDraft")}
|
||||
value={packageWorkspace.recruiterMessage}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
|
||||
statusLabel={recruiterMessageStatus.label}
|
||||
statusColor={recruiterMessageStatus.color}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<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>
|
||||
<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>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>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsCoverLetterDraft")}
|
||||
value={packageWorkspace.coverLetter}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
|
||||
statusLabel={coverLetterStatus.label}
|
||||
statusColor={coverLetterStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsShortApplicationAnswer")}
|
||||
value={packageWorkspace.applicationAnswer}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
|
||||
statusLabel={applicationAnswerStatus.label}
|
||||
statusColor={applicationAnswerStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsRecruiterMessageDraft")}
|
||||
value={packageWorkspace.recruiterMessage}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
|
||||
statusLabel={recruiterMessageStatus.label}
|
||||
statusColor={recruiterMessageStatus.color}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<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>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<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>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -43,6 +43,10 @@ beforeEach(() => {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
Object.assign(URL, {
|
||||
createObjectURL: jest.fn().mockReturnValue('blob:preview-pdf'),
|
||||
revokeObjectURL: jest.fn(),
|
||||
});
|
||||
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
@@ -61,7 +65,7 @@ beforeEach(() => {
|
||||
} } as any);
|
||||
}
|
||||
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') {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
@@ -69,6 +73,26 @@ beforeEach(() => {
|
||||
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);
|
||||
}
|
||||
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') {
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -85,20 +146,40 @@ afterEach(() => {
|
||||
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();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
|
||||
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();
|
||||
expect(await screen.findByDisplayValue('Backend Engineer')).toBeInTheDocument();
|
||||
expect((await screen.findByLabelText('Summary bullets')) as HTMLInputElement).toHaveValue('Built APIs\nShipped backend work');
|
||||
|
||||
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 applicationAnswer = await screen.findByDisplayValue('Draft answer');
|
||||
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(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(() => {
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
|
||||
coverLetterText: 'Edited cover letter',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
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 () => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -28,6 +28,57 @@ export interface WorkflowSignal {
|
||||
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 {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
|
||||
Reference in New Issue
Block a user