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-frontend | smart-work-permit-frontend | |
|---|---|---|---|
| Serves | everything | Contractor | Safety Officer and Inspector |
| Runtime | Bun 1.3.13 | static build | static build |
| Framework | ElysiaJS 1.4 | Vue 3.5 | Vue 3.5 |
| Ships as | Docker image → GHCR → droplet | Cloudflare Pages esw-contractor | Cloudflare Pages esw-safety |
| Origin | api.e-safework.com | app.e-safework.com | safety.e-safework.com |
API — smart-work-permit-api
Stack
| Layer | Choice |
|---|---|
| Runtime | Bun 1.3.13 |
| HTTP | Elysia 1.4.28, @elysiajs/cors, @elysiajs/openapi, @elysiajs/cron |
| ORM | Prisma 7 with @prisma/adapter-pg |
| Auth | better-auth 1.6 (session cookie) |
| Cache / rate limit | ioredis |
| Object storage | minio SDK |
nodemailer and mailgun.js, chosen by a mailer.plugin.ts indirection | |
| Dates | dayjs — all server-side, all UTC |
| Lint | oxlint, 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 modulessrc/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
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, :3000Resetting the local database
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 §7migrate 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 (Area → Pin, 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:
| Password | Role | |
|---|---|---|
contractor1@mail.com | Wasd#1234 | contractor |
safety1@mail.com | Wasd#1234 | safety_officer |
inspector1@mail.com | Wasd#1234 | inspector |
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:
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.
| Layer | Choice |
|---|---|
| Framework | Vue 3.5, <script setup>, TypeScript |
| Build | Vite 8, vue-tsc typecheck gate before every build |
| UI | PrimeVue 4.5 + Volt (unstyled PrimeVue copied into src/volt/, auto-imported) |
| CSS | Tailwind v4 via @tailwindcss/vite, tailwindcss-primeui, tailwind-merge |
| State | Pinia 3 + pinia-plugin-persistedstate |
| Routing | vue-router 5, createWebHistory |
| Forms | @primevue/forms with zod 4 resolvers |
| i18n | vue-i18n 11 (TH/EN), locale-keyed even for document titles |
| HTTP | axios, withCredentials: true |
| Lint | ESLint 9 flat config + @stylistic, run inside Vite via vite-plugin-eslint2 |
| Test | Vitest 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)
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:8080bun 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 buildVITE_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
| Contractor | Safety + Inspector | |
|---|---|---|
| Providers | 9 (permit, worker, certificate, facility-plan, pin, notification, user, auth, upload) | 14 (+ audit-log, dashboard, entrant, gas-log, inspector-visit, sync; no worker) |
| Router | flat: permit, certificate, worker, guide, profile, auth — plus history, now only a redirect to /permits?view=history | nested safety-officer/ and inspector/ trees |
| Menu | Permits · 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 guard | meta.auth | meta.auth + meta.permission via usePermission |
| Offline | none | stores/OfflineQueue.ts, IndexedDB, POST /sync/batch — but no service worker, see below |
| Camera | none | useQrScanner — BarcodeDetector with a jsqr fallback |
| Notifications | useNotificationPolling, 30 s, plus useRealtimeSocket.ts's live GET /v1/realtime socket (wayfinder 109) with GET /v1/badges polled only as its fallback | useSocket.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 errors | composables/useApiError.ts | utils/ApiError.ts |
| Mock mode | none | resources/mock + VITE_APP_USE_MOCK |
| Extra deps | — | jsqr, humps, fake-indexeddb (dev) |
| Worker directory | pages/worker/ plus printable QR cards — the card encodes the workerId the entrant scan reads | reads workers, never registers them |
| Inspector visits | the 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 included | inspector-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 |
| Closure | Request 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 exists | the 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
1. Login — and the flag/cookie split
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:
| Response | Returned to the provider |
|---|---|
{ message: 'success', data } | just data |
{ message: 'success', data, …siblings } — count/page/limit/totalPage, overdue | the whole body minus message |
anything else — login's { success, data }, a blob, an xlsx | untouched |
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
queueref 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
useSocketwas 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.tsopensGET /api/v1/realtime(a nativeWebSocket, Elysia.ws()on the api, no client library), carrying exactly two events (notification.created,badge.counts), withGET /v1/badgespolled only while the socket is not open.resources/gateway/ useGateway.tsis 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.humpsis an unused dependency in the safety app. The only mention insrc/is the comment inInterceptors.tsexplaining why no conversion happens. Safe to drop, along with@types/humps.branchAccessTokenStorageand the commented-outbranchTokenin the Auth store are lending template leftovers. This backend has no branch concept.prisma/models/example.prismais template scaffolding, not domain.- Both frontend
package.jsonfiles declare"name": "smart-work-permit-frontend". The contractor repo's is a copy-paste leftover.