13 — Safety Officer + Inspector Web Deployment (smart-work-permit-safety)
Read 10-DEPLOYMENT-OVERVIEW.md first. Deploys as a static bundle to Cloudflare Pages at safety.<domain>.
This is one app, two roles, and it has two deployment concerns the Contractor app does not: camera access and real offline support. Everything else mirrors 12-contractor-web-deployment.md.
1. Prerequisites
- Backend live and verified per
11-backend-deployment.md§12. https://safety.<domain>present in the API'sCORS_ORIGIN.- Cloudflare API token scoped Cloudflare Pages — Edit, plus the account ID.
2. Pre-flight code checks
Identical to 12-contractor-web-deployment.md §2 — prefix in baseURL, withCredentials: true, humps deleted both directions, envelope unwrapped once preserving siblings. This repo is where those fixes were done first; if the two have drifted, this one is the reference.
One extra: the review screen must not recompute validation client-side. It renders the backend's validationSummary pass/fail. That is a standing rule (05-permit-detail-sections.md §4), and it is worth re-checking before a production deploy because it is easy to reintroduce while fixing a display bug.
3. Pages project
| Setting | Value |
|---|---|
| Project name | esw-safety |
| Production branch | main |
| Build command | bun run build |
| Output directory | dist |
| Env var (Production and Preview) | VITE_APP_API_URL = https://api.<domain> |
Custom domain → safety.<domain>.
4. SPA fallback — required
public/_redirects:
/* /index.html 2005. Camera — the deploy-specific constraint
getUserMedia and BarcodeDetector require a secure context. Pages serves HTTPS, so production is fine; the failure mode is testing on http://<lan-ip>:5173 from a phone, where the camera API is simply absent and the scanner appears broken for reasons unrelated to your code. Test over the Pages preview URL or an HTTPS dev tunnel.
Also verify in production, on a real phone:
- The permissions prompt appears and the denied-permission state renders clearly.
- The manual permit-ID entry fallback works. Per
03-safety-inspector-web-vue-tasks.md, scanning must never be a hard blocker — field devices have camera failures. BarcodeDetectoris absent on iOS Safari; confirm the JS fallback decoder (@zxing/browser/jsQR) actually engages there rather than silently doing nothing.
There is no Cloudflare-side header needed for camera access on a top-level page. If you ever embed the scanner in an iframe, it will need allow="camera".
6. Service worker — this one is load-bearing
Not shipped. §6 and §7 describe an intended feature
vite-plugin-pwa is installed in neither frontend, so there is no service worker and no precached shell today. The IndexedDB queue (stores/OfflineQueue.ts) and the backend's POST /api/v1/sync/batch route both do exist and work while the tab stays open — what is missing is the cold-load-with-no-network half. Do not read the two sections below as a description of the deployed app.
Inspectors work offline (00-SHARED-CONTEXT.md). The shell must load without network, and the IndexedDB queue must survive.
VitePWA({
registerType: 'autoUpdate',
workbox: {
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [{
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkOnly',
}],
},
manifest: { /* only if add-to-home-screen was confirmed with the product owner */ },
})Never cache /api/ responses. A cached GET /permits/qr/:token would show an inspector a stale permit status in the field — the QR endpoint exists specifically to report live state, not a snapshot. Same reasoning for /entrants and /gas-log.
navigateFallbackDenylist matters here too: without it, an API 403 gets the HTML shell instead of the error body, and CERT_EXPIRED / PERMIT_NOT_ACTIVE stop surfacing as the unmistakable deny states they are required to be. (ENTRANTS_STILL_INSIDE used to be the other one; round 4 retired it — closing with entrants inside now succeeds and auto-checks them out.)
Cross-origin note: if any request goes to storage.<domain> for a presigned URL, its response is opaque to a naive cache-first handler. Leave those NetworkOnly as well.
7. Offline queue across deploys
The queue lives in IndexedDB, keyed by client-generated UUIDs for idempotent replay (POST /sync/batch). Two deploy-time rules:
- Never rename the IndexedDB database or bump its version destructively. A deploy that drops the object store discards an inspector's unsynced entrant scans and gas readings — data that exists nowhere else. If a schema change is unavoidable, write a migration, not a recreate.
- Deploy when the plant is quiet.
autoUpdatereloads the page when a new SW activates. Pair it with a "new version available" prompt rather than a silent reload if a mid-form reload could lose an in-progress scan.
8. CI/CD — .github/workflows/deploy.yml
name: Deploy Safety Web
on:
push: { branches: [main] }
pull_request:
concurrency:
group: safety-web-${{ github.ref }}
cancel-in-progress: true
jobs:
build-deploy:
runs-on: ubuntu-latest
permissions: { contents: read, deployments: write }
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bunx vue-tsc --noEmit
- run: node scripts/check-contract-sync.mjs
- run: node scripts/smoke-api.mjs # skips cleanly when no API reachable
- run: bun run test:unit --if-present
- run: bun run build
env:
VITE_APP_API_URL: https://api.<domain>
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: >-
pages deploy dist --project-name=esw-safety
--branch=${{ github.head_ref || github.ref_name }}smoke-api.mjs lives in this repo and asserts envelope, pagination, error-body and permit-detail shapes against a running API. Keeping it in CI is cheaper than discovering shape drift on the review screen.
9. PR previews
Same constraint as the Contractor app: *.pages.dev origins are not in CORS_ORIGIN and cannot log in against production. Point Preview-environment VITE_APP_API_URL at a staging API, or treat previews as visual-only. Do not wildcard the production CORS_ORIGIN.
10. Verification
From a real phone at https://safety.<domain>:
Safety Officer account
- Login lands on the Safety Officer home, not the Inspector home — routing is driven by
user.role, never a user-facing toggle. - Review Queue → Review Detail:
validationSummaryfailures render expanded by default, localized offerrorCode, never the backend's Englishmessage. - Approve with e-signature → status
ACTIVE, QR issued. Reject requires a reason. - Audit Log renders and has no edit or delete affordance anywhere.
- Dashboard summary loads.
- Risk map: switch plans with the Plan selector (the URL gains
?plan=), check the legend counts, and watch Updated Ns ago tick and reset after an automatic refresh. Facility plan: name and upload a plan, place and rename a pin. Do this on a staging API — on production, an active pin on an active plan makes a pin mandatory for every contractor's submit.
Inspector account
- Camera permission prompt; successful QR decode; live status shows Fire Watch countdown derived from the server's
fireWatch.remainingSeconds— reload the page mid-countdown and confirm it does not reset. - Worker badge scan with an expired certificate → unmistakable deny state, no override, and a
CERT_BLOCKEDaudit entry written. - Gas log renders with per-column pass/fail and the overdue banner.
- Offline pass: with the app already loaded, enable airplane mode → queue a check-in (from the Entrant register screen or a visit's worker table) and a gas reading → restore network → confirm they sync via
POST /sync/batch, and that replaying the same client UUID twice produces a single effect. Confirm Not Available and Submit Visit say plainly they were not saved rather than queueing. (The shell only survives a cold load in airplane mode once §6's service worker exists — it does not today. A replayed entry is stamped at sync time — ticket 126.) - Visit menu: start a visit from a scan; confirm Submit Visit stays blocked until the Check-in / Check-out item has been opened, and no longer. On a permit already
CLOSED, confirm the scan still shows its status but Start Visit is refused by the server (403 PERMIT_NOT_ACTIVE). The visit screen currently shows its generic "Could not start the visit" message for this rather than the localized reason — a known gap, not a deploy fault.
Both
- Touch targets ≥ 44×44px — this is used with gloves on a plant floor.
- Locale defaults to Thai; switcher persists; every review-screen tab named in both locales.
- Layout pass at 375px and 430px first, then tablet/desktop.
11. Rollback
Pages → Deployments → Rollback. One caveat specific to this app: a rollback ships the previous service worker, and clients holding the newer one update on next activation. If a bad deploy shipped a destructive IndexedDB migration, rolling back the bundle does not restore discarded queue entries. That is why §7 exists.