CODEX // oaoisme wiki

§ unswayed-backend

Employer jobs (Phase 4)

updated 2026-06-08

Employer jobs (Phase 4)

Phase 4 is the first place the backend writes a rich record instead of reading reference data. It is the supply side of the job board: an employer creates, lists, edits and deletes job posts; people who follow that employer see those posts in a network feed; and posting a job fans out notifications to interested applicants. Everything lives in src/employer-jobs/ and reproduces the frozen §41 wire contract exactly.

The shape of a job

A job (EmployerJob) is one central row joined to master data through six many-to-many pivots — categories, locations, types, shifts, states, cities — plus an asymmetry that trips people up:

  • country is a single FK (country_id). On the wire it is emitted as one name string.
  • states and cities are many-to-many joins. On the wire they are arrays of names.

That asymmetry is legacy-faithful and deliberate. The schema models the six pivots as explicit join tables (composite PK + timestamps) so the legacy column names survive (employer_job_states.state_id, employer_job_cities.city_id — note the local keys are state_id/city_id, not job_state_id). FK rules matter: employer_id cascades (deleting a job cascade-deletes its pivots), the master-data FKs restrict, and job_shift_timing_id is ON DELETE SET NULL — the column is nullable, so removing a shift-timing reference must null the field, not delete the job (the legacy migration left it cascade from before the column was nullable; we corrected it).

type is a Prisma enum (internal | external) internally but emits the literal string on the wire. salary/salary_max are nullable Int (validated and coerced — never the silent int-or-raw-string the legacy column allowed). status/click_count/requisition_number exist as columns but are never read from the client — the whitelist DTO closes that mass-assignment door.

The five endpoints

All are auth + employer user-type + active-account, resolved once per request by EmployerContextService (a job's employer_id is an employers.id, never a user_id).

Route Behaviour
GET /api/employer/jobs The employer's own jobs, newest first, with a pagination block. search matches the text columns; isRalli filters 1=internal/0=external (an empty value applies no filter — legacy filled()).
POST /api/employer/jobs 201 Job created successfully.; generates the requisition number, attaches the six pivots, fires the create-only notification fan-out.
PUT /api/employer/jobs/{id} 200; same validation as store; replaces all six pivots in one transaction; 403 if the job belongs to another employer; dispatches no notifications. The contract pins PUT, not PATCH.
DELETE /api/employer/jobs/{id} 200 data.job = { id }; the pivots cascade.
GET /api/employer/network-jobs Jobs from employers the caller follows, newest first.

Every read passes through a single reusable deactivatedJobWhere() helper that hides jobs whose owning employer-user is deactivated — the legacy global scope, made explicit so no read path forgets it.

The requisition number

It is server-generated, zero-padded, and never client input. Rather than "pick a random number, SELECT to check, then INSERT", the create does INSERT then catch P2002 (the unique-constraint violation) and retries with a fresh candidate. The DB constraint is global, which is exactly why this satisfies the legacy requirement that the uniqueness check bypass the deactivated scope. RequisitionNumberService.next(attempt) starts at 5 digits and grows the length every 100 collisions.

Two frozen quirks worth knowing

These look like bugs; they are the contract, and clients depend on them:

  1. The POST 201 response emits country: "". Legacy store never eager-loads the country relation before serializing, so the resource's whenLoaded('country', …, '') falls back to an empty string — even though states/cities are populated. index and update do load country and emit the real name. We reproduce this exactly (the store handler forces country: '').
  2. The network feed has no pagination block, and its states/cities are objects. GET /employer/network-jobs returns { jobs: [...] } with no pagination even though the query paginates. And its states/cities are arrays of { name } objects (legacy ->get(['name'])), not the flat name strings that MyJobResource uses (->pluck('name')). In each network row, employer.id is the owner's user_id while employer.employer_id is the employer-profile id.

Placeholder: applications_count is currently a constant 0. The JobApplication model that backs the real count lands in Phase 5; the key/shape are already in place so the value just gets wired through.

Create-only notifications

Posting a job (and only posting — never updating) fires a best-effort fan-out, reusing the Phase-1 plumbing:

  • Alerts. JobNotificationDispatcher matches saved JobAlert rows: an alert matches when, for all six facet dimensions, its saved id array intersects the new job's ids, and the alert's country equals the job's country. Each distinct matched user gets one send-job-alert queue job (consumed by the existing Phase-1 handler).
  • Skill emails (internal jobs only). Active applicants whose stored skills contain any of the job's skills get a queued JobSkillNotificationEmail. The jobRelatedEmails opt-out gate already lives inside that mailer handler, so the dispatcher just enqueues. A user already alerted is not also emailed.

The whole dispatch is wrapped so it never throws and runs after the create commits — a notification failure can never roll back or surface from a successful job creation. (The JobAlert table itself is modelled here read-only; its write surface — saving alerts — is Phase 5.)

How it was built

Shared contract first (the Prisma schema + a few seam files), then three parallel TDD subagents on disjoint slices — CRUD + requisition, the network feed, the notification dispatcher — followed by an adversarial wire-parity review against §41 and the legacy source. The review earned its keep: it caught that the network feed had been given flat-string states/cities (should be {name} objects) and that the dispatcher had no call site yet. Both were fixed during integration. The gate (npm run verify) is green at 1113 unit tests at ≥90% coverage + 141 e2e, with the e2e driving every route, both frozen quirks, the 401/403/404 gates, and the notification enqueue.

See docs/roadmap/phases/PHASE-04-employer-jobs.md for the build spec, ADR-0027 for the decisions, and docs/API-CONTRACT.md for the catalogued deviations.

Subpages