§ unswayed-backend
Employer jobs (Phase 4)
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:
countryis a single FK (country_id). On the wire it is emitted as one name string.statesandcitiesare 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:
- The
POST201 response emitscountry: "". Legacystorenever eager-loads thecountryrelation before serializing, so the resource'swhenLoaded('country', …, '')falls back to an empty string — even thoughstates/citiesare populated.indexandupdatedo loadcountryand emit the real name. We reproduce this exactly (the store handler forcescountry: ''). - The network feed has no pagination block, and its
states/citiesare objects.GET /employer/network-jobsreturns{ jobs: [...] }with nopaginationeven though the query paginates. And itsstates/citiesare arrays of{ name }objects (legacy->get(['name'])), not the flat name strings thatMyJobResourceuses (->pluck('name')). In each network row,employer.idis the owner's user_id whileemployer.employer_idis the employer-profile id.
Placeholder:
applications_countis currently a constant 0. TheJobApplicationmodel 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.
JobNotificationDispatchermatches savedJobAlertrows: 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 onesend-job-alertqueue 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. ThejobRelatedEmailsopt-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