Skip to content

04 — Real API contract & integration guide (Contractor web app)

The backend (smart-work-permit-api, Elysia + Prisma + better-auth) is built and running. This document replaces the assumptions in 01-backend-elysia-tasks.md wherever the two disagree: that file was the plan, this is what the server actually does.

Source of truth is docs/api/openapi.json in this repo — generated from a live boot, never hand-edited. Regenerate it after any backend change:

bash
cd ../smart-work-permit-api && ./scripts/dump-openapi.sh     # writes docs/openapi.json
cp ../smart-work-permit-api/docs/openapi.json docs/api/openapi.json

Prose below summarizes; on any conflict the JSON wins. It was first verified against a running server on 2026-08-17, re-checked against docs/openapi.json and the route guards on 2026-09-10 after CR round 3 replaced the work-window fields, made a worker an entity, and added areas, area drawings, permit coordinates and inspector visits — and re-checked again against the current router/model files on 2026-09-12 (wayfinder 128), after CR round 4 deleted Area entirely and replaced it, and the permit's geo coordinate, with a Pin on a named FacilityPlan (wayfinder 104/105/106).


1. Invariants

ThingValue
Base path/api/v1 — app prefix /api + module prefix /v1/<module>
CasingcamelCase in both directions. Do not run humps over requests or responses
Success body{ "message": "success", "data": … }
Paginated body{ "message", "data": [], "count", "page", "limit", "totalPage" }
Pagination querypage=1, limit=10, sortBy=createdAt, sortOrder=desc, search=''
Error body{ "code": <http status>, "message": string, "errorCode"?: string }
Dates outISO-8601 UTC strings
Dates instartDate, endDate, issuedDate, expiryDate, dateFrom, dateToYYYY-MM-DD; dailyStart, dailyEndHH:mm / HH:mm:ss clock time, no date part

Two responses deliberately break the success envelope: login answers {success, data:{token,user}} and the health route GET / (outside /api) answers a bare object. Everything else is enveloped.

Date fields are asymmetric — you send 2026-08-18, you get back 2026-08-18T00:00:00.000Z. Format them for display; do not feed a response value straight back into <input type="date">.

dailyStart / dailyEnd are worse than asymmetric and this is the one trap in this document worth memorizing. They are Postgres TIME columns — a clock time with no date at all — and Prisma reads every one of them back anchored to 1970-01-01T00:00:00Z. Convert them to local time before displaying them. Rendering them as a UTC wall clock shifts every migrated permit by the deployment's offset, and nothing errors: the backfill preserved each permit's real instant, so the wrong render is quietly plausible.

workDate, workTimeStart and workTimeEnd no longer exist, in either direction. Sending them is not a rename that still works; they are gone.

Enums, exactly:

EnumValues
PermitTypehot confined heights
PermitStatusDRAFT PENDING REJECTED ACTIVE FIRE_MONITOR CLOSED EXPIRED
UserRolecontractor safety_officer inspectorthere is no admin
JsaPhasepre process post
EntrantDirectionIN OUT (uppercase)

Authorization: Bearer <token> returns 401. Verified: no better-auth bearer plugin is mounted. The session lives in a cookie.

POST /api/v1/auth/user/public/login   { email, password }        # password min length 8
  → 200 { success: true, data: { token, user: { id, name, firstName, lastName, email, image, role } } }
  → Set-Cookie: better-auth.session_token=…            (dev:  SameSite=Lax, no Secure)
  → Set-Cookie: __Secure-better-auth.session_token=…   (prod: SameSite=None; Secure)
  • user.role is the domain role (permitRole in the DB). Route by it; a contractor account always reports contractor.
  • The returned token is not an API credential here. Keep it only as a client-side "is logged in" marker if your router guard wants one.
  • The cookie name changes between dev and prod — better-auth adds the __Secure- prefix when useSecureCookies is on, which is NODE_ENV === 'production'. Never hardcode the name.

Client requirements:

  1. withCredentials: true on the axios instance (already set in this repo's HttpRequest.ts).
  2. The API's CORS_ORIGIN must name this app's exact origin — credentialed CORS refuses *. Ask for your dev origin to be added; the API's .env.example ships CORS_ORIGIN="http://localhost:8080,http://localhost:8081".

Other auth routes: POST /auth/user/logout, POST /auth/user/public/user-request-password-reset{email}, POST /auth/user/public/user-reset-password {newPassword, token}, GET /auth/user/check-bearer/user (session probe).

Two things that do not exist, despite appearing in older docs or in this repo's provider: pre-login, check-token-reset-password, and every *-branch endpoint (active-branches, select-active-branch, approve-branch, reject-branch) — those are lending-app leftovers. Also, better-auth's own /api/auth/* routes are not mounted; only the routes listed here exist.

Self-registration is disabled in this deployment (USER_DISABLE_SIGNUP). Contractor accounts are provisioned by a safety officer via POST /api/v1/users.


3. What a contractor account may call

Role column below: allowed for contractor, 403 FORBIDDEN_ROLE, * any signed-in user.

Permits — /api/v1/permits

MethodPathContractorNotes
GET/*Paginated. Scoped to your own permits automatically — the contractorId filter is ignored for contractor accounts. Filters: status, type, dateFrom, dateTo; search matches id/title/location/foreman
POST/Required: {type, title, foreman, startDate, endDate, dailyStart, dailyEnd}. Optional: description, location, scheduleNote, outdoorWork, ppeDeclared, ppeNote, pinId — the pin the contractor selects, placed and named by safety (src/modules/permit/commands/create/create.model.ts). → DRAFT, id WP-{HOT|CONF|HT}-{YYYYMMDD}-{NNN}
GET/:id*Full detail. 403 on someone else's permit
PATCH/:idThe wizard's save. All fields optional — see §4
POST/:id/submitPENDING. This is where server-side validation bites — see §5
POST/:id/mark-completeHot work onlyFIRE_MONITOR, starts the 30-minute fire watch. 403 NOT_HOT_WORK, 403 PERMIT_NOT_ACTIVE
POST/:id/approvesafety_officer
POST/:id/rejectsafety_officer
POST/:id/closesafety_officer only (wayfinder 098, 2026-09-11 — reverses "closure is the foreman's action"; contractor/inspector request instead, below). Always 403 CLOSURE_REASON_REQUIRED without reason. 403 FIRE_WATCH_NOT_ELAPSED unchanged. ENTRANTS_STILL_INSIDE is retired — a Confined Space close with entrants still checked in now succeeds, auto-checking each out with 'system' provenance
POST/:id/close-requestwayfinder 098 — contractor (own permit) or inspector (any) raises a request; does not close. 403 PERMIT_NOT_ACTIVE outside ACTIVE/FIRE_MONITOR
GET/:id/qr*{ token }. 403 PERMIT_NOT_ACTIVE until approved
GET/qr/:tokenpublicLive status projection: {id,type,title,location,status,entrantCount,fireWatch,latestSafetyReading}. Rate-limited 30 req/60 s → 429 RATE_LIMITED
GET/:id/entrants*[{workerId, workerName, checkedInAt}] — who is currently inside. workerName is echoed for display; identity is workerId
GET/:id/gas-log*{ data: entries, overdue: boolean } — note overdue sits beside data
POST/:id/gas-loginspector
POST/:id/entrants/scaninspector
GET/:id/audit*Hash-chained audit rows for your permit
POST/:id/entrants/scaninspector — body {workerId, direction, offlineClientId?, source?}. The worker's QR card encodes the id
POST/:id/inspector-visitsinspector — starts one visit at scan time. Changes no permit field
POST/:id/inspector-visits/:visitId/submitinspector — fills the visit once with {ppeChecklist?, notes?, photos?}. No PATCH route exists
GET/:id/inspector-visitsinspector and safety_officer only. Whether a contractor may read these is an open question (wayfinder 083)

Certificates — /api/v1/certificates

MethodPathContractorNotes
GET/*Paginated, scoped to your own. Rows carry a computed expired — do not recompute it
POST/{workerId, certType, issuedDate, expiryDate, filePath?}. workerName and role were dropped — the worker is a row now, and role only ever copied the person's own role onto every card
PATCH/:idSame body, all optional
GET/worker/:workerId*That worker's certificates. Keyed by id, not by name

Workers — /api/v1/workers

A worker is an entity as of 2026-09-09. workerName exists on no table: Certificate, PermitWorker and EntrantEvent all carry workerId NOT NULL.

MethodPathContractorNotes
GET/*Paginated. Contractors read their own; officers and inspectors read all
GET/:id*One worker
POST/{name, role, idCardNo?, phone?}. 409 WORKER_ALREADY_EXISTS carries the existing workerId — an inline "create worker" should select that worker, not strand the user on an error they cannot act on
PATCH/:idYour own workers only
DELETE/:idSoft delete (deletedAt). A worker on a permit is never hard-deleted

Uniqueness is per contractor, case- and whitespace-insensitive, enforced on a derived nameKey. Clients never send nameKey. The worker's QR card encodes the workerId, which is what the entrant scan reads.

Pins — /api/v1/pins

Area (and its grants, approve/reject flow) is deleted — wayfinder 106/121/122. A Pin is a named position on a FacilityPlan, placed and named by safety; the contractor selects one instead of proposing their own (wayfinder 104/105). Verified against smart-work-permit-api/src/modules/pin/pin.module.ts and its controllers.

MethodPathContractorNotes
GET/*Every role may read (wayfinder 104 ruling 19 — the per-contractor scoping Area had is explicitly not carried forward). ?planId= filters to one plan; ?active=true is the contractor's picker (pin and its plan both active); ?active=false is the safety management view of retired pins; omit for everything
GET/:id*One pin by id, active or not — any row referencing a pin must always resolve it
POST/safety_officer. {planId, name, x, y}x/y are percentages of the plan image frame. Position is frozen from here on; no write path for x/y past create
PATCH/:idsafety_officer. {name} only — the one editable field a pin has past create
POST/:id/deactivatesafety_officer. Retires the pin without deleting it; still resolves by id for anything that references it. Idempotent

Facility plans — /api/v1/facility-plans

Plans are a flat, independently-active named set with immutable images (wayfinder 104) — not a version chain. GET .../active (singular) is gone; there is no "the active plan" concept once several can be active at once (see the "Everything else" table below).

MethodPathContractorNotes
GET/*Every role may read. ?active=true/?active=false filter; omit for everything
GET/:id*One plan version by id, active or not — a permit frozen against an old version must always resolve it
POST/uploadsafety_officer. Multipart image upload for a new plan version → {filePath, …}, passed to POST /
POST/safety_officer. {name, fileRef} — registers a new, inactive plan from an already-uploaded image
POST/:id/activatesafety_officer. Makes this version active; the frame is frozen from here on. Plans are independently-active — activating one never deactivates another
POST/:id/deactivatesafety_officer. Retires a named plan without deleting it; every pin on it and every permit frozen against it keeps resolving it by id. Idempotent

A permit's pin is optional at submit unless the deployment has an active plan with at least one active pin on it — see PERMIT_POSITION_REQUIRED in §5. There is no AREA_REQUIRED-style opt-in flag; this gate is implicit-by-data (src/modules/permit/commands/submit/submit.service.ts).

Everything else

MethodPathContractorNotes
GET/api/v1/notifications*limit only (default 50), no pagination envelope, unread first
POST/api/v1/notifications/:id/dismiss*id is numeric
POST/api/v1/upload*multipart file, optional subFolder{fileUrl, filePath, fileType, originalName}
GET/DELETE/api/v1/file?filePath=*signed URL / delete
GET/api/v1/auditsafety_officer — facility-wide log
GET/api/v1/dashboard/summarysafety_officer
POST/api/v1/sync/batchinspector — offline replay
POST/api/v1/userssafety_officer — account provisioning

4. The permit wizard against PATCH /api/v1/permits/:id

One endpoint backs every step. All fields optional; send only what the step changed.

jsonc
{
  "title": "…", "description": "…", "foreman": "…",
  "location": "…",            // free-text note; nothing queries it (wayfinder 036/106)

  // The work window: a calendar range plus ONE daily window repeating across it
  "startDate": "2026-08-18",
  "endDate":   "2026-08-20",
  "dailyStart": "08:00",      // clock time, NO date part
  "dailyEnd":   "17:00",
  "scheduleNote": "not working Sat/Sun",
  "outdoorWork": false,

  // wayfinder 097 — full replace on every PATCH that includes the key
  "ppeDeclared": ["helmet", "harness"],
  "ppeNote": "…",

  // GAPS.md row J — contractor create-wizard step 3 "Safety Checks", Yes/No/N-A per item
  // (13-17 items depending on permit type, keyed like `hot-1`..`hot-17`). Pre-work reference
  // data, NOT a safety gate — no server-side validation of item membership/completeness, no
  // submit/approve gate, no audit-log row. Whole-array replace on every PATCH that includes the
  // key (same convention as jsaSteps/workers above, not photos' per-slot upsert — there is no
  // "slot" in a single JSON column). Omit to leave unchanged, send `null` to clear. Distinct
  // from `closureChecklist` below — that one is a different, close-time-only checklist.
  "preWorkChecklist": [{ "itemKey": "hot-1", "answer": "yes" }, { "itemKey": "hot-2", "answer": "na" }],

  // Where. wayfinder 105/106: this ONE field REPLACES `areaId`, `position` (`{planId, planX,
  // planY}`) and the geo coordinate (`mapUrl`/`latitude`/`longitude` — wayfinder 068, reversed
  // the day after it shipped; those fields no longer exist on the wire in either direction).
  // The pin is placed and named by safety (wayfinder 104); the contractor only selects one.
  // Omit to leave unchanged, send `null` to clear, send an id to set/replace.
  "pinId": 12,

  // REPLACED WHOLESALE — send the full list every time, not a delta
  "jsaSteps": [{ "phase": "pre", "step": "…", "hazard": "…", "control": "…", "sortOrder": 0 }],
  "workers":  [{ "workerId": 42, "roleOnPermit": "…", "bloodPressure": "…", "alcoholReading": "…" }],

  // APPENDS a new reading row; the permit exposes only the latest one back
  "safetyReading": { "lel": 0, "o2": 20.9, "co": 3, "wind": 12, "height": 8 },

  // UPSERT per slotKey. fileRef is the `filePath` returned by POST /upload
  "photos": [{ "slotKey": "before", "fileRef": "permits/abc.jpg", "originalName": "…", "fileType": "image/jpeg" }]
}

Four things about this endpoint that a reader gets wrong:

  • workers[] sends workerId, never a name. Register the worker first (POST /workers, which answers 409 WORKER_ALREADY_EXISTS with the existing id when they are already on file).
  • pinId accepts only an id that resolves (prisma.pin.findFirst, existence only — not active; a client can still detect a since-deactivated pin itself). A bad id is a plain 400 Bad Request with no errorCode — same convention as every other request-validation 400 (src/modules/permit/commands/create/create.service.ts, src/modules/permit/commands/update/update.service.ts).
  • PATCH {} is refused with 400 PERMIT_UPDATE_EMPTY. Presence of a key is what counts, not its value: jsaSteps: [] and pinId: null are real edits (a deliberate clear).
  • 403 PERMIT_NOT_EDITABLE in ACTIVE, FIRE_MONITOR, CLOSED and EXPIRED. DRAFT and REJECTED edit normally. PENDING is accepted as a withdrawal: the same transaction drops the permit back to DRAFT, audits it and re-notifies the officers. Warn the user before the edit begins — it leaves the review queue and must be resubmitted.

The permit detail response shape (what you render):

jsonc
{
  "id": "WP-HOT-20260817-001", "type": "hot", "status": "DRAFT",
  "title": "…", "description": "…", "location": "…", "foreman": "…",
  "startDate": "…", "endDate": "…", "dailyStart": "…", "dailyEnd": "…",
  "scheduleNote": "…", "outdoorWork": false,
  "ppeDeclared": ["helmet"], "ppeNote": "…",
  "preWorkChecklist": [{ "itemKey": "hot-1", "answer": "yes" }] | null,
                           // GAPS.md row J — contractor step-3 answers, read-only for the
                           // Safety/Inspector app. Distinct from closureChecklist below.
  "createdById": "…", "createdBy": { "id", "email", "firstName", "lastName" } | null,
  "createdAt": "…", "updatedAt": "…",
  "submittedAt": null, "approvedById": null, "approvedBy": null, "approvedAt": null,
  "rejectedReason": null, "rejectedAt": null,
  "closedById": null, "closedBy": null, "closedAt": null, "closureChecklist": {},
  "closeRequestedAt": null, "closeRequestedById": null, "closeRequestedBy": null,
  "closeRequestedRole": null, "closeRequestReason": null,
  "fireMonitorStartedAt": null, "qrIssuedAt": null,
  "pinId": 12,             // wayfinder 105 — REPLACES planId/planX/planY/areaId/latitude/longitude.
                           // null means "unplaced" (pre-105 permits were not migrated, ruling 10)

  "entrantCount": 0,
  "fireWatch": {  } | null,
  "gasReadingStatus": { "dueAt", "overdue", "graceEndsAt", "escalated" } | null,
                                // NULL unless Confined Space in ACTIVE/FIRE_MONITOR
  "overlappingPermits": { "checked": false, "permits": [] },
                                // advisory ONLY; `checked:false` means "no pinId, nothing compared"
                                // (wayfinder 105 re-keyed this from `areaId` to `pinId`)
  "jsaSteps": [  ],            // FLAT, each with a `phase` — group client-side
  "workers":  [  ],            // `workerId` + the worker's `name`/`role` echoed for display
  "photos":   [  ],
  "latestSafetyReading": {  } | null,  // singular, and named this — not `safetyReading`
  "validationSummary": { "scope": "safety_readings", "passed", "failures": [  ] }
}

gasReadingStatus is the server's verdict on a 2-hour re-test interval with a 30-minute grace. Render it; never recompute the threshold. overlappingPermits never gates anything — checked: false and checked: true, permits: [] must not share one reassuring empty state.

Author fields (createdBy, approvedBy, closedBy) are objects or null, never name strings.


5. Errors — map the code, never render the message

Complete errorCode vocabulary:

GAS_OUT_OF_RANGE  LEL_MISSING  O2_MISSING  CO_MISSING  WIND_MISSING  WIND_OUT_OF_RANGE
CERT_EXPIRED  CERT_MISSING  ENTRANTS_STILL_INSIDE  FIRE_WATCH_NOT_ELAPSED
PERMIT_NOT_ACTIVE  PERMIT_NOT_PENDING  PERMIT_NOT_EDITABLE  PERMIT_NOT_SUBMITTABLE
PERMIT_NOT_CLOSABLE  PERMIT_UPDATE_EMPTY  PERMIT_POSITION_REQUIRED  CLOSURE_REASON_REQUIRED
NOT_HOT_WORK  INVALID_QR_TOKEN  RATE_LIMITED  USER_ALREADY_EXISTS  WORKER_ALREADY_EXISTS
ACCOUNT_DEACTIVATED  LAST_SAFETY_OFFICER
FILE_TYPE_NOT_ALLOWED  FILE_TOO_LARGE  UPLOAD_FOLDER_NOT_ALLOWED  STORAGE_UNAVAILABLE
UNAUTHENTICATED  FORBIDDEN_ROLE

AREA_NOT_APPROVED, AREA_NOT_PENDING and AREA_REQUIRED are gone along with Area itself (wayfinder 106) — a bad or missing pinId is a plain, code-less 400 (see §4 above), never one of these. PERMIT_POSITION_REQUIRED (already listed above) is the one pin-shaped code that survives, unchanged in spelling, now gated on a pin existing rather than an approved area.

The authoritative list is CONTEXT.md §2, and scripts/check-contract-sync.mjs fails if the backend emits a code either frontend cannot localize.

404s, ownership 403s and request-validation 400s carry no errorCode — its absence is normal. Map what you get to localized EN/TH copy and fall back to a generic string; the backend's message is English developer text and must never reach a user.

The codes the contractor app will actually hit, and where:

FlowCodes
POST /:id/submitLEL_MISSING O2_MISSING CO_MISSING WIND_MISSING GAS_OUT_OF_RANGE WIND_OUT_OF_RANGE CERT_MISSING CERT_EXPIRED (HTTP 400, message joins every failure with ; ), PERMIT_NOT_SUBMITTABLE (403)
PATCH /:idPERMIT_NOT_EDITABLE, PERMIT_UPDATE_EMPTY
POST /:id/mark-completeNOT_HOT_WORK, PERMIT_NOT_ACTIVE
GET /:id/qrPERMIT_NOT_ACTIVE
any guarded routeUNAUTHENTICATED (401), FORBIDDEN_ROLE (403)

Which readings are required depends on the permit type (server-enforced at submit): lel for hot/confined when outdoorWork is false, o2 for hot/confined, co for confined, wind for heights. Thresholds are LEL 0%, O₂ 19.5–23.5%, CO ≤50 ppm, wind ≤25 km/h; fire watch 30 min; gas re-test 120 min with a 30-min grace; cert warning 30 days. Every one of these is server-owned — the gas interval is exposed on the permit payload as gasReadingStatus precisely so no client has to hold a second copy of it.

Flag-gated. CERT_TYPE_REQUIRED is off by default. Unset, any unexpired certificate satisfies any permit type. Set, a permit type requires its matching certType (hotHot Work, confinedConfined Space Entry, heightsWorking at Heights) at both the submit check and the entrant scan. Gas Testing is a legal certificate type that satisfies no permit.

Validation is server-authoritative. Show the server's verdict. Client-side range hints are fine as UI affordance, but the submit result is the truth.


6. Integration recipe

This repo's HttpRequest.ts / Interceptors.ts came from the same template as the safety/inspector app, so it has the same four mismatches. The equivalent changes there are done and green; mirror them.

1. Put the prefix in the baseURL, not in each provider.

ts
export const API_PREFIX = '/api/v1'
this.url = `${url ?? import.meta.env.VITE_APP_API_URL ?? ''}${API_PREFIX}`

Then a provider's urlPrefix is just /permits, /certificates, … Strip any hardcoded /api/v1.

2. Unwrap the envelope once, in onResponse.

ts
const body: any = response.data
const isEnvelope = typeof body === 'object' && !Array.isArray(body)
  && body.message === 'success' && 'data' in body

if (isEnvelope) {
  const rest = { ...body }
  delete rest.message
  // Siblings (pagination's count/page/limit/totalPage, the gas log's `overdue`) must survive —
  // unwrapping straight to `data` silently drops the field the screen exists to show.
  return Promise.resolve(Object.keys(rest).length > 1 ? rest : rest.data)
}
return Promise.resolve(body)   // login's { success, data }, blobs, xlsx

Providers then type the payload directly (Promise<IPermitDetail>), and list providers type Promise<IBasePaginationResponse<T>> = { data, count, page, limit, totalPage }.

3. Delete the humps conversion, both directions. The API is camelCase. Camelizing responses is not merely redundant — it rewrites the keys inside closureChecklist and any free-form payload object, corrupting user data. (The request-side decamelizeKeys is commented out in this repo today; delete it rather than ever re-enabling it — it would rename scheduleNote to schedule_note and break every write.)

4. Error handling. Reject with the raw {code, message, errorCode}; key your i18n off errorCode. 401 still means "log out and go to login" — the session cookie is gone or expired.

Smoke check. The safety/inspector repo ships scripts/smoke-api.mjs, which logs in against a running API and asserts these shapes (envelope, pagination, error bodies, permit detail keys). It skips cleanly when no API is reachable. Copying it is cheaper than discovering a shape drift in a page test.


7. Known gaps

  • The permit list row carries no entrant count and no fire-watch remainder — only GET /permits/qr/:token and the permit detail report entrantCount and fireWatch. If a list screen needs those, it needs a backend change, not a client workaround.
  • Whether a contractor may read inspector visits is undecided (wayfinder 083)resolved. GET /permits/:id/inspector-visits now opens to the contractor on their own permit, notes included (wayfinder 119); inspector/safety_officer still read any permit's (src/modules/permit/queries/inspector-visit-list/inspector-visit-list.http.controller.ts).
  • How a plan version is retired is undecided (wayfinder 084)resolved. Plans are a flat, independently-active named set, not a version chain (wayfinder 104); POST /facility-plans/:id/deactivate retires one without deleting it, idempotently. See the Facility plans table in §3.
  • GET /notifications has no pagination — limit only.
  • Notification ids are numeric; permit ids are strings (WP-…); certificate ids are numeric.
  • The audit log is hash-chained (hash, prevHash) and append-only; there is no mutation endpoint.