§ unswayed-backend
Jobs & application-flow extras (Phase 18)
Jobs & application-flow extras (Phase 18 — UN-159, UN-164)
Phase 18 adds two ATS capabilities on top of the existing employer/hiring stack: soft-closing a job (with a permanent snapshot and the ability to reopen) and custom application questions (with AI-suggested questions and applicant answers stored on the application). Both build on modules that already existed — the point of this phase was to extend without duplicating. The design decisions live in ADR-0048.
UN-159 — soft-close, snapshot, and reopen
The problem it solves
Employers need to take a job out of circulation without losing its history. A
hard delete would orphan applications and erase analytics. Phase 15 (UN-153)
already gave jobs a lifecycle status integer (draft=0, published=1, closed=2, archived=3) and a PATCH /api/employer/jobs/{id}/close that flipped it — but
that flip did nothing else: applications were left dangling, nobody was notified,
and there was no audit snapshot.
One notion of "closed"
The key design call (ADR-0048) was not to invent a second "closed" state.
The new full close is status=closed (2) — the same state Phase 15 introduced.
What's new is the behaviour around it, which lives in a dedicated
CloseJobService:
- Guard — the caller must own the job and it must currently be published
(else
422 Only a published job can be closed.). - Snapshot — the entire job row (scalars + all six pivots) is serialized
into a new
closed_jobstable (jobDataJSON). This is an immutable audit record that survives even if the job schema later changes. - Soft-delete — the source
employer_jobsrow is kept and just flipped tostatus=closed. Because the row survives, every foreign key from applications, interviews, offers, and scores stays valid. - Archive applications — all still-pending applications for that job are
bulk-updated to
status=archive, and each transition is written to the append-onlyApplicationStatusHistoryso the recruitment-analytics funnel stays accurate. - Notify — after the transaction commits, each affected applicant gets a
job_closedin-app notification. This is best-effort: a notification failure is logged and swallowed, never rolling back the (already-committed) close.
Reopen (POST /api/v1/employer/jobs/closed/:closedJobId/reopen) republishes
the source row (status=published) and stamps reopenedAt on the snapshot so it
drops out of the closed-jobs list. It deliberately does not un-archive the
applications — a reopened job starts fresh.
Two routes, one behaviour
The new bare-camelCase surface is the canonical ATS close:
POST /api/v1/employer/jobs/:jobId/close{reason?}→{message, closedJobId}GET /api/v1/employer/jobs/closed→{data, total, page}(excludes reopened)POST /api/v1/employer/jobs/closed/:closedJobId/reopen→{message, jobId}
To honour "one notion of closed," the legacy PATCH /api/employer/jobs/{id}/close
now simply delegates to CloseJobService.closeOwnedJob and then reloads the
job into its existing MyJobResource snake_case shape. So whether a client closes
via the old PATCH or the new POST, the snapshot is written, applications are
archived, and applicants are notified — identically.
Keeping closed jobs out of matching
A closed job must stop being discoverable and stop being a MERIT scoring
target. Discovery was already gated on status=1 (career search, job detail,
apply, the dashboards), so closing a job removed it from those automatically.
This phase closed the remaining gaps on the scoring side:
CompatibilityScoringService.runScoreJob(the queued scorer) andcomputeForCandidate(the on-demand path) now skip any job that isn't published — no score is computed or persisted.JobDiscoveryService.recommended()filters its candidate-score query byjob.status = published, so a closed job can't surface in an applicant's recommended feed even if they were scored against it before it closed.
UN-164 — custom application questions
What employers get
An employer can attach up to 10 custom questions to a job. Each has a
questionType of text, yes_no, or multiple_choice (the last carries an
options array), an isRequired flag, a display order, and a source
(employer_written or lenox_ai_suggested — provenance tracking for adopted AI
suggestions). The CRUD lives in the new src/job-questions/ module on the
bare-camelCase v1 surface:
POST /api/v1/employer/jobs/:jobId/questions— body is a bare JSON array (bound withParseArrayPipe); validates the ≤10 cap, the type/option rules, and that the job is still editable.GET /api/v1/employer/jobs/:jobId/questionsDELETE /api/v1/employer/jobs/:jobId/questions/:questionId
Questions are edit-locked once the job is closed (422 Questions cannot be edited once the job is closed.) — only draft or published jobs accept
changes.
Lenux AI suggestions
GET /api/v1/employer/jobs/:jobId/lenox/suggest-questions returns exactly
three typed suggestions generated from the job's title, description, skills, and
type. It reuses the Lenux machinery wholesale: its own AiCompletionPort binding
(per ADR-0042), the questionGeneration feature flag, a 30/hour/company rate
limit, and the same JSON-mode generate → parse → validate → retry pattern the
interview-question generator uses. The suggestions are stateless — nothing is
persisted until the employer explicitly saves one with source: lenox_ai_suggested. Edge behaviour:
- A thin job (the model can't produce 3 valid typed questions) → three graceful professional defaults, never an error.
- A job missing a title or description →
422. - An unconfigured AI provider →
503(the user-safe "AI is not configured" message).
Answers ride the existing apply path
The most important UN-164 decision (ADR-0048) was to not build a second apply
endpoint. The real apply is the rich, multipart
POST /api/applicant/job/{id}/apply (reused internally by Lexi auto-apply), so we
extended it:
ApplyDtogained an optionalanswersfield. On a multipart body it arrives as a JSON string, so a@Transformparses it before the array validators run (it must be declared on the DTO because the global ValidationPipe runs withwhitelist: trueand would otherwise strip it).- A new
ApplicationAnswersService(exported fromJobQuestionsModule, injected intoApplyService) does the work in two halves:validate()runs in the apply read phase — so a bad answer set is rejected before any file upload — andpersist()runs inside the apply transaction, so answers commit atomically with the application. Validation enforces three rules: every required question must have a non-empty answer, every answer must target a real question for that job, and amultiple_choiceanswer must match one of the defined options. - Auto-apply passes no answers; a job with required questions simply can't be
auto-applied (it logs
failed), which is the correct behaviour.
Applicants can preview the questions before applying via
GET /api/v1/applicant/jobs/:jobId/questions. The submitted answers surface on
the shared application detail (GET /api/application-details/{id}) as
custom_answers, visible to both the employer and the applicant.
Data model
Three new tables and two enums:
closed_jobs— the close snapshot (jobDataJSON +reason,closedBy,closedAt,reopenedAt).job_custom_questions— the questions (questionType,options,isRequired,source,order).application_question_answers— one row per submitted answer.- enums
JobQuestionTypeandJobQuestionSource.
Deliberate deviations from the tickets (recorded in ADR-0048): the tickets
sketched UUID foreign keys, but EmployerJob.id and JobApplication.id are
auto-increment integers, so the FK columns are Int while the new tables' own PKs
are UUID; the detail field is snake_case custom_answers to match that legacy
resource; and the suggest route keeps the ticket's "lenox" spelling in its path
even though the codebase otherwise spells the assistant "Lenux."
How it was built
Contracts first (the Prisma schema + migration, a shared job-question.types.ts,
and the ApplicationAnswersService seam), then five parallel TDD slice agents
on disjoint file sets — close-job, the scoring/discovery guards, the question
CRUD + applicant view, the Lenux suggestion service, and the apply-answers + detail
integration — then orchestrator integration (wiring JobQuestionsModule, adding
NotificationsModule to the employer-jobs module, importing JobQuestionsModule
into the applications module, and converting JobsService.close into a
delegation). The whole thing is covered by npm run verify and a 35-case
test/phase-18-jobs-application-flow.e2e-spec.ts.
A worth-remembering gotcha: SWC (which runs the unit tests) doesn't type-check, so
a handful of Int-vs-String id mismatches in test fixtures only surfaced under
tsc during the gate — a good reminder that the coverage run and the typecheck
catch different classes of error.