Add CV template preview and PDF export pipeline

This commit is contained in:
2026-03-29 00:43:54 +01:00
parent 2392b135c2
commit 839a2ed80d
15 changed files with 2288 additions and 97 deletions
@@ -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)
{
+1
View File
@@ -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>
+67
View File
@@ -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
View File
@@ -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(" &nbsp;•&nbsp; ", new[]
{
string.IsNullOrWhiteSpace(companyName) ? null : $"Targeting {Encode(companyName)}",
Encode(jobTitle)
}.Where(x => !string.IsNullOrWhiteSpace(x)));
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — Harvard</title>
<style>
:root {{ --accent:{accent}; --ink:#111; --muted:#333; --line:#111; --paper:#fff; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#f5f5f5; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; padding:16mm 18mm; }}
.header {{ text-align:center; margin-bottom:6mm; }}
.name {{ margin:0; font-size:23pt; font-weight:700; }}
.headline {{ margin-top:2mm; font-size:10pt; font-style:italic; }}
.meta {{ margin-top:4mm; font-size:9pt; }}
{BaseSectionCss(accent, "harvard")}
.section-title {{ color:var(--ink); }}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<header class=""header"">
<h1 class=""name"">{Encode(candidateName)}</h1>
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
<div class=""meta"">{contactLine}</div>
</header>
{body}
</main>
</body>
</html>";
}
private static string RenderSidebar(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl, string templateLabel, bool roundedPhoto, bool curvedHeader)
{
var accent = ResolveAccent(document.RenderOptions.AccentColor);
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var sidebarSections = new StringBuilder();
sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[]
{
$"Name\n{Encode(candidateName)}",
$"Target role\n{Encode(jobTitle)}",
string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{Encode(companyName)}"
}));
if (document.CustomSections.Count > 0)
{
foreach (var section in document.CustomSections.Take(2))
{
sidebarSections.Append(RenderSidebarMetaSection(section.Title ?? "Additional", section.Items));
}
}
if (document.SelectedSkills.Count > 0)
{
sidebarSections.Append(RenderSidebarMetaSection("Skills", document.SelectedSkills.Take(8)));
}
var main = RenderMainSections(document, accent, headingStyle: "sidebar");
var photoClass = roundedPhoto ? "photo round" : "photo";
var photoMarkup = showPhoto ? $"<div class=\"{photoClass}\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var heroClass = curvedHeader ? "hero curved" : "hero";
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — {Encode(templateLabel)}</title>
<style>
:root {{ --accent:{accent}; --ink:#1f2937; --muted:#4b5563; --line:#d1d5db; --sidebar:#f3f4f6; --paper:#fff; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#edf2f7; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; display:grid; grid-template-columns:34% 66%; }}
.sidebar {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 8%, white)); color:#fff; padding:12mm 8mm 12mm 10mm; }}
.hero {{ margin:-12mm -8mm 8mm -10mm; padding:10mm 10mm 8mm 10mm; background:var(--accent); }}
.hero.curved {{ border-bottom-right-radius:28mm; }}
.name {{ margin:0; font-size:18pt; letter-spacing:.08em; font-weight:700; }}
.headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
.photo {{ width:34mm; height:34mm; margin-top:6mm; border:2px solid rgba(255,255,255,.85); overflow:hidden; }}
.photo.round {{ border-radius:50%; }}
.photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
.sidebar-section {{ margin-top:7mm; }}
.sidebar-title {{ margin:0 0 3mm 0; font-size:9pt; text-transform:uppercase; letter-spacing:.16em; }}
.sidebar-item {{ margin:0 0 2.4mm 0; font-size:8.8pt; line-height:1.4; white-space:pre-line; }}
.content {{ padding:14mm 14mm 14mm 10mm; }}
{BaseSectionCss(accent, "sidebar")}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<aside class=""sidebar"">
<div class=""{heroClass}"">
<h1 class=""name"">{Encode(candidateName)}</h1>
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
{photoMarkup}
</div>
{sidebarSections}
</aside>
<section class=""content"">
{main}
</section>
</main>
</body>
</html>";
}
private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle)
{
var sectionOrder = document.RenderOptions.SectionOrder.Count == 0
? new List<string> { "summary", "skills", "experience", "education", "custom" }
: document.RenderOptions.SectionOrder;
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["summary"] = RenderListSection("Profile", document.Summary, bulletList: true),
["skills"] = RenderSkillSection(document.SelectedSkills),
["experience"] = RenderExperienceSection(document.Experience),
["education"] = RenderEducationSection(document.Education),
["custom"] = string.Join(string.Empty, document.CustomSections.Select(RenderCustomSection)),
};
return string.Join(string.Empty, sectionOrder
.Select(key => sections.TryGetValue(key, out var section) ? section : string.Empty)
.Where(section => !string.IsNullOrWhiteSpace(section)));
}
private static string BaseSectionCss(string accent, string headingStyle)
{
var headingCss = headingStyle switch
{
"harvard" => ".section-title{font-size:17pt;font-weight:700;border-bottom:1.5px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}",
"sidebar" => ".section-title{font-size:14pt;font-weight:700;letter-spacing:.02em;margin-bottom:3mm;}",
_ => ".section-title{font-size:9pt;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);border-bottom:1px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}"
};
return $@"
.section{{margin-top:6mm;}}
{headingCss}
.summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}}
.summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;}}
.skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}}
.skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;}}
.entry{{margin-bottom:4.8mm;}}
.entry-header{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;margin-bottom:1.2mm;}}
.entry-title{{font-weight:700;font-size:11pt;}}
.entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:nowrap;}}
.entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;}}";
}
private static string RenderSidebarMetaSection(string title, IEnumerable<string?> items)
{
var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"<p class=\"sidebar-item\">{item}</p>"));
if (string.IsNullOrWhiteSpace(content)) return string.Empty;
return $"<section class=\"sidebar-section\"><h2 class=\"sidebar-title\">{Encode(title)}</h2>{content}</section>";
}
private static string RenderListSection(string title, IReadOnlyCollection<string> items, bool bulletList)
{
if (items.Count == 0) return string.Empty;
var tag = bulletList ? "summary" : "custom-list";
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(title)}</h2><ul class=\"{tag}\">{string.Join(string.Empty, items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
}
private static string RenderSkillSection(IReadOnlyCollection<string> skills)
{
if (skills.Count == 0) return string.Empty;
return $"<section class=\"section\"><h2 class=\"section-title\">Skills</h2><ul class=\"skills\">{string.Join(string.Empty, skills.Select(skill => $"<li class=\"skill-pill\">{Encode(skill)}</li>"))}</ul></section>";
}
private static string RenderExperienceSection(IReadOnlyCollection<TailoredCvExperienceItem> experience)
{
if (experience.Count == 0) return string.Empty;
var items = new StringBuilder();
foreach (var entry in experience)
{
var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode));
var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent);
items.Append("<article class=\"entry\">");
items.Append($"<div class=\"entry-header\"><div class=\"entry-title\">{Encode(entry.Title)}</div><div class=\"entry-meta\">{Encode(dateRange)}</div></div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Bullets.Count > 0) items.Append($"<ul class=\"experience-bullets\">{string.Join(string.Empty, entry.Bullets.Select(bullet => $"<li>{Encode(bullet)}</li>"))}</ul>");
items.Append("</article>");
}
return $"<section class=\"section\"><h2 class=\"section-title\">Professional Experience</h2>{items}</section>";
}
private static string RenderEducationSection(IReadOnlyCollection<TailoredCvEducationItem> education)
{
if (education.Count == 0) return string.Empty;
var items = new StringBuilder();
foreach (var entry in education)
{
var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) }
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(Encode));
items.Append("<article class=\"entry\">");
items.Append($"<div class=\"entry-title\">{Encode(entry.Qualification)}</div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
items.Append("</article>");
}
return $"<section class=\"section\"><h2 class=\"section-title\">Education</h2>{items}</section>";
}
private static string RenderCustomSection(TailoredCvCustomSection section)
{
if (section.Items.Count == 0) return string.Empty;
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(section.Title ?? "Additional Information")}</h2><ul class=\"custom-list\">{string.Join(string.Empty, section.Items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
}
private static string FormatDateRange(string? start, string? end, bool isCurrent)
{
var normalizedStart = (start ?? string.Empty).Trim();
var normalizedEnd = (end ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedStart) && string.IsNullOrWhiteSpace(normalizedEnd)) return string.Empty;
if (string.IsNullOrWhiteSpace(normalizedStart)) return normalizedEnd;
return $"{normalizedStart} - {(isCurrent ? "Present" : string.IsNullOrWhiteSpace(normalizedEnd) ? "Present" : normalizedEnd)}";
}
private static string ResolveAccent(string? accentColor)
{
var normalized = (accentColor ?? string.Empty).Trim().ToLowerInvariant();
return normalized switch
{
"slate" => "#334155",
"blue" => "#1d4ed8",
"emerald" => "#047857",
"plum" => "#7c3aed",
"brick" => "#b45309",
_ when normalized.StartsWith("#") => normalized,
_ => "#334155"
};
}
private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "&#39;", StringComparison.Ordinal);
private static string Slugify(string value)
{
var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray());
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
return cleaned.Trim('-');
}
}
@@ -0,0 +1,66 @@
using Microsoft.Playwright;
namespace JobTrackerApi.Services;
public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] Bytes);
public interface ICvPdfExporter
{
Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken);
}
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
{
private readonly AppPaths _paths;
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger)
{
_paths = paths;
_logger = logger;
}
public async Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
Directory.CreateDirectory(folder);
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
: renderResult.SuggestedFileName;
var storagePath = Path.Combine(folder, fileName);
try
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
});
var page = await browser.NewPageAsync();
await page.SetContentAsync(renderResult.Html, new PageSetContentOptions
{
WaitUntil = WaitUntilState.Load,
});
var bytes = await page.PdfAsync(new PagePdfOptions
{
Format = "A4",
PrintBackground = true,
Margin = new()
{
Top = "0",
Right = "0",
Bottom = "0",
Left = "0",
}
});
await File.WriteAllBytesAsync(storagePath, bytes, cancellationToken);
return new CvPdfArtifact(fileName, storagePath, bytes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
throw new InvalidOperationException("CV PDF export is unavailable. Ensure Chromium is installed for Playwright on this machine.", ex);
}
}
}