Skip to content

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's CORS_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

SettingValue
Project nameesw-safety
Production branchmain
Build commandbun run build
Output directorydist
Env var (Production and Preview)VITE_APP_API_URL = https://api.<domain>

Custom domain → safety.<domain>.

4. SPA fallback — required

public/_redirects:

/*  /index.html  200

5. 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.
  • BarcodeDetector is 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.

ts
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. autoUpdate reloads 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

yaml
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

  1. Login lands on the Safety Officer home, not the Inspector home — routing is driven by user.role, never a user-facing toggle.
  2. Review Queue → Review Detail: validationSummary failures render expanded by default, localized off errorCode, never the backend's English message.
  3. Approve with e-signature → status ACTIVE, QR issued. Reject requires a reason.
  4. Audit Log renders and has no edit or delete affordance anywhere.
  5. Dashboard summary loads.
  6. 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

  1. 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.
  2. Worker badge scan with an expired certificate → unmistakable deny state, no override, and a CERT_BLOCKED audit entry written.
  3. Gas log renders with per-column pass/fail and the overdue banner.
  4. 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.)
  5. 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

  1. Touch targets ≥ 44×44px — this is used with gloves on a plant floor.
  2. Locale defaults to Thai; switcher persists; every review-screen tab named in both locales.
  3. 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.