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'sCORS_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.
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.
| Setting | Value |
|---|---|
| Project name | esw-contractor |
| Production branch | main |
| Build command | bun run build |
| Output directory | dist |
| 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 200Without 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:
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
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_URLon the Preview environment set tohttps://api-stg.<domain>), whoseCORS_ORIGINmay 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>:
- Log in as a
contractor. DevTools → Application → Cookies shows__Secure-better-auth.session_tokenon.<domain>. GET /api/v1/permitsin the Network tab returns 200 and is scoped to your own permits.- Hard-refresh a permit detail URL — no 404 (validates §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/submitshould be treated as authoritative — deliberately submit an out-of-range O₂ and confirm the screen renders the server'sGAS_OUT_OF_RANGEvia your localized mapping, not the backend's Englishmessage. - At step 3, confirm the work window saves as a date range plus a daily start/end, and that the times render in
Asia/Bangkokrather 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 apinId. 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.) - Upload a photo in Step 5 and tick at least one PPE item; confirm the photo round-trips through
POST /api/v1/uploadand renders back via thefilePath→ signed URL path, and that the PPE declaration is on the permit after a reload. - 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 localizedCERT_LICENCE_OR_ATTACHMENT_REQUIREDmessage. - On an
ACTIVEConfined Space permit, run the closure checklist and confirm the screen shows the localizedFORBIDDEN_ROLErefusal. 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. - Switch locale to EN and back; confirm persistence and that default is Thai.
- Timestamps display
Asia/Bangkok. Cross-check one permit'screatedAtagainst the raw UTC value in the API response — a 7-hour discrepancy means a containerTZproblem, not a frontend one. - 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.