CODEX // oaoisme wiki

§ unswayed-backend · Employer jobs (Phase 4)

Employer job lifecycle — drafts & status transitions (Phase 15, UN-153)

updated 2026-06-22

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 created published (status 1) and the create-time alert + skill-match notification fan-out fires — exactly the frozen Phase-4 behaviour, untouched.
  • true → the job is created as a draft (status 0) 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.service now adds status: PUBLISHED_STATUS to its load, so a draft/closed/archived job falls through to the existing 404 Job not found.
  • apply.service rejects !isPublished(job.status) with the same 404 before the deadline check. (A published job past its deadline still returns the existing Job Application is Closed 422 — 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

  • status is an integer on the wire, consistent with the existing job-detail resource (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. publish only 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.