Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 839a2ed80d |
@@ -24,6 +24,7 @@ namespace JobTrackerApi.Data
|
|||||||
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
|
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
|
||||||
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
|
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
|
||||||
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
|
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
|
||||||
|
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -101,6 +102,19 @@ namespace JobTrackerApi.Data
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.ArtifactId)
|
.HasForeignKey(x => x.ArtifactId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
modelBuilder.Entity<TailoredCvDraft>()
|
||||||
|
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<TailoredCvDraft>()
|
||||||
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<TailoredCvDraft>()
|
||||||
|
.HasOne(x => x.JobApplication)
|
||||||
|
.WithOne(j => j.TailoredCvDraft)
|
||||||
|
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,9 +234,232 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext);
|
Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
|
[Fact]
|
||||||
|
public async Task Get_tailored_cv_draft_returns_legacy_fallback_when_no_structured_draft_exists()
|
||||||
{
|
{
|
||||||
var controller = new JobApplicationsController(db, summarizer, Mock.Of<IAppEmailSender>(), CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance);
|
await using var db = CreateDb();
|
||||||
|
var company = new Company
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
OwnerUserId = "user-1"
|
||||||
|
};
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Backend Developer",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
TailoredCvText = "Existing tailored CV text",
|
||||||
|
TailoredCvUpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||||
|
var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var payload = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(ok.Value);
|
||||||
|
Assert.True(payload.IsLegacyFallback);
|
||||||
|
Assert.Equal("legacy-text", payload.TemplateId);
|
||||||
|
Assert.Contains("Existing tailored CV text", payload.RenderedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Generate_and_save_tailored_cv_draft_persists_job_scoped_document_without_mutating_master_cv()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company
|
||||||
|
{
|
||||||
|
Name = "Acme",
|
||||||
|
OwnerUserId = "user-1"
|
||||||
|
};
|
||||||
|
db.Companies.Add(company);
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "user-1",
|
||||||
|
UserName = "user@example.test",
|
||||||
|
Email = "user@example.test",
|
||||||
|
CurrentCvProfileVersion = 7,
|
||||||
|
ProfileCvText = "Built APIs and owned backend delivery.",
|
||||||
|
ProfileCvStructureJson = """
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"contact": {
|
||||||
|
"fullName": "Demo User",
|
||||||
|
"headline": "Backend Developer"
|
||||||
|
},
|
||||||
|
"summary": ["Backend-focused developer with API delivery experience."],
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"title": "System Developer",
|
||||||
|
"company": "Acme Consulting",
|
||||||
|
"location": "Oslo",
|
||||||
|
"start": "2021",
|
||||||
|
"end": "2024",
|
||||||
|
"isCurrent": false,
|
||||||
|
"bullets": ["Owned .NET API delivery across multiple services."],
|
||||||
|
"skills": [".NET", "SQL", "APIs"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"education": [],
|
||||||
|
"skills": [".NET", "SQL", "APIs"],
|
||||||
|
"languages": [{ "name": "English", "level": "Native" }],
|
||||||
|
"interests": [],
|
||||||
|
"otherSections": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Backend Developer",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
Description = "Need .NET API ownership and strong SQL skills."
|
||||||
|
};
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
|
summarizer
|
||||||
|
.Setup(service => service.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||||
|
.ReturnsAsync((string instruction, string _, int __, int ___) =>
|
||||||
|
{
|
||||||
|
if (instruction.Contains("headline", StringComparison.OrdinalIgnoreCase)) return "Senior Backend Engineer";
|
||||||
|
if (instruction.Contains("summary bullets", StringComparison.OrdinalIgnoreCase)) return "Led backend API delivery.\nImproved SQL-backed workflows.";
|
||||||
|
return "Draft";
|
||||||
|
});
|
||||||
|
|
||||||
|
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||||
|
var generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None);
|
||||||
|
var generateOk = Assert.IsType<OkObjectResult>(generateResult.Result);
|
||||||
|
var generated = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(generateOk.Value);
|
||||||
|
|
||||||
|
Assert.False(generated.IsLegacyFallback);
|
||||||
|
Assert.Equal(7, generated.CanonicalProfileVersion);
|
||||||
|
Assert.Equal("Senior Backend Engineer", generated.Headline);
|
||||||
|
Assert.Contains("Led backend API delivery.", generated.RenderedText);
|
||||||
|
|
||||||
|
var saveResult = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest(
|
||||||
|
generated.TemplateId,
|
||||||
|
"Principal Backend Engineer",
|
||||||
|
new List<string> { "Own backend delivery for critical APIs." },
|
||||||
|
new List<string> { ".NET", "SQL" },
|
||||||
|
generated.Experience,
|
||||||
|
generated.Education,
|
||||||
|
generated.CustomSections,
|
||||||
|
generated.RenderOptions,
|
||||||
|
"edited"), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(saveResult);
|
||||||
|
|
||||||
|
var savedDraft = await db.TailoredCvDrafts.SingleAsync();
|
||||||
|
var savedJob = await db.JobApplications.SingleAsync();
|
||||||
|
var savedUser = await db.Users.SingleAsync();
|
||||||
|
|
||||||
|
Assert.Equal("edited", savedDraft.Status);
|
||||||
|
Assert.Equal(7, savedDraft.CanonicalProfileVersion);
|
||||||
|
Assert.Contains("Principal Backend Engineer", savedJob.TailoredCvText);
|
||||||
|
Assert.Equal("Built APIs and owned backend delivery.", savedUser.ProfileCvText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_and_export_tailored_cv_use_same_renderer_contract_and_profile_avatar_default()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
db.Users.Add(new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = "user-1",
|
||||||
|
UserName = "user@example.test",
|
||||||
|
Email = "user@example.test",
|
||||||
|
AvatarImageDataUrl = "data:image/png;base64,abc123"
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Backend Developer",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
TailoredCvText = "Saved tailored CV"
|
||||||
|
};
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var renderer = new TestCvTemplateRenderer();
|
||||||
|
var exporter = new TestCvPdfExporter();
|
||||||
|
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1", renderer, exporter);
|
||||||
|
var request = new JobApplicationsController.TailoredCvRenderRequest(
|
||||||
|
"ats-minimal",
|
||||||
|
"Backend Engineer",
|
||||||
|
new List<string> { "Built APIs" },
|
||||||
|
new List<string> { ".NET" },
|
||||||
|
new List<TailoredCvExperienceItem>(),
|
||||||
|
new List<TailoredCvEducationItem>(),
|
||||||
|
new List<TailoredCvCustomSection>(),
|
||||||
|
new TailoredCvRenderOptions { ShowPhoto = true, AccentColor = "#123456" },
|
||||||
|
null,
|
||||||
|
true);
|
||||||
|
|
||||||
|
var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None);
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(previewResult.Result);
|
||||||
|
var preview = Assert.IsType<JobApplicationsController.TailoredCvPreviewDto>(ok.Value);
|
||||||
|
Assert.Equal("ats-minimal", preview.TemplateId);
|
||||||
|
Assert.Equal("preview.pdf", preview.SuggestedFileName);
|
||||||
|
Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl);
|
||||||
|
|
||||||
|
var exportResult = await controller.ExportTailoredCvPdf(job.Id, request, CancellationToken.None);
|
||||||
|
var file = Assert.IsType<FileContentResult>(exportResult);
|
||||||
|
Assert.Equal("application/pdf", file.ContentType);
|
||||||
|
Assert.Equal("preview.pdf", file.FileDownloadName);
|
||||||
|
Assert.NotNull(exporter.LastRenderResult);
|
||||||
|
Assert.Equal(preview.Html, exporter.LastRenderResult!.Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Template_renderer_supports_named_variants()
|
||||||
|
{
|
||||||
|
var renderer = new CvTemplateRenderer();
|
||||||
|
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||||
|
{
|
||||||
|
TemplateId = "harvard",
|
||||||
|
Headline = "Product Manager",
|
||||||
|
Summary = new List<string> { "Built and shipped product roadmaps." },
|
||||||
|
SelectedSkills = new List<string> { "Strategy", "Stakeholder management" },
|
||||||
|
Experience = new List<TailoredCvExperienceItem>
|
||||||
|
{
|
||||||
|
new() { Title = "Product Manager", Company = "Acme", Start = "2022", End = "2025", Bullets = new List<string> { "Launched new product line." } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var harvard = renderer.Render(document, "harvard", "Andrew O'Sullivan", "Product Manager", "Acme", null);
|
||||||
|
var auckland = renderer.Render(document, "auckland", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc");
|
||||||
|
var edinburgh = renderer.Render(document, "edinburgh", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc");
|
||||||
|
|
||||||
|
Assert.Equal("harvard", harvard.TemplateId);
|
||||||
|
Assert.Contains("Template: ATS Minimal", renderer.Render(document, "ats-minimal", "Andrew O'Sullivan", "Product Manager", "Acme", null).Html);
|
||||||
|
Assert.Contains("Andrew O'Sullivan", harvard.Html);
|
||||||
|
Assert.Contains("sidebar", auckland.Html, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId, ICvTemplateRenderer? renderer = null, ICvPdfExporter? exporter = null)
|
||||||
|
{
|
||||||
|
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
|
||||||
|
var controller = new JobApplicationsController(
|
||||||
|
db,
|
||||||
|
summarizer,
|
||||||
|
Mock.Of<IAppEmailSender>(),
|
||||||
|
CreateUserManager(user).Object,
|
||||||
|
NullLogger<JobApplicationsController>.Instance,
|
||||||
|
renderer ?? new TestCvTemplateRenderer(),
|
||||||
|
exporter ?? new TestCvPdfExporter());
|
||||||
controller.ControllerContext = new ControllerContext
|
controller.ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
HttpContext = new DefaultHttpContext
|
HttpContext = new DefaultHttpContext
|
||||||
@@ -260,10 +483,10 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
return new JobTrackerContext(options, currentUser.Object);
|
return new JobTrackerContext(options, currentUser.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
private static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? user = null)
|
||||||
{
|
{
|
||||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||||
return new Mock<UserManager<ApplicationUser>>(
|
var manager = new Mock<UserManager<ApplicationUser>>(
|
||||||
store.Object,
|
store.Object,
|
||||||
Options.Create(new IdentityOptions()),
|
Options.Create(new IdentityOptions()),
|
||||||
new PasswordHasher<ApplicationUser>(),
|
new PasswordHasher<ApplicationUser>(),
|
||||||
@@ -273,5 +496,29 @@ public sealed class JobApplicationsApplicationPackageTests
|
|||||||
new IdentityErrorDescriber(),
|
new IdentityErrorDescriber(),
|
||||||
null!,
|
null!,
|
||||||
new NullLogger<UserManager<ApplicationUser>>());
|
new NullLogger<UserManager<ApplicationUser>>());
|
||||||
|
manager.Setup(x => x.FindByIdAsync(It.IsAny<string>())).ReturnsAsync((string id) => user is not null && user.Id == id ? user : null);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestCvTemplateRenderer : ICvTemplateRenderer
|
||||||
|
{
|
||||||
|
public string? LastPhotoDataUrl { get; private set; }
|
||||||
|
|
||||||
|
public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
|
||||||
|
{
|
||||||
|
LastPhotoDataUrl = photoDataUrl;
|
||||||
|
return new TailoredCvRenderResult(templateId ?? "ats-minimal", "preview.pdf", $"<html><body>{candidateName}|{jobTitle}|{companyName}|{document?.Headline}|{photoDataUrl}</body></html>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestCvPdfExporter : ICvPdfExporter
|
||||||
|
{
|
||||||
|
public TailoredCvRenderResult? LastRenderResult { get; private set; }
|
||||||
|
|
||||||
|
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastRenderResult = renderResult;
|
||||||
|
return Task.FromResult(new CvPdfArtifact("preview.pdf", "/tmp/preview.pdf", new byte[] { 1, 2, 3 }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using JobTrackerApi.Models;
|
|||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
using JobTrackerApi.Services.JobImport;
|
using JobTrackerApi.Services.JobImport;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@@ -20,14 +21,26 @@ namespace JobTrackerApi.Controllers
|
|||||||
private readonly IAppEmailSender _email;
|
private readonly IAppEmailSender _email;
|
||||||
private readonly UserManager<ApplicationUser> _users;
|
private readonly UserManager<ApplicationUser> _users;
|
||||||
private readonly ILogger<JobApplicationsController> _logger;
|
private readonly ILogger<JobApplicationsController> _logger;
|
||||||
|
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||||
|
private readonly ICvPdfExporter _cvPdfExporter;
|
||||||
|
|
||||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger)
|
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_summarizer = summarizer;
|
_summarizer = summarizer;
|
||||||
_email = email;
|
_email = email;
|
||||||
_users = users;
|
_users = users;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||||
|
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||||
|
{
|
||||||
|
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? CurrentUserId =>
|
private string? CurrentUserId =>
|
||||||
@@ -153,6 +166,275 @@ namespace JobTrackerApi.Controllers
|
|||||||
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ComputeGenerationContextHash(string value)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable<string> matchedTags)
|
||||||
|
{
|
||||||
|
var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) }
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
var score = 0;
|
||||||
|
foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag)))
|
||||||
|
{
|
||||||
|
if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4;
|
||||||
|
}
|
||||||
|
score += Math.Min(job.Bullets.Count, 4);
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> SelectTailoredSkills(StructuredCvProfile structured, string jobText)
|
||||||
|
{
|
||||||
|
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var prioritized = structured.Skills
|
||||||
|
.Select(skill => new
|
||||||
|
{
|
||||||
|
Skill = skill,
|
||||||
|
Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0
|
||||||
|
})
|
||||||
|
.OrderByDescending(entry => entry.Score)
|
||||||
|
.ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(entry => entry.Skill)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (prioritized.Count == 0)
|
||||||
|
{
|
||||||
|
prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return prioritized.Take(10).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job)
|
||||||
|
{
|
||||||
|
var text = (job.TailoredCvText ?? string.Empty).Trim();
|
||||||
|
var document = new TailoredCvDocument
|
||||||
|
{
|
||||||
|
Headline = job.JobTitle,
|
||||||
|
CustomSections = string.IsNullOrWhiteSpace(text)
|
||||||
|
? new List<TailoredCvCustomSection>()
|
||||||
|
: new List<TailoredCvCustomSection>
|
||||||
|
{
|
||||||
|
new TailoredCvCustomSection
|
||||||
|
{
|
||||||
|
Title = "Legacy draft text",
|
||||||
|
Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return TailoredCvDraftJson.Normalize(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft)
|
||||||
|
{
|
||||||
|
var document = TailoredCvDraftJson.FromDraft(draft);
|
||||||
|
return new TailoredCvDraftDto(
|
||||||
|
draft.Id,
|
||||||
|
draft.CanonicalProfileVersion,
|
||||||
|
draft.TemplateId,
|
||||||
|
document.Headline,
|
||||||
|
document.Summary,
|
||||||
|
document.SelectedSkills,
|
||||||
|
document.Experience,
|
||||||
|
document.Education,
|
||||||
|
document.CustomSections,
|
||||||
|
document.RenderOptions,
|
||||||
|
draft.GenerationContextHash,
|
||||||
|
draft.LastGeneratedAtUtc,
|
||||||
|
draft.LastEditedAtUtc,
|
||||||
|
draft.Status,
|
||||||
|
TailoredCvDraftJson.RenderPlainText(document),
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job)
|
||||||
|
{
|
||||||
|
var document = BuildLegacyTailoredCvFallback(job);
|
||||||
|
return new TailoredCvDraftDto(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"legacy-text",
|
||||||
|
document.Headline,
|
||||||
|
document.Summary,
|
||||||
|
document.SelectedSkills,
|
||||||
|
document.Experience,
|
||||||
|
document.Education,
|
||||||
|
document.CustomSections,
|
||||||
|
document.RenderOptions,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
job.TailoredCvUpdatedAt,
|
||||||
|
string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import",
|
||||||
|
TailoredCvDraftJson.RenderPlainText(document),
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job)
|
||||||
|
{
|
||||||
|
var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job);
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
return baseDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||||
|
{
|
||||||
|
TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal",
|
||||||
|
Headline = request.Headline ?? baseDocument.Headline,
|
||||||
|
Summary = request.Summary ?? baseDocument.Summary,
|
||||||
|
SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills,
|
||||||
|
Experience = request.Experience ?? baseDocument.Experience,
|
||||||
|
Education = request.Education ?? baseDocument.Education,
|
||||||
|
CustomSections = request.CustomSections ?? baseDocument.CustomSections,
|
||||||
|
RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TailoredCvDraft?> FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TailoredCvRenderResult RenderTailoredCv(JobApplication job, TailoredCvDocument document, ApplicationUser? user, string? photoDataUrl)
|
||||||
|
{
|
||||||
|
return _cvTemplateRenderer.Render(
|
||||||
|
document,
|
||||||
|
document.TemplateId,
|
||||||
|
GetPreferredDisplayName(user),
|
||||||
|
job.JobTitle,
|
||||||
|
job.Company?.Name,
|
||||||
|
photoDataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record TailoredCvPreviewDto(string TemplateId, string Html, string SuggestedFileName);
|
||||||
|
public sealed record TailoredCvRenderRequest(
|
||||||
|
string? TemplateId,
|
||||||
|
string? Headline,
|
||||||
|
List<string>? Summary,
|
||||||
|
List<string>? SelectedSkills,
|
||||||
|
List<TailoredCvExperienceItem>? Experience,
|
||||||
|
List<TailoredCvEducationItem>? Education,
|
||||||
|
List<TailoredCvCustomSection>? CustomSections,
|
||||||
|
TailoredCvRenderOptions? RenderOptions,
|
||||||
|
string? PhotoDataUrl,
|
||||||
|
bool? UseProfileAvatar);
|
||||||
|
|
||||||
|
private async Task<TailoredCvDraft> UpsertGeneratedTailoredCvDraftAsync(JobApplication job, ApplicationUser user, string? mode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||||
|
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl }
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||||
|
var structuredCvContext = BuildStructuredCvContext(user);
|
||||||
|
var generationContext = $@"Job title: {job.JobTitle}
|
||||||
|
Company: {job.Company?.Name}
|
||||||
|
Status: {job.Status}
|
||||||
|
Generation mode: {mode ?? "default"}
|
||||||
|
|
||||||
|
Job context:
|
||||||
|
{jobText}
|
||||||
|
|
||||||
|
Canonical profile:
|
||||||
|
{structuredCvContext}
|
||||||
|
";
|
||||||
|
|
||||||
|
var headline = await _summarizer.SummarizeSectionAsync(
|
||||||
|
"Write a short, role-specific CV headline for this candidate. Keep it factual, scannable, and under 12 words. Return headline text only.",
|
||||||
|
generationContext,
|
||||||
|
48,
|
||||||
|
24);
|
||||||
|
|
||||||
|
var summary = await BuildListFromAiAsync(
|
||||||
|
$"Write 4 short CV summary bullets tailored to this job. Use only facts supported by the canonical profile. Keep each line tight and credible. {BuildPackageModeInstruction(mode)}",
|
||||||
|
generationContext,
|
||||||
|
cancellationToken,
|
||||||
|
fallbackPrefix: job.JobTitle);
|
||||||
|
|
||||||
|
var selectedSkills = SelectTailoredSkills(structured, jobText);
|
||||||
|
var matchedTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var experience = structured.Jobs
|
||||||
|
.OrderByDescending(entry => ScoreTailoredExperience(entry, matchedTags))
|
||||||
|
.ThenByDescending(entry => entry.IsCurrent)
|
||||||
|
.Take(4)
|
||||||
|
.Select(entry => new TailoredCvExperienceItem
|
||||||
|
{
|
||||||
|
Title = entry.Title,
|
||||||
|
Company = entry.Company,
|
||||||
|
Location = entry.Location,
|
||||||
|
Start = entry.Start,
|
||||||
|
End = entry.End,
|
||||||
|
IsCurrent = entry.IsCurrent,
|
||||||
|
Bullets = entry.Bullets.Take(4).ToList(),
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var education = structured.Education
|
||||||
|
.Take(3)
|
||||||
|
.Select(entry => new TailoredCvEducationItem
|
||||||
|
{
|
||||||
|
Qualification = entry.Qualification,
|
||||||
|
Institution = entry.Institution,
|
||||||
|
Location = entry.Location,
|
||||||
|
Start = entry.Start,
|
||||||
|
End = entry.End,
|
||||||
|
Details = entry.Details.Take(3).ToList(),
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var customSections = new List<TailoredCvCustomSection>();
|
||||||
|
if (structured.Languages.Count > 0)
|
||||||
|
{
|
||||||
|
customSections.Add(new TailoredCvCustomSection
|
||||||
|
{
|
||||||
|
Title = "Languages",
|
||||||
|
Items = structured.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
customSections.AddRange(structured.OtherSections.Take(2).Select(section => new TailoredCvCustomSection
|
||||||
|
{
|
||||||
|
Title = section.Title,
|
||||||
|
Items = section.Items.Take(4).ToList(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||||
|
{
|
||||||
|
TemplateId = "ats-minimal",
|
||||||
|
Headline = string.IsNullOrWhiteSpace(headline) ? structured.Contact.Headline ?? job.JobTitle : headline.Trim(),
|
||||||
|
Summary = summary,
|
||||||
|
SelectedSkills = selectedSkills,
|
||||||
|
Experience = experience,
|
||||||
|
Education = education,
|
||||||
|
CustomSections = customSections,
|
||||||
|
RenderOptions = new TailoredCvRenderOptions(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == job.Id, cancellationToken)
|
||||||
|
?? new TailoredCvDraft
|
||||||
|
{
|
||||||
|
OwnerUserId = user.Id,
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
draft.OwnerUserId = user.Id;
|
||||||
|
draft.CanonicalProfileVersion = user.CurrentCvProfileVersion;
|
||||||
|
draft.GenerationContextHash = ComputeGenerationContextHash(generationContext);
|
||||||
|
draft.LastGeneratedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
draft.Status = "generated";
|
||||||
|
TailoredCvDraftJson.ApplyToDraft(draft, document);
|
||||||
|
|
||||||
|
if (draft.Id == 0)
|
||||||
|
{
|
||||||
|
_db.TailoredCvDrafts.Add(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||||
|
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
|
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
|
||||||
{
|
{
|
||||||
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
||||||
@@ -1729,6 +2011,33 @@ namespace JobTrackerApi.Controllers
|
|||||||
string? CoverLetterDraft,
|
string? CoverLetterDraft,
|
||||||
string? RecruiterMessageDraft);
|
string? RecruiterMessageDraft);
|
||||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||||
|
public sealed record TailoredCvDraftDto(
|
||||||
|
int? Id,
|
||||||
|
int? CanonicalProfileVersion,
|
||||||
|
string TemplateId,
|
||||||
|
string? Headline,
|
||||||
|
List<string> Summary,
|
||||||
|
List<string> SelectedSkills,
|
||||||
|
List<TailoredCvExperienceItem> Experience,
|
||||||
|
List<TailoredCvEducationItem> Education,
|
||||||
|
List<TailoredCvCustomSection> CustomSections,
|
||||||
|
TailoredCvRenderOptions RenderOptions,
|
||||||
|
string? GenerationContextHash,
|
||||||
|
DateTimeOffset? LastGeneratedAtUtc,
|
||||||
|
DateTimeOffset? LastEditedAtUtc,
|
||||||
|
string Status,
|
||||||
|
string RenderedText,
|
||||||
|
bool IsLegacyFallback);
|
||||||
|
public sealed record SaveTailoredCvDraftRequest(
|
||||||
|
string? TemplateId,
|
||||||
|
string? Headline,
|
||||||
|
List<string>? Summary,
|
||||||
|
List<string>? SelectedSkills,
|
||||||
|
List<TailoredCvExperienceItem>? Experience,
|
||||||
|
List<TailoredCvEducationItem>? Education,
|
||||||
|
List<TailoredCvCustomSection>? CustomSections,
|
||||||
|
TailoredCvRenderOptions? RenderOptions,
|
||||||
|
string? Status);
|
||||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
|
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
|
||||||
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||||
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||||
@@ -2029,6 +2338,155 @@ Candidate master CV:
|
|||||||
return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
|
return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/tailored-cv-draft")]
|
||||||
|
public async Task<ActionResult<TailoredCvDraftDto>> GetTailoredCvDraft([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
|
||||||
|
var draft = await _db.TailoredCvDrafts
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken);
|
||||||
|
|
||||||
|
return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/tailored-cv-preview")]
|
||||||
|
public async Task<ActionResult<TailoredCvPreviewDto>> PreviewTailoredCv([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications
|
||||||
|
.Include(j => j.Company)
|
||||||
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
|
||||||
|
var user = await GetCurrentUserAsync(cancellationToken);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
|
||||||
|
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
|
||||||
|
request.TemplateId,
|
||||||
|
request.Headline,
|
||||||
|
request.Summary,
|
||||||
|
request.SelectedSkills,
|
||||||
|
request.Experience,
|
||||||
|
request.Education,
|
||||||
|
request.CustomSections,
|
||||||
|
request.RenderOptions,
|
||||||
|
draft?.Status ?? "generated"), draft, job);
|
||||||
|
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
|
||||||
|
? request!.PhotoDataUrl
|
||||||
|
: request?.UseProfileAvatar == false
|
||||||
|
? null
|
||||||
|
: user.AvatarImageDataUrl;
|
||||||
|
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
||||||
|
return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/export-tailored-cv-pdf")]
|
||||||
|
public async Task<IActionResult> ExportTailoredCvPdf([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications
|
||||||
|
.Include(j => j.Company)
|
||||||
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
|
||||||
|
var user = await GetCurrentUserAsync(cancellationToken);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
|
||||||
|
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
|
||||||
|
request.TemplateId,
|
||||||
|
request.Headline,
|
||||||
|
request.Summary,
|
||||||
|
request.SelectedSkills,
|
||||||
|
request.Experience,
|
||||||
|
request.Education,
|
||||||
|
request.CustomSections,
|
||||||
|
request.RenderOptions,
|
||||||
|
draft?.Status ?? "generated"), draft, job);
|
||||||
|
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
|
||||||
|
? request!.PhotoDataUrl
|
||||||
|
: request?.UseProfileAvatar == false
|
||||||
|
? null
|
||||||
|
: user.AvatarImageDataUrl;
|
||||||
|
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
||||||
|
var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken);
|
||||||
|
return File(artifact.Bytes, "application/pdf", artifact.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/generate-tailored-cv-draft")]
|
||||||
|
public async Task<ActionResult<TailoredCvDraftDto>> GenerateTailoredCvDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications
|
||||||
|
.Include(j => j.Company)
|
||||||
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
|
||||||
|
var user = await GetCurrentUserAsync(cancellationToken);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
if (string.IsNullOrWhiteSpace(user.ProfileCvText))
|
||||||
|
{
|
||||||
|
return BadRequest("Add your profile CV text on the Profile page before generating a tailored CV draft.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||||
|
if (structured.Summary.Count == 0 && structured.Jobs.Count == 0 && structured.Skills.Count == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("Build and review your canonical structured CV on the Profile page before generating a tailored draft.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken);
|
||||||
|
return Ok(ToTailoredCvDraftDto(draft));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}/tailored-cv-draft")]
|
||||||
|
public async Task<IActionResult> SaveTailoredCvDraft([FromRoute] int id, [FromBody] SaveTailoredCvDraftRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
|
||||||
|
var user = await GetCurrentUserAsync(cancellationToken);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken)
|
||||||
|
?? new TailoredCvDraft
|
||||||
|
{
|
||||||
|
OwnerUserId = user.Id,
|
||||||
|
JobApplicationId = id,
|
||||||
|
CanonicalProfileVersion = user.CurrentCvProfileVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
var document = new TailoredCvDocument
|
||||||
|
{
|
||||||
|
TemplateId = request.TemplateId ?? draft.TemplateId,
|
||||||
|
Headline = request.Headline,
|
||||||
|
Summary = request.Summary ?? new List<string>(),
|
||||||
|
SelectedSkills = request.SelectedSkills ?? new List<string>(),
|
||||||
|
Experience = request.Experience ?? new List<TailoredCvExperienceItem>(),
|
||||||
|
Education = request.Education ?? new List<TailoredCvEducationItem>(),
|
||||||
|
CustomSections = request.CustomSections ?? new List<TailoredCvCustomSection>(),
|
||||||
|
RenderOptions = request.RenderOptions ?? new TailoredCvRenderOptions(),
|
||||||
|
};
|
||||||
|
|
||||||
|
draft.OwnerUserId = user.Id;
|
||||||
|
draft.CanonicalProfileVersion ??= user.CurrentCvProfileVersion;
|
||||||
|
draft.Status = string.IsNullOrWhiteSpace(request.Status) ? "edited" : request.Status.Trim();
|
||||||
|
draft.LastEditedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
TailoredCvDraftJson.ApplyToDraft(draft, document);
|
||||||
|
|
||||||
|
if (draft.Id == 0)
|
||||||
|
{
|
||||||
|
_db.TailoredCvDrafts.Add(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||||
|
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}/tailored-cv")]
|
[HttpPut("{id:int}/tailored-cv")]
|
||||||
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ builder.Services.AddHttpContextAccessor();
|
|||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
||||||
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
||||||
|
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
||||||
|
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<AppPaths>();
|
builder.Services.AddSingleton<AppPaths>();
|
||||||
|
|
||||||
@@ -673,6 +675,31 @@ CREATE TABLE IF NOT EXISTS "CvExtractionRuns" (
|
|||||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");""");
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");""");
|
||||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
|
||||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");""");
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");""");
|
||||||
|
|
||||||
|
Exec(c, """
|
||||||
|
CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
|
"JobApplicationId" INTEGER NOT NULL,
|
||||||
|
"CanonicalProfileVersion" INTEGER NULL,
|
||||||
|
"TemplateId" TEXT NOT NULL,
|
||||||
|
"Headline" TEXT NULL,
|
||||||
|
"SummaryJson" TEXT NULL,
|
||||||
|
"SelectedSkillsJson" TEXT NULL,
|
||||||
|
"ExperienceJson" TEXT NULL,
|
||||||
|
"EducationJson" TEXT NULL,
|
||||||
|
"CustomSectionsJson" TEXT NULL,
|
||||||
|
"RenderOptionsJson" TEXT NULL,
|
||||||
|
"GenerationContextHash" TEXT NULL,
|
||||||
|
"LastGeneratedAtUtc" TEXT NULL,
|
||||||
|
"LastEditedAtUtc" TEXT NULL,
|
||||||
|
"Status" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");""");
|
||||||
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsureGmailConnectionsTable(conn);
|
EnsureGmailConnectionsTable(conn);
|
||||||
@@ -911,6 +938,32 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
|
||||||
|
`Id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
|
`JobApplicationId` int NOT NULL,
|
||||||
|
`CanonicalProfileVersion` int NULL,
|
||||||
|
`TemplateId` varchar(100) NOT NULL,
|
||||||
|
`Headline` longtext NULL,
|
||||||
|
`SummaryJson` longtext NULL,
|
||||||
|
`SelectedSkillsJson` longtext NULL,
|
||||||
|
`ExperienceJson` longtext NULL,
|
||||||
|
`EducationJson` longtext NULL,
|
||||||
|
`CustomSectionsJson` longtext NULL,
|
||||||
|
`RenderOptionsJson` longtext NULL,
|
||||||
|
`GenerationContextHash` longtext NULL,
|
||||||
|
`LastGeneratedAtUtc` datetime(6) NULL,
|
||||||
|
`LastEditedAtUtc` datetime(6) NULL,
|
||||||
|
`Status` varchar(100) NOT NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
@@ -945,6 +998,20 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
|||||||
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
|
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ namespace JobTrackerApi.Services
|
|||||||
public string DataRoot { get; }
|
public string DataRoot { get; }
|
||||||
public string AttachmentsRoot { get; }
|
public string AttachmentsRoot { get; }
|
||||||
public string CvArtifactsRoot { get; }
|
public string CvArtifactsRoot { get; }
|
||||||
|
public string CvExportsRoot { get; }
|
||||||
|
|
||||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,13 @@ namespace JobTrackerApi.Services
|
|||||||
|
|
||||||
Directory.CreateDirectory(cvArtifactsRoot);
|
Directory.CreateDirectory(cvArtifactsRoot);
|
||||||
CvArtifactsRoot = cvArtifactsRoot;
|
CvArtifactsRoot = cvArtifactsRoot;
|
||||||
|
|
||||||
|
var cvExportsRoot = (cfg["Data:CvExportsRoot"] ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(cvExportsRoot)) cvExportsRoot = Path.Combine(DataRoot, "CvExports");
|
||||||
|
if (!Path.IsPathRooted(cvExportsRoot)) cvExportsRoot = Path.Combine(env.ContentRootPath, cvExportsRoot);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(cvExportsRoot);
|
||||||
|
CvExportsRoot = cvExportsRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
public sealed record TailoredCvRenderResult(string TemplateId, string SuggestedFileName, string Html);
|
||||||
|
|
||||||
|
public interface ICvTemplateRenderer
|
||||||
|
{
|
||||||
|
TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||||
|
{
|
||||||
|
public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
|
||||||
|
{
|
||||||
|
var normalized = TailoredCvDraftJson.Normalize(document);
|
||||||
|
var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId);
|
||||||
|
normalized.TemplateId = effectiveTemplateId;
|
||||||
|
var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf";
|
||||||
|
var html = effectiveTemplateId switch
|
||||||
|
{
|
||||||
|
"harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName),
|
||||||
|
"auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false),
|
||||||
|
"edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true),
|
||||||
|
_ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl)
|
||||||
|
};
|
||||||
|
return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeTemplateId(string? value)
|
||||||
|
{
|
||||||
|
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"base" => "ats-minimal",
|
||||||
|
"legacy-text" => "ats-minimal",
|
||||||
|
"harvard" => "harvard",
|
||||||
|
"auckland" => "auckland",
|
||||||
|
"edinburgh" => "edinburgh",
|
||||||
|
_ => "ats-minimal"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderAtsMinimal(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||||
|
{
|
||||||
|
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||||
|
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||||
|
var body = RenderMainSections(document, accent, headingStyle: "caps-rule");
|
||||||
|
var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>Company focus: {Encode(companyName)}</span>";
|
||||||
|
var photoMarkup = showPhoto ? $"<div class=\"photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||||
|
|
||||||
|
return $@"<!DOCTYPE html>
|
||||||
|
<html lang=""en"">
|
||||||
|
<head>
|
||||||
|
<meta charset=""utf-8"" />
|
||||||
|
<title>{Encode(candidateName)} — ATS Minimal</title>
|
||||||
|
<style>
|
||||||
|
:root {{ --accent:{accent}; --ink:#111827; --muted:#4b5563; --line:#d1d5db; --paper:#fff; }}
|
||||||
|
* {{ box-sizing:border-box; }}
|
||||||
|
body {{ margin:0; background:#eef2f7; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||||
|
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
|
||||||
|
.header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; border-bottom:2px solid var(--accent); padding-bottom:8mm; margin-bottom:7mm; }}
|
||||||
|
.name {{ margin:0; font-size:25pt; letter-spacing:.02em; }}
|
||||||
|
.headline {{ margin-top:2mm; color:var(--muted); font-size:11pt; }}
|
||||||
|
.meta {{ margin-top:3mm; display:flex; flex-wrap:wrap; gap:3mm; color:var(--muted); font-size:9pt; }}
|
||||||
|
.photo {{ width:28mm; height:36mm; border-radius:5mm; overflow:hidden; border:1px solid var(--line); }}
|
||||||
|
.photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||||
|
{BaseSectionCss(accent, "caps-rule")}
|
||||||
|
@page {{ size:A4; margin:0; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class=""page"">
|
||||||
|
<header class=""header"">
|
||||||
|
<div>
|
||||||
|
<h1 class=""name"">{Encode(candidateName)}</h1>
|
||||||
|
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||||
|
<div class=""meta""><span>Target role: {Encode(jobTitle)}</span>{companyFocusMarkup}<span>Template: ATS Minimal</span></div>
|
||||||
|
</div>
|
||||||
|
{photoMarkup}
|
||||||
|
</header>
|
||||||
|
{body}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderHarvard(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName)
|
||||||
|
{
|
||||||
|
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||||
|
var body = RenderMainSections(document, accent, headingStyle: "harvard");
|
||||||
|
var contactLine = string.Join(" • ", 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? TailoredCvUpdatedAt { get; set; }
|
||||||
public DateTime? LastReminderEmailSentAt { get; set; }
|
public DateTime? LastReminderEmailSentAt { get; set; }
|
||||||
|
|
||||||
|
public TailoredCvDraft? TailoredCvDraft { get; set; }
|
||||||
public List<Correspondence> Messages { get; set; } = new();
|
public List<Correspondence> Messages { get; set; } = new();
|
||||||
public List<Attachment> Attachments { get; set; } = new();
|
public List<Attachment> Attachments { get; set; } = new();
|
||||||
public List<JobEvent> Events { get; set; } = new();
|
public List<JobEvent> Events { get; set; } = new();
|
||||||
|
|||||||
@@ -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";
|
} from "@mui/material";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
|
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||||
|
|
||||||
import Correspondence from "./Correspondence";
|
import Correspondence from "./Correspondence";
|
||||||
import Attachments from "./Attachments";
|
import Attachments from "./Attachments";
|
||||||
@@ -47,6 +48,12 @@ type PackageWorkspaceState = {
|
|||||||
recruiterMessage: string;
|
recruiterMessage: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TailoredCvPreviewResponse = {
|
||||||
|
templateId: string;
|
||||||
|
html: string;
|
||||||
|
suggestedFileName: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
jobId: number | null;
|
jobId: number | null;
|
||||||
@@ -129,6 +136,22 @@ function getWorkspaceStatus(currentValue: string, savedValue: string) {
|
|||||||
return { label: "Empty", color: "default" as const };
|
return { label: "Empty", color: "default" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeTailoredDraft(draft: TailoredCvDraft) {
|
||||||
|
const normalized = normalizeTailoredCvDraft(draft);
|
||||||
|
return JSON.stringify({
|
||||||
|
templateId: normalized.templateId,
|
||||||
|
headline: normalized.headline ?? "",
|
||||||
|
summary: normalized.summary,
|
||||||
|
selectedSkills: normalized.selectedSkills,
|
||||||
|
experience: normalized.experience,
|
||||||
|
education: normalized.education,
|
||||||
|
customSections: normalized.customSections,
|
||||||
|
renderOptions: normalized.renderOptions,
|
||||||
|
status: normalized.status,
|
||||||
|
isLegacyFallback: normalized.isLegacyFallback,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
|
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -153,13 +176,22 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
||||||
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
|
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
|
||||||
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
|
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
|
||||||
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
|
|
||||||
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
|
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
|
||||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||||
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
||||||
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
|
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
|
||||||
const [tailoredCvText, setTailoredCvText] = useState("");
|
const [tailoredCvDraft, setTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
|
||||||
|
const [savedTailoredCvDraft, setSavedTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
|
||||||
|
const [loadingTailoredCvDraft, setLoadingTailoredCvDraft] = useState(false);
|
||||||
|
const [generatingTailoredCvDraft, setGeneratingTailoredCvDraft] = useState(false);
|
||||||
|
const [savingTailoredCvDraft, setSavingTailoredCvDraft] = useState(false);
|
||||||
|
const [tailoredCvPreview, setTailoredCvPreview] = useState<TailoredCvPreviewResponse | null>(null);
|
||||||
|
const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false);
|
||||||
|
const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false);
|
||||||
|
const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState<string | null>(null);
|
||||||
|
const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState<string | null>(null);
|
||||||
|
const [useProfilePhoto, setUseProfilePhoto] = useState(true);
|
||||||
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||||
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||||
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
||||||
@@ -182,11 +214,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
setJobAttachments([]);
|
setJobAttachments([]);
|
||||||
setSelectedAttachmentIds([]);
|
setSelectedAttachmentIds([]);
|
||||||
setPackageGeneratedAt(null);
|
setPackageGeneratedAt(null);
|
||||||
|
setTailoredCvDraft(emptyTailoredCvDraft());
|
||||||
|
setSavedTailoredCvDraft(emptyTailoredCvDraft());
|
||||||
|
setTailoredCvPreview(null);
|
||||||
|
setProfileAvatarImageDataUrl(null);
|
||||||
|
setCustomPhotoDataUrl(null);
|
||||||
|
setUseProfilePhoto(true);
|
||||||
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||||
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||||
setJob(r.data);
|
setJob(r.data);
|
||||||
setTailoredCvText(r.data.tailoredCvText ?? "");
|
|
||||||
const savedWorkspace = {
|
const savedWorkspace = {
|
||||||
coverLetter: r.data.coverLetterText ?? "",
|
coverLetter: r.data.coverLetterText ?? "",
|
||||||
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
||||||
@@ -206,10 +243,30 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
setJobAttachments([]);
|
setJobAttachments([]);
|
||||||
setSelectedAttachmentIds([]);
|
setSelectedAttachmentIds([]);
|
||||||
});
|
});
|
||||||
api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false));
|
api.get(`/auth/me`).then((r) => {
|
||||||
|
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||||
|
setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null);
|
||||||
|
}).catch(() => {
|
||||||
|
setIsAdmin(false);
|
||||||
|
setProfileAvatarImageDataUrl(null);
|
||||||
|
});
|
||||||
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||||
}, [open, jobId, initialTab, initialFollowUpMode]);
|
}, [open, jobId, initialTab, initialFollowUpMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !jobId || tab !== 3) return;
|
||||||
|
setLoadingTailoredCvDraft(true);
|
||||||
|
api.get<TailoredCvDraft>(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => {
|
||||||
|
const normalized = normalizeTailoredCvDraft(r.data);
|
||||||
|
setTailoredCvDraft(normalized);
|
||||||
|
setSavedTailoredCvDraft(normalized);
|
||||||
|
}).catch(() => {
|
||||||
|
const empty = emptyTailoredCvDraft();
|
||||||
|
setTailoredCvDraft(empty);
|
||||||
|
setSavedTailoredCvDraft(empty);
|
||||||
|
}).finally(() => setLoadingTailoredCvDraft(false));
|
||||||
|
}, [open, jobId, tab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !jobId || tab !== 4) return;
|
if (!open || !jobId || tab !== 4) return;
|
||||||
setLoadingDraft(true);
|
setLoadingDraft(true);
|
||||||
@@ -303,51 +360,160 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
</Box>
|
</Box>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? "");
|
const tailoredCvDraftStatus = getWorkspaceStatus(tailoredCvDraft.renderedText, savedTailoredCvDraft.renderedText);
|
||||||
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
|
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
|
||||||
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
|
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
|
||||||
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
|
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
|
||||||
|
const hasUnsavedTailoredCvDraftChanges = serializeTailoredDraft(tailoredCvDraft) !== serializeTailoredDraft(savedTailoredCvDraft);
|
||||||
const hasUnsavedPackageChanges = [
|
const hasUnsavedPackageChanges = [
|
||||||
tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(),
|
|
||||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
|
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
|
||||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
|
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
|
||||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
|
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
|
||||||
].some(Boolean);
|
].some(Boolean);
|
||||||
|
|
||||||
|
const saveTailoredCvDraft = async () => {
|
||||||
|
if (!jobId) return;
|
||||||
|
|
||||||
|
const normalized = normalizeTailoredCvDraft({
|
||||||
|
...tailoredCvDraft,
|
||||||
|
status: tailoredCvDraft.status === "empty" ? "edited" : tailoredCvDraft.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSavingTailoredCvDraft(true);
|
||||||
|
await api.put(`/jobapplications/${jobId}/tailored-cv-draft`, {
|
||||||
|
templateId: normalized.templateId,
|
||||||
|
headline: normalized.headline,
|
||||||
|
summary: normalized.summary,
|
||||||
|
selectedSkills: normalized.selectedSkills,
|
||||||
|
experience: normalized.experience,
|
||||||
|
education: normalized.education,
|
||||||
|
customSections: normalized.customSections,
|
||||||
|
renderOptions: normalized.renderOptions,
|
||||||
|
status: normalized.status,
|
||||||
|
});
|
||||||
|
setTailoredCvDraft(normalized);
|
||||||
|
setSavedTailoredCvDraft(normalized);
|
||||||
|
setJob((prev) => prev ? {
|
||||||
|
...prev,
|
||||||
|
tailoredCvText: normalized.renderedText,
|
||||||
|
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||||
|
} : prev);
|
||||||
|
setReadiness(null);
|
||||||
|
toast("Tailored CV draft saved.", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to save the tailored CV draft."), "error");
|
||||||
|
} finally {
|
||||||
|
setSavingTailoredCvDraft(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTailoredCvDraft = async () => {
|
||||||
|
if (!jobId) return;
|
||||||
|
if (hasUnsavedTailoredCvDraftChanges) {
|
||||||
|
const confirmed = await confirmAction("Regenerating the tailored CV draft will replace your unsaved edits.", {
|
||||||
|
title: "Replace unsaved tailored CV edits?",
|
||||||
|
confirmLabel: "Regenerate draft",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGeneratingTailoredCvDraft(true);
|
||||||
|
const res = await api.post<TailoredCvDraft>(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } });
|
||||||
|
const normalized = normalizeTailoredCvDraft(res.data);
|
||||||
|
setTailoredCvDraft(normalized);
|
||||||
|
setSavedTailoredCvDraft(normalized);
|
||||||
|
setJob((prev) => prev ? {
|
||||||
|
...prev,
|
||||||
|
tailoredCvText: normalized.renderedText,
|
||||||
|
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||||
|
} : prev);
|
||||||
|
toast("Tailored CV draft generated.", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to generate a tailored CV draft."), "error");
|
||||||
|
} finally {
|
||||||
|
setGeneratingTailoredCvDraft(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTailoredCvDraftToSaved = () => {
|
||||||
|
setTailoredCvDraft(savedTailoredCvDraft);
|
||||||
|
toast("Restored the last saved tailored CV draft.", "info");
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTailoredCvRenderPayload = () => ({
|
||||||
|
templateId: tailoredCvDraft.templateId,
|
||||||
|
headline: tailoredCvDraft.headline,
|
||||||
|
summary: tailoredCvDraft.summary,
|
||||||
|
selectedSkills: tailoredCvDraft.selectedSkills,
|
||||||
|
experience: tailoredCvDraft.experience,
|
||||||
|
education: tailoredCvDraft.education,
|
||||||
|
customSections: tailoredCvDraft.customSections,
|
||||||
|
renderOptions: tailoredCvDraft.renderOptions,
|
||||||
|
photoDataUrl: customPhotoDataUrl,
|
||||||
|
useProfileAvatar: useProfilePhoto,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshTailoredCvPreview = async () => {
|
||||||
|
if (!jobId) return;
|
||||||
|
try {
|
||||||
|
setLoadingTailoredCvPreview(true);
|
||||||
|
const res = await api.post<TailoredCvPreviewResponse>(`/jobapplications/${jobId}/tailored-cv-preview`, buildTailoredCvRenderPayload());
|
||||||
|
setTailoredCvPreview(res.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to build the CV preview."), "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingTailoredCvPreview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportTailoredCvPdf = async () => {
|
||||||
|
if (!jobId) return;
|
||||||
|
try {
|
||||||
|
setExportingTailoredCvPdf(true);
|
||||||
|
const response = await api.post(`/jobapplications/${jobId}/export-tailored-cv-pdf`, buildTailoredCvRenderPayload(), { responseType: "blob" });
|
||||||
|
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = tailoredCvPreview?.suggestedFileName || `${(job?.jobTitle ?? "tailored-cv").replace(/\s+/g, "-").toLowerCase()}.pdf`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast("Tailored CV PDF downloaded.", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to export the CV PDF."), "error");
|
||||||
|
} finally {
|
||||||
|
setExportingTailoredCvPdf(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const savePackageWorkspace = async () => {
|
const savePackageWorkspace = async () => {
|
||||||
if (!jobId || !job) return;
|
if (!jobId || !job) return;
|
||||||
|
|
||||||
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
|
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
|
||||||
const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim();
|
|
||||||
const draftsChanged =
|
const draftsChanged =
|
||||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
|
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
|
||||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
|
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
|
||||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
|
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
|
||||||
|
|
||||||
if (!tailoredCvChanged && !draftsChanged) {
|
if (!draftsChanged) {
|
||||||
toast("No unsaved package changes.", "info");
|
toast("No unsaved package changes.", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (tailoredCvChanged) {
|
|
||||||
setSavingTailoredCv(true);
|
|
||||||
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (draftsChanged) {
|
|
||||||
setSavingApplicationDrafts(true);
|
setSavingApplicationDrafts(true);
|
||||||
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
||||||
coverLetterText: packageWorkspace.coverLetter,
|
coverLetterText: packageWorkspace.coverLetter,
|
||||||
notes: nextNotes,
|
notes: nextNotes,
|
||||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
setJob((prev) => prev ? {
|
setJob((prev) => prev ? {
|
||||||
...prev,
|
...prev,
|
||||||
tailoredCvText,
|
|
||||||
tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt,
|
|
||||||
coverLetterText: packageWorkspace.coverLetter,
|
coverLetterText: packageWorkspace.coverLetter,
|
||||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||||
notes: nextNotes,
|
notes: nextNotes,
|
||||||
@@ -359,13 +525,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
|
toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingTailoredCv(false);
|
|
||||||
setSavingApplicationDrafts(false);
|
setSavingApplicationDrafts(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetPackageWorkspaceToSaved = () => {
|
const resetPackageWorkspaceToSaved = () => {
|
||||||
setTailoredCvText(job?.tailoredCvText ?? "");
|
|
||||||
setPackageWorkspace(savedPackageWorkspace);
|
setPackageWorkspace(savedPackageWorkspace);
|
||||||
toast("Restored the last saved package.", "info");
|
toast("Restored the last saved package.", "info");
|
||||||
};
|
};
|
||||||
@@ -489,11 +653,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
|
|
||||||
{tab === 3 && (
|
{tab === 3 && (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedTailoredCvDraftChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
|
<Typography variant="overline">Tailored CV draft</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Build the package here, then save the working copy back onto this job.</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>This draft is job-scoped. It stays separate from your master CV and from the package drafts below.</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
@@ -506,6 +670,214 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
|
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
|
<InputLabel>Template</InputLabel>
|
||||||
|
<Select value={tailoredCvDraft.templateId} label="Template" onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, templateId: e.target.value, status: "edited" }))}>
|
||||||
|
<MenuItem value="ats-minimal">ATS Minimal</MenuItem>
|
||||||
|
<MenuItem value="harvard">Harvard</MenuItem>
|
||||||
|
<MenuItem value="auckland">Auckland</MenuItem>
|
||||||
|
<MenuItem value="edinburgh">Edinburgh</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Accent"
|
||||||
|
type="color"
|
||||||
|
value={tailoredCvDraft.renderOptions.accentColor?.startsWith("#") ? tailoredCvDraft.renderOptions.accentColor : "#334155"}
|
||||||
|
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||||
|
...current,
|
||||||
|
renderOptions: { ...current.renderOptions, accentColor: e.target.value },
|
||||||
|
status: "edited",
|
||||||
|
}))}
|
||||||
|
sx={{ width: 110 }}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
<Button size="small" variant={tailoredCvDraft.renderOptions.showPhoto ? "contained" : "outlined"} onClick={() => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||||
|
...current,
|
||||||
|
renderOptions: { ...current.renderOptions, showPhoto: !current.renderOptions.showPhoto },
|
||||||
|
status: "edited",
|
||||||
|
}))}>{tailoredCvDraft.renderOptions.showPhoto ? "Photo on" : "Photo off"}</Button>
|
||||||
|
<Button size="small" variant={useProfilePhoto ? "contained" : "outlined"} onClick={() => setUseProfilePhoto((current) => !current)}>{useProfilePhoto ? "Using profile photo" : "Profile photo off"}</Button>
|
||||||
|
<Button size="small" variant="outlined" component="label">
|
||||||
|
Pick photo
|
||||||
|
<input hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setCustomPhotoDataUrl(typeof reader.result === "string" ? reader.result : null);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}} />
|
||||||
|
</Button>
|
||||||
|
{customPhotoDataUrl ? <Button size="small" variant="text" onClick={() => setCustomPhotoDataUrl(null)}>Clear custom photo</Button> : null}
|
||||||
|
<Button size="small" variant="outlined" disabled={loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : "Generate tailored draft"}</Button>
|
||||||
|
<Button size="small" variant="outlined" disabled={loadingTailoredCvPreview} onClick={refreshTailoredCvPreview}>{loadingTailoredCvPreview ? "Building preview..." : "Preview PDF layout"}</Button>
|
||||||
|
<Button size="small" variant="outlined" disabled={exportingTailoredCvPdf} onClick={exportTailoredCvPdf}>{exportingTailoredCvPdf ? "Exporting PDF..." : "Download PDF"}</Button>
|
||||||
|
<Button size="small" variant="outlined" disabled={!hasUnsavedTailoredCvDraftChanges} onClick={resetTailoredCvDraftToSaved}>Reset to saved draft</Button>
|
||||||
|
<Button size="small" variant="contained" disabled={savingTailoredCvDraft || loadingTailoredCvDraft} onClick={saveTailoredCvDraft}>{savingTailoredCvDraft ? t("jobDetailsSaving") : "Save tailored draft"}</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
||||||
|
<Chip size="small" label={`Tailored CV · ${tailoredCvDraftStatus.label}`} color={tailoredCvDraftStatus.color} />
|
||||||
|
<Chip size="small" variant="outlined" label={`Template · ${tailoredCvDraft.templateId}`} />
|
||||||
|
{tailoredCvDraft.isLegacyFallback ? <Chip size="small" color="warning" variant="outlined" label="Legacy text fallback" /> : null}
|
||||||
|
{tailoredCvDraft.lastGeneratedAtUtc ? <Chip size="small" variant="outlined" label={`Generated ${new Date(tailoredCvDraft.lastGeneratedAtUtc).toLocaleString()}`} /> : null}
|
||||||
|
{tailoredCvDraft.canonicalProfileVersion ? <Chip size="small" variant="outlined" label={`Profile v${tailoredCvDraft.canonicalProfileVersion}`} /> : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loadingTailoredCvDraft ? (
|
||||||
|
<Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.1fr 0.9fr" }, gap: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Headline"
|
||||||
|
value={tailoredCvDraft.headline ?? ""}
|
||||||
|
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, headline: e.target.value, status: "edited" }))}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Summary bullets"
|
||||||
|
value={joinLines(tailoredCvDraft.summary)}
|
||||||
|
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, summary: splitLines(e.target.value), status: "edited" }))}
|
||||||
|
multiline
|
||||||
|
minRows={5}
|
||||||
|
fullWidth
|
||||||
|
helperText="One bullet per line."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Selected skills"
|
||||||
|
value={joinLines(tailoredCvDraft.selectedSkills)}
|
||||||
|
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, selectedSkills: splitLines(e.target.value), status: "edited" }))}
|
||||||
|
multiline
|
||||||
|
minRows={4}
|
||||||
|
fullWidth
|
||||||
|
helperText="One skill per line."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Experience"
|
||||||
|
value={tailoredCvDraft.experience.map((item) => [
|
||||||
|
[item.title, item.company].filter(Boolean).join(" — "),
|
||||||
|
[item.location, item.start, item.end].filter(Boolean).join(" | "),
|
||||||
|
...(item.bullets ?? []).map((bullet) => `- ${bullet}`),
|
||||||
|
].filter(Boolean).join("\n")).join("\n\n")}
|
||||||
|
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||||
|
...current,
|
||||||
|
experience: e.target.value
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((block) => {
|
||||||
|
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
|
const [titleCompany = "", meta = "", ...bulletLines] = lines;
|
||||||
|
const [title = "", company = ""] = titleCompany.split("—").map((part) => part.trim());
|
||||||
|
const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
company,
|
||||||
|
location,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
bullets: bulletLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
status: "edited",
|
||||||
|
}))}
|
||||||
|
multiline
|
||||||
|
minRows={10}
|
||||||
|
fullWidth
|
||||||
|
helperText="Separate entries with a blank line. First line: Title — Company. Second line: Location | Start | End."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Education"
|
||||||
|
value={tailoredCvDraft.education.map((item) => [
|
||||||
|
[item.qualification, item.institution].filter(Boolean).join(" — "),
|
||||||
|
[item.location, item.start, item.end].filter(Boolean).join(" | "),
|
||||||
|
...(item.details ?? []).map((detail) => `- ${detail}`),
|
||||||
|
].filter(Boolean).join("\n")).join("\n\n")}
|
||||||
|
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||||
|
...current,
|
||||||
|
education: e.target.value
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((block) => {
|
||||||
|
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
|
const [qualificationInstitution = "", meta = "", ...detailLines] = lines;
|
||||||
|
const [qualification = "", institution = ""] = qualificationInstitution.split("—").map((part) => part.trim());
|
||||||
|
const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
qualification,
|
||||||
|
institution,
|
||||||
|
location,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
details: detailLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
status: "edited",
|
||||||
|
}))}
|
||||||
|
multiline
|
||||||
|
minRows={8}
|
||||||
|
fullWidth
|
||||||
|
helperText="Separate entries with a blank line. First line: Qualification — Institution. Second line: Location | Start | End."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Custom sections"
|
||||||
|
value={tailoredCvDraft.customSections.map((section) => `${section.title || "Additional Information"}\n${(section.items ?? []).join("\n")}`).join("\n\n")}
|
||||||
|
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||||
|
...current,
|
||||||
|
customSections: e.target.value
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((block) => {
|
||||||
|
const [title = "", ...items] = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||||
|
return { title, items };
|
||||||
|
}),
|
||||||
|
status: "edited",
|
||||||
|
}))}
|
||||||
|
multiline
|
||||||
|
minRows={7}
|
||||||
|
fullWidth
|
||||||
|
helperText="Each block starts with the section title, followed by one item per line."
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||||
|
<Typography variant="overline">Rendered CV snapshot</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>This plain-text snapshot stays deterministic and is what the job stores immediately after saving the draft.</Typography>
|
||||||
|
<TextField value={tailoredCvDraft.renderedText} multiline minRows={12} fullWidth InputProps={{ readOnly: true }} />
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||||
|
<Typography variant="overline">PDF-style preview</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here.</Typography>
|
||||||
|
{tailoredCvPreview ? (
|
||||||
|
<iframe title="Tailored CV preview" srcDoc={tailoredCvPreview.html} style={{ width: "100%", minHeight: 780, border: "1px solid rgba(15,23,42,0.08)", borderRadius: 12, background: "white" }} />
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>Build the PDF layout preview to inspect the ATS template before downloading.</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||||
|
<Typography variant="overline">Saved job material</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Saving the tailored draft updates the job-scoped CV text without touching your master profile.</Typography>
|
||||||
|
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||||
|
<Typography variant="body2"><strong>Master CV:</strong> Never overwritten here</Typography>
|
||||||
|
<Typography variant="body2"><strong>Photo source:</strong> {customPhotoDataUrl ? "Custom preview photo" : useProfilePhoto && profileAvatarImageDataUrl ? "Profile picture" : "No photo source selected"}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline">Application package drafts</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>These drafts stay separate from the tailored CV draft. Save them when you want reusable role-specific copy on the job.</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||||
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
|
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
|
||||||
<Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}>
|
<Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}>
|
||||||
@@ -515,22 +887,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
|
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Button size="small" variant="outlined" onClick={async () => {
|
|
||||||
try {
|
|
||||||
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
|
||||||
setTailoredCvText(me.data?.profileCvText ?? "");
|
|
||||||
toast(t("jobDetailsLoadedMasterCv"), "success");
|
|
||||||
} catch {
|
|
||||||
toast(t("jobDetailsLoadMasterCvFailed"), "error");
|
|
||||||
}
|
|
||||||
}}>{t("jobDetailsStartFromMasterCv")}</Button>
|
|
||||||
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
|
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
setGeneratingPackage(true);
|
setGeneratingPackage(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
|
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
|
||||||
setApplicationPackage(res.data);
|
setApplicationPackage(res.data);
|
||||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
|
||||||
setPackageWorkspace({
|
setPackageWorkspace({
|
||||||
coverLetter: res.data.coverLetterDraft ?? "",
|
coverLetter: res.data.coverLetterDraft ?? "",
|
||||||
applicationAnswer: res.data.applicationAnswerDraft ?? "",
|
applicationAnswer: res.data.applicationAnswerDraft ?? "",
|
||||||
@@ -545,21 +907,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
}
|
}
|
||||||
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
|
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
|
||||||
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
|
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
|
||||||
<Button size="small" variant="contained" disabled={savingTailoredCv || savingApplicationDrafts} onClick={savePackageWorkspace}>{savingTailoredCv || savingApplicationDrafts ? t("jobDetailsSaving") : "Save package to job"}</Button>
|
<Button size="small" variant="contained" disabled={savingApplicationDrafts} onClick={savePackageWorkspace}>{savingApplicationDrafts ? t("jobDetailsSaving") : "Save package drafts"}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
||||||
<Chip size="small" label={`Tailored CV · ${tailoredCvStatus.label}`} color={tailoredCvStatus.color} />
|
|
||||||
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
|
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
|
||||||
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
|
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
|
||||||
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
|
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
|
||||||
<Chip size="small" variant="outlined" label="Saved package material feeds follow-up drafting" />
|
<Chip size="small" variant="outlined" label="Saved package material feeds follow-up drafting" />
|
||||||
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
|
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
|
|
||||||
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
<WorkspaceDraftCard
|
<WorkspaceDraftCard
|
||||||
@@ -583,11 +940,10 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
statusLabel={recruiterMessageStatus.label}
|
statusLabel={recruiterMessageStatus.label}
|
||||||
statusColor={recruiterMessageStatus.color}
|
statusColor={recruiterMessageStatus.color}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||||
<Typography variant="overline">Saved working material</Typography>
|
<Typography variant="overline">Saved working material</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
|
||||||
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||||
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||||
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||||
@@ -599,6 +955,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 4 && (
|
{tab === 4 && (
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ beforeEach(() => {
|
|||||||
writeText: jest.fn().mockResolvedValue(undefined),
|
writeText: jest.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Object.assign(URL, {
|
||||||
|
createObjectURL: jest.fn().mockReturnValue('blob:preview-pdf'),
|
||||||
|
revokeObjectURL: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
mockedApi.get.mockImplementation((url: string) => {
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
if (url === '/jobapplications/42') {
|
if (url === '/jobapplications/42') {
|
||||||
@@ -61,7 +65,7 @@ beforeEach(() => {
|
|||||||
} } as any);
|
} } as any);
|
||||||
}
|
}
|
||||||
if (url === '/auth/me') {
|
if (url === '/auth/me') {
|
||||||
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
|
return Promise.resolve({ data: { roles: [], avatarImageDataUrl: 'data:image/png;base64,avatar123' } } as any);
|
||||||
}
|
}
|
||||||
if (url === '/jobapplications/42/history') {
|
if (url === '/jobapplications/42/history') {
|
||||||
return Promise.resolve({ data: [] } as any);
|
return Promise.resolve({ data: [] } as any);
|
||||||
@@ -69,6 +73,26 @@ beforeEach(() => {
|
|||||||
if (url === '/attachments/42') {
|
if (url === '/attachments/42') {
|
||||||
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
|
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
|
||||||
}
|
}
|
||||||
|
if (url === '/jobapplications/42/tailored-cv-draft') {
|
||||||
|
return Promise.resolve({ data: {
|
||||||
|
id: 5,
|
||||||
|
canonicalProfileVersion: 3,
|
||||||
|
templateId: 'ats-minimal',
|
||||||
|
headline: 'Backend Engineer',
|
||||||
|
summary: ['Built APIs', 'Shipped backend work'],
|
||||||
|
selectedSkills: ['.NET', 'SQL'],
|
||||||
|
experience: [],
|
||||||
|
education: [],
|
||||||
|
customSections: [],
|
||||||
|
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
|
||||||
|
generationContextHash: 'abc123',
|
||||||
|
lastGeneratedAtUtc: new Date().toISOString(),
|
||||||
|
lastEditedAtUtc: null,
|
||||||
|
status: 'generated',
|
||||||
|
renderedText: 'Backend Engineer\n\nProfessional Summary\n- Built APIs\n- Shipped backend work',
|
||||||
|
isLegacyFallback: false,
|
||||||
|
} } as any);
|
||||||
|
}
|
||||||
if (url === '/jobapplications/42/candidate-fit') {
|
if (url === '/jobapplications/42/candidate-fit') {
|
||||||
return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any);
|
return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any);
|
||||||
}
|
}
|
||||||
@@ -77,7 +101,44 @@ beforeEach(() => {
|
|||||||
}
|
}
|
||||||
return Promise.resolve({ data: [] } as any);
|
return Promise.resolve({ data: [] } as any);
|
||||||
});
|
});
|
||||||
mockedApi.post.mockResolvedValue({ data: { tailoredCvText: 'Generated CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
|
|
||||||
|
mockedApi.post.mockImplementation((url: string, body?: any, config?: any) => {
|
||||||
|
if (url === '/jobapplications/42/generate-tailored-cv-draft') {
|
||||||
|
return Promise.resolve({ data: {
|
||||||
|
id: 5,
|
||||||
|
canonicalProfileVersion: 3,
|
||||||
|
templateId: 'ats-minimal',
|
||||||
|
headline: 'Senior Backend Engineer',
|
||||||
|
summary: ['Owned API delivery', 'Improved SQL workflows'],
|
||||||
|
selectedSkills: ['.NET', 'SQL', 'APIs'],
|
||||||
|
experience: [],
|
||||||
|
education: [],
|
||||||
|
customSections: [],
|
||||||
|
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
|
||||||
|
generationContextHash: 'def456',
|
||||||
|
lastGeneratedAtUtc: new Date().toISOString(),
|
||||||
|
lastEditedAtUtc: null,
|
||||||
|
status: 'generated',
|
||||||
|
renderedText: 'Senior Backend Engineer\n\nProfessional Summary\n- Owned API delivery\n- Improved SQL workflows',
|
||||||
|
isLegacyFallback: false,
|
||||||
|
} } as any);
|
||||||
|
}
|
||||||
|
if (url === '/jobapplications/42/tailored-cv-preview') {
|
||||||
|
return Promise.resolve({ data: {
|
||||||
|
templateId: body?.templateId ?? 'ats-minimal',
|
||||||
|
suggestedFileName: `${body?.templateId ?? 'ats-minimal'}.pdf`,
|
||||||
|
html: `<html><body data-template="${body?.templateId ?? 'ats-minimal'}" data-accent="${body?.renderOptions?.accentColor ?? ''}" data-photo="${body?.useProfileAvatar ? 'profile' : 'custom'}"></body></html>`,
|
||||||
|
} } as any);
|
||||||
|
}
|
||||||
|
if (url === '/jobapplications/42/export-tailored-cv-pdf') {
|
||||||
|
return Promise.resolve({ data: new Blob(['pdf'], { type: 'application/pdf' }) } as any);
|
||||||
|
}
|
||||||
|
if (url === '/jobapplications/42/generate-application-package') {
|
||||||
|
return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
|
||||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,20 +146,40 @@ afterEach(() => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('application package workspace reflects saved job material, generated drafts, and save state', async () => {
|
test('tailored cv tab loads, regenerates, and saves the structured tailored draft', async () => {
|
||||||
renderDialog();
|
renderDialog();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||||
|
|
||||||
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
|
expect(await screen.findByDisplayValue('Backend Engineer')).toBeInTheDocument();
|
||||||
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
|
expect((await screen.findByLabelText('Summary bullets')) as HTMLInputElement).toHaveValue('Built APIs\nShipped backend work');
|
||||||
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
|
|
||||||
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
|
|
||||||
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /generate application package/i }));
|
fireEvent.click(screen.getByRole('button', { name: /generate tailored draft/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('Senior Backend Engineer')).toBeInTheDocument();
|
||||||
|
const headline = screen.getByDisplayValue('Senior Backend Engineer');
|
||||||
|
fireEvent.change(headline, { target: { value: 'Principal Backend Engineer' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save tailored draft/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-draft', expect.objectContaining({
|
||||||
|
headline: 'Principal Backend Engineer',
|
||||||
|
summary: ['Owned API delivery', 'Improved SQL workflows'],
|
||||||
|
selectedSkills: ['.NET', 'SQL', 'APIs'],
|
||||||
|
status: 'edited',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockedApi.put).not.toHaveBeenCalledWith('/jobapplications/42/tailored-cv', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('application package drafts save separately from the tailored cv draft', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /generate application package/i }));
|
||||||
|
|
||||||
expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument();
|
|
||||||
const coverLetter = await screen.findByDisplayValue('Draft letter');
|
const coverLetter = await screen.findByDisplayValue('Draft letter');
|
||||||
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
|
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
|
||||||
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
|
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
|
||||||
@@ -107,18 +188,59 @@ test('application package workspace reflects saved job material, generated draft
|
|||||||
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
|
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
|
||||||
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
|
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /save package to job/i }));
|
fireEvent.click(screen.getByRole('button', { name: /save package drafts/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
|
|
||||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
|
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
|
||||||
coverLetterText: 'Edited cover letter',
|
coverLetterText: 'Edited cover letter',
|
||||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||||
recruiterMessageDraft: 'Edited recruiter note',
|
recruiterMessageDraft: 'Edited recruiter note',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(await screen.findAllByText(/saved to job/i)).not.toHaveLength(0);
|
test('template switching refreshes preview and export uses the selected template payload', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||||
|
|
||||||
|
const comboboxes = await screen.findAllByRole('combobox');
|
||||||
|
fireEvent.mouseDown(comboboxes[1]);
|
||||||
|
fireEvent.click(await screen.findByRole('option', { name: 'Harvard' }));
|
||||||
|
|
||||||
|
const accent = screen.getByLabelText('Accent');
|
||||||
|
fireEvent.change(accent, { target: { value: '#123456' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /preview pdf layout/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-preview', expect.objectContaining({
|
||||||
|
templateId: 'harvard',
|
||||||
|
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
|
||||||
|
useProfileAvatar: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByTitle('Tailored CV preview')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const appendChildSpy = jest.spyOn(document.body, 'appendChild');
|
||||||
|
const removeSpy = jest.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => {});
|
||||||
|
const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /download pdf/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/export-tailored-cv-pdf', expect.objectContaining({
|
||||||
|
templateId: 'harvard',
|
||||||
|
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
|
||||||
|
}), expect.objectContaining({ responseType: 'blob' }));
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
appendChildSpy.mockRestore();
|
||||||
|
removeSpy.mockRestore();
|
||||||
|
clickSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('strategy snapshot can be generated from overview', async () => {
|
test('strategy snapshot can be generated from overview', async () => {
|
||||||
|
|||||||
@@ -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;
|
hasInterviewPrepNotes: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TailoredCvExperienceItem {
|
||||||
|
title?: string | null;
|
||||||
|
company?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
bullets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailoredCvEducationItem {
|
||||||
|
qualification?: string | null;
|
||||||
|
institution?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
details: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailoredCvCustomSection {
|
||||||
|
title?: string | null;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailoredCvRenderOptions {
|
||||||
|
showPhoto: boolean;
|
||||||
|
pageMode: string;
|
||||||
|
accentColor: string;
|
||||||
|
sectionOrder: string[];
|
||||||
|
bulletDensity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailoredCvDraft {
|
||||||
|
id?: number | null;
|
||||||
|
canonicalProfileVersion?: number | null;
|
||||||
|
templateId: string;
|
||||||
|
headline?: string | null;
|
||||||
|
summary: string[];
|
||||||
|
selectedSkills: string[];
|
||||||
|
experience: TailoredCvExperienceItem[];
|
||||||
|
education: TailoredCvEducationItem[];
|
||||||
|
customSections: TailoredCvCustomSection[];
|
||||||
|
renderOptions: TailoredCvRenderOptions;
|
||||||
|
generationContextHash?: string | null;
|
||||||
|
lastGeneratedAtUtc?: string | null;
|
||||||
|
lastEditedAtUtc?: string | null;
|
||||||
|
status: string;
|
||||||
|
renderedText: string;
|
||||||
|
isLegacyFallback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobApplication {
|
export interface JobApplication {
|
||||||
id: number;
|
id: number;
|
||||||
jobTitle: string;
|
jobTitle: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user