CODEX // oaoisme wiki

§ unswayed-backend · Applicant profile

Async resume generation

updated 2026-06-16

Why

POST /applicant/resume/affinda-generate builds a polished, anonymised resume by running a three-stage external pipeline inline in the HTTP request:

  1. Affinda — upload the file, then poll (every 3s, up to 60s) until Affinda finishes parsing it asynchronously.
  2. OpenAI — one enhanceSections call that rewrites the prose.
  3. Render + upload — pdfkit renders the PDF, Cloudinary stores it.

Each stage feeds the next, so they can't be parallelised — the request just waits, routinely 10–35 seconds, holding the connection open the whole time. That's the slowness users hit. (ADR-0040 has the full diagnosis.)

The async variant (ADR-0040)

Rather than change the existing endpoint (which would break mobile clients), a second, asynchronous endpoint was added. The synchronous one is untouched.

POST /applicant/resume/affinda-generate-async   →  202 { job: { id, status:'processing' } }
GET  /applicant/resume/affinda-generate/jobs/:id →  200 { job: { id, status, resume, error } }

The request now returns immediately with a job token. The heavy pipeline runs on the platform queue (BullMQ when Redis is up, the durable DB queue otherwise), and the client either polls the job or awaits a push — the worker fires a resume_ready / resume_failed notification, which the notifications service fans out to persistence + realtime socket + FCM.

The moving parts

  • ResumeGenerationJob (a new table) — the unit of state. A uuid primary key doubles as the client's non-enumerable poll token. status walks processing → ready | failed; on success it links the produced resumeId, on failure it stores a user-safe error.
  • AffindaAsyncServicesubmit (create the job, base64 the ≤5MB file into the queue payload, enqueue with attempts: 1), getJob (read it back, scoped to the owning user via the applicant.userId relation), and the markReady / markFailed transitions.
  • AffindaGenerateHandler — the queue worker. It self-registers on module init (the same pattern the follow-notification handler uses), decodes the file, and runs the exact same pipeline as the sync endpoint — both call the shared AffindaService.runGenerate(applicant, buffer, filename), extracted so the two paths can never drift. It never throws: every outcome is a terminal job state, and a notification failure is swallowed because the job row is the source of truth.

Two deliberate choices

  • attempts: 1 (no retry). The pipeline has side effects — it uploads a PDF to Cloudinary and inserts a resume row. Retrying a half-finished run would produce duplicate resumes, so a failure is terminal and the user simply re-submits.
  • File rides the queue as base64. At ≤5MB and this volume, embedding the bytes in the payload is simpler than staging the upload somewhere and passing a URL (which would leave a temp asset to clean up).

Wiring gotcha worth remembering

NotificationsModule is not a global module (it only exports NotificationsService). The handler needs it, so ApplicantProfileModule had to import NotificationsModule. The unit tests (which mock everything) stayed green; the missing import only surfaced when the e2e suite booted the real module graph and Nest couldn't resolve the dependency — a good reminder that DI wiring is something only an integration test proves.

Contract note

These two routes are additions — they aren't in the read-only endpoint_documentation.md §36, which is left untouched. The divergence is recorded in ADR-0040, docs/API-CONTRACT.md, and docs/FEATURES.md. Deferred: a cron to prune old job rows.