Skip to content

12 — Contractor Web Deployment (smart-work-permit-contractor)

Read 10-DEPLOYMENT-OVERVIEW.md first. Deploys as a static bundle to Cloudflare Pages at app.<domain>.


1. Prerequisites

  • Backend live and verified per 11-backend-deployment.md §12.
  • https://app.<domain> present in the API's CORS_ORIGIN.
  • Cloudflare API token scoped Cloudflare Pages — Edit, plus the account ID.

2. Pre-flight code checks

Four things must be true before the first deploy. Three come from 04-api-contract.md §6 and are the usual cause of a build that deploys fine and then 401s on every request.

Prefix in the baseURL, not in providers.

ts
export const API_PREFIX = '/api/v1'
this.url = `${url ?? import.meta.env.VITE_APP_API_URL ?? ''}${API_PREFIX}`

Providers then use /permits, /certificates. Strip any hardcoded /api/v1, and do not put it in VITE_APP_API_URL.

withCredentials: true on the axios instance. Without it the session cookie is never sent and every guarded route returns 401 while login itself looks successful.

humps deleted, both directions. The API is camelCase. Camelizing responses rewrites keys inside closureChecklist — that is user data corruption in an audit-relevant field, not a cosmetic issue. The request-side decamelizeKeys would rename scheduleNote to schedule_note and break every write.

Envelope unwrapped once in onResponse, preserving siblings — pagination's count/page/limit/totalPage and the gas log's overdue sit beside data and must survive.

3. Pages project

Dashboard → Workers & Pages → Create → Pages → Connect to Git.

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

Vite inlines VITE_* at build time. Changing it requires a rebuild, not a restart.

Then Custom domains → Set up a domain → app.<domain>. Cloudflare creates the CNAME automatically when the zone is on the same account.

4. SPA fallback — required

public/_redirects:

/*  /index.html  200

Without this, a hard refresh on /permits/WP-HOT-20260817-001 returns 404. Contractors deep-link to permits constantly; this will be reported on day one if it's missing.

5. PWA / service worker

If vite-plugin-pwa is enabled, two settings matter in production:

ts
VitePWA({
  registerType: 'autoUpdate',
  workbox: {
    navigateFallback: '/index.html',
    navigateFallbackDenylist: [/^\/api\//],
  },
})

Without autoUpdate, users hold a cached index.html pointing at deleted hashed asset files after a deploy — a white screen that a refresh doesn't fix. Without the denylist, API 4xx responses get served the HTML shell instead of the error body, and your errorCode mapping silently stops working.

Offline support is an Inspector requirement (00-SHARED-CONTEXT.md), not a contractor one. If the contractor app has no offline story, leaving the plugin off is the simpler correct answer.

6. CI/CD — .github/workflows/deploy.yml

yaml
name: Deploy Contractor Web
on:
  push: { branches: [main] }
  pull_request:

concurrency:
  group: contractor-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   # fails the build on API drift
      - 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-contractor
            --branch=${{ github.head_ref || github.ref_name }}

check-contract-sync.mjs running before build is deliberate: a backend shape change should break this pipeline, not the Review & Submit screen.

7. PR previews — decide this now

Preview builds land on https://<hash>.esw-contractor.pages.dev. That origin is not in the API's CORS_ORIGIN, and credentialed CORS refuses wildcards — so previews cannot log in against production. Pick one:

  • Recommended: point previews at a staging API (VITE_APP_API_URL on the Preview environment set to https://api-stg.<domain>), whose CORS_ORIGIN may list preview origins.
  • Or: treat previews as visual-only and accept that auth fails there.

Do not add *.pages.dev to the production CORS_ORIGIN. Any PR from any fork would then get a credentialed origin against production data.

8. Verification

After the first production deploy, from https://app.<domain>:

  1. Log in as a contractor. DevTools → Application → Cookies shows __Secure-better-auth.session_token on .<domain>.
  2. GET /api/v1/permits in the Network tab returns 200 and is scoped to your own permits.
  3. Hard-refresh a permit detail URL — no 404 (validates §4).
  4. Run the seven-step wizard end to end — 1 Type, 2 Basic info, 3 Where & when, 4 Safety checks, 5 PPE & Workers, 6 JSA, 7 Review. Review is always last and no step is filtered out, whether or not a facility plan has ever been activated. Step 4 should show client-side range hints, and POST /:id/submit should be treated as authoritative — deliberately submit an out-of-range O₂ and confirm the screen renders the server's GAS_OUT_OF_RANGE via your localized mapping, not the backend's English message.
  5. At step 3, confirm the work window saves as a date range plus a daily start/end, and that the times render in Asia/Bangkok rather than as a UTC wall clock — that is the one bug this release can ship silently. Pick a facility plan and a pin and confirm the pin's marker shows on the plan image and the permit saves a pinId. If no plan is active the step says so and still lets you continue. (The map-link field and the stored coordinate were removed in round 4 — there is nothing to paste.)
  6. Upload a photo in Step 5 and tick at least one PPE item; confirm the photo round-trips through POST /api/v1/upload and renders back via the filePath → signed URL path, and that the PPE declaration is on the permit after a reload.
  7. Register a worker and print their QR card; confirm the card is what the inspector's entrant scan resolves (it encodes the workerId, not a name). Add a certificate with neither a licence number nor a file and confirm it is refused with the localized CERT_LICENCE_OR_ATTACHMENT_REQUIRED message.
  8. On an ACTIVE Confined Space permit, run the closure checklist and confirm the screen shows the localized FORBIDDEN_ROLE refusal. That is expected since round 4 (only a safety officer may close; the contractor app's request-close half is not built) — it proves the error mapping, not a deploy fault. See Current state, blocker 11.
  9. Switch locale to EN and back; confirm persistence and that default is Thai.
  10. Timestamps display Asia/Bangkok. Cross-check one permit's createdAt against the raw UTC value in the API response — a 7-hour discrepancy means a container TZ problem, not a frontend one.
  11. Narrow-viewport pass at 375px, then 768px and 1280px (02-contractor-web-vue-tasks.md).

9. Rollback

Pages keeps every deployment. Dashboard → project → Deployments → … → Rollback. Instant, no rebuild. If the frontend broke because of an API change, roll the API back too (§10 of 11-backend-deployment.md) — the contract has two sides.