Add CV template preview and PDF export pipeline
This commit is contained in:
@@ -5,6 +5,7 @@ using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.JobImport;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -20,14 +21,26 @@ namespace JobTrackerApi.Controllers
|
||||
private readonly IAppEmailSender _email;
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly ILogger<JobApplicationsController> _logger;
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger)
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
||||
{
|
||||
_db = db;
|
||||
_summarizer = summarizer;
|
||||
_email = email;
|
||||
_users = users;
|
||||
_logger = logger;
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
}
|
||||
|
||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||
{
|
||||
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
|
||||
}
|
||||
}
|
||||
|
||||
private string? CurrentUserId =>
|
||||
@@ -153,6 +166,275 @@ namespace JobTrackerApi.Controllers
|
||||
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
||||
}
|
||||
|
||||
private static string ComputeGenerationContextHash(string value)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable<string> matchedTags)
|
||||
{
|
||||
var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||||
.ToLowerInvariant();
|
||||
var score = 0;
|
||||
foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag)))
|
||||
{
|
||||
if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4;
|
||||
}
|
||||
score += Math.Min(job.Bullets.Count, 4);
|
||||
return score;
|
||||
}
|
||||
|
||||
private static List<string> SelectTailoredSkills(StructuredCvProfile structured, string jobText)
|
||||
{
|
||||
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var prioritized = structured.Skills
|
||||
.Select(skill => new
|
||||
{
|
||||
Skill = skill,
|
||||
Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0
|
||||
})
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => entry.Skill)
|
||||
.ToList();
|
||||
|
||||
if (prioritized.Count == 0)
|
||||
{
|
||||
prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
return prioritized.Take(10).ToList();
|
||||
}
|
||||
|
||||
private static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job)
|
||||
{
|
||||
var text = (job.TailoredCvText ?? string.Empty).Trim();
|
||||
var document = new TailoredCvDocument
|
||||
{
|
||||
Headline = job.JobTitle,
|
||||
CustomSections = string.IsNullOrWhiteSpace(text)
|
||||
? new List<TailoredCvCustomSection>()
|
||||
: new List<TailoredCvCustomSection>
|
||||
{
|
||||
new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Legacy draft text",
|
||||
Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
|
||||
}
|
||||
}
|
||||
};
|
||||
return TailoredCvDraftJson.Normalize(document);
|
||||
}
|
||||
|
||||
private static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft)
|
||||
{
|
||||
var document = TailoredCvDraftJson.FromDraft(draft);
|
||||
return new TailoredCvDraftDto(
|
||||
draft.Id,
|
||||
draft.CanonicalProfileVersion,
|
||||
draft.TemplateId,
|
||||
document.Headline,
|
||||
document.Summary,
|
||||
document.SelectedSkills,
|
||||
document.Experience,
|
||||
document.Education,
|
||||
document.CustomSections,
|
||||
document.RenderOptions,
|
||||
draft.GenerationContextHash,
|
||||
draft.LastGeneratedAtUtc,
|
||||
draft.LastEditedAtUtc,
|
||||
draft.Status,
|
||||
TailoredCvDraftJson.RenderPlainText(document),
|
||||
false);
|
||||
}
|
||||
|
||||
private static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job)
|
||||
{
|
||||
var document = BuildLegacyTailoredCvFallback(job);
|
||||
return new TailoredCvDraftDto(
|
||||
null,
|
||||
null,
|
||||
"legacy-text",
|
||||
document.Headline,
|
||||
document.Summary,
|
||||
document.SelectedSkills,
|
||||
document.Experience,
|
||||
document.Education,
|
||||
document.CustomSections,
|
||||
document.RenderOptions,
|
||||
null,
|
||||
null,
|
||||
job.TailoredCvUpdatedAt,
|
||||
string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import",
|
||||
TailoredCvDraftJson.RenderPlainText(document),
|
||||
true);
|
||||
}
|
||||
|
||||
private static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job)
|
||||
{
|
||||
var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job);
|
||||
if (request is null)
|
||||
{
|
||||
return baseDocument;
|
||||
}
|
||||
|
||||
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||
{
|
||||
TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal",
|
||||
Headline = request.Headline ?? baseDocument.Headline,
|
||||
Summary = request.Summary ?? baseDocument.Summary,
|
||||
SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills,
|
||||
Experience = request.Experience ?? baseDocument.Experience,
|
||||
Education = request.Education ?? baseDocument.Education,
|
||||
CustomSections = request.CustomSections ?? baseDocument.CustomSections,
|
||||
RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TailoredCvDraft?> FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken);
|
||||
}
|
||||
|
||||
private TailoredCvRenderResult RenderTailoredCv(JobApplication job, TailoredCvDocument document, ApplicationUser? user, string? photoDataUrl)
|
||||
{
|
||||
return _cvTemplateRenderer.Render(
|
||||
document,
|
||||
document.TemplateId,
|
||||
GetPreferredDisplayName(user),
|
||||
job.JobTitle,
|
||||
job.Company?.Name,
|
||||
photoDataUrl);
|
||||
}
|
||||
|
||||
public sealed record TailoredCvPreviewDto(string TemplateId, string Html, string SuggestedFileName);
|
||||
public sealed record TailoredCvRenderRequest(
|
||||
string? TemplateId,
|
||||
string? Headline,
|
||||
List<string>? Summary,
|
||||
List<string>? SelectedSkills,
|
||||
List<TailoredCvExperienceItem>? Experience,
|
||||
List<TailoredCvEducationItem>? Education,
|
||||
List<TailoredCvCustomSection>? CustomSections,
|
||||
TailoredCvRenderOptions? RenderOptions,
|
||||
string? PhotoDataUrl,
|
||||
bool? UseProfileAvatar);
|
||||
|
||||
private async Task<TailoredCvDraft> UpsertGeneratedTailoredCvDraftAsync(JobApplication job, ApplicationUser user, string? mode, CancellationToken cancellationToken)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
var structuredCvContext = BuildStructuredCvContext(user);
|
||||
var generationContext = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
Status: {job.Status}
|
||||
Generation mode: {mode ?? "default"}
|
||||
|
||||
Job context:
|
||||
{jobText}
|
||||
|
||||
Canonical profile:
|
||||
{structuredCvContext}
|
||||
";
|
||||
|
||||
var headline = await _summarizer.SummarizeSectionAsync(
|
||||
"Write a short, role-specific CV headline for this candidate. Keep it factual, scannable, and under 12 words. Return headline text only.",
|
||||
generationContext,
|
||||
48,
|
||||
24);
|
||||
|
||||
var summary = await BuildListFromAiAsync(
|
||||
$"Write 4 short CV summary bullets tailored to this job. Use only facts supported by the canonical profile. Keep each line tight and credible. {BuildPackageModeInstruction(mode)}",
|
||||
generationContext,
|
||||
cancellationToken,
|
||||
fallbackPrefix: job.JobTitle);
|
||||
|
||||
var selectedSkills = SelectTailoredSkills(structured, jobText);
|
||||
var matchedTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var experience = structured.Jobs
|
||||
.OrderByDescending(entry => ScoreTailoredExperience(entry, matchedTags))
|
||||
.ThenByDescending(entry => entry.IsCurrent)
|
||||
.Take(4)
|
||||
.Select(entry => new TailoredCvExperienceItem
|
||||
{
|
||||
Title = entry.Title,
|
||||
Company = entry.Company,
|
||||
Location = entry.Location,
|
||||
Start = entry.Start,
|
||||
End = entry.End,
|
||||
IsCurrent = entry.IsCurrent,
|
||||
Bullets = entry.Bullets.Take(4).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var education = structured.Education
|
||||
.Take(3)
|
||||
.Select(entry => new TailoredCvEducationItem
|
||||
{
|
||||
Qualification = entry.Qualification,
|
||||
Institution = entry.Institution,
|
||||
Location = entry.Location,
|
||||
Start = entry.Start,
|
||||
End = entry.End,
|
||||
Details = entry.Details.Take(3).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var customSections = new List<TailoredCvCustomSection>();
|
||||
if (structured.Languages.Count > 0)
|
||||
{
|
||||
customSections.Add(new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Languages",
|
||||
Items = structured.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||
});
|
||||
}
|
||||
customSections.AddRange(structured.OtherSections.Take(2).Select(section => new TailoredCvCustomSection
|
||||
{
|
||||
Title = section.Title,
|
||||
Items = section.Items.Take(4).ToList(),
|
||||
}));
|
||||
|
||||
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||
{
|
||||
TemplateId = "ats-minimal",
|
||||
Headline = string.IsNullOrWhiteSpace(headline) ? structured.Contact.Headline ?? job.JobTitle : headline.Trim(),
|
||||
Summary = summary,
|
||||
SelectedSkills = selectedSkills,
|
||||
Experience = experience,
|
||||
Education = education,
|
||||
CustomSections = customSections,
|
||||
RenderOptions = new TailoredCvRenderOptions(),
|
||||
});
|
||||
|
||||
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == job.Id, cancellationToken)
|
||||
?? new TailoredCvDraft
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
JobApplicationId = job.Id,
|
||||
};
|
||||
|
||||
draft.OwnerUserId = user.Id;
|
||||
draft.CanonicalProfileVersion = user.CurrentCvProfileVersion;
|
||||
draft.GenerationContextHash = ComputeGenerationContextHash(generationContext);
|
||||
draft.LastGeneratedAtUtc = DateTimeOffset.UtcNow;
|
||||
draft.Status = "generated";
|
||||
TailoredCvDraftJson.ApplyToDraft(draft, document);
|
||||
|
||||
if (draft.Id == 0)
|
||||
{
|
||||
_db.TailoredCvDrafts.Add(draft);
|
||||
}
|
||||
|
||||
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return draft;
|
||||
}
|
||||
|
||||
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
|
||||
{
|
||||
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
||||
@@ -1729,6 +2011,33 @@ namespace JobTrackerApi.Controllers
|
||||
string? CoverLetterDraft,
|
||||
string? RecruiterMessageDraft);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
public sealed record TailoredCvDraftDto(
|
||||
int? Id,
|
||||
int? CanonicalProfileVersion,
|
||||
string TemplateId,
|
||||
string? Headline,
|
||||
List<string> Summary,
|
||||
List<string> SelectedSkills,
|
||||
List<TailoredCvExperienceItem> Experience,
|
||||
List<TailoredCvEducationItem> Education,
|
||||
List<TailoredCvCustomSection> CustomSections,
|
||||
TailoredCvRenderOptions RenderOptions,
|
||||
string? GenerationContextHash,
|
||||
DateTimeOffset? LastGeneratedAtUtc,
|
||||
DateTimeOffset? LastEditedAtUtc,
|
||||
string Status,
|
||||
string RenderedText,
|
||||
bool IsLegacyFallback);
|
||||
public sealed record SaveTailoredCvDraftRequest(
|
||||
string? TemplateId,
|
||||
string? Headline,
|
||||
List<string>? Summary,
|
||||
List<string>? SelectedSkills,
|
||||
List<TailoredCvExperienceItem>? Experience,
|
||||
List<TailoredCvEducationItem>? Education,
|
||||
List<TailoredCvCustomSection>? CustomSections,
|
||||
TailoredCvRenderOptions? RenderOptions,
|
||||
string? Status);
|
||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
|
||||
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||
@@ -2029,6 +2338,155 @@ Candidate master CV:
|
||||
return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/tailored-cv-draft")]
|
||||
public async Task<ActionResult<TailoredCvDraftDto>> GetTailoredCvDraft([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var draft = await _db.TailoredCvDrafts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken);
|
||||
|
||||
return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft));
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/tailored-cv-preview")]
|
||||
public async Task<ActionResult<TailoredCvPreviewDto>> PreviewTailoredCv([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
|
||||
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
|
||||
request.TemplateId,
|
||||
request.Headline,
|
||||
request.Summary,
|
||||
request.SelectedSkills,
|
||||
request.Experience,
|
||||
request.Education,
|
||||
request.CustomSections,
|
||||
request.RenderOptions,
|
||||
draft?.Status ?? "generated"), draft, job);
|
||||
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
|
||||
? request!.PhotoDataUrl
|
||||
: request?.UseProfileAvatar == false
|
||||
? null
|
||||
: user.AvatarImageDataUrl;
|
||||
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
||||
return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName));
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/export-tailored-cv-pdf")]
|
||||
public async Task<IActionResult> ExportTailoredCvPdf([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
|
||||
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
|
||||
request.TemplateId,
|
||||
request.Headline,
|
||||
request.Summary,
|
||||
request.SelectedSkills,
|
||||
request.Experience,
|
||||
request.Education,
|
||||
request.CustomSections,
|
||||
request.RenderOptions,
|
||||
draft?.Status ?? "generated"), draft, job);
|
||||
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
|
||||
? request!.PhotoDataUrl
|
||||
: request?.UseProfileAvatar == false
|
||||
? null
|
||||
: user.AvatarImageDataUrl;
|
||||
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
||||
var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken);
|
||||
return File(artifact.Bytes, "application/pdf", artifact.FileName);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/generate-tailored-cv-draft")]
|
||||
public async Task<ActionResult<TailoredCvDraftDto>> GenerateTailoredCvDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText))
|
||||
{
|
||||
return BadRequest("Add your profile CV text on the Profile page before generating a tailored CV draft.");
|
||||
}
|
||||
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
if (structured.Summary.Count == 0 && structured.Jobs.Count == 0 && structured.Skills.Count == 0)
|
||||
{
|
||||
return BadRequest("Build and review your canonical structured CV on the Profile page before generating a tailored draft.");
|
||||
}
|
||||
|
||||
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken);
|
||||
return Ok(ToTailoredCvDraftDto(draft));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/tailored-cv-draft")]
|
||||
public async Task<IActionResult> SaveTailoredCvDraft([FromRoute] int id, [FromBody] SaveTailoredCvDraftRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken)
|
||||
?? new TailoredCvDraft
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
JobApplicationId = id,
|
||||
CanonicalProfileVersion = user.CurrentCvProfileVersion,
|
||||
};
|
||||
|
||||
var document = new TailoredCvDocument
|
||||
{
|
||||
TemplateId = request.TemplateId ?? draft.TemplateId,
|
||||
Headline = request.Headline,
|
||||
Summary = request.Summary ?? new List<string>(),
|
||||
SelectedSkills = request.SelectedSkills ?? new List<string>(),
|
||||
Experience = request.Experience ?? new List<TailoredCvExperienceItem>(),
|
||||
Education = request.Education ?? new List<TailoredCvEducationItem>(),
|
||||
CustomSections = request.CustomSections ?? new List<TailoredCvCustomSection>(),
|
||||
RenderOptions = request.RenderOptions ?? new TailoredCvRenderOptions(),
|
||||
};
|
||||
|
||||
draft.OwnerUserId = user.Id;
|
||||
draft.CanonicalProfileVersion ??= user.CurrentCvProfileVersion;
|
||||
draft.Status = string.IsNullOrWhiteSpace(request.Status) ? "edited" : request.Status.Trim();
|
||||
draft.LastEditedAtUtc = DateTimeOffset.UtcNow;
|
||||
TailoredCvDraftJson.ApplyToDraft(draft, document);
|
||||
|
||||
if (draft.Id == 0)
|
||||
{
|
||||
_db.TailoredCvDrafts.Add(draft);
|
||||
}
|
||||
|
||||
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/tailored-cv")]
|
||||
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
||||
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
|
||||
@@ -673,6 +675,31 @@ CREATE TABLE IF NOT EXISTS "CvExtractionRuns" (
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"CanonicalProfileVersion" INTEGER NULL,
|
||||
"TemplateId" TEXT NOT NULL,
|
||||
"Headline" TEXT NULL,
|
||||
"SummaryJson" TEXT NULL,
|
||||
"SelectedSkillsJson" TEXT NULL,
|
||||
"ExperienceJson" TEXT NULL,
|
||||
"EducationJson" TEXT NULL,
|
||||
"CustomSectionsJson" TEXT NULL,
|
||||
"RenderOptionsJson" TEXT NULL,
|
||||
"GenerationContextHash" TEXT NULL,
|
||||
"LastGeneratedAtUtc" TEXT NULL,
|
||||
"LastEditedAtUtc" TEXT NULL,
|
||||
"Status" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
@@ -911,6 +938,32 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`CanonicalProfileVersion` int NULL,
|
||||
`TemplateId` varchar(100) NOT NULL,
|
||||
`Headline` longtext NULL,
|
||||
`SummaryJson` longtext NULL,
|
||||
`SelectedSkillsJson` longtext NULL,
|
||||
`ExperienceJson` longtext NULL,
|
||||
`EducationJson` longtext NULL,
|
||||
`CustomSectionsJson` longtext NULL,
|
||||
`RenderOptionsJson` longtext NULL,
|
||||
`GenerationContextHash` longtext NULL,
|
||||
`LastGeneratedAtUtc` datetime(6) NULL,
|
||||
`LastEditedAtUtc` datetime(6) NULL,
|
||||
`Status` varchar(100) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -945,6 +998,20 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
||||
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace JobTrackerApi.Services
|
||||
public string DataRoot { get; }
|
||||
public string AttachmentsRoot { get; }
|
||||
public string CvArtifactsRoot { get; }
|
||||
public string CvExportsRoot { get; }
|
||||
|
||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||
{
|
||||
@@ -31,6 +32,13 @@ namespace JobTrackerApi.Services
|
||||
|
||||
Directory.CreateDirectory(cvArtifactsRoot);
|
||||
CvArtifactsRoot = cvArtifactsRoot;
|
||||
|
||||
var cvExportsRoot = (cfg["Data:CvExportsRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(cvExportsRoot)) cvExportsRoot = Path.Combine(DataRoot, "CvExports");
|
||||
if (!Path.IsPathRooted(cvExportsRoot)) cvExportsRoot = Path.Combine(env.ContentRootPath, cvExportsRoot);
|
||||
|
||||
Directory.CreateDirectory(cvExportsRoot);
|
||||
CvExportsRoot = cvExportsRoot;
|
||||
}
|
||||
|
||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record TailoredCvRenderResult(string TemplateId, string SuggestedFileName, string Html);
|
||||
|
||||
public interface ICvTemplateRenderer
|
||||
{
|
||||
TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null);
|
||||
}
|
||||
|
||||
public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
{
|
||||
public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
|
||||
{
|
||||
var normalized = TailoredCvDraftJson.Normalize(document);
|
||||
var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId);
|
||||
normalized.TemplateId = effectiveTemplateId;
|
||||
var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf";
|
||||
var html = effectiveTemplateId switch
|
||||
{
|
||||
"harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName),
|
||||
"auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false),
|
||||
"edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true),
|
||||
_ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl)
|
||||
};
|
||||
return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html);
|
||||
}
|
||||
|
||||
private static string NormalizeTemplateId(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"base" => "ats-minimal",
|
||||
"legacy-text" => "ats-minimal",
|
||||
"harvard" => "harvard",
|
||||
"auckland" => "auckland",
|
||||
"edinburgh" => "edinburgh",
|
||||
_ => "ats-minimal"
|
||||
};
|
||||
}
|
||||
|
||||
private static string RenderAtsMinimal(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "caps-rule");
|
||||
var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>Company focus: {Encode(companyName)}</span>";
|
||||
var photoMarkup = showPhoto ? $"<div class=\"photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — ATS Minimal</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#111827; --muted:#4b5563; --line:#d1d5db; --paper:#fff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#eef2f7; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
|
||||
.header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; border-bottom:2px solid var(--accent); padding-bottom:8mm; margin-bottom:7mm; }}
|
||||
.name {{ margin:0; font-size:25pt; letter-spacing:.02em; }}
|
||||
.headline {{ margin-top:2mm; color:var(--muted); font-size:11pt; }}
|
||||
.meta {{ margin-top:3mm; display:flex; flex-wrap:wrap; gap:3mm; color:var(--muted); font-size:9pt; }}
|
||||
.photo {{ width:28mm; height:36mm; border-radius:5mm; overflow:hidden; border:1px solid var(--line); }}
|
||||
.photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||
{BaseSectionCss(accent, "caps-rule")}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<header class=""header"">
|
||||
<div>
|
||||
<h1 class=""name"">{Encode(candidateName)}</h1>
|
||||
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
<div class=""meta""><span>Target role: {Encode(jobTitle)}</span>{companyFocusMarkup}<span>Template: ATS Minimal</span></div>
|
||||
</div>
|
||||
{photoMarkup}
|
||||
</header>
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderHarvard(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "harvard");
|
||||
var contactLine = string.Join(" • ", new[]
|
||||
{
|
||||
string.IsNullOrWhiteSpace(companyName) ? null : $"Targeting {Encode(companyName)}",
|
||||
Encode(jobTitle)
|
||||
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — Harvard</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#111; --muted:#333; --line:#111; --paper:#fff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#f5f5f5; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; padding:16mm 18mm; }}
|
||||
.header {{ text-align:center; margin-bottom:6mm; }}
|
||||
.name {{ margin:0; font-size:23pt; font-weight:700; }}
|
||||
.headline {{ margin-top:2mm; font-size:10pt; font-style:italic; }}
|
||||
.meta {{ margin-top:4mm; font-size:9pt; }}
|
||||
{BaseSectionCss(accent, "harvard")}
|
||||
.section-title {{ color:var(--ink); }}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<header class=""header"">
|
||||
<h1 class=""name"">{Encode(candidateName)}</h1>
|
||||
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
<div class=""meta"">{contactLine}</div>
|
||||
</header>
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderSidebar(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl, string templateLabel, bool roundedPhoto, bool curvedHeader)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var sidebarSections = new StringBuilder();
|
||||
sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[]
|
||||
{
|
||||
$"Name\n{Encode(candidateName)}",
|
||||
$"Target role\n{Encode(jobTitle)}",
|
||||
string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{Encode(companyName)}"
|
||||
}));
|
||||
if (document.CustomSections.Count > 0)
|
||||
{
|
||||
foreach (var section in document.CustomSections.Take(2))
|
||||
{
|
||||
sidebarSections.Append(RenderSidebarMetaSection(section.Title ?? "Additional", section.Items));
|
||||
}
|
||||
}
|
||||
if (document.SelectedSkills.Count > 0)
|
||||
{
|
||||
sidebarSections.Append(RenderSidebarMetaSection("Skills", document.SelectedSkills.Take(8)));
|
||||
}
|
||||
|
||||
var main = RenderMainSections(document, accent, headingStyle: "sidebar");
|
||||
var photoClass = roundedPhoto ? "photo round" : "photo";
|
||||
var photoMarkup = showPhoto ? $"<div class=\"{photoClass}\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
var heroClass = curvedHeader ? "hero curved" : "hero";
|
||||
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — {Encode(templateLabel)}</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#1f2937; --muted:#4b5563; --line:#d1d5db; --sidebar:#f3f4f6; --paper:#fff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#edf2f7; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; display:grid; grid-template-columns:34% 66%; }}
|
||||
.sidebar {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 8%, white)); color:#fff; padding:12mm 8mm 12mm 10mm; }}
|
||||
.hero {{ margin:-12mm -8mm 8mm -10mm; padding:10mm 10mm 8mm 10mm; background:var(--accent); }}
|
||||
.hero.curved {{ border-bottom-right-radius:28mm; }}
|
||||
.name {{ margin:0; font-size:18pt; letter-spacing:.08em; font-weight:700; }}
|
||||
.headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
|
||||
.photo {{ width:34mm; height:34mm; margin-top:6mm; border:2px solid rgba(255,255,255,.85); overflow:hidden; }}
|
||||
.photo.round {{ border-radius:50%; }}
|
||||
.photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||
.sidebar-section {{ margin-top:7mm; }}
|
||||
.sidebar-title {{ margin:0 0 3mm 0; font-size:9pt; text-transform:uppercase; letter-spacing:.16em; }}
|
||||
.sidebar-item {{ margin:0 0 2.4mm 0; font-size:8.8pt; line-height:1.4; white-space:pre-line; }}
|
||||
.content {{ padding:14mm 14mm 14mm 10mm; }}
|
||||
{BaseSectionCss(accent, "sidebar")}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<aside class=""sidebar"">
|
||||
<div class=""{heroClass}"">
|
||||
<h1 class=""name"">{Encode(candidateName)}</h1>
|
||||
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
{photoMarkup}
|
||||
</div>
|
||||
{sidebarSections}
|
||||
</aside>
|
||||
<section class=""content"">
|
||||
{main}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle)
|
||||
{
|
||||
var sectionOrder = document.RenderOptions.SectionOrder.Count == 0
|
||||
? new List<string> { "summary", "skills", "experience", "education", "custom" }
|
||||
: document.RenderOptions.SectionOrder;
|
||||
|
||||
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["summary"] = RenderListSection("Profile", document.Summary, bulletList: true),
|
||||
["skills"] = RenderSkillSection(document.SelectedSkills),
|
||||
["experience"] = RenderExperienceSection(document.Experience),
|
||||
["education"] = RenderEducationSection(document.Education),
|
||||
["custom"] = string.Join(string.Empty, document.CustomSections.Select(RenderCustomSection)),
|
||||
};
|
||||
|
||||
return string.Join(string.Empty, sectionOrder
|
||||
.Select(key => sections.TryGetValue(key, out var section) ? section : string.Empty)
|
||||
.Where(section => !string.IsNullOrWhiteSpace(section)));
|
||||
}
|
||||
|
||||
private static string BaseSectionCss(string accent, string headingStyle)
|
||||
{
|
||||
var headingCss = headingStyle switch
|
||||
{
|
||||
"harvard" => ".section-title{font-size:17pt;font-weight:700;border-bottom:1.5px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}",
|
||||
"sidebar" => ".section-title{font-size:14pt;font-weight:700;letter-spacing:.02em;margin-bottom:3mm;}",
|
||||
_ => ".section-title{font-size:9pt;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);border-bottom:1px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}"
|
||||
};
|
||||
|
||||
return $@"
|
||||
.section{{margin-top:6mm;}}
|
||||
{headingCss}
|
||||
.summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}}
|
||||
.summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;}}
|
||||
.skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}}
|
||||
.skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;}}
|
||||
.entry{{margin-bottom:4.8mm;}}
|
||||
.entry-header{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;margin-bottom:1.2mm;}}
|
||||
.entry-title{{font-weight:700;font-size:11pt;}}
|
||||
.entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:nowrap;}}
|
||||
.entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;}}";
|
||||
}
|
||||
|
||||
private static string RenderSidebarMetaSection(string title, IEnumerable<string?> items)
|
||||
{
|
||||
var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"<p class=\"sidebar-item\">{item}</p>"));
|
||||
if (string.IsNullOrWhiteSpace(content)) return string.Empty;
|
||||
return $"<section class=\"sidebar-section\"><h2 class=\"sidebar-title\">{Encode(title)}</h2>{content}</section>";
|
||||
}
|
||||
|
||||
private static string RenderListSection(string title, IReadOnlyCollection<string> items, bool bulletList)
|
||||
{
|
||||
if (items.Count == 0) return string.Empty;
|
||||
var tag = bulletList ? "summary" : "custom-list";
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(title)}</h2><ul class=\"{tag}\">{string.Join(string.Empty, items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
|
||||
}
|
||||
|
||||
private static string RenderSkillSection(IReadOnlyCollection<string> skills)
|
||||
{
|
||||
if (skills.Count == 0) return string.Empty;
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">Skills</h2><ul class=\"skills\">{string.Join(string.Empty, skills.Select(skill => $"<li class=\"skill-pill\">{Encode(skill)}</li>"))}</ul></section>";
|
||||
}
|
||||
|
||||
private static string RenderExperienceSection(IReadOnlyCollection<TailoredCvExperienceItem> experience)
|
||||
{
|
||||
if (experience.Count == 0) return string.Empty;
|
||||
var items = new StringBuilder();
|
||||
foreach (var entry in experience)
|
||||
{
|
||||
var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode));
|
||||
var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent);
|
||||
items.Append("<article class=\"entry\">");
|
||||
items.Append($"<div class=\"entry-header\"><div class=\"entry-title\">{Encode(entry.Title)}</div><div class=\"entry-meta\">{Encode(dateRange)}</div></div>");
|
||||
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
|
||||
if (entry.Bullets.Count > 0) items.Append($"<ul class=\"experience-bullets\">{string.Join(string.Empty, entry.Bullets.Select(bullet => $"<li>{Encode(bullet)}</li>"))}</ul>");
|
||||
items.Append("</article>");
|
||||
}
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">Professional Experience</h2>{items}</section>";
|
||||
}
|
||||
|
||||
private static string RenderEducationSection(IReadOnlyCollection<TailoredCvEducationItem> education)
|
||||
{
|
||||
if (education.Count == 0) return string.Empty;
|
||||
var items = new StringBuilder();
|
||||
foreach (var entry in education)
|
||||
{
|
||||
var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(Encode));
|
||||
items.Append("<article class=\"entry\">");
|
||||
items.Append($"<div class=\"entry-title\">{Encode(entry.Qualification)}</div>");
|
||||
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
|
||||
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
|
||||
items.Append("</article>");
|
||||
}
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">Education</h2>{items}</section>";
|
||||
}
|
||||
|
||||
private static string RenderCustomSection(TailoredCvCustomSection section)
|
||||
{
|
||||
if (section.Items.Count == 0) return string.Empty;
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(section.Title ?? "Additional Information")}</h2><ul class=\"custom-list\">{string.Join(string.Empty, section.Items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
|
||||
}
|
||||
|
||||
private static string FormatDateRange(string? start, string? end, bool isCurrent)
|
||||
{
|
||||
var normalizedStart = (start ?? string.Empty).Trim();
|
||||
var normalizedEnd = (end ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedStart) && string.IsNullOrWhiteSpace(normalizedEnd)) return string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalizedStart)) return normalizedEnd;
|
||||
return $"{normalizedStart} - {(isCurrent ? "Present" : string.IsNullOrWhiteSpace(normalizedEnd) ? "Present" : normalizedEnd)}";
|
||||
}
|
||||
|
||||
private static string ResolveAccent(string? accentColor)
|
||||
{
|
||||
var normalized = (accentColor ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"slate" => "#334155",
|
||||
"blue" => "#1d4ed8",
|
||||
"emerald" => "#047857",
|
||||
"plum" => "#7c3aed",
|
||||
"brick" => "#b45309",
|
||||
_ when normalized.StartsWith("#") => normalized,
|
||||
_ => "#334155"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "'", StringComparison.Ordinal);
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray());
|
||||
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] Bytes);
|
||||
|
||||
public interface ICvPdfExporter
|
||||
{
|
||||
Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
{
|
||||
private readonly AppPaths _paths;
|
||||
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
|
||||
|
||||
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger)
|
||||
{
|
||||
_paths = paths;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
|
||||
Directory.CreateDirectory(folder);
|
||||
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
||||
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
|
||||
: renderResult.SuggestedFileName;
|
||||
var storagePath = Path.Combine(folder, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
using var playwright = await Playwright.CreateAsync();
|
||||
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.SetContentAsync(renderResult.Html, new PageSetContentOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.Load,
|
||||
});
|
||||
var bytes = await page.PdfAsync(new PagePdfOptions
|
||||
{
|
||||
Format = "A4",
|
||||
PrintBackground = true,
|
||||
Margin = new()
|
||||
{
|
||||
Top = "0",
|
||||
Right = "0",
|
||||
Bottom = "0",
|
||||
Left = "0",
|
||||
}
|
||||
});
|
||||
await File.WriteAllBytesAsync(storagePath, bytes, cancellationToken);
|
||||
return new CvPdfArtifact(fileName, storagePath, bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
|
||||
throw new InvalidOperationException("CV PDF export is unavailable. Ensure Chromium is installed for Playwright on this machine.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user