diff --git a/README.md b/README.md index 18aad9d..b32f8ae 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ killing the process: | `PUT` | `/api/apps/{name}/raw?expected_version=…` | Replace the full Markdown document. | | `DELETE` | `/api/apps/{name}` | Delete → `204`. | | `POST` | `/api/apps/{name}/draft` | Draft a tailored cover letter via the configured LLM; saves it and returns `{ok, material}`. Rate-limited. | +| `GET` | `/api/apps/{name}/cover-letter.pdf` | Download the saved cover letter as a PDF. | | `POST` | `/api/poll` | Enqueue an on-demand poll → `{count:0}`. Rate-limited; the worker drains it. | | `POST` | `/api/scrape` | Body `{url}`. Fetch a posting page server-side (SSRF-guarded) and extract `{company, role, location, salary, source, description}` for the editor's Autofill. Rate-limited; 502 when the page can't be read. | @@ -343,7 +344,7 @@ résumé you control — provider-agnostic, and built so your data can stay on-p AES-256-GCM under `APPLYTRACK_SECRETS_KEY` and never echoed back. Without that master key the per-tenant-key path is disabled (the instance default still works). - **Generate from the application sheet.** Each app gets a **Generate cover letter** - action; the result renders inline with copy / download `.md` / regenerate / + action; the result renders inline with copy / download `.md` or PDF / regenerate / discard. Letters are stored per application and are excluded from the export snapshot by design. @@ -510,7 +511,7 @@ v1 is intentionally focused. Deferred, with clean seams already in place: - **Link checking.** `/api/apps/{name}/check-link` returns 501 today; the SSRF-hardened prober already exists in the poller for when it's enabled. - **Richer cover-letter output.** The materials engine ships plain-text/Markdown - letters ([Cover letters](#cover-letters)); LaTeX/PDF rendering is the next module. + letters ([Cover letters](#cover-letters)) with direct Markdown and PDF downloads. ## Contributing diff --git a/api/ApplyTrack.Api.Tests/CoverLetterDrafterTests.cs b/api/ApplyTrack.Api.Tests/CoverLetterDrafterTests.cs index 5f80488..a457bbc 100644 --- a/api/ApplyTrack.Api.Tests/CoverLetterDrafterTests.cs +++ b/api/ApplyTrack.Api.Tests/CoverLetterDrafterTests.cs @@ -60,6 +60,14 @@ public async Task The_returned_body_is_trimmed() Assert.Equal(new string('y', 100), body); } + [Fact] + public async Task A_saved_signature_is_appended_exactly() + { + var body = await new CoverLetterDrafter(new StubLlmClient()) + .DraftAsync(new AppFields { Company = "Acme" }, SampleResume(), Cfg, "Sincerely,\nAda Byte"); + Assert.EndsWith("Sincerely,\nAda Byte", body); + } + [Theory] [InlineData("too short")] // under the 40-char floor [InlineData("")] // empty diff --git a/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs b/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs index e7337e9..a2da878 100644 --- a/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs +++ b/api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs @@ -316,6 +316,34 @@ public async Task Cover_letters_default_on_and_the_toggle_round_trips() Assert.Equal("llama3.1", after.GetProperty("model").GetString()); } + [Fact] + public async Task Cover_letter_signature_round_trips_and_is_appended_to_new_drafts() + { + using var client = await AuthedClientAsync(NewFactory(WithStub(new StubLlmClient()))); + await client.PutAsync("/api/resume", Json(NonEmptyResume)); + await client.PutAsync("/api/llm-settings", Json("""{"cover_letter_signature":"Sincerely,\nAda Byte"}""")); + var settings = await ReadJson(await client.GetAsync("/api/llm-settings")); + Assert.Equal("Sincerely,\nAda Byte", settings.GetProperty("cover_letter_signature").GetString()); + + var name = await CreateAppAsync(client, "Acme Corp", "Engineer"); + var draft = await ReadJson(await client.PostAsync($"/api/apps/{name}/draft", null)); + Assert.EndsWith("Sincerely,\nAda Byte", draft.GetProperty("material").GetString()); + } + + [Fact] + public async Task Cover_letter_pdf_download_returns_a_pdf() + { + using var client = await AuthedClientAsync(NewFactory(WithStub(new StubLlmClient()))); + await client.PutAsync("/api/resume", Json(NonEmptyResume)); + var name = await CreateAppAsync(client, "Acme Corp", "Engineer"); + await client.PostAsync($"/api/apps/{name}/draft", null); + + var pdf = await client.GetAsync($"/api/apps/{name}/cover-letter.pdf"); + Assert.Equal(HttpStatusCode.OK, pdf.StatusCode); + Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType); + Assert.Equal("%PDF-", Encoding.ASCII.GetString((await pdf.Content.ReadAsByteArrayAsync())[..5])); + } + [Fact] public async Task Draft_is_refused_when_cover_letters_are_disabled() { diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index 9667dc0..166e835 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.10.0 + 1.11.0 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/api/ApplyTrack.Api/Data/LlmSettingsRepo.cs b/api/ApplyTrack.Api/Data/LlmSettingsRepo.cs index 206b9d6..a60e582 100644 --- a/api/ApplyTrack.Api/Data/LlmSettingsRepo.cs +++ b/api/ApplyTrack.Api/Data/LlmSettingsRepo.cs @@ -33,7 +33,9 @@ public LlmSettingsRepo( _log = log; } - private sealed record Row(string BaseUrl, string Model, string ApiKeyCiphertext, bool CoverLettersEnabled); + private sealed record Row( + string BaseUrl, string Model, string ApiKeyCiphertext, + bool CoverLettersEnabled, string CoverLetterSignature); /// The tenant's override with the API key decrypted, or null when no row exists. public async Task GetOverrideAsync() @@ -76,6 +78,10 @@ private sealed record Row(string BaseUrl, string Model, string ApiKeyCiphertext, : (row.BaseUrl, row.Model, row.ApiKeyCiphertext.Length > 0, row.CoverLettersEnabled); } + /// The tenant's exact multi-line closing signature, or blank when unset. + public async Task GetCoverLetterSignatureAsync() => + (await ReadRowAsync())?.CoverLetterSignature ?? ""; + /// /// Save the override. distinguishes "leave the stored /// key alone" (false) from "set/clear it" (true): a blank @@ -85,7 +91,8 @@ private sealed record Row(string BaseUrl, string Model, string ApiKeyCiphertext, /// public async Task UpsertAsync( string baseUrl, string model, bool changeKey, string? newKeyPlaintext, - bool? coverLettersEnabled = null, IDbTransaction? tx = null) + bool? coverLettersEnabled = null, string? coverLetterSignature = null, + IDbTransaction? tx = null) { var ciphertext = ""; if (changeKey && !string.IsNullOrEmpty(newKeyPlaintext)) @@ -101,16 +108,24 @@ public async Task UpsertAsync( var keyUpdate = changeKey ? "api_key_ciphertext = EXCLUDED.api_key_ciphertext," : ""; await _conn.ExecuteAsync( $""" - INSERT INTO llm_settings (tenant_id, base_url, model, api_key_ciphertext, cover_letters_enabled, updated_at) - VALUES (@t, @baseUrl, @model, @ciphertext, coalesce(@enabled, true), now()) + INSERT INTO llm_settings ( + tenant_id, base_url, model, api_key_ciphertext, + cover_letters_enabled, cover_letter_signature, updated_at) + VALUES (@t, @baseUrl, @model, @ciphertext, coalesce(@enabled, true), + coalesce(@signature, ''), now()) ON CONFLICT (tenant_id) DO UPDATE SET base_url = EXCLUDED.base_url, model = EXCLUDED.model, {keyUpdate} cover_letters_enabled = coalesce(@enabled, llm_settings.cover_letters_enabled), + cover_letter_signature = coalesce(@signature, llm_settings.cover_letter_signature), updated_at = now() """, - new { t = _t, baseUrl = baseUrl.Trim(), model = model.Trim(), ciphertext, enabled = coverLettersEnabled }, + new + { + t = _t, baseUrl = baseUrl.Trim(), model = model.Trim(), ciphertext, + enabled = coverLettersEnabled, signature = coverLetterSignature?.Trim(), + }, tx); } @@ -119,7 +134,8 @@ ON CONFLICT (tenant_id) DO UPDATE SET // Dapper's global MatchNamesWithUnderscores flag. _conn.QuerySingleOrDefaultAsync( "SELECT base_url AS baseurl, model, api_key_ciphertext AS apikeyciphertext, " - + "cover_letters_enabled AS coverlettersenabled " + + "cover_letters_enabled AS coverlettersenabled, " + + "cover_letter_signature AS coverlettersignature " + "FROM llm_settings WHERE tenant_id = @t", new { t = _t }); } diff --git a/api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs b/api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs index 56d202d..f204efc 100644 --- a/api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs +++ b/api/ApplyTrack.Api/Endpoints/AppsEndpoints.cs @@ -108,7 +108,8 @@ public static void MapAppsEndpoints(this IEndpointRouteBuilder app) var resume = await resumes.GetAsync(); var cfg = EffectiveLlmConfig.Resolve(instance, await llm.GetOverrideAsync()); - var body = await drafter.DraftAsync(rec.Fields, resume, cfg, ct); + var body = await drafter.DraftAsync( + rec.Fields, resume, cfg, await llm.GetCoverLetterSignatureAsync(), ct); await letters.UpsertAsync(rec.Name, body, cfg.Model); return Results.Ok(new { ok = true, material = body }); }).RequireRateLimiting("draft"); diff --git a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs index 42c6e09..7432abb 100644 --- a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs +++ b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs @@ -64,6 +64,7 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) base_url = baseUrl, model, has_api_key = hasKey, + cover_letter_signature = await repo.GetCoverLetterSignatureAsync(), // Whether this tenant wants cover letters at all; OFF hides the // drafting UI and the draft endpoint refuses. cover_letters_enabled = lettersEnabled, @@ -99,7 +100,13 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) ? en.GetBoolean() : null; - await repo.UpsertAsync(baseUrl, model, changeKey, newKey, lettersEnabled); + string? signature = payload.ValueKind == JsonValueKind.Object + && payload.TryGetProperty("cover_letter_signature", out var sig) + && sig.ValueKind == JsonValueKind.String + ? sig.GetString()?.Trim() + : null; + + await repo.UpsertAsync(baseUrl, model, changeKey, newKey, lettersEnabled, signature); var (savedUrl, savedModel, hasKey, savedEnabled) = await repo.GetViewAsync(); return Results.Ok(new @@ -107,6 +114,7 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) base_url = savedUrl, model = savedModel, has_api_key = hasKey, + cover_letter_signature = await repo.GetCoverLetterSignatureAsync(), cover_letters_enabled = savedEnabled, }); }); @@ -119,6 +127,14 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) ? Results.NoContent() : throw new AppNotFoundException($"no cover letter for '{name}'"); }); + + app.MapGet("/api/apps/{name}/cover-letter.pdf", async (string name, CoverLetterRepo letters) => + { + var body = await letters.GetBodyAsync(name) + ?? throw new AppNotFoundException($"no cover letter for '{name}'"); + var filename = $"{Slug.NameStem(Slug.Normalize(name))}-cover-letter.pdf"; + return Results.File(CoverLetterPdfRenderer.Render(body), "application/pdf", filename); + }); } private static string GetString(JsonElement obj, string key) => diff --git a/api/ApplyTrack.Api/Materials/CoverLetterDrafter.cs b/api/ApplyTrack.Api/Materials/CoverLetterDrafter.cs index 42e5854..319c94a 100644 --- a/api/ApplyTrack.Api/Materials/CoverLetterDrafter.cs +++ b/api/ApplyTrack.Api/Materials/CoverLetterDrafter.cs @@ -27,7 +27,8 @@ public sealed class CoverLetterDrafter }; public async Task DraftAsync( - AppFields app, Resume resume, EffectiveLlmConfig cfg, CancellationToken ct = default) + AppFields app, Resume resume, EffectiveLlmConfig cfg, + string signature = "", CancellationToken ct = default) { if (resume.IsEmpty) throw new AppValidationException("add your résumé in Résumé settings before drafting a cover letter"); @@ -38,14 +39,14 @@ public async Task DraftAsync( // Reject empty/implausible output rather than save a broken letter. if (body.Length is < 40 or > 6000) throw new LlmUnavailableException("the model returned an unusable draft — try again"); - return body; + return signature.Trim() is { Length: > 0 } closing + ? $"{body}\n\n{closing}" + : body; } private static (string System, string User) BuildPrompt(AppFields app, Resume resume) { var lane = LaneLead.TryGetValue(app.Lane, out var lead) ? lead : LaneLead["ai"]; - var name = resume.FullName.Length > 0 ? resume.FullName : "the candidate"; - var system = $""" You are an expert cover-letter writer drafting a tailored, ready-to-send cover @@ -65,7 +66,8 @@ and company are about. Structure the letter as: - a "Dear Hiring Team," greeting, - 2-3 short body paragraphs, ~200-280 words total, - - a brief sign-off ending with "{name}". + - end after the final body paragraph; do not write a sign-off or signature. + The application will append the applicant's saved signature exactly. Do NOT include a date or a postal address. """; diff --git a/api/ApplyTrack.Api/Materials/CoverLetterPdfRenderer.cs b/api/ApplyTrack.Api/Materials/CoverLetterPdfRenderer.cs new file mode 100644 index 0000000..ad74d0e --- /dev/null +++ b/api/ApplyTrack.Api/Materials/CoverLetterPdfRenderer.cs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +using System.Text.RegularExpressions; +using System.Text; +using System.Globalization; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.Fonts.Standard14Fonts; +using UglyToad.PdfPig.Writer; +using UglyToad.PdfPig.Core; + +namespace ApplyTrack.Api.Materials; + +/// Renders a generated letter into a small, portable text PDF. +public static partial class CoverLetterPdfRenderer +{ + [GeneratedRegex(@"[*_`#]", RegexOptions.CultureInvariant)] + private static partial Regex MarkdownMarkers(); + + public static byte[] Render(string body) + { + var builder = new PdfDocumentBuilder(); + var font = builder.AddStandard14Font(Standard14Font.Helvetica); + var lines = Wrap(ToPdfText(MarkdownMarkers().Replace(body.Replace("\r\n", "\n"), ""))); + const double left = 54; + const double top = 770; + const double lineHeight = 16; + const double bottom = 54; + var page = builder.AddPage(PageSize.A4); + var y = top; + + foreach (var line in lines) + { + if (y < bottom) + { + page = builder.AddPage(PageSize.A4); + y = top; + } + + if (line.Length > 0) + page.AddText(line, 11, new PdfPoint(left, y), font); + y -= lineHeight; + } + + return builder.Build(); + } + + private static IReadOnlyList Wrap(string text) + { + var result = new List(); + foreach (var paragraph in text.Split('\n')) + { + if (paragraph.Length == 0) + { + result.Add(""); + continue; + } + + var words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var line = ""; + foreach (var word in words) + { + var candidate = line.Length == 0 ? word : $"{line} {word}"; + if (candidate.Length > 88 && line.Length > 0) + { + result.Add(line); + line = word; + } + else + { + line = candidate; + } + } + result.Add(line); + } + return result; + } + + // PdfPig's Standard 14 fonts are intentionally ASCII-only. Fold accented + // Latin characters and replace any remaining non-ASCII glyphs safely. + private static string ToPdfText(string text) + { + var normalized = text.Normalize(NormalizationForm.FormD); + var chars = normalized + .Where(c => CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) + .Select(c => c <= 127 ? c : '?') + .ToArray(); + return new string(chars); + } +} diff --git a/api/ApplyTrack.Api/Migrations/0014_cover_letter_signature.sql b/api/ApplyTrack.Api/Migrations/0014_cover_letter_signature.sql new file mode 100644 index 0000000..2efa9c7 --- /dev/null +++ b/api/ApplyTrack.Api/Migrations/0014_cover_letter_signature.sql @@ -0,0 +1,5 @@ +-- SPDX-License-Identifier: Apache-2.0 +-- Copyright 2026 Aaron K. Clark +-- Tenant-provided closing signature appended to generated cover letters. +ALTER TABLE llm_settings + ADD COLUMN IF NOT EXISTS cover_letter_signature text NOT NULL DEFAULT ''; diff --git a/api/ApplyTrack.Api/wwwroot/app.js b/api/ApplyTrack.Api/wwwroot/app.js index 66564f4..0f850d5 100644 --- a/api/ApplyTrack.Api/wwwroot/app.js +++ b/api/ApplyTrack.Api/wwwroot/app.js @@ -371,6 +371,8 @@ function renderView(data) { if (matCopyEl) matCopyEl.onclick = () => copyText(data.material, "Cover letter copied."); const matDownEl = contentEl.querySelector('[data-act="mat-download"]'); if (matDownEl) matDownEl.onclick = () => downloadMaterial(data); + const matPdfEl = contentEl.querySelector('[data-act="mat-pdf"]'); + if (matPdfEl) matPdfEl.onclick = () => downloadMaterialPdf(data); const matDiscardEl = contentEl.querySelector('[data-act="mat-discard"]'); if (matDiscardEl) matDiscardEl.onclick = () => discardMaterial(data.filename); const checkEl = contentEl.querySelector('[data-act="check-link"]'); @@ -440,6 +442,7 @@ function materialSection(data) { Copy Download .md + Download PDF ${regenerate} Discard @@ -471,6 +474,26 @@ function downloadMaterial(data) { URL.revokeObjectURL(url); } +async function downloadMaterialPdf(data) { + try { + const res = await fetch(`/api/apps/${encodeURIComponent(data.filename)}/cover-letter.pdf`, { + credentials: "same-origin", + }); + if (!res.ok) throw new Error("Couldn't download the cover letter PDF."); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${stem(data.filename)}-cover-letter.pdf`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + toast(e.message); + } +} + async function discardMaterial(name) { if (!await confirmAction({ title: "Discard cover letter?", @@ -1167,6 +1190,13 @@ function llmMarkup(s) { placeholder="${escapeHtml(inst.model || "llama3.1")}" /> + + Cover-letter signature + ${escapeHtml(s.cover_letter_signature || "")} + This text is appended exactly to every newly generated cover letter. + + API key ${secretsOff ? "— unavailable on this instance" : "— write-only"}
This text is appended exactly to every newly generated cover letter.