§ unswayed-backend · Employer jobs (Phase 4)
Employer job lifecycle — drafts & status transitions (Phase 15, UN-153)
Employer job lifecycle — drafts & status transitions
Phase 15 · UN-153 · ADR-0044. This extends the Phase-4 employer-jobs module (src/employer-jobs/) so an employer can save a job as a draft before publishing it, and move it through a lifecycle: Draft → Published → Closed → Archived.
The core idea: reuse the status integer
EmployerJob.status has always been an Int (default 1), described in the schema as "carried; never client-trusted." Critically, every public read already filters on status = 1 — the career search, the public job-detail load, and the apply path. So 1 was already "the published/visible state" without anyone calling it that.
Rather than add a new enum column (and disturb the frozen §41 wire), Phase 15 gives those integers names in one shared module, src/employer-jobs/job-status.ts:
export const JOB_STATUS = { draft: 0, published: 1, closed: 2, archived: 3 } as const;
export const PUBLISHED_STATUS = 1;
isPublished(value) // value === 1
jobStatusName(value) // 0 → 'draft', …
jobStatusValue(name) // 'draft' → 0, …
There is no schema/migration change for the lifecycle — only the meaning layered on top of the existing column. 1 = published stays the default, so nothing about existing jobs changes.
Creating a draft
POST /api/employer/jobs gains one optional body field, save_as_draft (boolean):
- omitted /
false→ the job is createdpublished(status1) and the create-time alert + skill-match notification fan-out fires — exactly the frozen Phase-4 behaviour, untouched. true→ the job is created as adraft(status0) and the fan-out is skipped. A draft is not announced to anyone.
The status is set only in JobsService.create's data object — not in the shared scalars() helper that update() also uses, so editing a draft never silently flips its status.
The transitions
Three employer-only, ownership-checked endpoints drive a small state machine:
| Endpoint | Allowed from | Becomes | Side effect |
|---|---|---|---|
PATCH /api/employer/jobs/{id}/publish |
draft |
published |
fires the alert/skill fan-out (followers are notified when the job goes live, not when the draft was saved) |
PATCH /api/employer/jobs/{id}/close |
published |
closed |
— |
PATCH /api/employer/jobs/{id}/archive |
published or closed |
archived |
— |
Each resolves the owning employer (EmployerContextService.require), 404s Job not found. for an unknown id, 403s You are not allowed to edit this job. for a foreign job, and otherwise validates the current status. An invalid transition is a keyed 422 with an exact message:
Only a draft job can be published.Only a published job can be closed.Only a published or closed job can be archived.
publish() reloads the full job (all six pivots) so the controller can feed JobNotificationDispatcher.dispatchJobCreated; close()/archive() return the lighter MyJobResource.
"Published-only is public"
The requirement is "drafts are not discoverable/applyable until published." Phase 15 enforces a single rule — only status = 1 is public — across the three public paths:
- Career search already filtered
status = 1(unchanged). job-detail.servicenow addsstatus: PUBLISHED_STATUSto its load, so a draft/closed/archived job falls through to the existing404 Job not found.apply.servicerejects!isPublished(job.status)with the same 404 before the deadline check. (A published job past its deadline still returns the existingJob Application is Closed422 — that path is untouched.)
So draft / closed / archived jobs behave as if they don't exist to the public. The owning employer still sees and manages every one of their jobs.
The employer's own view
GET /api/employer/jobs gains an optional ?status=draft|published|closed|archived filter (mapped to the int via jobStatusValue); unset returns all the employer's jobs, drafts included. And MyJobResource now emits a status integer so the client can show each job's lifecycle state.
Gotchas
statusis an integer on the wire, consistent with the existingjob-detailresource (status: 1). It is not a string.- The fan-out fires once — at create for a published job, or at publish for a draft — never twice.
publishonly works from draft, so a published-on-create job can't be "published" again. - Closed = withdrawn from the public. Closing a job 404s its public detail page (the "published-only is public" rule). That's a deliberate, revisitable product choice (ADR-0044), not the deadline-based "closed for applications" behaviour, which is separate and unchanged.