§ unswayed-backend · Profiles (Phase 3)
Applicant profile
The applicant side of Phase 3 — everything an applicant fills in about themselves, plus the résumé machinery. It lives in src/applicant-profile/ as one module with four route groups.
Profile read/update (§29)
GET /applicant/profile and PATCH /applicant/update-profile/{id} both return the same ApplicantResource. The update is a single prisma.$transaction: it updates the User (username, is_completed = true) and upserts the Applicant row by user_id, then re-reads and serializes.
Three rules are enforced before the write:
- Ownership — the path
{id}must equal the authenticated user's id, else 403 "You are not allowed to edit this profile." - Age —
dobmust be ≥ 18 (a class-validator@IsAdultrule with the exact message "You must be at least 18 years old."). - Username — must be unique (excluding the editing user) and may not equal the first/last/first+last name (case-insensitive). Both surface as a 422 keyed under
username.
country/state/city/gender/ethnicity arrive as bare ids (no _id suffix) and are mapped to the columns; on the way out they're resolved to {id, name} objects via MasterDataLookupService. change-profile-pic (POST /applicant/change-profile-pic) uploads through the MediaStoragePort and returns the raw stored URL.
Education CRUD (§30)
List / create / update-via-POST / delete, under /applicant/education. A few legacy quirks are preserved deliberately:
- Update is a
POST(POST /applicant/education/{id}), not PUT/PATCH — because the legacy route was registered that way for multipart file uploads. start_date/end_dateare'm/Y'strings (not dates);is_continueis a"true"/"false"string coerced to a boolean.- Accessing another applicant's row returns 404 "Education not found." (ownership is masked as not-found, never 403).
- Delete returns the id under
data.alert.id; the list is ordered byiddesc.
Education documents upload through the same MediaStoragePort (folder unswayed/educations, raw resource type), and the old file is best-effort deleted on replace.
The résumé pipeline (§35 / §26 / §36)
This is the heaviest part. The whole surface — list, upload, extract-for-autocomplete, build, rebuild, replace, rename, delete, AI-enhance, and the two Affinda paths — runs through one shared ResumeGenerationService that orchestrates four ports:
| Port | Real adapter | Stub (when unconfigured) |
|---|---|---|
ResumeTextExtractorPort |
pdf-parse / mammoth / utf-8 |
— (always real) |
ResumeAiPort |
OpenAI (gpt-4o) — section extraction + enhance |
deterministic empty/identity |
AffindaParserPort |
Affinda HTTP (bounded poll) | returns null |
ResumePdfRendererPort |
pdfkit |
— (always real) |
The port indirection is what makes the pipeline unit-testable without a network and lets e2e run against deterministic stubs. The adapters are chosen at boot by config (OPENAI_API_KEY, the three AFFINDA_* vars); absent → stub.
Behaviours preserved verbatim:
type='upload'vs'build':upload/updatekeep the original file + an OpenAI-filtered PDF and do not prune empty sections;build/rebuild/both Affinda paths render a generated PDF, prune empty sections, and persisttype='build'.- File rules: the OpenAI paths accept a 10 MB file by extension OR MIME (the allow-list deliberately includes
application/octet-streamfor React Native and the zip MIMEs for DOCX mis-detection);affinda-generateis stricter —pdf/docx, 5 MB. extract-auto-completenever persists — it only returns parsedresume_data.- An empty text extraction → 500 "Invalid Resume!…"; a null Affinda extract → 500 on
affinda-uploadbut 422 onaffinda-generate; an empty-after-prune Affinda result → 422. - Enhance runs only on
affinda-generate, neveraffinda-upload. - The generated PDF prints the UCN only, never the candidate's name — locked in by a render test. The UCN is generated at applicant-create (in
UsersService).
The legacy "render the Affinda PDF with type='upload' even though it saves type='build'" copy/paste bug is fixed: both Affinda paths render as a generated résumé while still persisting type='build'. The phase doc's idea of offloading the long Affinda/OpenAI calls to a BullMQ job was deferred — the queue port is fire-and-forget, so the calls stay synchronous (bounded by adapter timeouts), which is what keeps the synchronous wire shape.
POST /enhance (§26) is bound to two routes — the top-level one (any authenticated user) and /applicant/resume/enhance (applicant-only) — both calling the same EnhanceService.
Filter preferences (§38)
A one-row-per-applicant free-form JSON blob for syncing job-search filters across devices. GET returns filter_data: null (never 404) when unset; PUT (the verb is PUT) upserts on the UNIQUE applicant_id so a second write replaces rather than duplicating; DELETE returns 200 even when nothing existed. A size guard rejects an over-large blob (422) — the only "do it right" addition.
Subpages