§ unswayed-backend · Applicant profile
Async resume generation
Why
POST /applicant/resume/affinda-generate builds a polished, anonymised resume by
running a three-stage external pipeline inline in the HTTP request:
- Affinda — upload the file, then poll (every 3s, up to 60s) until Affinda finishes parsing it asynchronously.
- OpenAI — one
enhanceSectionscall that rewrites the prose. - 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.statuswalksprocessing → ready | failed; on success it links the producedresumeId, on failure it stores a user-safeerror.AffindaAsyncService—submit(create the job, base64 the ≤5MB file into the queue payload, enqueue withattempts: 1),getJob(read it back, scoped to the owning user via theapplicant.userIdrelation), and themarkReady/markFailedtransitions.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 sharedAffindaService.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.