§ unswayed-backend · API contract & docs
Lexi AI — resume settings & download (Phase 14, UN-108)
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
- The per-download
incognitooverride never mutates stored settings. It is read into the response and used to pick the render, but no write touchesresume_settings. An e2e test asserts the storedincognitoModeis unchanged after aincognito:falsedownload. - The bare 422 needs
@Res(). The global exception filter only passes the rate-limit body through verbatim; any otherHttpExceptionis re-wrapped into the error envelope (keeping onlymessage/errors/action). To emit the Jira's{ error, fields }shape, the controller catches the service'sResumeSettingsValidationErrorand 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.