ce76046a29
- 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
109 lines
6.3 KiB
Markdown
109 lines
6.3 KiB
Markdown
# 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, 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.
|