Backend Repository — Implementation Tasks
Read this first, before any task below
- This repository already has an Elysia.js template. Read its README/CLAUDE.md (if present), inspect the existing folder structure, installed packages, lint/format config, and any existing route/module conventions.
- Read
00-SHARED-CONTEXT.md(copied alongside this file) for the product model, roles, permit status machine, and business rules. That file is the single source of truth for what the system does; this file is what to build here.- Follow the template's existing conventions (folder layout, plugin style, DB client, validation style) rather than introducing new ones. If the template has no opinion on something below, choose the simplest option consistent with Elysia idioms and note the choice in a short ADR-style comment.
- This backend is the single API for two separate frontend repos (Contractor web, Safety/Inspector web). Design the API to be role-agnostic at the transport layer — authorization is enforced per-endpoint based on the authenticated user's role, not by which frontend is calling.
Goal
Implement the API, data model, and business rules described below and in 00-SHARED-CONTEXT.md. Treat the design HTML (SmartWorkPermit-v3.dc.html, referenced in the shared context) purely as a spec for field names, statuses, and copy — do not attempt to render it.
Auth & roles
- Roles:
contractor,safety_officer,inspector(see shared context). One role per account. - Provide login + session/token issuance (JWT or the template's existing auth pattern if one exists — use it).
- Middleware/guard that attaches
{ userId, role }to context and rejects unauthorized role access per route (see per-route roles below).
Data model
Design tables/collections for (adapt names to the template's existing DB/ORM conventions):
users— id, name, role, contact info.permits— id (human-readable format likeWP-{TYPE}-{YYYYMMDD}-{seq}), type (hot|confined|heights), title, location, foreman, work_date, work_time_start, work_time_end, status, outdoor_work (bool), created_by, created_at, submitted_at, approved_at, approved_by, rejected_reason, closed_at, closed_by, fire_monitor_started_at.permit_safety_readings— permit_id, lel, o2, co, wind, height, recorded_at, recorded_by.jsa_steps— permit_id, phase (pre|process|post), step, hazard, control, sort_order.permit_workers— permit_id, worker_name, role_on_permit, health_check (bp/alcohol reading, confined space only).permit_photos— permit_id, slot_key, file_ref, uploaded_at.certificates— id, worker_name, role, cert_type, issued_date, expiry_date.entrant_events— permit_id, worker_name, direction (in|out), at, recorded_by (inspector), offline_client_id (for dedupe).gas_log_entries— permit_id, lel, o2, co, so2, tester, recorded_at, offline_client_id.audit_log— id, permit_id (nullable), actor_id, action, payload_json, prev_hash, hash, created_at. Insert-only — no update/delete route ever.notifications— id, target_role, permit_id, title, created_at, read_by (map of user->bool or separate table).
Core endpoints
Group by REST-ish resource; adapt casing/pathing to the template's existing style.
Permits (contractor writes own drafts/submissions; safety_officer reads all + approves/rejects/closes; inspector reads by QR token)
POST /permits— create draft (contractor).PATCH /permits/:id— update draft (contractor, only whileDRAFT).POST /permits/:id/submit— validate all safety-check ranges + required certs server-side (see shared context), setPENDING, notify safety officers.GET /permits— list, filterable by status/type/contractor/date (role-scoped: contractor sees own; safety_officer/inspector see all).GET /permits/:id— detail.POST /permits/:id/approve— safety_officer only; re-validate readings; setACTIVE; issue QR; write audit entry with e-signature metadata.POST /permits/:id/reject— safety_officer only; requiresreason; setREJECTED; audit entry.POST /permits/:id/mark-complete— foreman/contractor; Hot Work only; startsFIRE_MONITOR30-min timer.POST /permits/:id/close—safety_officeronly (wayfinder 098, 2026-09-11 — closure moved off the contractor/foreman). Always requiresreason(403CLOSURE_REASON_REQUIRED), plus closure checklist answers + signature; 403 if Hot Work and Fire Watch countdown not yet elapsed (no override). A Confined Space entrant still checked in no longer blocks close — auto-checked-out at the closure timestamp with'system'provenance; setCLOSED; audit entry.POST /permits/:id/close-request— new, wayfinder 098 —contractor(own permit) orinspector(any permit) requests closure without closing it. 403PERMIT_NOT_ACTIVEoutside ACTIVE/FIRE_MONITOR.GET /permits/:id/qr— returns QR payload/token for anACTIVE/FIRE_MONITORpermit.GET /permits/qr/:token— public-ish verify endpoint used by Inspector's scanner; returns live status, not a cached snapshot. Rate-limit this route.GET /permits/:id/audit— audit trail for one permit.
Certificates
GET/POST /certificates— contractor manages own workers' certs.GET /certificates/worker/:name— used by entry-check flow to validate expiry.
Entrant register (inspector)
POST /permits/:id/entrants/scan— body includes worker identity + direction; server checks certificate expiry — if expired, deny and writeCERT_BLOCKEDaudit entry (403); else recordentrant_eventsrow and return updated register.GET /permits/:id/entrants— current in/out state.
Gas log (inspector)
POST /permits/:id/gas-log— acceptsoffline_client_idfor idempotent replay.GET /permits/:id/gas-log— history, most recent first, flagoverdueif last reading older than the facility's re-test interval (make this a config constant).
Offline sync (inspector)
POST /sync/batch— accepts an array of queued actions (entrant scans, gas log entries), each with a client-generated UUID; process idempotently (skip if UUID already applied); return per-item success/conflict so the client can show conflict UI.
Audit log
GET /audit— safety_officer only, facility-wide, filterable by action/date.
Notifications
GET /notifications— role-scoped, unread-first.POST /notifications/:id/dismiss.
Dashboard/reporting (safety_officer)
GET /dashboard/summary— counts by status, 7-day activity, expiring-soon certs — whatever aggregation is cheapest given the chosen DB; keep it a single endpoint the frontend can poll.
Validation rules to implement server-side (authoritative — see shared context for exact ranges)
Implement as a pure, unit-tested function (e.g. validatePermitReadings(type, readings, outdoorWork)) called from both submit and approve so the same rule can't be bypassed by hitting either endpoint directly.
Non-functional
- Input validation via Elysia's schema/typebox (or whatever the template already uses) on every route.
- Store all timestamps in UTC; the frontend handles
Asia/Bangkokdisplay. - Rate-limit
GET /permits/qr/:token(unauthenticated-ish, field-facing). - Structured error responses with a machine-readable
code(e.g.GAS_OUT_OF_RANGE,ENTRANTS_STILL_INSIDE,CERT_EXPIRED,FIRE_WATCH_NOT_ELAPSED) so both frontends can localize the message client-side (EN/TH) instead of the backend returning locale-baked strings.
Tests to write
- Status-machine transitions (valid/invalid transitions rejected).
- Safety-reading validation boundaries (0%, 19.5/23.5%, 50ppm, 25km/h edges).
- Closure blocked while entrants inside / Fire Watch not elapsed.
- Audit hash-chain integrity (tamper detection).
- Sync batch idempotency (replay same client UUID twice → single effect).