CODEX // oaoisme wiki

§ unswayed-backend

Hiring pipeline (Phase 6)

updated 2026-06-09

Hiring pipeline (Phase 6)

Phase 5 ends with a job application sitting at status='pending'. Phase 6 is the machine that moves it — interview invites, offers, accept/decline, rejection — on both sides of the table, with the emails, push notifications, and audit timeline that go with each step. It closes the job-board core loop: job (Phase 4) → application (Phase 5) → interview/offer → hired.

Two modules: src/hiring-pipeline/ (this page + the Google Calendar subpage) and src/employer-reviews/ (its own subpage).

One state machine to rule the strings

Every status value, history type, and pinned error message in this phase is emitted verbatim on the wire, so they live in exactly one place: src/hiring-pipeline/application-status.machine.ts. It maps each employer action and each applicant response to a descriptor — the new JobApplication.status, the interview/offer record status, and the history row's type/title/description. Both endpoint groups consume the same table, so they cannot drift.

This is also where the Phase 5 deferral closes: JobApplication.status is now the JobApplicationStatus Prisma enum (pending, interview_invite, interview_accept, interview_decline, offer_letter_sent, counter_offer_letter_sent, position_filled, offer_decline, archive — the exact legacy strings). The migration converts the column with a USING cast, which is data-safe because Phase 5 only ever wrote 'pending'.

The employer action (§42)

PATCH /api/employer/job-application/{id}/action is one endpoint with four types:

  • reject → application status='archive' + rejection_reason, history application_rejected.
  • interview → creates a JobInterview (proposed dates as JSON, meeting_link, timezone), application → interview_invite, history row carrying the interview id + link.
  • offer_letter / counter_offer_letter → creates a JobOffer — the only thing distinguishing the two is title ('Offer Letter' / 'Counter Offer Letter'); salary is a free-text string (legacy altered the column from decimal to string so ranges fit). Application → offer_letter_sent / counter_offer_letter_sent.

Two invariants worth remembering:

  1. The applicant's ucn is denormalized onto every interview and offer at creation — the employer-side UI searches and displays candidates by UCN, never by name (the anonymisation thread that started with the Phase 3 resume builder).
  2. No offer until an interview is accepted. The service checks for an interview_accept history row and otherwise returns the byte-exact legacy 422: "An offer cannot be sent until the applicant has accepted an interview invitation."

The whole write — status transition, interview/offer row, history row — runs in one prisma.$transaction. The in-app notification and the branch's queued email (application-rejected / interview-invite / offer-received) fire after the commit, best-effort (try/catch + a warn log). That ordering is a deliberate legacy bug fix: the old code ran the notification service inside the transaction and rethrew on FCM failure, so a slow push could roll back a committed hiring decision. Here it can't.

The read side, GET /api/employer/job-applications{/jobId}, lists the employer's applicants: searchable by UCN, filterable archived/unarchived (the employer_visible flag — each side of the table has its own archive view), always excluding status='archive', with a 403 if the jobId in the path belongs to someone else's job. (Note the route syntax: Nest 11's router is path-to-regexp v8, where the old :jobId? optional-param form throws at route registration — the modern form is {/:jobId}. Unit tests never catch this class of bug; only booting the real router does, which is one reason the e2e layer exists.)

The applicant response (§34)

POST /api/applicant/job-application/{id}/interview-response and .../offer-response — and the first thing to know is that {id} is the interview/offer id, not the application id, despite what the path suggests.

  • Interview accept (requires selected_date) → application interview_accept; decline (requires reason) → interview_decline.
  • Offer accept → application position_filled (the terminal happy state); declineoffer_decline.

Each response checks, in order: the record exists (404), the application belongs to the caller (404), the record hasn't expired — 30 days from createdAt, computed via ClockService — which returns a 403 with the exact legacy message ("Interview invite has been expired!" / "Offer has been expired!"), and finally the new pending-only guard: a response is legal only while the record is still pending. That last check is the "do it right this time" fix — legacy never re-validated state, so an applicant could decline an already-accepted offer within the window.

Side effects mirror the employer flow (post-commit, best-effort): declines queue a decline-notification email to the employer (one template, branching on whether it was an interview or an offer); an offer accept queues two emails — congratulations to the applicant, good-news to the employer.

The detail reads (§22)

GET /api/application/{id}/interview-detail and .../offer-detail serve both user types, ownership-scoped: the owning applicant and the issuing employer may read; anyone else gets 401 'Unauthorized!' (not 403 — legacy's choice, frozen). A missing record is 404 ("Interview Not Found!" / "Offer Letter Not Found!").

The subtle rule: is_expired is computed only while the record is pending — an interview accepted six months ago reads is_expired: false, because expiry only ever gated the response, and a responded record has nothing left to expire.

Where things land in the data model

Two new tables — job_interviews and job_offers — both Cascade-owned by the application and the employer, both carrying the copied ucn, a shared InterviewOfferStatus enum (pending/accept/decline), and every column defined up front (meeting_link, both reasons…). That last point is another legacy-bug fix: the old models listed fields in $fillable years before the columns existed, so writes silently no-op'd. With Prisma the schema is the model — the drift class is gone.

JobApplicationHistory (created in Phase 5 with a single writer) now has its primary writer: every transition appends a row, and the Phase 5 my-activities timeline surfaces the full story.

How it was built

The implementation landed shared-contract-first (schema + state machine, then the endpoint groups against it) with TDD unit suites per slice. This session completed the phase to its Definition of Done: fixed the lint debt and a stale configure-app spec, fixed the :jobId? route crash that broke every e2e suite at boot, then authored the three missing e2e suites via a three-slice author → adversarial-review → patch agent workflow — each agent iterating red→green against its own isolated Postgres database — and ran the full gate green (lint + typecheck + ≥90% coverage + e2e; exact counts in the repo memory entry docs/ai/memory/0014-phase-6-hiring-pipeline.md).

See docs/roadmap/phases/PHASE-06-hiring-pipeline.md for the build spec and ADR-0029 for the decisions.

Subpages