Files
jobtrackingapp/docs/architecture/cv-builder.md
T
2026-08-24 20:21:23 +02:00

7.7 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 + normalized overrides: printable colours, curated heading/body fonts, typography, density, A4/Letter, margins/gaps, one/two-column layout, sidebar assignment, heading/header and skills treatments, photo/icons and the retained page-number extension point.
  • 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 themes/{id}/preview; 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.

CV/job associations are authorized server-side during creation. A caller cannot attach a CV to another user's job merely by supplying its numeric ID. List cards resolve an associated job's title and company only through the owner-filtered relationship.

Builder workflow (frontend)

/career/builder lists variants (CvBuilderPage) with real rendered template thumbnails, language, modified/version state and associated-job context. Creation asks only for a name and template. The editor (CvBuilderEditor) separates Content, Template, Design, Layout, AI and History beside an always-on desktop 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). Contact details and career history remain master data; the Content tab explains this boundary and links to the master editor while variant-specific wording, order, headings and visibility stay local.

Preview displays separate labelled A4/Letter page sheets rather than one infinite document. It has zoom controls, measured Fit, a ceiling-based page count with prev/next navigation, 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.