CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lexi AI — resume settings & download (Phase 14, UN-108)

updated 2026-06-17

Lexi AI — resume settings & download (Phase 14, UN-108)

T-14.1 is the first Phase-14 (Lexi) slice. It lets an applicant choose how their generated résumé looks (template, font, font size, theme colour) and whether it is rendered incognito (anonymised, UCN-only — no name, no PII), then download the rendered PDF. It rides the same /api/v1/* surface as Lenux: bare camelCase bodies, the verbatim 429, per-user rate limits, applicant-only.

Where it lives

src/lexi-ai/resume-settings/. The roadmap had detected this under applicant-profile/resume/settings/, but it ships under src/lexi-ai/ for cohesion with the rest of the Lexi surface (the placement deviation is documented in the module's doc comment and PHASE-14-DESIGN.md §4).

The endpoints

All applicant-only, per-user. {userId} in the path must equal the caller (403 otherwise, via LexiUserContextService.resolveSelfApplicant).

Method + path Rate limit What it does
GET /api/v1/resume-settings/options 60/min/user Static picker catalogue (no {userId}; still authed applicant).
GET /api/v1/users/{userId}/resume-settings 30/min/user The user's row, or sensible defaults when none.
PUT /api/v1/users/{userId}/resume-settings 60/min/user Validated upsert.
POST /api/v1/users/{userId}/resume/download 10/min/user Render + upload the latest résumé PDF.

options

Returns a fixed catalogue the UI renders:

{
  "templates": [{ "id": "classic", "name": "Classic", "previewUrl": "..." }, ...4],
  "fonts": [{ "id": "inter", "label": "Inter" }, ...5],
  "fontSizes": [{ "id": "small", "label": "Small" }, ...3],
  "themeColorPresets": ["#22A84B", "#0D2240", "#6B7CB8", "#1E2A3B"]
}

GET …/resume-settings

Returns { userId, templateType, fontFamily, fontSize, themeColor, incognitoMode, updatedAt }. When the user has no resume_settings row, it returns the schema defaults (classic / inter / medium / #22A84B / incognitoMode: true) with updatedAt: null — the absence of a row is not an error.

PUT …/resume-settings

A partial upsert: only the fields present in the body are written; on a first write the missing fields fall back to the defaults. Validation is enforced server-side (not via class-validator) so a miss returns the Jira's bare 422, not the repo's { status, message } envelope:

{ "error": "validation_error", "fields": { "themeColor": "Must be a valid hex color (#RRGGBB)" } }

Rules: templateType/fontFamily/fontSize must be valid enum values; themeColor must match ^#[0-9A-Fa-f]{6}$; incognitoMode must be a boolean. Multiple bad fields are reported together. Nothing is persisted on a validation miss.

POST …/resume/download

Body { incognito?, format? } (both optional). It loads the caller's latest résumé (ApplicantResume ordered by createdAt desc; 404 if they have none), then renders + uploads a PDF through the Phase-3 ResumeGenerationService.renderAndUpload. Returns:

{ "fileUrl": "...", "expiresAt": "<+1h ISO>", "incognitoApplied": true, "templateUsed": "classic" }

incognitoApplied = the per-request incognito override if present, else the stored incognitoMode, else the default (true). templateUsed = the stored template (or classic).

How incognito works (the iceberg)

The pdfkit renderer is anonymised by construction — it prints only Candidate {ucn} as the header and never a name or other PII. So an incognito render contains zero identity fields by design; the slice does not strip anything itself. A non-incognito download uses the same anonymised renderer today (identity-bearing template variants are a future renderer concern, documented in the service).

The two gotchas

  1. The per-download incognito override never mutates stored settings. It is read into the response and used to pick the render, but no write touches resume_settings. An e2e test asserts the stored incognitoMode is unchanged after a incognito:false download.
  2. The bare 422 needs @Res(). The global exception filter only passes the rate-limit body through verbatim; any other HttpException is re-wrapped into the error envelope (keeping only message/errors/action). To emit the Jira's { error, fields } shape, the controller catches the service's ResumeSettingsValidationError and writes the body directly via @Res({ passthrough: true }) — so the happy path still returns its object normally (and is left bare by @BareResponse()).

Wiring

ResumeSettingsModule imports LexiCommonModule (shared v1 guards + rate-limit + ownership context) and binds the reused resume pipeline locally: ResumeGenerationService plus the two ports it needs (ResumePdfRendererPort → pdfkit; ResumeAiPort → the OpenAI/stub factory). MediaStoragePort comes from the @Global StorageModule. In e2e, storage falls back to the dev LogStorageService (no Cloudinary), so downloads still produce a deterministic URL. The module is orchestrator-owned (imported alongside AppModule in e2e; folded into the app on integration) and exports ResumeSettingsService.

Tests

  • Unit: resume-settings.service.spec.ts, resume-settings.controller.spec.ts, resume-settings.module.spec.ts (100% statements/branches/functions/lines on the slice).
  • e2e: test/lexi-resume-settings.e2e-spec.ts — options/get-defaults/get-stored, upsert + idempotency, bare 422 (themeColor + enum), ownership 403, applicant-only 403, download happy/override-no-mutation/stored-settings/404, and the verbatim 429.