Files
jobtrackingapp/docs/architecture/cv-builder.md
T
cesnimda 074c78a7ef
CI and Deploy / test (push) Failing after 2m4s
CI and Deploy / deploy (push) Has been skipped
docs(architecture): record Phase 4.5 builder polish
cv-builder.md (outline-driven Content tab, DnD, rich text, preview page nav,
public-CV routing fix + deployment note), cv-theme-engine.md (rich text/ATS/
print), roadmap Phase 4.5 shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:48:33 +02:00

5.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 (Models/CvVariant.cs) + CvVariantVersion (autosave history). The whole lens lives in one SettingsJson blob (CvVariantSettings, 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).

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 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.