Files
jobtrackingapp/docs/architecture/cv-builder.md
T
cesnimda 5203ddea72
CI and Deploy / test (pull_request) Failing after 3m0s
CI and Deploy / deploy (pull_request) Has been skipped
feat(cv): harden multi-page builder
Wrap pathological content, paginate oversized entries, measure A4 and Letter previews correctly, unify section ordering, and gate stored-output actions on saved state.
2026-08-15 14:09:51 +02:00

6.9 KiB

CV Builder (Career Workspace Builder)

Phase 4 (2026-07-18). The builder that turns the master career profile into themed, exportable, optionally-public CVs. Companion to docs/architecture/cv-theme-engine.md, docs/architecture/career-profile-model.md, and MASTER_IMPLEMENTATION_GUIDE.md. Verified against code and a running container.

Source-of-truth chain

CareerProfile (the only editable career source)
   ↓ read (never copied)
CvVariant       — a LENS: section order/visibility, per-item overrides, theme + builder settings
   ↓ resolve
CvRenderModel   — presentation projection (CvVariantResolver)
   ↓ render (one path, theme = data)
HTML            — ThemedCvRenderer
   ↓ export / share
PDF  |  Public CV (/cv/{slug})

The master profile is never written by the builder. A variant references career items by their stable ItemKey; overriding a bullet stores the override on the variant, leaving the profile untouched (CvBuilderTests.Resolver_applies_item_override_bullets_without_touching_the_master).

Variant model

CvVariant (JobTrackerApi/Models/CvVariant.cs) + CvVariantVersion (autosave history). The whole lens lives in one SettingsJson blob (CvVariantSettings, JobTrackerApi/Models/CvVariantSettings.cs) because it is edited and saved as a unit — never queried field-by-field. A variant stores:

  • ThemeId + overrides: accent, heading/body font, density, page size, photo/icons/page-numbers.
  • Sections: ordered list with per-section Hidden + optional renamed Title.
  • Overrides: keyed by ItemKey{ Hidden, Title, Subtitle, Bullets } (per-item, job-specific).
  • CustomSections: variant-only sections not in the master profile.

PublicSlug (unguessable, globally unique) backs the public URL. IsPublic is off by default.

Rendering pipeline

  1. CvVariantResolver.Build(profile, settings, fallbackName, photoDataUrl)CvRenderModel. Applies order, visibility, item overrides and custom sections. Pure projection, owns nothing.
  2. ThemedCvRenderer.Render(model, theme, settings) → HTML. One render path; the theme (data) drives layout/palette/typography/spacing. See cv-theme-engine.md.
  3. PlaywrightCvPdfExporter (reused unchanged) turns the HTML into a PDF.

API

Authenticated (/api/cv, CvVariantController): GET themes; GET/POST variants; GET/PUT/DELETE variants/{id}; POST variants/{id}/duplicate; PUT variants/{id}/public; GET variants/{id}/versions, POST …/versions/{v}/restore; GET variants/{id}/preview and POST preview (live preview of unsaved settings); POST variants/{id}/export-pdf; POST ai/assist.

Anonymous (/api/public-cv/{slug}, PublicCvController): serves a public variant read-only, X-Robots-Tag: noindex. Loads the owner's profile via LoadStructuredForOwnerAsync (bypasses the tenant filter — there is no current user). Unknown/private slug → 404 (CvBuilderTests.Private_variant_is_not_served_publicly).

Recruiters can download the same public render as PDF through GET /api/public-cv/{slug}/pdf. The endpoint applies the identical public/private slug check; unknown, revoked, or private links return 404 without invoking the exporter. Anonymous PDF generation is limited to three requests per minute per public link because it launches Chromium. PublicCvPage exposes it as a native Download PDF link.

Builder workflow (frontend)

/career/builder lists variants (CvBuilderPage); the editor (CvBuilderEditor) is three tabs — Content, Customize, AI Tools (plus History) — beside an always-on live preview that re-renders through POST /api/cv/preview on a 300 ms debounce. Edits autosave on an 800 ms debounce (source: autosave), appending a version each save; the header shows Unsaved / Saving / Saved. /cv/:slug (PublicCvPage) renders a public CV in a sandboxed iframe.

Content tab reads GET /api/cv/outline (the master profile as sections+entries with ItemKeys). Sections and entries reorder by native drag-and-drop (useDragReorder, no dependency) or arrow buttons (the keyboard-accessible path); entry order is stored per section as ItemOrder on the variant, never on the profile. Each entry exposes hide, title/subtitle override, and rich-text bullet editing (RichTextField — a markdown toolbar over a textarea; storage stays plain text, the server renderer converts the **bold** *italic* __underline__ [text](url) whitelist to safe HTML).

Preview has zoom presets (±, slider, measured Fit), physical A4/Letter dimensions, a ceiling-based page count with prev/next navigation and page-break indicators, and an "updating…" chip. Three-page and longer documents receive content-focus guidance rather than automatic font shrinking. Preview requests and autosaves are ordered so stale responses cannot replace newer edits; PDF/public actions save the current variant before consuming the stored render. Customize badges ATS-friendly themes.

Profile-backed and custom sections share one section order. Custom-section content remains stored in CustomSections, while Sections holds its custom:<key> position and visibility. Legacy variants without those order rows still append custom sections and acquire the shared order on their next edit.

AI

POST /api/cv/ai/assist reuses the existing ISummarizerService provider abstraction. It returns a suggestion the user copies in themselves — it never mutates the profile or the variant. Every prompt carries "preserve every factual claim — never invent" (MASTER_IMPLEMENTATION_GUIDE AI philosophy).

Extension points

  • New theme: append a CvTheme to the catalog (cv-theme-engine.md). No renderer change.
  • New section kind: add a builder in CvVariantResolver + a Kind branch in ThemedCvRenderer.
  • DOCX export: add an IDocxExporter consuming the same CvRenderModel; the model is format-neutral.
  • PDF page numbers: ShowPageNumbers is carried but the footer is a Playwright footerTemplate concern, not CSS — see cv-theme-engine.md.

Deployment — public CV routing (fixed 2026-07-18)

/cv/{slug} (and every deep link) must work on direct navigation, refresh, and shared links. The app is a React Router SPA behind Next.js output: export, which previously prerendered only /, so a hard load of any deep path hit Next's client not-found before React Router could route it.

Fix: the single app/page.tsx is now an optional catch-all app/[[...slug]]/ (a server page.tsx exporting generateStaticParams + a "use client" ClientShell, because generateStaticParams can't live in a client file). The catch-all matches every client path, so React Router owns routing on any hard load. Build still emits just index.html; nginx serves it for all unknown paths via try_files $uri /index.html (unchanged). No infra/nginx change was required — the fix is entirely in the frontend build. Verified live: /login and /cv/{slug} both resolve on direct navigation.