- consolidate API ownership and remove dead vendor code - add Stripe billing, learning paths, and public CV hardening - add migration, recovery, security, audit, and browser gates
6.3 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, andMASTER_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-sectionHidden+ optional renamedTitle.Overrides: keyed byItemKey→{ 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
CvVariantResolver.Build(profile, settings, fallbackName, photoDataUrl)→CvRenderModel. Applies order, visibility, item overrides and custom sections. Pure projection, owns nothing.ThemedCvRenderer.Render(model, theme, settings)→ HTML. One render path; the theme (data) drives layout/palette/typography/spacing. Seecv-theme-engine.md.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, Fit), a measured page count with prev/next page navigation and dashed page-break indicators, and an "updating…" chip. Customize badges ATS-friendly themes.
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
CvThemeto the catalog (cv-theme-engine.md). No renderer change. - New section kind: add a builder in
CvVariantResolver+ aKindbranch inThemedCvRenderer. - DOCX export: add an
IDocxExporterconsuming the sameCvRenderModel; the model is format-neutral. - PDF page numbers:
ShowPageNumbersis carried but the footer is a PlaywrightfooterTemplateconcern, not CSS — seecv-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.