CODEX // oaoisme wiki

§ unswayed-backend · Profiles (Phase 3)

Applicant profile

updated 2026-06-05

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."
  • Agedob must be ≥ 18 (a class-validator @IsAdult rule 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_date are 'm/Y' strings (not dates); is_continue is 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 by id desc.

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/update keep 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 persist type='build'.
  • File rules: the OpenAI paths accept a 10 MB file by extension OR MIME (the allow-list deliberately includes application/octet-stream for React Native and the zip MIMEs for DOCX mis-detection); affinda-generate is stricter — pdf/docx, 5 MB.
  • extract-auto-complete never persists — it only returns parsed resume_data.
  • An empty text extraction → 500 "Invalid Resume!…"; a null Affinda extract → 500 on affinda-upload but 422 on affinda-generate; an empty-after-prune Affinda result → 422.
  • Enhance runs only on affinda-generate, never affinda-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