Skip to content

The three applications

One backend, two frontends. The frontends are separate repos, not a monorepo — they share an in-house Vue template, which is why they look alike and why their differences are the interesting part.

smart-work-permit-api…-contractor-frontendsmart-work-permit-frontend
ServeseverythingContractorSafety Officer and Inspector
RuntimeBun 1.3.13static buildstatic build
FrameworkElysiaJS 1.4Vue 3.5Vue 3.5
Ships asDocker image → GHCR → dropletCloudflare Pages esw-contractorCloudflare Pages esw-safety
Originapi.e-safework.comapp.e-safework.comsafety.e-safework.com

API — smart-work-permit-api

Stack

LayerChoice
RuntimeBun 1.3.13
HTTPElysia 1.4.28, @elysiajs/cors, @elysiajs/openapi, @elysiajs/cron
ORMPrisma 7 with @prisma/adapter-pg
Authbetter-auth 1.6 (session cookie)
Cache / rate limitioredis
Object storageminio SDK
Mailnodemailer and mailgun.js, chosen by a mailer.plugin.ts indirection
Datesdayjs — all server-side, all UTC
Lintoxlint, not ESLint

Composition

src/app.ts is four .use() calls, in a load-bearing order:

App
├── AppPlugin      cors + better-auth + logger
├── ErrorHandler   lazy-loaded here on purpose — inside AppPlugin it never fires
├── AppController  bare GET / health route, outside /api
└── AppModule      prefix: '/api', mounts all 14 domain modules

src/app.ts also mounts PermitExpiryCronPlugin — the one-minute in-process tick that drives the permit-expiry and gas-reading sweeps. It is mounted there and nowhere else, so that specs importing app.module.ts never start a cron against the test database.

Each module under src/modules/<name>/ splits into commands/ (writes) and queries/ (reads), one directory per operation, each holding its own *.service.ts, *.http.controller.ts and *.model.ts. A shared lib/ per module holds the Elysia validation models. This is why src/modules/permit/commands/ reads as a list of verbs — approve, reject, submit, close, mark-complete — and why each status transition has exactly one place it can happen.

src/libs/ holds the cross-cutting pieces: guards/ (role checks), middlewares/error-handler, plugins/ (better-auth, redis, dayjs, the two mailers, the expiry cron), config/ (the server-owned safety ranges, the gas re-test interval, and the compiled-in ECertType, EWorkerRole and EPpeItem vocabularies) and utils/ (including cors.util.ts).

The fourteen modules are permit, worker, certificate, facility-plan, pin, user, auth, better-auth, notification, audit, dashboard, upload, file and sync. worker and pin are the two that most often surprise a reader of an older doc: a worker is an entity now, not a name string, and a pin — a named position safety places on a facility plan — is the structured answer to "where is the work". The area module that used to answer it was deleted in round 4 (2026-09-11); see Data model §7.

Dev setup

bash
cd smart-work-permit-api
bun install                       # postinstall runs prisma format + generate
cp .env.example .env              # then fill it in
bun run prisma:migrate            # migrate dev
bun run seed                      # or: bun run seed:e2e  /  seed:e2e:reset
bun run dev                       # bun --watch src/app.ts, :3000

Resetting the local database

bash
bunx prisma migrate reset   # drops the db, replays every migration, prompts to confirm
bun run seed                # system admin + the three demo accounts below
bun run seed:e2e            # optional — the F0-F6 fixture set, see E2E test plan §7

migrate reset does not run bun run seed itself — prisma.config.ts declares no seed command, so the two steps above are always separate. Safe to run any time against a local/dev database with no real data. Never run migrate reset against the production database — see Resetting the production database if that is genuinely what's needed; it is a different, much more careful procedure, not this one pointed at a different host.

A migration squash was considered and reverted (2026-09-12)

prisma/migrations had grown to 27 files tracking a schema reworked twice (AreaPin, the Worker entity cutover), and a single squashed init migration was drafted. It was not shipped: production's _prisma_migrations table already has all 27 recorded, and deploying a brand-new, never-seen migration would make prisma migrate deploy try to CREATE TABLE over tables that already exist — crashing the container on every future deploy until fixed. Squashing the history is still possible later, but only via a deliberate prod-baselining step (prisma migrate resolve --applied), never as a plain commit to the migrations folder.

bun run seed also creates three fixed accounts for manual sign-in, distinct from the seed:e2e/seed:e2e:reset fixture set in E2E test plan §7:

EmailPasswordRole
[email protected]Wasd#1234contractor
[email protected]Wasd#1234safety_officer
[email protected]Wasd#1234inspector

Plain accounts — isDemoAccount stays false on them. That column and the demo-login route it once gated were both removed by wayfinder 042 ("no demo environment will exist"); these three sign in through the ordinary login form like any other account.

Needs Postgres, Redis and MinIO reachable. Checks:

bash
bun run lint         # oxlint
bun run typecheck    # tsc --noEmit
bun test             # NOT `bun run test`

bun run test is a trap

The test npm script is still the template's echo "Error: no test specified" && exit 1. The real invocation is bun test, which finds every .spec.ts file itself. CI uses bun test. (This page used to quote a file count; the suite grows every round, and a count written into prose does not — the same reason CONTEXT.md stopped quoting the error-code count.)


The shared frontend template

Both SPAs are the same skeleton. Knowing it once covers both.

LayerChoice
FrameworkVue 3.5, <script setup>, TypeScript
BuildVite 8, vue-tsc typecheck gate before every build
UIPrimeVue 4.5 + Volt (unstyled PrimeVue copied into src/volt/, auto-imported)
CSSTailwind v4 via @tailwindcss/vite, tailwindcss-primeui, tailwind-merge
StatePinia 3 + pinia-plugin-persistedstate
Routingvue-router 5, createWebHistory
Forms@primevue/forms with zod 4 resolvers
i18nvue-i18n 11 (TH/EN), locale-keyed even for document titles
HTTPaxios, withCredentials: true
LintESLint 9 flat config + @stylistic, run inside Vite via vite-plugin-eslint2
TestVitest 4 + @vue/test-utils + jsdom; Playwright for E2E

Directory convention

src/
├── resources/      HttpRequest.ts, Interceptors.ts, provider/<domain>/*.provider.ts
├── stores/         Pinia: Auth, Loading, Notification (+ OfflineQueue in the safety app)
├── composables/    use*.ts
├── models/         request/ and response/ interfaces, one file per domain
├── pages/          <section>/pages/<screen>/{pages,components,composables,schema}
├── router/modules/ one router file per section
├── plugins/        pinia, i18n, primevue, dayjs, sanitize, toast
├── volt/           unstyled PrimeVue components, auto-imported by unplugin-vue-components
└── utils/          Formatter, Storage, ApiError, Permission, …

A provider is the only thing that knows a URL. It extends HttpRequest, declares a urlPrefix, and exposes typed methods. Pages never call axios.

Dev setup (identical in both repos)

bash
cd smart-work-permit-<repo>
bun install
cp .env.example .env               # set VITE_APP_API_URL
bun run dev                        # Vite on 0.0.0.0:8080
bash
bun run lint                       # eslint (also runs live in the dev server)
bun run typecheck                  # vue-tsc -p tsconfig.app.json
bun run test:run                   # vitest
bun run test:playwright            # E2E
bun run build                      # typecheck THEN vite build

VITE_APP_API_URL must appear verbatim in the API's CORS_ORIGIN

Auth is a credentialed cookie. A wildcard CORS origin silently disables credentialed CORS and login will not stick — the request succeeds, the cookie is dropped, everything after is 401. VITE_* is inlined at build time, so changing it needs a rebuild, not a restart.


Where the two frontends differ

ContractorSafety + Inspector
Providers9 (permit, worker, certificate, facility-plan, pin, notification, user, auth, upload)14 (+ audit-log, dashboard, entrant, gas-log, inspector-visit, sync; no worker)
Routerflat: permit, certificate, worker, guide, profile, auth — plus history, now only a redirect to /permits?view=historynested safety-officer/ and inspector/ trees
MenuPermits · Personnel (Certificates, Workers); Getting started in the top bar (round 4)one role-filtered list, cut from ten to five for the officer and six to three for the inspector (wayfinder 110, useNavItems.ts's NAV_ITEMS) — Users (tabbed), Audit log moved into Dashboard, Getting started in the app bar
Route guardmeta.authmeta.auth + meta.permission via usePermission
Offlinenonestores/OfflineQueue.ts, IndexedDB, POST /sync/batchbut no service worker, see below
CameranoneuseQrScannerBarcodeDetector with a jsqr fallback
NotificationsuseNotificationPolling, 30 s, plus useRealtimeSocket.ts's live GET /v1/realtime socket (wayfinder 109) with GET /v1/badges polled only as its fallbackuseSocket.ts's live GET /api/v1/realtime socket (wayfinder 109), /v1/badges polled only as its fallback; both transports write the same Notification store so no caller branches on which one fired
API errorscomposables/useApiError.tsutils/ApiError.ts
Mock modenoneresources/mock + VITE_APP_USE_MOCK
Extra depsjsqr, humps, fake-indexeddb (dev)
Worker directorypages/worker/ plus printable QR cards — the card encodes the workerId the entrant scan readsreads workers, never registers them
Inspector visitsthe API lets a contractor read visits on their own permits since round 4, and — since wayfinder 112's contractor half — a Report tab renders them in full, notes and photos includedinspector-visit provider and the one-visit-per-scan action menu (it replaced the fixed stepper in round 4); a Report tab (wayfinder 112's safety half) mirrors the same content
ClosureRequest Closure → (RequestCloseModal.vue, wayfinder 098's contractor half) sends POST /close-request; the old checklist and its e-signature are deleted, PermitProvider.close() no longer existsthe officer's close, presented as a normal action (Close permit / Approve close request), plus a CloseRequestBanner.vue, a PermitCard chip and an All-permits filter; the inspector's Request Close

The safety app carries both officer and inspector roles because they share the permit data and differ mainly in which routes meta.permission lets them reach.

Mock mode (safety app only)

VITE_APP_USE_MOCK=true swaps in an axios mock adapter backed by fixtures for permits, dashboard, gas logs, entrants, audit, certificates, users and notifications. Lets the UI be developed with no API running. Default off leaves real network calls untouched.


Data flow

The router guard is not authentication

guardRoute gates on authStore.userToken.accessToken, persisted by utils/Storage.ts as a JS-readable, non-httpOnly cookie user_access_token holding btoa(<pinia state>), default lifetime 3 days. HttpRequest never sends an Authorization header — every request authenticates purely with the better-auth session cookie via withCredentials: true.

So the stored token is a client-side "am I signed in" flag for routing, nothing more. The server remains the only authority, which is why this is a UX artifact and not a hole. But the two lifetimes drift independently:

  • Session expires first → guard passes, the shell renders, the first XHR 401s, and the response interceptor logs out and hard-redirects to /auth/login.
  • Flag expires first → the user is bounced to login while still holding a valid session.

btoa is base64, not encryption. Anything in response.data.token is readable by any script on the origin.

2. A normal read

onResponse has three cases, and the middle one is the non-obvious one:

ResponseReturned to the provider
{ message: 'success', data }just data
{ message: 'success', data, …siblings }count/page/limit/totalPage, overduethe whole body minus message
anything else — login's { success, data }, a blob, an xlsxuntouched

Unwrapping to data in the second case would silently drop the sibling, which is usually the exact field the screen exists to render.

onResponseError normalises everything to the backend's { code, message, errorCode? } shape, including reading a JSON error out of a responseType: 'blob' request. Its 401 branch skips /auth/ URLs and the /auth page, because a failed sign-in is also a 401 — logging out there would replace the form's error message with a page reload.

No case conversion, in either direction

The backend is camelCase both ways, so nothing camelizes or snake-cases. That is deliberate: closureChecklist and a sync item's payload are free-form key/value objects round-tripped verbatim, and rewriting their keys would corrupt user data.

3. Offline write and replay — inspector only

The queue is shipped; the offline shell is not

vite-plugin-pwa is installed in neither frontend, so there is no service worker and no precached app shell. The queue survives a lost connection while the tab is open; a cold load with no network still gets nothing. 13-safety-inspector-web-deployment.md §6–§7 describes an intended feature, not the shipped app.

Two design points:

  • IndexedDB is the source of truth, the Pinia queue ref is only a reactive snapshot of it.
  • It is a Pinia store, not a composable. The nav badge, the gas-log screen, the entrant register and the queue screen must all observe one queue. As a composable each caller got its own ref, so enqueueing on one screen left the badge on another stale.

Idempotency is server-side: entrant_events.offlineClientId and gas_log_entries.offlineClientId carry unique indexes, so a re-sent batch collides instead of duplicating. Per-item failures come back with their domain error code (CERT_EXPIRED, PERMIT_NOT_ACTIVE) rather than failing the whole batch.


Dead scaffolding — do not build on it

  • useSocket was rewired, not dead — the note above is stale as of wayfinder 109 (2026-09-11). What used to be vestigial lending-era scaffolding is now the real implementation: src/composables/useSocket.ts opens GET /api/v1/realtime (a native WebSocket, Elysia .ws() on the api, no client library), carrying exactly two events (notification.created, badge.counts), with GET /v1/badges polled only while the socket is not open. resources/gateway/ useGateway.ts is gone from the tree entirely. The contractor app got its own equivalent, useRealtimeSocket.ts, in the same round. What is still true: neither transport replaces the permit-list or risk-map refresh (ruling 14) — those keep their own poll.
  • humps is an unused dependency in the safety app. The only mention in src/ is the comment in Interceptors.ts explaining why no conversion happens. Safe to drop, along with @types/humps.
  • branchAccessTokenStorage and the commented-out branchToken in the Auth store are lending template leftovers. This backend has no branch concept.
  • prisma/models/example.prisma is template scaffolding, not domain.
  • Both frontend package.json files declare "name": "smart-work-permit-frontend". The contractor repo's is a copy-paste leftover.