CODEX // oaoisme wiki

§ unswayed-backend

Jobs & application-flow extras (Phase 18)

updated 2026-06-25

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:

  1. Guard — the caller must own the job and it must currently be published (else 422 Only a published job can be closed.).
  2. Snapshot — the entire job row (scalars + all six pivots) is serialized into a new closed_jobs table (jobData JSON). This is an immutable audit record that survives even if the job schema later changes.
  3. Soft-delete — the source employer_jobs row is kept and just flipped to status=closed. Because the row survives, every foreign key from applications, interviews, offers, and scores stays valid.
  4. Archive applications — all still-pending applications for that job are bulk-updated to status=archive, and each transition is written to the append-only ApplicationStatusHistory so the recruitment-analytics funnel stays accurate.
  5. Notify — after the transaction commits, each affected applicant gets a job_closed in-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) and computeForCandidate (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 by job.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 with ParseArrayPipe); validates the ≤10 cap, the type/option rules, and that the job is still editable.
  • GET /api/v1/employer/jobs/:jobId/questions
  • DELETE /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:

  • ApplyDto gained an optional answers field. On a multipart body it arrives as a JSON string, so a @Transform parses it before the array validators run (it must be declared on the DTO because the global ValidationPipe runs with whitelist: true and would otherwise strip it).
  • A new ApplicationAnswersService (exported from JobQuestionsModule, injected into ApplyService) does the work in two halves: validate() runs in the apply read phase — so a bad answer set is rejected before any file upload — and persist() 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 a multiple_choice answer 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 (jobData JSON + reason, closedBy, closedAt, reopenedAt).
  • job_custom_questions — the questions (questionType, options, isRequired, source, order).
  • application_question_answers — one row per submitted answer.
  • enums JobQuestionType and JobQuestionSource.

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.