Compare commits

...

71 commits

Author SHA1 Message Date
8ec91f0dac
ci: run the Terraform isolation gate as a job in the CI workflow 2026-09-10 19:37:27 -04:00
7ab6fa30e7
fix(terraform): lock provider hashes for linux and darwin platforms 2026-09-10 19:34:10 -04:00
0bc7e22884
docs(terraform): mark the isolation override as temporary 2026-09-10 19:22:43 -04:00
b71c0ad87b
docs: document the dev Terraform adoption runbook and gates
Two-phase runbook, ownership boundary, workspace invariants, rollback
per phase, and operational rules in terraform/README.md; CDK adoption
mode and the retired cd-cdk path in infra/cdk/README.md; gate matrix and
deployment section updates.
2026-09-10 19:15:14 -04:00
08da408a13
ci(governance): wire Terraform and CDK gates and isolate Terraform PRs
Governance now runs the import-plan checker tests, Terraform fmt and
validate for terraform/live/dev, the isolation gate tests, and the CDK
build, tests, and synth in both modes. A new terraform-isolation
workflow fails PRs that change terraform/** together with application
code; the terraform-isolation-override label is the reviewed exception.
Renovate gains the terraform manager.
2026-09-10 19:15:13 -04:00
87e79072ad
ci(deploy): make dev content deploy workflow_dispatch only
Remove the push-to-dev trigger and the org cd-cdk.yaml caller so CI no
longer runs cdk deploy during the adoption. The workflow assumes the
pinned dev role and runs the simple scripts/deploy-web.sh against a
pinned bucket and distribution, which keeps content deploys working
after CloudFormation relinquishes the stack outputs. Staging is
untouched.
2026-09-10 19:15:12 -04:00
82361e14b5
feat(cdk): add Terraform adoption retain mode
retainForTerraformAdoption=true adds the required ManageSiteInfrastructure
parameter, conditions the 13 transferred resources and the S3 auto-delete
custom resource on it, applies Retain policies, pins the live dev origin
ID, attaches the deploy boundary and HcpTerraformWorkspace tag, and
narrows the OIDC subject to StringEquals. Normal synthesis is unchanged;
template tests cover both modes.
2026-09-10 19:15:11 -04:00
78398482cf
feat(terraform): add dev root and import guard for HCP adoption
Port the reviewed dev root and environment-owned/inventory modules from
111eb556 with the 13 pinned dev identifiers. adoption_complete is pinned
to false in code; the root has no variables so a workspace variable
cannot change what applies. The tf-poc root, staging root, and tf-poc
map entries are dropped; staging constants stay only for the checker's
cross-environment negative tests.
2026-09-10 19:15:09 -04:00
Alexandre Brandizzi
73346371e5
Merge pull request #167 from Sea-Haven-Industries/feat/sh-298-sentry
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
Deploy / Upload private source maps (push) Blocked by required conditions
feat: add browser Sentry tracing (SH-298)
2026-09-10 15:52:28 -03:00
Codex Review Integration
fdd97c24f9 Merge branch 'dev' into feat/sh-298-sentry
Three conflicts, all where dev refactored code this branch had instrumented:

- api.ts — dev extracted the session-expiry helper into
  lib/auth/expire-session. Took dev's import, dropped the now-duplicate local
  copy, kept the tracing import.
- work-order-board-documents-api.ts — dev replaced the ky upload with
  uploadFormWithProgress, an XHR path that exists because ky's
  onUploadProgress streams the body and browsers refuse that over HTTP/1.1.
  Kept dev's helper and wrapped it in traceHttpOperation so the upload stays
  instrumented; neither change is lost.
- work-orders-api.test.ts — kept both mock surfaces, since the merged
  work-orders-api calls apiRequestRaw while other code uses the ky instance.

getMediaContent arrived from dev calling `api.get` directly, which this file
no longer imports; routed it through apiRequestRaw like its siblings, which
also brings it under tracing.
2026-09-10 14:44:12 -03:00
Arthur Bassi
b7d531f554
Merge pull request #173 from Sea-Haven-Industries/feat/SH-186-schedule-clear
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): persist Schedule On clear for SH-186
2026-09-09 18:31:17 -03:00
Arthur Bassi
1fdecdd45d Merge remote-tracking branch 'origin/dev' into feat/SH-186-schedule-clear 2026-09-09 18:18:11 -03:00
Arthur Bassi
8e0f7617aa
Merge pull request #172 from Sea-Haven-Industries/feat/SH-171-wo-documents
feat(work-orders): complete Extra Docs authorized attachment lifecycle
2026-09-09 18:17:59 -03:00
Arthur Bassi
117f7949f5 fix(work-orders): pass HTTP status into Extra Docs error helper 2026-09-09 18:05:03 -03:00
Arthur Bassi
15de3fca0a Merge remote-tracking branch 'origin/dev' into feat/SH-186-schedule-clear 2026-09-09 17:47:37 -03:00
Arthur Bassi
11743caa61 Merge remote-tracking branch 'origin/dev' into feat/SH-171-wo-documents 2026-09-09 17:46:41 -03:00
Arthur Bassi
a7d359ffc4
Merge pull request #171 from Sea-Haven-Industries/fix/SH-181-vendor-save-gate
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
fix(workorders): correct vendor save behavior
2026-09-09 17:46:19 -03:00
Arthur Bassi
eab83f6562 Merge branch 'dev' into feat/SH-171-wo-documents 2026-09-09 17:46:10 -03:00
Arthur Bassi
54671baf5c Merge remote-tracking branch 'origin/dev' into fix/SH-181-vendor-save-gate 2026-09-09 17:33:58 -03:00
Arthur Bassi
ef2ba9da1f
Merge pull request #170 from Sea-Haven-Industries/feat/SH-299-completion-doc-diag
fix: restore work-order completion media uploads
2026-09-09 17:33:04 -03:00
Arthur Bassi
a54fcaae9f fix(work-orders): persist extra media placement 2026-09-09 17:09:28 -03:00
Arthur Bassi
252c0d0856 fix(work-orders): remove stale schedule clear handlers 2026-09-09 17:09:26 -03:00
Arthur Bassi
ba69706fcf fix(work-orders): make extra document opening reliable 2026-09-09 17:09:26 -03:00
Arthur Bassi
3bb7acde2d fix(work-orders): preserve staged vendor selection 2026-09-09 17:09:26 -03:00
Arthur Bassi
c71d9482ec feat(work-orders): align Schedule On clear with shared demotion path
Board Clear uses empty-week patch; slide-over Save derives Pending via mergeAutoSchedulePatch.
2026-09-09 15:44:49 -03:00
Arthur Bassi
dee1578efe feat(work-orders): persist Schedule On clear from board and slide-over
Clearing a concrete date must go through the shared schedule patch so status can revert to Pending.
2026-09-09 10:23:23 -03:00
Arthur Bassi
e761e0dae5 test(work-orders): drop Photos categorize-after-upload assertion 2026-09-08 13:53:17 -03:00
Arthur Bassi
1607bac8ad fix(work-orders): align Extra Docs and Photos copy with AAP 2026-09-08 13:48:02 -03:00
Arthur Bassi
47f78fec89 feat(work-orders): open Extra Docs through authorized content 2026-09-08 11:33:21 -03:00
Arthur Bassi
edf4a51d4c feat(work-orders): fetch Extra Docs via authenticated content GET 2026-09-08 11:31:12 -03:00
Arthur Bassi
599e38aae3 feat(work-orders): add media content API path 2026-09-08 11:30:04 -03:00
Arthur Bassi
4006145ef8 feat(work-orders): keep Photos allowlist media-only at 50MB 2026-09-08 11:28:48 -03:00
Arthur Bassi
807d8ac1a8 feat(work-orders): match Extra Docs AAP accept copy 2026-09-08 11:28:37 -03:00
Arthur Bassi
6c765e8b42 feat(work-orders): align Extra Docs accept and 50MB limits 2026-09-08 11:27:52 -03:00
Arthur Bassi
747515870e fix(workorders): correct vendor save behavior 2026-09-07 20:48:30 -03:00
Arthur Bassi
74e5c58e5d fix(work-orders): restore completion media uploads over HTTP/1.1
Use XHR for media progress so Ky does not stream the body on HTTP/1.1
(Vite proxy), and keep completion vs extra tiles from mixing after upload.
2026-09-07 15:19:39 -03:00
Arthur Bassi
793230f0c2
Merge pull request #168 from Sea-Haven-Industries/feat/SH-170-wo-inline-type-correction
Some checks failed
Frontend checks / Build and test (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
feat(work-orders): align inline type correction with the board AAP
2026-09-07 12:19:18 -03:00
Arthur Bassi
dc9cc07a73
Merge branch 'dev' into feat/SH-170-wo-inline-type-correction 2026-09-07 11:51:30 -03:00
Arthur Bassi
ccdad96170 fix(work-orders): persist type via PATCH and lock completed rows 2026-09-07 11:39:12 -03:00
Arthur Bassi
a766dc9c50
Merge pull request #169 from Sea-Haven-Industries/feat/SH-310-schedule-on-range
Some checks failed
Frontend checks / Build and test (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
fix(work-orders): restore Range tab on Schedule On for inline and slide-over
2026-09-04 18:07:18 -03:00
Arthur Bassi
f51f6bd493 chore(work-orders): keep type/severity audit rules after merging origin/dev 2026-09-04 16:41:42 -03:00
Arthur Bassi
418eb3749d fix(work-orders): restore Range tab on Schedule On for inline and slide-over
Share Week/Date/Range on inline, board, and slide-over DateCell.
A selected range lands on the start day, matching the wizard.
2026-09-04 15:59:53 -03:00
Arthur Bassi
7cc7cc19d0
Merge pull request #165 from Sea-Haven-Industries/feat/SH-169-wo-inline-lifecycle
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): align inline lifecycle transitions with AAP
2026-09-04 14:58:44 -03:00
Arthur Bassi
b0c58958ae
Merge branch 'dev' into feat/SH-169-wo-inline-lifecycle 2026-09-04 14:47:38 -03:00
Arthur Bassi
3ed8600beb fix(work-orders): promote dated pre-schedule picks to Scheduled
Align create, wizard, and board status patches with AAP maybeAutoSchedule.
Include the previous status in audit copy when the API sends it.
2026-09-04 14:31:08 -03:00
Alexandre Brandizzi
d6163e7eb6
Merge branch 'dev' into feat/sh-298-sentry 2026-09-04 14:23:41 -03:00
Arthur Bassi
fc07371f0d
Merge pull request #164 from Sea-Haven-Industries/feat/SH-168-wo-board-row-order
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
feat(work-orders): persist board row order locally and support keyboard reorder
2026-09-04 14:07:28 -03:00
Arthur Bassi
b8349783a7 chore(work-orders): keep carry-over display and local board row-order 2026-09-04 13:46:04 -03:00
Arthur Bassi
e3ffa5a94b
Merge pull request #163 from Sea-Haven-Industries/feat/sh-167-wo-board-carry-over
feat(work-orders): show carry-over pill and map day-tab for prior-week WOs
2026-09-04 13:43:48 -03:00
Arthur Bassi
9f9aa5c824 fix(work-orders): restore AAP span grip while keeping keyboard reorder 2026-09-04 13:27:53 -03:00
Codex Review Integration
fa866c5ea7 fix(observability): preserve HTTP error status 2026-09-03 21:51:40 -03:00
Codex Review Integration
207199559f feat(observability): identify and scrub Sentry transactions 2026-09-03 17:12:49 -03:00
Arthur Bassi
27ddeb2903 feat(work-orders): align inline type correction with the board AAP
Keep type correction on the board: Type & severity, SEV toast, and audit copy.
2026-09-03 16:30:10 -03:00
Codex Review Integration
dc313dc50a fix: capture React root errors in Sentry 2026-09-03 15:53:50 -03:00
Codex Review Integration
a24002bc24 feat: activate Sentry deployment environments 2026-09-03 15:10:01 -03:00
Arthur Bassi
370b2a68ad fix(work-orders): align reorder grip button box-model with prior span
Reset display, vertical-align, and box-sizing so visual snapshots stay on the approved grip.
2026-09-03 14:42:22 -03:00
Arthur Bassi
3a8f41a444 fix(work-orders): keep explicit wizard status picks on dated drafts
Derive Scheduled or Pending only when the patch touches schedule fields, matching the board path.
2026-09-03 14:42:19 -03:00
Codex Review Integration
c1863c4274 feat: add browser Sentry tracing 2026-09-03 13:59:13 -03:00
Arthur Bassi
9fc67516ca fix(work-orders): derive lifecycle status only when the schedule patch changes 2026-09-03 13:35:26 -03:00
Arthur Bassi
ddd83ffec1 fix(work-orders): skip canceled keyboard reorder and persist destination-week order 2026-09-03 13:34:28 -03:00
Arthur Bassi
c2d1add79c fix(work-orders): persist AAP auto-schedule on a single board patch
Merge derived status once so dated rows cannot stay Pending or Incomplete.
Create and wizard demote Scheduled when there is no concrete date.
2026-09-03 11:13:24 -03:00
Arthur Bassi
9cc9bc4dfb feat(work-orders): align inline lifecycle transitions with AAP
Use the prototype status list and gates on the board and wizard.
Auto-derive Scheduled or Pending from a concrete schedule date.
2026-09-02 15:11:31 -03:00
Arthur Bassi
dc178340de feat(work-orders): persist board row order locally and support keyboard reorder
Keep same-day WO order across refresh and sign-out in the same browser.
Add keyboard reorder on the row grip.
2026-09-02 11:07:27 -03:00
Arthur Bassi
bc1e7a1fa2 fix(work-orders): keep carry-over rows in day reorder and one-char search 2026-09-02 09:47:34 -03:00
Arthur Bassi
08e00c213d
Merge branch 'dev' into feat/sh-167-wo-board-carry-over 2026-09-02 09:32:58 -03:00
Arthur Bassi
c70587941c
Merge pull request #162 from Sea-Haven-Industries/fix/sh-297-assigned-to-save-toast
Some checks failed
Frontend checks / Build and test (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
fix(work-orders): toast after assigned-to save on the board
2026-09-02 09:32:21 -03:00
Arthur Bassi
426889427c feat(work-orders): show carry-over pill and map day-tab for prior-week WOs
Keep source week visible and map weekday tabs without rewriting schedule.
2026-09-01 14:16:52 -03:00
Arthur Bassi
7e1dcb7a36 fix(work-orders): toast after assigned-to save on the board
Show the save toast only after the board PATCH succeeds.
2026-09-01 10:20:38 -03:00
Arthur Bassi
0ee2879234
Merge pull request #161 from Sea-Haven-Industries/fix/sh-296-vendor-invalid-dispatch
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
fix(work-orders): omit non-positive primaryDispatchId on board patches
2026-09-01 09:50:44 -03:00
Arthur Bassi
85ea33b0ad
Merge branch 'dev' into fix/sh-296-vendor-invalid-dispatch 2026-08-31 16:36:50 -03:00
Arthur Bassi
72417bbb77 fix(work-orders): omit non-positive primaryDispatchId on board patches
A GET echo of 0 on primaryDispatchId made vendor PATCH fail InvalidDispatch.
2026-08-31 15:48:31 -03:00
176 changed files with 11257 additions and 977 deletions

View file

@ -8,5 +8,13 @@
# Production: absolute URLs MUST include the /api path segment
VITE_API_URL=https://api.seahavenindustries.com/api
# Sentry browser DSN. This value is public configuration and is baked into the bundle.
# Leave blank to disable telemetry for that build.
VITE_SENTRY_DSN=
# Sentry environment label (development, staging, production). Blank falls back
# to the Vite build mode.
#VITE_SENTRY_ENVIRONMENT=
# Development proxy target (used by vite.config.ts)
#VITE_API_TARGET=http://localhost:5141

View file

@ -2,3 +2,9 @@
# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
VITE_API_URL=https://api.dev.seahaven.com/api
# Public browser configuration; the DSN is public by design and baked into the
# bundle. Blank disables telemetry. These are the DEV deploy values: staging
# and future prod jobs override them per environment.
VITE_SENTRY_DSN=https://902f431c6e762aa849c9a07b77f0c3ac@o4511989453029376.ingest.de.sentry.io/4511990364700752
VITE_SENTRY_ENVIRONMENT=development

View file

@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["npm", "custom.regex"],
"enabledManagers": ["npm", "custom.regex", "terraform"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict",
"customManagers": [
@ -17,6 +17,12 @@
}
],
"packageRules": [
{
"description": ["Group non-major Terraform provider updates"],
"matchManagers": ["terraform"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "terraform minor and patch"
},
{
"description": ["Do not open major or replacement PRs until approved on the dashboard"],
"matchUpdateTypes": ["major", "replacement"],

View file

@ -23,9 +23,11 @@ jobs:
# repository, independent of (and in addition to) the reusable workflow.
# `npm run verify` is the single command that chains: format check, lint
# (--max-warnings=0), type-check + build, unit tests, then the governance
# checks in scripts/governance-check.mjs (godfile ratchet + changed-file
# maintainability gate). If the reusable workflow is later confirmed to run
# every gate, this job can be slimmed to `npm run governance`.
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
# maintainability gate, Terraform fmt/validate, Terraform import-plan guard
# tests, Terraform isolation gate tests, CDK build/test/synth). If the
# reusable workflow is later confirmed to run every gate, this job can be
# slimmed to `npm run governance`.
#
# GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>)
@ -53,6 +55,13 @@ jobs:
base="origin/dev"
fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- name: Set up Terraform
# Same minor as the HCP workspace (1.16.x) so fmt/validate see what
# the remote run will see.
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
@ -62,6 +71,30 @@ jobs:
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
terraform-isolation:
# Fails a pull request that changes `terraform/**` together with deployable
# application code (scripts/check-terraform-isolation.mjs). A merge that
# does both queues an HCP VCS run and a content release at the same time,
# and the two race for the workspace lock. The
# `terraform-isolation-override` label is the reviewed exception; it is
# read when the job runs, so re-run this workflow after labeling.
name: Terraform and application changes are isolated
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: Check changed files
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"
visual-regression:
name: Visual regression
runs-on: ubuntu-latest

View file

@ -32,6 +32,8 @@ jobs:
environment: staging
env:
VITE_API_URL: https://api.staging.seahaven.com/api
VITE_SENTRY_ENVIRONMENT: staging
VITE_APP_COMMIT_SHA: ${{ github.sha }}
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -68,15 +70,21 @@ jobs:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
aws-region: us-east-1
# Builds the SPA with the staging VITE_API_URL (process env overrides the
# dev value committed in .env.production), syncs to the staging bucket,
# and invalidates CloudFront.
# Builds the SPA with the staging VITE_API_URL and Sentry environment
# label (process env overrides the dev values committed in
# .env.production), syncs to the staging bucket, and invalidates
# CloudFront.
- name: Build and publish SPA
run: bash scripts/deploy-web.sh
env:
STACK_NAME: shoc-frontend-staging
WAIT_FOR_INVALIDATION: "true"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Verify deployment
run: |
set -euo pipefail

View file

@ -1,22 +1,22 @@
name: Deploy
name: Deploy dev content
# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`.
# Manual dev content deployment during the Terraform adoption (SH-300).
#
# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs
# `cdk deploy` (provisioning the infra in infra/cdk) and then the
# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates
# CloudFront. Both run as the OIDC deploy role created by the stack.
# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are
# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are
# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the
# resources, and move to HCP Terraform (`terraform/README.md`) as adoption
# completes. Automatic push-to-`dev` releases return with the Terraform
# content-CD change, gated on a repository variable.
#
# When staging/prod accounts exist, add jobs keyed to their branches and their
# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow.
# This workflow publishes only content: verify, build, `aws s3 sync`, and a
# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC
# deploy role. The bucket and distribution are pinned here so a content deploy
# keeps working after CloudFormation relinquishes the stack outputs.
on:
push:
branches: [dev]
workflow_dispatch: {}
# OIDC needs id-token: write — it is never in the default token set and cannot
# be granted to the reusable workflow unless the caller has it.
permissions:
id-token: write
contents: read
@ -27,12 +27,82 @@ concurrency:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
with:
node-version: "24"
region: us-east-1
cdk-dir: infra/cdk
stack-name: shoc-frontend-dev
post-deploy-script: scripts/deploy-web.sh
secrets:
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
name: Publish content to dev
# Deploy only the exact dev branch ref: workflow_dispatch can be invoked
# from arbitrary refs, and the deploy role trusts only refs/heads/dev.
if: github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
env:
AWS_REGION: us-east-1
VITE_APP_COMMIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Set up Terraform
# Required by `npm run verify` (governance runs terraform fmt/validate).
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.16.0"
terraform_wrapper: false
- name: Quality gates (full verify before any deploy)
run: npm ci && npm run verify
env:
GOVERNANCE_BASE: origin/dev
- name: Assume dev deploy role (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
aws-region: us-east-1
# Builds with the dev values committed in .env.production (VITE_API_URL,
# Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront.
- name: Build and publish SPA
run: bash scripts/deploy-web.sh
env:
SITE_BUCKET: seahaven-shoc-frontend-dev
CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P
WAIT_FOR_INVALIDATION: "true"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Verify deployment
run: |
set -euo pipefail
SITE_URL="https://dev.seahaven.com"
if grep -Rq "api.staging.seahaven.com" dist/; then
echo "::error::Built assets contain the staging API URL." >&2
exit 1
fi
grep -Rq "api.dev.seahaven.com" dist/
echo "Built assets reference the dev API URL."
# The invalidation has completed, but give edges a short window to
# converge before calling the served index.html wrong.
remote_dir="$(mktemp -d)"
trap 'rm -rf "${remote_dir}"' EXIT
matched=false
for i in 1 2 3 4 5 6; do
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \
&& cmp -s dist/index.html "${remote_dir}/index.html"; then
matched=true
break
fi
echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..."
sleep 20
done
if [[ "${matched}" != "true" ]]; then
echo "::error::Served index.html does not match the build just published." >&2
exit 1
fi
echo "Served index.html matches the published build."
curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login"
echo "Extensionless SPA route serves."

12
.gitignore vendored
View file

@ -47,3 +47,15 @@ infra/cdk/bin/*.d.ts
infra/cdk/bin/*.js
infra/cdk/lib/*.d.ts
infra/cdk/lib/*.js
# terraform (the provider lock file is committed)
**/.terraform/*
*.tfstate
*.tfstate.*
*.tfplan
*.tfvars
*.tfvars.json
# python
__pycache__/
*.py[cod]

View file

@ -7,7 +7,10 @@ npm run verify
```
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
`test` (`vitest run`) → `governance`. A task is not done until this is green.
`test` (`vitest run`) → `governance`. Governance also runs the repository
gates: the Terraform import-plan checker tests, the Terraform isolation gate
tests, Terraform formatting and validation, and the CDK build, template tests,
and synthesis. A task is not done until this is green.
## Gate matrix
@ -23,6 +26,11 @@ npm run verify
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes |
| Terraform/app change isolation | `ci.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
## No-false-pass guarantees
@ -36,6 +44,15 @@ npm run verify
- **Changed-file maintainability fails closed without a valid base** — in CI the
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
(push). An absent or unresolvable base is a failure, not a pass.
- **Terraform gates never touch live state** — `terraform init -backend=false
-lockfile=readonly` and `validate` run offline; the plan checker is tested
against synthetic plan JSON. Real import and controlled-update plans from HCP
are migration evidence reviewed by a human before an approved apply
(`terraform/README.md`).
- **The isolation gate reads the override when it runs** — the
`terraform-isolation-override` label is the only way to merge a mixed
Terraform/application PR, the gate logs the override on the run, and the
workflow must be re-run after the label is added or removed.
## Where the gates run
@ -44,11 +61,16 @@ npm run verify
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
format/lint/build/tests, **and** a repo-owned `governance` job runs
`npm run verify` so the maintainability ratchets are guaranteed from this
repository regardless of the reusable workflow.
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
ratchets and repository gates are guaranteed from this repository regardless
of the reusable workflow. On pull requests the same workflow's
`terraform-isolation` job fails when `terraform/**` and application code
change together.
## Toolchain pin
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
`corepack npm …` if your default `npm` is older). The lockfile is
`package-lock.json` v3; install with `npm ci`.
`package-lock.json` v3; install with `npm ci`. Governance also needs
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and
`python3` (3.10+) on `PATH`.

121
README.md
View file

@ -29,10 +29,15 @@ graph LR
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
ROLE -->|cdk deploy + s3 sync + invalidation| S3
GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
ROLE -->|s3 sync + invalidation| S3
TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3
```
Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see
[`terraform/README.md`](terraform/README.md) for the phase runbook and the
current ownership state.
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
@ -57,12 +62,13 @@ No Lambdas, queues, or databases — this stack is static hosting only.
### Secrets
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
repo secret**:
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions
repo secret** is:
| Secret | Purpose |
| --------------------- | ----------------------------------------------------------------------------------- |
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
| Secret | Purpose |
| ------------------- | ------------------------------------------------------------------ |
| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy |
### Environment variables (build-time, `VITE_*`)
@ -78,7 +84,8 @@ build otherwise. See [`.env.example`](.env.example),
[`.env.development`](.env.development), and [`.env.production`](.env.production).
CDK context (domain, certificate ARN, hosted zone) lives in
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs
`cdk deploy` with no flags.
## Local Development
@ -95,17 +102,22 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
the deployed dev API).
| Command | Description |
| ------------------------------------------ | -------------------------------------------------------- |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) |
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
| `npm run verify` | **All gates**: format + lint + build + test + governance |
| Command | Description |
| ------------------------------------------ | ------------------------------------------------------------ |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) |
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) |
| `npm run verify` | **All gates**: format + lint + build + test + governance |
`npm run governance` needs `terraform` and `python3` on `PATH` for the
Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`,
`npm run test:terraform-isolation`) and installs `infra/cdk` for
`npm run test:infra`.
Husky + lint-staged run ESLint and Prettier on staged files at commit;
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
@ -123,66 +135,81 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
a green CI run and an approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
Merged branches are deleted automatically.
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
exists yet).
- A PR that changes `terraform/**` may not also change application code (the
`terraform-isolation` CI job); ship Terraform in its own PR.
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through the
**Deploy dev content** workflow while the Terraform adoption is in progress)
`→ main` (production promotion — no prod environment exists yet).
## Deployment
CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
and PRs to `main`/`dev`, calls
and PRs to `main`/`dev`/`staging`, calls
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
format check, lint, build, tests; **and** runs a repo-owned `governance` job
that calls `npm run verify` so every gate (including the maintainability
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
guaranteed from this repository. Conventions and gates are documented under
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
the Terraform gates, and the CDK template tests) is guaranteed from this
repository. Conventions and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
- **Terraform isolation** (the `terraform-isolation` job in
[`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — fails a PR that
mixes `terraform/**` with application code, so a Terraform merge never races
a content release for the HCP workspace.
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
— `workflow_dispatch` on `dev` only while the Terraform adoption is in
progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`,
and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
CloudFront invalidation. Both run as the OIDC deploy role.
CloudFront invalidation, then uploads source maps and checks the served
`index.html` matches the build. Push-to-`dev` releases return with the
Terraform content-CD change.
- **Staging content**
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
— on push to `staging`, unchanged.
- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence
and the HCP Terraform workspace `shoc-frontend-new-dev`
([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy`
([`infra/cdk/README.md`](infra/cdk/README.md)).
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
[`infra/cdk/README.md`](infra/cdk/README.md).
Manual deploy (emergency/reference only — needs credentials for the
external-dev AWS account; the normal path is push to `dev`):
Manual content deploy (emergency/reference only — needs credentials for the
external-dev AWS account):
```bash
(cd infra/cdk && npx cdk deploy)
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \
AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
## Operations
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy** run in
the Actions tab; confirm a deep link (e.g. a work-orders route) loads
directly and API calls succeed.
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy dev
content** run in the Actions tab; confirm a deep link (e.g. a work-orders
route) loads directly and API calls succeed.
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
are no CloudWatch application logs — the stack is static hosting; runtime
errors surface in the browser and on the backend API's side.
- **Common failure modes:**
- _Stale content after deploy_ — the CloudFront invalidation step failed or
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev`
on this repo; deploys from other branches/repos are rejected by design.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
on this repo; dispatching the workflow from another branch is rejected by
design.
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
suffix or carrying the wrong environment's host (it is baked in at build time).
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
not proxy `/api`.
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
deploys (matches the org's push-time-CD model; gating deploy on CI is known
follow-up work).
- **Dev has no push-triggered deploy during the adoption.** Merging to `dev`
runs CI only; publish through the **Deploy dev content** workflow. Merging a
`terraform/**` change also queues an HCP Terraform run that a human confirms
or discards (see the operational rules in `terraform/README.md`).
## Documentation
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md)
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
design system and UI docs under [`docs/`](docs/)

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { LOCAL_DEVELOPMENT_RELEASE, resolveSentryRelease } from "./sentry-release";
describe("resolveSentryRelease", () => {
it("maps the exact commit to the frontend release", () => {
const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
expect(resolveSentryRelease(sha, true)).toEqual({
commitSha: sha.toLowerCase(),
release: `shoc-frontend@${sha.toLowerCase()}`,
});
});
it("fails CI when the commit identity is missing or malformed", () => {
expect(() => resolveSentryRelease(undefined, true)).toThrow(/40-character commit SHA/);
expect(() => resolveSentryRelease("not-a-sha", true)).toThrow(/40-character commit SHA/);
});
it("labels non-CI builds without a commit as local development", () => {
expect(resolveSentryRelease(undefined, false)).toEqual({
commitSha: LOCAL_DEVELOPMENT_RELEASE,
release: LOCAL_DEVELOPMENT_RELEASE,
});
});
});

32
config/sentry-release.ts Normal file
View file

@ -0,0 +1,32 @@
export const SENTRY_SERVICE_NAME = "shoc-frontend";
export const LOCAL_DEVELOPMENT_RELEASE = "local-development";
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
export function resolveSentryRelease(
candidate: string | undefined,
ci: boolean,
): {
commitSha: string;
release: string;
} {
const commitSha = candidate?.trim();
if (commitSha && COMMIT_SHA.test(commitSha)) {
return {
commitSha: commitSha.toLowerCase(),
release: `${SENTRY_SERVICE_NAME}@${commitSha.toLowerCase()}`,
};
}
if (ci) {
throw new Error(
"CI builds require VITE_APP_COMMIT_SHA or GITHUB_SHA as a 40-character commit SHA",
);
}
return {
commitSha: LOCAL_DEVELOPMENT_RELEASE,
release: LOCAL_DEVELOPMENT_RELEASE,
};
}

View file

@ -0,0 +1,94 @@
# 0002. Browser error and transaction telemetry with Sentry
## Status
Accepted
## Context
The SeaHaven admin SPA has no centralized browser error reporting or distributed
transaction tracing. Operational failures therefore have to be reconstructed
from user reports and isolated backend logs. We need browser navigation and API
request transactions to join the backend trace while preserving the existing UI,
routing, authentication, and server-state behavior. Every emitted transaction
must also identify its exact build, trace, transaction, operation, function, and
opaque actor without sending credentials, request bodies, query values, or
personal data.
## Decision
Use the pinned `@sentry/react` 10.73.0 SDK. Initialize it before the application
router is created, and wrap `createBrowserRouter` with Sentry's current React
Router compatibility API. Configure the browser tracing integration with a 100%
transaction sample rate, and propagate trace headers only to the SPA origin and
the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the
`onCaughtError`, `onUncaughtError`, and `onRecoverableError` callbacks of
`ReactDOM.createRoot`, so React 19's root error hooks capture caught, uncaught,
and recoverable component errors as Sentry events.
All shared API helpers, direct Ky calls, vendor-link `fetch` calls, and vendor
document `XMLHttpRequest` calls run inside explicit `http.client` transactions.
They use normalized route templates and attach `code.function`, `operation.type`,
HTTP method, `trace_id`, and `transaction_id`. Browser navigation transactions
receive the same searchable trace identifiers in `beforeSendTransaction`.
Authenticated sessions set only the application's opaque numeric/string user ID;
logout and session expiry clear it.
The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public
browser configuration because every `VITE_` value is embedded in the built
JavaScript. `.env.production` commits the public DSN of the real
`shoc-frontend` Sentry project, so dev and staging deploys are live; a missing
or blank DSN in any other build leaves telemetry inactive without preventing
the application from starting. Default personally identifiable information
collection remains disabled.
The Sentry environment label comes from `VITE_SENTRY_ENVIRONMENT` when set and
non-blank, falling back to the Vite build mode otherwise. The committed
`.env.production` labels dev deploys `development`, the staging deploy job
overrides the label to `staging`, and a future production pipeline can set
`production` without code changes.
Every deployment embeds `shoc-frontend@<40-character commit SHA>` as the Sentry
release and exposes the same SHA as the `app.commit` tag. CI fails if it cannot
resolve that identity. Vite creates hidden source maps, and the dev/staging
deployment workflows upload them to the private `shoc-frontend` Sentry project
using the repository's `SENTRY_AUTH_TOKEN` secret. The public S3 sync explicitly
excludes `*.map`; the upload credential is never available to the browser build.
Before-send processors reduce request data to normalized URL plus HTTP method,
reduce users to opaque ID only, attach native trace/transaction identifiers as
searchable tags, and reduce breadcrumbs to safe network method/status/normalized
URL metadata. Root and child span attributes use the same allowlist, retaining
function, operation, actor type, normalized route, method/status, service host,
timing, and native IDs while removing raw URLs and query values. Bodies, headers,
cookies, query strings/values, free-form breadcrumb messages, usernames, email
addresses, IP addresses, tokens, and filenames are not intentionally sent.
`sendDefaultPii` remains disabled.
### Alternatives considered
- **Manual error and timing calls** — rejected because they would miss route
transitions and distributed request context, and would require every feature
to maintain its own instrumentation.
- **Propagate tracing headers to every request** — rejected because third-party
requests must not receive SeaHaven trace metadata and broad propagation can
introduce cross-origin request failures.
- **Publish source maps with the SPA** — rejected because source code belongs in
Sentry's private artifact store, not the public application bucket.
## Consequences
- Browser navigations and API requests are recorded as connected transactions
when the deployment supplies a valid DSN.
- Events are labeled by `VITE_SENTRY_ENVIRONMENT` (`development` on dev
deploys, `staging` on the staging job), falling back to the Vite build mode
when unset.
- Every transaction is sampled. This meets the current requirement but increases
event volume and should be reviewed against Sentry quota and retention after
real traffic is measured.
- Cross-origin API tracing depends on the backend accepting `sentry-trace` and
`baggage` request headers; the current API CORS policy allows request headers.
- Deployed stack traces resolve against the exact commit's private source maps
once the scoped upload secret is present and the deployment workflow runs.
- Rollback is isolated: remove `@sentry/react`, the initialization module and
import, restore the unwrapped router factory, and remove the Sentry env entries.

View file

@ -1,34 +1,44 @@
# Infrastructure & CI/CD — Sea Haven SHOC frontend
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
deployed through the org's **reusable** GitHub Actions workflow.
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo.
Infrastructure deploys are administrator-run; GitHub Actions publishes content
only.
> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack
> `shoc-frontend-dev` is in the retain/transfer sequence described under
> [Terraform adoption mode](#terraform-adoption-mode) and in
> [`terraform/README.md`](../terraform/README.md). Do not run a plain
> `cdk deploy` against dev while that sequence is in progress. Staging is
> unaffected and stays on this CDK path (SH-287 tracks its cutover).
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
- **API:** the SPA calls the backend **directly** over HTTPS at
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
allows CORS). CloudFront serves static content only — no `/api` proxy.
- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy`
picks them up with no flags. `VITE_API_URL` is baked into the build, so it's
- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks
them up with no flags. `VITE_API_URL` is baked into the build, so it's
per-environment (see the note under "Adding staging / prod").
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
`Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy`
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
- **Content workflows:** `.github/workflows/deploy.yml` (dev,
`workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to
`staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy
role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was
retired with the adoption PR.
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and
`staging` (push to `staging`, via the standalone `deploy-staging.yml`).
```
infra/cdk/
bin/app.ts entry point (reads -c context)
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
bin/app.ts entry point (reads -c context)
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition)
test/frontend-stack.test.mjs template assertions for both modes
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
.github/workflows/
ci.yaml quality gates (lint / build / test / e2e)
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
deploy-staging.yml standalone staging deploy (push to staging)
ci.yaml quality gates (lint / build / test / governance / terraform isolation)
deploy.yml dev content publish (workflow_dispatch on dev)
deploy-staging.yml standalone staging deploy (push to staging)
```
## What the stack creates
@ -40,12 +50,67 @@ scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on
`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's
pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`),
and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a
singleton account resource — the stack only _imports_ it (created in step 2),
so `cdk destroy` can't delete a resource shared by other roles.
The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants:
`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`,
read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It
is left byte-identical on purpose so the Terraform import is a no-op; the
Terraform content-CD change narrows it. The OIDC **provider** is a singleton
account resource — the stack only _imports_ it (created in step 2), so
`cdk destroy` can't delete a resource shared by other roles.
## Terraform adoption mode
`-c retainForTerraformAdoption=true` switches the stack into the safety mode
used only while HCP Terraform adopts the dev resources. It is off by default
and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts
both). In adoption mode the stack:
- pins the origin ID CloudFormation generated for the live distribution
(`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is
metadata-only; environments without a verified value fail synthesis
- attaches the `seahaven-org-baseline` permissions boundary
`shoc-frontend-new-dev-deploy-boundary` and the
`HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role
- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the
same exact value
- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13
transferred resources (bucket, bucket policy, distribution, OAC, SPA
function, A and AAAA records, deploy role, inline policy) and to
`SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider
Lambda and role stay unretained
- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no
default) and conditions those same resources and every output on it
- emits `TerraformImport*` outputs carrying the exact import IDs
`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must
state the ownership phase:
```bash
cd infra/cdk && npm ci
# Phase 1, before the Terraform import: keep the resources in the stack and
# install Retain on them. Update-only change set.
npx cdk deploy shoc-frontend-dev \
-c retainForTerraformAdoption=true \
--parameters ManageSiteInfrastructure=true
# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish
# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource.
npx cdk deploy shoc-frontend-dev \
-c retainForTerraformAdoption=true \
--parameters ManageSiteInfrastructure=false
```
Both deploys must use the same reviewed SHA. Review the change set before
confirming: Phase 1 must show no create, delete, or replace. After the
`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used
again. If the `true` deploy rolls back, inspect the stack resources and the
live bucket before retrying; retained resources can outlive a failed update and
must not be cleaned up automatically. Never delete the auto-delete custom
resource while its handler can still empty the versioned bucket.
Local checks (`npm run test:infra` from the repo root) build the app, run the
template assertions, and synthesize both modes.
---
@ -102,48 +167,31 @@ cd infra/cdk
npx cdk deploy
```
Note the `DeployRoleArn` output. Then push the first content (or just push to
`dev` and let CI do everything from here on):
Note the `DeployRoleArn` output. Then publish the first content manually:
```bash
# from repo root, optional manual first content publish:
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
### 6. Set the one GitHub secret
### 6. Content deploys
`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable):
```bash
REPO=Sea-Haven-Industries/shoc-frontend-new
gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \
--body "arn:aws:iam::<acct>:role/githubdeploy-shoc-frontend-new-dev"
```
(Or **Settings → Secrets and variables → Actions → Secrets**.)
### 7. From now on: push to `dev`
```bash
git push origin dev
```
`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs
`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open
the `SiteUrl` output.
> First-run verification: this first push is what actually exercises the role's
> permissions and the OIDC trust through the reusable workflow (the local
> bootstrap used admin creds and tested none of that). Watch for
> credential/OIDC errors and a green post-deploy step.
The deploy role ARN is deterministic and pinned in
`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the
Terraform adoption, dev content deploys run only through **Actions → Deploy dev
content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes
`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the
pinned bucket and distribution, uploads source maps, and verifies the served
`index.html` matches the build. Automatic push-to-`dev` releases return with the
Terraform content-CD change.
---
## Staging environment (same account, exact OIDC subject)
Staging lives in the same AWS account (396287094661) but deploys through its
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
reusable `cd-cdk.yaml`:
Staging lives in the same AWS account (396287094661) and deploys through its
own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to
`staging`:
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
@ -212,10 +260,11 @@ for prod.
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
differs only in that its CD workflow also runs `npm run verify` itself
before deploying); a red-CI commit still deploys on `dev` (matches the
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
of enabling CICD.
Never run it against dev during or after the Terraform adoption: the
adoption-mode stack retains the transferred resources, and after Phase 2
Terraform owns them.
- **CI and staging CD both fire on push to `staging`** in parallel; the
staging CD workflow runs `npm run verify` itself before deploying. Dev has
no push-triggered deploy during the adoption.
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.

View file

@ -25,6 +25,12 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required
// ManageSiteInfrastructure parameter and Retain policies on the transferred
// resources. Off by default so ordinary synthesis is unchanged.
const retainForTerraformAdoption =
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true";
// Staging and beyond protect their stacks from accidental deletion; dev
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
// at deploy time — it is not part of the synthesized template.
@ -40,6 +46,7 @@ const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
certificateArn,
hostedZoneId,
hostedZoneName,
retainForTerraformAdoption,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",

View file

@ -1,4 +1,16 @@
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
import {
Aspects,
CfnCondition,
CfnOutput,
CfnParameter,
CfnResource,
Duration,
Fn,
RemovalPolicy,
Stack,
StackProps,
Tags,
} from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
@ -7,6 +19,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as targets from "aws-cdk-lib/aws-route53-targets";
import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption";
export interface FrontendStackProps extends StackProps {
/** Environment label, e.g. "dev". Used in names/tags. */
@ -42,6 +55,11 @@ export interface FrontendStackProps extends StackProps {
readonly hostedZoneId: string;
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
readonly hostedZoneName: string;
/**
* Opt-in safety mode used only during the reviewed Terraform adoption.
* Normal dev/staging synthesis remains unchanged when false.
*/
readonly retainForTerraformAdoption?: boolean;
}
/**
@ -50,11 +68,10 @@ export interface FrontendStackProps extends StackProps {
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
* - a GitHub Actions OIDC deploy role
*
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
* stack only owns the infrastructure, and the deploy role carries the
* permissions those post-deploy steps need.
* Content (the built `dist/`) is NOT uploaded here. Manual environment
* workflows run `scripts/deploy-web.sh` independently of infrastructure
* changes, so this stack only owns infrastructure and the deploy role carries
* content-publication permissions.
*/
export class FrontendStack extends Stack {
constructor(scope: Construct, id: string, props: FrontendStackProps) {
@ -69,8 +86,23 @@ export class FrontendStack extends Stack {
certificateArn,
hostedZoneId,
hostedZoneName,
retainForTerraformAdoption = false,
} = props;
const manageSiteInfrastructureCondition = retainForTerraformAdoption
? new CfnCondition(this, "ManageSiteInfrastructureCondition", {
expression: Fn.conditionEquals(
new CfnParameter(this, "ManageSiteInfrastructure", {
type: "String",
allowedValues: ["true", "false"],
description:
"Set true only before Terraform adoption. After ownership transfer, always reuse false.",
}).valueAsString,
"true",
),
})
: undefined;
const hasCustomDomain = domainNames.length > 0;
if (hasCustomDomain && !certificateArn) {
throw new Error(
@ -114,6 +146,16 @@ export class FrontendStack extends Stack {
// --- CloudFront: serves the static SPA from S3 -------------------------
// The SPA calls the backend directly at its absolute HTTPS URL
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
// Adoption mode pins the origin ID CloudFormation generated for the live
// distribution so the retention deploy is a metadata-only update. Only
// environments with a read-back-verified value may enter adoption mode.
const adoptionOriginIds: Record<string, string> = {
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
};
const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined;
if (retainForTerraformAdoption && !originId) {
throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`);
}
const distribution = new cloudfront.Distribution(this, "Distribution", {
comment: `SeaHaven SHOC frontend (${envName})`,
defaultRootObject: "index.html",
@ -130,7 +172,9 @@ export class FrontendStack extends Stack {
: undefined,
defaultBehavior: {
// withOriginAccessControl wires up OAC + the bucket policy automatically.
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, {
originId,
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
@ -158,9 +202,9 @@ export class FrontendStack extends Stack {
// Trust conditions for the OIDC principal. With a GitHub environment
// (staging): exact StringEquals match on both aud and the environment
// subject — the staging workflow declares `environment: staging`, so only
// runs in that environment can assume the role. Without one (dev): keep
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
// deploy branch (reusable-workflow runs still carry the caller-based sub).
// runs in that environment can assume the role. Normal dev synthesis keeps
// the current branch-ref StringLike trust. The adoption prerequisite
// narrows that already-exact value to StringEquals before Terraform import.
const oidcConditions = githubEnvironment
? {
StringEquals: {
@ -168,30 +212,48 @@ export class FrontendStack extends Stack {
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
},
}
: {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
StringLike: {
// Tightly scoped: only pushes to this repo's deploy branch. For a
// reusable-workflow run the OIDC `sub` is still caller-based, so this
// matches even though the deploy job lives in the `.github` repo.
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
};
: retainForTerraformAdoption
? {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
}
: {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
},
StringLike: {
// Tightly scoped: only pushes to this repo's deploy branch. For a
// reusable-workflow run the OIDC `sub` is still caller-based, so this
// matches even though the deploy job lives in the `.github` repo.
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
},
};
const deployPermissionsBoundary = retainForTerraformAdoption
? iam.ManagedPolicy.fromManagedPolicyArn(
this,
"GithubDeployPermissionsBoundary",
`arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
)
: undefined;
const deployRole = new iam.Role(this, "GithubDeployRole", {
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
maxSessionDuration: Duration.hours(1),
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
permissionsBoundary: deployPermissionsBoundary,
});
if (retainForTerraformAdoption) {
Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`);
}
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
// intentionally narrower: its recurring promotion workflow only publishes
// application assets to this stack's bucket/distribution. Infrastructure
// changes remain an administrator-run CDK operation, so the staging OIDC
// role cannot inherit the bootstrap roles' account-wide deployment power.
// Preserve dev's legacy CDK capability until the reviewed adoption update
// replaces this inline policy. Staging is intentionally narrower: its
// content role only publishes application assets to this stack's
// bucket/distribution. Infrastructure changes remain administrator-run.
if (!githubEnvironment) {
deployRole.addToPolicy(
new iam.PolicyStatement({
@ -224,6 +286,8 @@ export class FrontendStack extends Stack {
// --- DNS: point the custom domain at CloudFront ------------------------
// Only when a hosted zone is supplied (it must be in THIS account). Creates
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
let aliasA: route53.ARecord | undefined;
let aliasAaaa: route53.AaaaRecord | undefined;
if (hostedZoneId && hasCustomDomain) {
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
hostedZoneId,
@ -233,31 +297,169 @@ export class FrontendStack extends Stack {
// apex record when the domain equals the zone name.
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
new route53.ARecord(this, "AliasA", { zone, recordName, target });
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target });
aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", {
zone,
recordName,
target,
});
}
const gateOutput = (output: CfnOutput): CfnOutput => {
if (manageSiteInfrastructureCondition) {
output.condition = manageSiteInfrastructureCondition;
}
return output;
};
// --- Outputs -----------------------------------------------------------
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
new CfnOutput(this, "SiteUrl", {
value: hasCustomDomain
? `https://${domainNames[0]}`
: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
});
new CfnOutput(this, "DistributionDomainName", {
value: distribution.distributionDomainName,
description: "CloudFront domain — point the custom-domain DNS record here",
});
new CfnOutput(this, "BucketName", {
value: bucket.bucketName,
});
new CfnOutput(this, "DistributionId", {
value: distribution.distributionId,
});
new CfnOutput(this, "DeployRoleArn", {
value: deployRole.roleArn,
description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN",
});
gateOutput(
new CfnOutput(this, "SiteUrl", {
value: hasCustomDomain
? `https://${domainNames[0]}`
: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
}),
);
gateOutput(
new CfnOutput(this, "DistributionDomainName", {
value: distribution.distributionDomainName,
description: "CloudFront domain — point the custom-domain DNS record here",
}),
);
gateOutput(
new CfnOutput(this, "BucketName", {
value: bucket.bucketName,
}),
);
gateOutput(
new CfnOutput(this, "DistributionId", {
value: distribution.distributionId,
}),
);
gateOutput(
new CfnOutput(this, "DeployRoleArn", {
value: deployRole.roleArn,
description: "Pinned GitHub OIDC content-deployment role",
}),
);
if (retainForTerraformAdoption) {
const originAccessControl = distribution.node
.findAll()
.find(
(node): node is cloudfront.CfnOriginAccessControl =>
node instanceof cloudfront.CfnOriginAccessControl,
);
if (!originAccessControl || !aliasA || !aliasAaaa) {
throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records.");
}
const originAccessControlConfig =
originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty;
const rolePolicy = deployRole.node
.findAll()
.find((node): node is iam.Policy => node instanceof iam.Policy);
const autoDeleteProviderRole = this.node
.findAll()
.find(
(node): node is CfnResource =>
node instanceof CfnResource &&
node.cfnResourceType === "AWS::IAM::Role" &&
node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"),
);
if (!rolePolicy || !autoDeleteProviderRole) {
throw new Error("Terraform adoption outputs require deploy and auto-delete roles.");
}
const recordName = domainNames[0];
gateOutput(
new CfnOutput(this, "TerraformWorkspaceTag", {
value: `shoc-frontend-new-${envName}`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformDeployBoundaryArn", {
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
}),
);
gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName }));
gateOutput(
new CfnOutput(this, "TerraformImportBucketPolicy", {
value: bucket.bucketName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDistribution", {
value: distribution.distributionId,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportOriginAccessControl", {
value: originAccessControl.attrId,
}),
);
gateOutput(
new CfnOutput(this, "TerraformOriginAccessControlName", {
value: originAccessControlConfig.name,
}),
);
gateOutput(
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
value: "EMPTY_STRING",
description: "Use an empty Terraform string because the generated OAC has no description",
}),
);
gateOutput(
new CfnOutput(this, "TerraformDistributionOriginId", {
value: originId!,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
value: spaRewrite.functionName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportAliasA", {
value: `${hostedZoneId}_${recordName}_A`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportAliasAAAA", {
value: `${hostedZoneId}_${recordName}_AAAA`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDeployRole", {
value: deployRole.roleName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
value: rolePolicy.policyName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
value: autoDeleteProviderRole.getAtt("Arn").toString(),
}),
);
gateOutput(
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
value: "SiteBucket/AutoDeleteObjectsCustomResource",
description:
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
}),
);
Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition));
}
}
}

View file

@ -0,0 +1,63 @@
import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
import { IConstruct } from "constructs";
const TRANSFERRED_RESOURCE_TYPES = new Set([
"AWS::S3::Bucket",
"AWS::S3::BucketPolicy",
"AWS::CloudFront::Distribution",
"AWS::CloudFront::Function",
"AWS::CloudFront::OriginAccessControl",
"AWS::Route53::RecordSet",
]);
function isTransferredResource(resource: CfnResource): boolean {
if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) {
return true;
}
if (
resource.cfnResourceType === "Custom::S3AutoDeleteObjects" &&
resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource")
) {
return true;
}
return (
(resource.cfnResourceType === "AWS::IAM::Role" ||
resource.cfnResourceType === "AWS::IAM::Policy") &&
resource.node.path.includes("/GithubDeployRole")
);
}
/**
* Retains only the resources in the approved Terraform transfer set.
*
* The bucket auto-delete custom resource is intentionally retained while the
* generated provider Lambda, role, log group, and CDK metadata remain excluded.
* When a management condition is supplied, those same resources share it so
* CloudFormation can later relinquish them without deleting them.
*/
export class RetainForTerraformAdoption implements IAspect {
constructor(private readonly manageCondition?: CfnCondition) {}
public visit(node: IConstruct): void {
if (!(node instanceof CfnResource) || !isTransferredResource(node)) {
return;
}
// Keep the L2 bucket's configured DESTROY policy visible to its
// AutoDeleteObjects validator while overriding the emitted CloudFormation
// resource. This preserves the custom resource and retains both together.
if (node.cfnResourceType === "AWS::S3::Bucket") {
node.addOverride("DeletionPolicy", "Retain");
node.addOverride("UpdateReplacePolicy", "Retain");
} else {
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
}
if (this.manageCondition) {
node.cfnOptions.condition = this.manageCondition;
}
}
}

View file

@ -11,7 +11,9 @@
},
"scripts": {
"build": "tsc",
"test": "npm run build && node --test test/*.test.mjs",
"synth": "cdk synth",
"synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true",
"diff": "cdk diff",
"deploy": "cdk deploy"
},

View file

@ -0,0 +1,225 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { test } from "node:test";
const require = createRequire(import.meta.url);
const { App } = require("aws-cdk-lib");
const { Template } = require("aws-cdk-lib/assertions");
const { FrontendStack } = require("../lib/frontend-stack.js");
const account = "396287094661";
const region = "us-east-1";
const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev";
const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1";
const CONDITION = "ManageSiteInfrastructureCondition";
const RETAINED_TYPES = new Set([
"AWS::S3::Bucket",
"AWS::S3::BucketPolicy",
"AWS::CloudFront::Distribution",
"AWS::CloudFront::Function",
"AWS::CloudFront::OriginAccessControl",
"AWS::Route53::RecordSet",
"Custom::S3AutoDeleteObjects",
]);
function devTemplate(retainForTerraformAdoption, overrides = {}) {
const app = new App();
const stack = new FrontendStack(app, "shoc-frontend-dev", {
envName: "dev",
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
deployBranch: "dev",
domainNames: ["dev.seahaven.com"],
certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`,
hostedZoneId: "Z07671212N75U4YLPWZR8",
hostedZoneName: "dev.seahaven.com",
retainForTerraformAdoption,
env: { account, region },
...overrides,
});
return Template.fromStack(stack).toJSON();
}
function entriesByType(template, type) {
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
}
function isTransferred(logicalId, resource) {
const isDeployRoleResource =
(resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) ||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource;
}
test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => {
const template = devTemplate(true);
assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1);
assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
const transferred = Object.entries(template.Resources).filter(([id, resource]) =>
isTransferred(id, resource),
);
// Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline
// policy = 9 CloudFormation resources (Terraform splits the bucket into 6
// addresses) plus the retained custom resource.
assert.equal(transferred.length, 10);
});
test("adoption mode preserves the live dev identifiers", () => {
const template = devTemplate(true);
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev");
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
assert.ok(
bucket.Properties.Tags.some(
(tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true",
),
);
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID);
assert.equal(
distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId,
DEV_ORIGIN_ID,
);
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
);
assert.equal(
deployRole.Properties.PermissionsBoundary,
`arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`,
);
assert.ok(
deployRole.Properties.Tags.some(
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev",
),
);
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
assert.equal(
condition.StringEquals["token.actions.githubusercontent.com:sub"],
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
);
assert.equal(condition.StringLike, undefined);
// Legacy inline policy stays byte-compatible with the live document.
const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) =>
id.startsWith("GithubDeployRole"),
);
const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid);
assert.deepEqual(sids, [
"AssumeCdkBootstrapRoles",
"DescribeStack",
undefined,
"InvalidateDistribution",
]);
for (const output of [
"TerraformWorkspaceTag",
"TerraformDeployBoundaryArn",
"TerraformImportBucket",
"TerraformImportBucketPolicy",
"TerraformImportDistribution",
"TerraformImportOriginAccessControl",
"TerraformOriginAccessControlName",
"TerraformOriginAccessControlDescription",
"TerraformDistributionOriginId",
"TerraformImportSpaRewriteFunction",
"TerraformImportAliasA",
"TerraformImportAliasAAAA",
"TerraformImportDeployRole",
"TerraformImportDeployRolePolicy",
"TerraformDeployInlinePolicyName",
"TerraformBucketAutoDeleteHelperRoleArn",
"TerraformRetainedAutoDeleteCustomResource",
]) {
assert.ok(template.Outputs[output], `missing output ${output}`);
}
assert.equal(
template.Outputs.TerraformImportAliasA.Value,
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A",
);
assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID);
});
test("adoption mode retains exactly the transferred resources", () => {
const template = devTemplate(true);
for (const [logicalId, resource] of Object.entries(template.Resources)) {
if (isTransferred(logicalId, resource)) {
assert.equal(resource.DeletionPolicy, "Retain", logicalId);
assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId);
} else {
assert.notEqual(resource.DeletionPolicy, "Retain", logicalId);
assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId);
}
}
// The auto-delete provider Lambda, role, and log group stay unretained.
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
for (const [, resource] of entriesByType(template, type)) {
assert.notEqual(resource.DeletionPolicy, "Retain");
}
}
const providerRoles = entriesByType(template, "AWS::IAM::Role").filter(
([, resource]) => resource.Properties.RoleName !== DEV_ROLE,
);
assert.equal(providerRoles.length, 1);
assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain");
});
test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => {
const template = devTemplate(true);
const parameter = template.Parameters.ManageSiteInfrastructure;
assert.ok(parameter);
assert.equal(parameter.Type, "String");
assert.deepEqual(parameter.AllowedValues, ["true", "false"]);
assert.equal(parameter.Default, undefined);
assert.ok(template.Conditions[CONDITION]);
for (const [logicalId, resource] of Object.entries(template.Resources)) {
if (isTransferred(logicalId, resource)) {
assert.equal(resource.Condition, CONDITION, logicalId);
} else {
assert.notEqual(resource.Condition, CONDITION, logicalId);
}
}
for (const [outputName, output] of Object.entries(template.Outputs)) {
assert.equal(output.Condition, CONDITION, outputName);
}
});
test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => {
const template = devTemplate(false);
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
assert.equal(bucket.DeletionPolicy, "Delete");
assert.equal(bucket.UpdateReplacePolicy, "Delete");
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
assert.notEqual(customResource.DeletionPolicy, "Retain");
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
);
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
assert.equal(
condition.StringLike["token.actions.githubusercontent.com:sub"],
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
);
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined);
assert.equal(template.Conditions?.[CONDITION], undefined);
for (const resource of Object.values(template.Resources)) {
assert.equal(resource.Condition, undefined);
}
});
test("adoption mode refuses an environment without a verified origin ID", () => {
assert.throws(
() => devTemplate(true, { envName: "staging" }),
/No verified Terraform adoption origin ID exists for staging/,
);
});

308
package-lock.json generated
View file

@ -21,6 +21,7 @@
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@sentry/react": "10.73.0",
"@tanstack/query-broadcast-client-experimental": "5.101.2",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-query-devtools": "5.101.2",
@ -45,6 +46,7 @@
"@commitlint/config-conventional": "^21.2.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@sentry/cli": "3.7.0",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
@ -1974,6 +1976,295 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/@sentry/browser": {
"version": "10.73.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz",
"integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.73.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.73.0",
"@sentry/feedback": "10.73.0",
"@sentry/replay": "10.73.0",
"@sentry/replay-canvas": "10.73.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser-utils": {
"version": "10.73.0",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz",
"integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.73.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz",
"integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==",
"dev": true,
"hasInstallScript": true,
"license": "FSL-1.1-MIT",
"dependencies": {
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0",
"undici": "^6.22.0",
"which": "^2.0.2"
},
"bin": {
"sentry-cli": "bin/sentry-cli"
},
"engines": {
"node": ">= 18"
},
"optionalDependencies": {
"@sentry/cli-darwin": "3.7.0",
"@sentry/cli-linux-arm": "3.7.0",
"@sentry/cli-linux-arm64": "3.7.0",
"@sentry/cli-linux-i686": "3.7.0",
"@sentry/cli-linux-x64": "3.7.0",
"@sentry/cli-win32-arm64": "3.7.0",
"@sentry/cli-win32-i686": "3.7.0",
"@sentry/cli-win32-x64": "3.7.0"
}
},
"node_modules/@sentry/cli-darwin": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz",
"integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==",
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli-linux-arm": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz",
"integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==",
"cpu": [
"arm"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli-linux-arm64": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz",
"integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli-linux-i686": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz",
"integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli-linux-x64": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz",
"integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==",
"cpu": [
"x64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli-win32-arm64": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz",
"integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli-win32-i686": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz",
"integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli-win32-x64": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz",
"integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==",
"cpu": [
"x64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/cli/node_modules/undici": {
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/@sentry/conventions": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@sentry/core": {
"version": "10.73.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz",
"integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/feedback": {
"version": "10.73.0",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz",
"integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.73.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.73.0",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz",
"integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.73.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.73.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/replay": {
"version": "10.73.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz",
"integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "10.73.0",
"@sentry/core": "10.73.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/replay-canvas": {
"version": "10.73.0",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz",
"integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.73.0",
"@sentry/replay": "10.73.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@simple-libs/child-process-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
@ -5736,6 +6027,16 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -5753,6 +6054,13 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true,
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View file

@ -12,6 +12,10 @@
"test:e2e": "playwright test",
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
"test:e2e:ui": "playwright test --ui",
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
"test:terraform": "node scripts/terraform-validate.mjs",
"test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",
@ -55,6 +59,7 @@
"@hookform/resolvers": "^5.4.0",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@sentry/react": "10.73.0",
"@tanstack/query-broadcast-client-experimental": "5.101.2",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-query-devtools": "5.101.2",
@ -79,6 +84,7 @@
"@commitlint/config-conventional": "^21.2.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@sentry/cli": "3.7.0",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",

View file

@ -0,0 +1,628 @@
#!/usr/bin/env python3
"""Reject plans that violate the frontend Terraform adoption boundary."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from terraform_import_plan_resources import (
CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG,
REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES,
)
BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site"
BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site"
DEPLOY_POLICY_ADDRESS = (
"module.environment_owned.aws_iam_role_policy.github_deploy"
)
DISTRIBUTION_ADDRESS = (
"module.environment_owned.aws_cloudfront_distribution.site"
)
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
BUCKET_POLICY_ADDRESS,
DEPLOY_POLICY_ADDRESS,
}
OWNERSHIP_TAGS = {
"Environment": None,
"ManagedBy": "terraform",
"Ownership": "terraform",
"Project": "shoc-frontend",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("plan_json", type=Path)
parser.add_argument(
"--environment",
required=True,
choices=sorted(REQUIRED_RESOURCES),
help="Exact environment ownership boundary expected in the plan.",
)
modes = parser.add_mutually_exclusive_group()
modes.add_argument(
"--post-import-no-op",
action="store_true",
help=(
"Require all managed resources to be no-op after import and forbid "
"import metadata."
),
)
modes.add_argument(
"--allow-update-address",
action="append",
default=[],
metavar="ADDRESS",
help=(
"Enter controlled-update mode and allow one exact reviewed address. "
"Repeat for every expected update."
),
)
return parser.parse_args()
def _load_plan(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError("plan JSON root must be an object")
if not isinstance(value.get("resource_changes"), list):
raise ValueError("plan JSON must contain a resource_changes array")
return value
def _validate_import_metadata(
*,
address: str,
change: dict[str, Any],
environment: str,
) -> list[str]:
importing = change.get("importing")
if not isinstance(importing, dict) or set(importing) != {"id"}:
return [f"{address}: import metadata must be exactly {{'id': <string>}}"]
import_id = importing.get("id")
if not isinstance(import_id, str) or not import_id.strip():
return [f"{address}: import ID must be a non-empty string"]
if import_id.startswith("REPLACE_WITH_"):
return [f"{address}: import ID is still a placeholder"]
expected = REQUIRED_IMPORT_IDS[environment][address]
if expected is not None and import_id != expected:
return [f"{address}: expected import ID {expected!r}, got {import_id!r}"]
other_environment_ids = {
imports[address]
for name, imports in REQUIRED_IMPORT_IDS.items()
if name != environment and imports[address] is not None
}
if import_id in other_environment_ids:
return [f"{address}: import ID belongs to another environment"]
return []
def _contains_unknown(value: Any) -> bool:
if value is True:
return True
if isinstance(value, dict):
return any(_contains_unknown(item) for item in value.values())
if isinstance(value, list):
return any(_contains_unknown(item) for item in value)
return False
def _changed_leaf_paths(
before: Any,
after: Any,
path: tuple[str, ...] = (),
) -> set[tuple[str, ...]]:
if isinstance(before, dict) and isinstance(after, dict):
result: set[tuple[str, ...]] = set()
for key in set(before) | set(after):
result.update(
_changed_leaf_paths(
before.get(key),
after.get(key),
(*path, str(key)),
)
)
return result
if before != after:
return {path}
return set()
def _canonical(value: Any) -> Any:
if isinstance(value, dict):
return {key: _canonical(value[key]) for key in sorted(value)}
if isinstance(value, list):
items = [_canonical(item) for item in value]
return sorted(items, key=lambda item: json.dumps(item, sort_keys=True))
return value
def _parse_policy(value: Any, address: str, side: str) -> tuple[Any, list[str]]:
if not isinstance(value, str):
return None, [f"{address}: {side} policy must be a JSON string"]
try:
document = json.loads(value)
except json.JSONDecodeError:
return None, [f"{address}: {side} policy is not valid JSON"]
if not isinstance(document, dict):
return None, [f"{address}: {side} policy must be a JSON object"]
return _canonical(document), []
def _distribution_id(
plan: dict[str, Any],
environment: str,
) -> str | None:
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
if isinstance(configured, str):
return configured
for resource in plan["resource_changes"]:
if not isinstance(resource, dict) or resource.get("address") != DISTRIBUTION_ADDRESS:
continue
after = resource.get("change", {}).get("after")
if isinstance(after, dict):
identifier = after.get("id")
if isinstance(identifier, str) and identifier.strip():
return identifier
return None
def _expected_pre_adoption_bucket_policy(
environment: str,
distribution_id: str,
) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
distribution_arn = (
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
)
return _canonical(
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": config["bucket_auto_delete_helper_role_arn"]
},
"Action": [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*",
"s3:PutBucketPolicy",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"{bucket_arn}/*",
"Condition": {
"StringEquals": {"AWS:SourceArn": distribution_arn}
},
},
{
"Effect": "Deny",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": [bucket_arn, f"{bucket_arn}/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
},
],
}
)
def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}"
distribution_arn = (
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
)
return _canonical(
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"{bucket_arn}/*",
"Condition": {
"StringEquals": {"AWS:SourceArn": distribution_arn}
},
},
{
"Effect": "Deny",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": [bucket_arn, f"{bucket_arn}/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
},
],
}
)
def _expected_pre_adoption_deploy_policy(
environment: str,
distribution_id: str,
) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
distribution_arn = (
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
)
statements: list[dict[str, Any]] = []
if environment == "dev":
statements.append(
{
"Sid": "AssumeCdkBootstrapRoles",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
}
)
statements.extend(
[
{
"Sid": "DescribeStack",
"Effect": "Allow",
"Action": "cloudformation:DescribeStacks",
"Resource": (
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
f"{config['cloudformation_stack_name']}/*"
),
},
{
"Effect": "Allow",
"Action": [
"s3:Abort*",
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Sid": "InvalidateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
],
"Resource": distribution_arn,
},
]
)
return _canonical({"Version": "2012-10-17", "Statement": statements})
def _expected_deploy_policy(environment: str, distribution_id: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}"
distribution_arn = (
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
)
return _canonical(
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadDeploymentBucket",
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation",
"s3:GetBucketVersioning",
"s3:ListBucket",
"s3:ListBucketVersions",
],
"Resource": bucket_arn,
},
{
"Sid": "PublishAndRollbackSiteObjects",
"Effect": "Allow",
"Action": [
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
],
"Resource": f"{bucket_arn}/*",
},
{
"Sid": "InvalidateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
],
"Resource": distribution_arn,
},
],
}
)
def _validate_tag_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
) -> list[str]:
changed = _changed_leaf_paths(before, after)
invalid = {
path
for path in changed
if len(path) != 2 or path[0] not in {"tags", "tags_all"}
}
violations = [
f"{address}: controlled tag update changes forbidden path {'.'.join(path)}"
for path in sorted(invalid)
]
expected = {**OWNERSHIP_TAGS, "Environment": environment}
if address == ROLE_ADDRESS:
expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][
"workspace_name"
]
if address == BUCKET_ADDRESS:
expected["aws-cdk:auto-delete-objects"] = None
expected_after = {
key: value for key, value in expected.items() if value is not None
}
for tag_attribute in ("tags", "tags_all"):
if after.get(tag_attribute) != expected_after:
violations.append(
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
)
for path in sorted(changed - invalid):
key = path[1]
if key not in expected:
violations.append(f"{address}: tag {key!r} is not an ownership tag")
elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}):
violations.append(
f"{address}: legacy auto-delete ownership tag was not removed"
)
elif after.get(path[0], {}).get(key) != expected[key]:
violations.append(
f"{address}: tag {key!r} does not have its expected adopted value"
)
if not changed:
violations.append(f"{address}: update has no changed leaf values")
return violations
def _validate_policy_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
distribution_id: str | None,
) -> list[str]:
changed = _changed_leaf_paths(before, after)
if changed != {("policy",)}:
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
before_policy, violations = _parse_policy(before.get("policy"), address, "before")
after_policy, after_violations = _parse_policy(
after.get("policy"), address, "after"
)
violations.extend(after_violations)
if before_policy == after_policy:
violations.append(f"{address}: policy semantics did not change")
if distribution_id is None:
violations.append(
f"{address}: cannot verify policy without the pinned distribution ID"
)
return violations
expected_before = (
_expected_pre_adoption_bucket_policy(environment, distribution_id)
if address == BUCKET_POLICY_ADDRESS
else _expected_pre_adoption_deploy_policy(environment, distribution_id)
)
expected_after = (
_expected_bucket_policy(environment, distribution_id)
if address == BUCKET_POLICY_ADDRESS
else _expected_deploy_policy(environment, distribution_id)
)
if before_policy is not None and before_policy != expected_before:
violations.append(f"{address}: pre-adoption policy semantics are not exact")
if after_policy is not None and after_policy != expected_after:
violations.append(f"{address}: post-adoption policy semantics are not exact")
return violations
def _validate_controlled_update(
address: str,
change: dict[str, Any],
environment: str,
distribution_id: str | None,
) -> list[str]:
violations: list[str] = []
replace_paths = change.get("replace_paths", [])
if replace_paths not in (None, []):
violations.append(f"{address}: replace_paths must be empty")
if _contains_unknown(change.get("after_unknown", {})):
violations.append(f"{address}: controlled update contains unknown values")
before = change.get("before")
after = change.get("after")
if not isinstance(before, dict) or not isinstance(after, dict):
return [*violations, f"{address}: controlled update requires before/after objects"]
if address in TAG_UPDATE_ADDRESSES:
violations.extend(_validate_tag_update(address, before, after, environment))
elif address in {BUCKET_POLICY_ADDRESS, DEPLOY_POLICY_ADDRESS}:
violations.extend(
_validate_policy_update(
address,
before,
after,
environment,
distribution_id,
)
)
return violations
def check_plan(
plan: dict[str, Any],
*,
environment: str,
mode: str,
allowed_updates: set[str],
) -> list[str]:
violations: list[str] = []
invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES
for address in sorted(invalid_allowed):
violations.append(
f"{address}: address is not eligible for the controlled adoption update"
)
distribution_id = _distribution_id(plan, environment)
seen_addresses: set[str] = set()
seen_updates: set[str] = set()
required_resources = REQUIRED_RESOURCES[environment]
for resource in plan["resource_changes"]:
if not isinstance(resource, dict):
violations.append("<unknown>: resource change must be an object")
continue
if resource.get("mode", "managed") != "managed":
continue
address = resource.get("address")
if not isinstance(address, str):
violations.append("<unknown>: managed resource has no valid address")
continue
if address in seen_addresses:
violations.append(f"{address}: duplicate managed resource change")
seen_addresses.add(address)
expected_type = required_resources.get(address)
if expected_type is None:
violations.append(f"{address}: managed address is outside the ownership boundary")
elif resource.get("type") != expected_type:
violations.append(
f"{address}: expected managed type {expected_type!r}, "
f"got {resource.get('type')!r}"
)
change = resource.get("change")
if not isinstance(change, dict):
violations.append(f"{address}: missing change object")
continue
actions = change.get("actions")
if not isinstance(actions, list) or not all(
isinstance(action, str) for action in actions
):
violations.append(f"{address}: actions must be a string array")
continue
if change.get("replace_paths") not in (None, []):
violations.append(f"{address}: replace_paths must be empty")
if mode == "import":
if actions != ["no-op"]:
violations.append(
f"{address}: import mode requires no-op, got {actions!r}"
)
if expected_type is not None:
violations.extend(
_validate_import_metadata(
address=address,
change=change,
environment=environment,
)
)
elif mode == "post-import":
if actions != ["no-op"]:
violations.append(
f"{address}: post-import mode requires no-op, got {actions!r}"
)
if "importing" in change:
violations.append(
f"{address}: import metadata is forbidden in post-import mode"
)
else:
if "importing" in change:
violations.append(
f"{address}: import metadata is forbidden in controlled-update mode"
)
if actions == ["update"]:
seen_updates.add(address)
if address not in allowed_updates:
violations.append(f"{address}: update is not explicitly allowlisted")
else:
violations.extend(
_validate_controlled_update(
address,
change,
environment,
distribution_id,
)
)
elif actions != ["no-op"]:
violations.append(f"{address}: unsafe controlled actions {actions!r}")
for missing in sorted(set(required_resources) - seen_addresses):
violations.append(f"{missing}: required managed resource is absent")
for unused in sorted(allowed_updates - seen_updates):
violations.append(f"{unused}: allowlisted update address is not updating")
return violations
def main() -> int:
args = parse_args()
try:
plan = _load_plan(args.plan_json)
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"FAIL: unable to read Terraform plan JSON: {error}", file=sys.stderr)
return 1
allowed_updates = set(args.allow_update_address or [])
if args.post_import_no_op:
mode = "post-import"
elif allowed_updates:
mode = "controlled"
else:
mode = "import"
violations = check_plan(
plan,
environment=args.environment,
mode=mode,
allowed_updates=allowed_updates,
)
if violations:
print("FAIL: Terraform plan is not adoption-safe", file=sys.stderr)
for violation in violations:
print(f" - {violation}", file=sys.stderr)
return 1
label = {
"import": "zero-change import",
"post-import": "post-import no-op",
"controlled": "controlled update",
}[mode]
print(
f"PASS: {label} plan has {len(REQUIRED_RESOURCES[args.environment])} "
f"managed resources and {len(allowed_updates)} exact updates"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,120 @@
// Terraform/application change isolation gate.
//
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
// on the workspace. If the same merge also changes deployable application
// code, the content release and the VCS run race for the workspace lock
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
// or discarded by a human before the next content release.
//
// Files that may accompany a Terraform change without triggering a release:
// the Terraform tree itself, its plan-guard tooling, and documentation.
//
// Usage:
// node scripts/check-terraform-isolation.mjs --base <ref> --head <ref>
// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin
//
// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets
// it only when the PR carries the `terraform-isolation-override` label, which
// reviewers grant to the rare change that must introduce Terraform variables
// together with the workflow that consumes them.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const OVERRIDE_LABEL = "terraform-isolation-override";
export function isTerraformPath(file) {
return file.startsWith("terraform/");
}
export function mayAccompanyTerraform(file) {
if (isTerraformPath(file)) return true;
if (file.endsWith(".md")) return true;
if (file.startsWith("docs/")) return true;
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
return false;
}
/**
* @param {string[]} files changed paths relative to the repository root
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
*/
export function classifyChangedFiles(files) {
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
const terraform = unique.filter(isTerraformPath);
const application = unique.filter((file) => !mayAccompanyTerraform(file));
return {
terraform,
application,
mixed: terraform.length > 0 && application.length > 0,
};
}
function changedFilesFromGit(base, head) {
const mergeBase = execFileSync("git", ["merge-base", base, head], {
cwd: ROOT,
encoding: "utf8",
}).trim();
return execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
{ cwd: ROOT, encoding: "utf8" },
)
.split("\n")
.filter(Boolean);
}
function parseArgs(argv) {
const options = { base: null, head: "HEAD", stdin: false };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--base") options.base = argv[++index];
else if (argument === "--head") options.head = argv[++index];
else if (argument === "--stdin") options.stdin = true;
else throw new Error(`unknown argument: ${argument}`);
}
if (!options.stdin && !options.base) {
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
}
return options;
}
function main(argv) {
const options = parseArgs(argv);
const files = options.stdin
? readFileSync(0, "utf8").split("\n")
: changedFilesFromGit(options.base, options.head);
const result = classifyChangedFiles(files);
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
console.log("─".repeat(64));
console.log(
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
);
if (!result.mixed) {
console.log(" PASS: Terraform and application changes are not mixed");
return 0;
}
console.log(" Terraform files:");
for (const file of result.terraform) console.log(` ${file}`);
console.log(" Application files that cannot ship in the same PR:");
for (const file of result.application) console.log(` ${file}`);
if (override) {
console.log(
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
);
return 0;
}
console.log(
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
);
return 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exit(main(process.argv.slice(2)));
}

View file

@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
OVERRIDE_LABEL,
classifyChangedFiles,
mayAccompanyTerraform,
} from "./check-terraform-isolation.mjs";
const SCRIPT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"check-terraform-isolation.mjs",
);
function runGate(files, env = {}) {
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
input: `${files.join("\n")}\n`,
encoding: "utf8",
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
});
}
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
for (const file of [
"terraform/live/dev/main.tf",
"terraform/live/modules/environment-owned/main.tf",
"terraform/README.md",
"README.md",
"docs/adr/0003-terraform.md",
"scripts/check-terraform-import-plan.py",
"scripts/terraform_import_plan_resources.py",
"scripts/test-terraform-import-plan-check.py",
"scripts/terraform-validate.mjs",
"scripts/check-terraform-isolation.mjs",
]) {
assert.equal(mayAccompanyTerraform(file), true, file);
}
});
test("application, workflow, CDK, and dependency files count as application changes", () => {
for (const file of [
"src/App.tsx",
"public/favicon.ico",
"index.html",
"package.json",
"package-lock.json",
".env.production",
"vite.config.ts",
".github/workflows/deploy.yml",
"infra/cdk/lib/frontend-stack.ts",
"scripts/deploy-web.sh",
"scripts/governance-check.mjs",
"e2e/login.spec.ts",
]) {
assert.equal(mayAccompanyTerraform(file), false, file);
}
});
test("terraform-only and application-only changes are not mixed", () => {
assert.equal(
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
false,
);
assert.equal(
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
false,
);
assert.equal(classifyChangedFiles([]).mixed, false);
});
test("terraform plus application is mixed and lists the offending files", () => {
const result = classifyChangedFiles([
"terraform/live/dev/main.tf",
"src/App.tsx",
"README.md",
" ",
"src/App.tsx",
]);
assert.equal(result.mixed, true);
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
assert.deepEqual(result.application, ["src/App.tsx"]);
});
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
assert.match(mixed.stdout, /FAIL/);
assert.match(mixed.stdout, /src\/App\.tsx/);
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
assert.match(isolated.stdout, /PASS/);
});
test("CLI override downgrades a mixed change to a warning that names the label", () => {
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
TERRAFORM_ISOLATION_OVERRIDE: "true",
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /WARNING/);
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
TERRAFORM_ISOLATION_OVERRIDE: "yes",
});
assert.equal(notTrue.status, 1);
});
test("CLI refuses to run without a base ref or --stdin", () => {
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
assert.notEqual(result.status, 0);
});

View file

@ -1,14 +1,16 @@
#!/usr/bin/env bash
#
# Post-deploy step for the org reusable workflow `cd-cdk.yaml`
# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`).
# Content publish step for the environment deploy workflows
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
#
# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub
# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with
# the right cache headers, and invalidates CloudFront.
# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the
# environment's S3 bucket with the right cache headers, and invalidates
# CloudFront. It never touches infrastructure.
#
# Runs from the repo root. Reads the bucket + distribution from stack outputs,
# so it has no hardcoded resource IDs.
# Runs from the repo root. The target is resolved from, in order:
# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by
# dev, whose CloudFormation outputs disappear during Terraform adoption)
# 2. the BucketName/DistributionId outputs of STACK_NAME (staging)
set -euo pipefail
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
@ -16,24 +18,35 @@ REGION="${AWS_REGION:-us-east-1}"
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
npm ci
npm run build
echo "Reading stack outputs from ${STACK_NAME}..."
stack_output() {
aws cloudformation describe-stacks \
--stack-name "${STACK_NAME}" \
--region "${REGION}" \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
--output text
}
BUCKET="${SITE_BUCKET:-}"
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
BUCKET="$(stack_output BucketName)"
DIST_ID="$(stack_output DistributionId)"
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then
echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}."
elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then
echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2
exit 1
else
echo "Reading stack outputs from ${STACK_NAME}..."
stack_output() {
aws cloudformation describe-stacks \
--stack-name "${STACK_NAME}" \
--region "${REGION}" \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
--output text
}
BUCKET="$(stack_output BucketName)"
DIST_ID="$(stack_output DistributionId)"
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
exit 1
fi
fi
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
@ -41,6 +54,7 @@ echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
aws s3 sync dist/ "s3://${BUCKET}/" \
--delete \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
echo "Uploading index.html (never cached)..."

View file

@ -17,6 +17,14 @@ const MAINTAINABILITY_RULES = [
const GOVERNED_ROOTS = ["src/", "config/"];
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
// Repository-level gates that run after the source gates. Each is an npm
// script so it can also be run on its own.
const REPOSITORY_GATES = [
["Terraform import-plan contract", "test:terraform-import-plan"],
["Terraform isolation gate", "test:terraform-isolation"],
["Terraform formatting and validation", "test:terraform"],
["CDK build, tests, and synth", "test:infra"],
];
function isGoverned(relativePath) {
return (
@ -194,6 +202,20 @@ function plural(count, word) {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
function runRepositoryGate(label, script) {
// Reuse the npm that launched us when available (matches its version and
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
const npmCli = process.env.npm_execpath;
const executable = npmCli ? process.execPath : "npm";
const args = npmCli ? [npmCli, "run", script] : ["run", script];
const result = spawnSync(executable, args, {
cwd: ROOT,
encoding: "utf8",
stdio: "inherit",
});
return { label, status: result.status, error: result.error };
}
function main() {
const failures = [];
const baseRef = resolveBaseRef();
@ -281,6 +303,17 @@ function main() {
}
}
for (const [label, script] of REPOSITORY_GATES) {
console.log("─".repeat(64));
console.log(`${label}: npm run ${script}`);
const gate = runRepositoryGate(label, script);
if (gate.error) {
failures.push(`${label}: could not start: ${gate.error.message}`);
} else if (gate.status !== 0) {
failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`);
}
}
console.log("─".repeat(64));
if (failures.length > 0) {
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);

View file

@ -0,0 +1,35 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
// Only dev has a live root. Staging adoption (SH-287) adds its own root here.
const ENVIRONMENTS = ["dev"];
const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment));
function run(args, cwd = ROOT) {
const result = spawnSync(TERRAFORM, args, {
cwd,
encoding: "utf8",
stdio: "inherit",
});
if (result.error) {
throw new Error(`could not start Terraform: ${result.error.message}`, {
cause: result.error,
});
}
if (result.status !== 0) {
throw new Error(`terraform ${args.join(" ")} failed with exit code ${result.status}`);
}
}
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]);
for (const root of ROOTS) {
// -backend=false never touches HCP state; -lockfile=readonly refuses to
// silently rewrite the committed provider lock.
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root);
run(["validate", "-no-color"], root);
}
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);

View file

@ -0,0 +1,131 @@
"""Canonical frontend Terraform ownership and import-ID maps.
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
are kept so the checker can prove that a dev plan carrying a staging identifier
is rejected; they do not authorize a staging import.
"""
COMMON_RESOURCES = {
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
"module.environment_owned.aws_s3_bucket_public_access_block.site": (
"aws_s3_bucket_public_access_block"
),
"module.environment_owned.aws_s3_bucket_ownership_controls.site": (
"aws_s3_bucket_ownership_controls"
),
"module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site": (
"aws_s3_bucket_server_side_encryption_configuration"
),
"module.environment_owned.aws_s3_bucket_versioning.site": "aws_s3_bucket_versioning",
"module.environment_owned.aws_s3_bucket_policy.site": "aws_s3_bucket_policy",
"module.environment_owned.aws_cloudfront_distribution.site": (
"aws_cloudfront_distribution"
),
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
"aws_cloudfront_origin_access_control"
),
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
"aws_cloudfront_function"
),
"module.environment_owned.aws_route53_record.site_a": "aws_route53_record",
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
}
REQUIRED_RESOURCES = {
environment: dict(COMMON_RESOURCES)
for environment in ("dev", "staging")
}
CONTROLLED_UPDATE_ADDRESSES = frozenset(
{
"module.environment_owned.aws_s3_bucket.site",
"module.environment_owned.aws_s3_bucket_policy.site",
"module.environment_owned.aws_cloudfront_distribution.site",
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
"module.environment_owned.aws_iam_role.github_deploy",
"module.environment_owned.aws_iam_role_policy.github_deploy",
}
)
ENVIRONMENT_CONFIG = {
"dev": {
"bucket_name": "seahaven-shoc-frontend-dev",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
),
"cloudformation_stack_name": "shoc-frontend-dev",
"distribution_id": "E2CWLM1AFB964P",
"workspace_name": "shoc-frontend-new-dev",
},
"staging": {
"bucket_name": "seahaven-shoc-frontend-staging",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
),
"cloudformation_stack_name": "shoc-frontend-staging",
"distribution_id": "E2JDVEZ6EGD49J",
"workspace_name": "shoc-frontend-new-staging",
},
}
def _bucket_imports(bucket_name: str) -> dict[str, str]:
return {
address: bucket_name
for address in COMMON_RESOURCES
if address.startswith("module.environment_owned.aws_s3_bucket")
}
REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"dev": {
**_bucket_imports("seahaven-shoc-frontend-dev"),
"module.environment_owned.aws_cloudfront_distribution.site": "E2CWLM1AFB964P",
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
"E30VSIK87N8H64"
),
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
"us-east-1shocfrontenddevSpaRewrite58674DB8"
),
"module.environment_owned.aws_route53_record.site_a": (
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A"
),
"module.environment_owned.aws_route53_record.site_aaaa": (
"Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA"
),
"module.environment_owned.aws_iam_role.github_deploy": (
"githubdeploy-shoc-frontend-new-dev"
),
"module.environment_owned.aws_iam_role_policy.github_deploy": (
"githubdeploy-shoc-frontend-new-dev:"
"GithubDeployRoleDefaultPolicyE8F540D1"
),
},
"staging": {
**_bucket_imports("seahaven-shoc-frontend-staging"),
"module.environment_owned.aws_cloudfront_distribution.site": "E2JDVEZ6EGD49J",
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
"E1PF5R6QQNBZAI"
),
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
"us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
),
"module.environment_owned.aws_route53_record.site_a": (
"Z02602739VQWBWCAGXP4_staging.seahaven.com_A"
),
"module.environment_owned.aws_route53_record.site_aaaa": (
"Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA"
),
"module.environment_owned.aws_iam_role.github_deploy": (
"githubdeploy-shoc-frontend-new-staging"
),
"module.environment_owned.aws_iam_role_policy.github_deploy": (
"githubdeploy-shoc-frontend-new-staging:"
"GithubDeployRoleDefaultPolicyE8F540D1"
),
},
}

View file

@ -0,0 +1,638 @@
#!/usr/bin/env python3
"""Deterministic unit tests for the frontend Terraform plan checker."""
from __future__ import annotations
import copy
import json
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from terraform_import_plan_resources import (
CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG,
REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES,
)
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
REPOSITORY = SCRIPT.parent.parent
BUCKET_POLICY = "module.environment_owned.aws_s3_bucket_policy.site"
BUCKET = "module.environment_owned.aws_s3_bucket.site"
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
def import_id(environment: str, address: str) -> str:
expected = REQUIRED_IMPORT_IDS[environment][address]
assert expected is not None, f"{environment} must pin an import ID for {address}"
return expected
def distribution_id(environment: str) -> str:
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
return configured
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
source = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": config["bucket_auto_delete_helper_role_arn"]
},
"Action": [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*",
"s3:PutBucketPolicy",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"{bucket_arn}/*",
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
},
{
"Effect": "Deny",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": [bucket_arn, f"{bucket_arn}/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
},
],
}
def bucket_policy(environment: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}"
source = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "cloudfront.amazonaws.com"},
"Action": "s3:GetObject",
"Resource": f"{bucket_arn}/*",
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
},
{
"Effect": "Deny",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": [bucket_arn, f"{bucket_arn}/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
},
],
}
def pre_adoption_deploy_policy(environment: str) -> dict[str, Any]:
config = ENVIRONMENT_CONFIG[environment]
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
distribution_arn = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
statements: list[dict[str, Any]] = []
if environment == "dev":
statements.append(
{
"Sid": "AssumeCdkBootstrapRoles",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
}
)
statements.extend(
[
{
"Sid": "DescribeStack",
"Effect": "Allow",
"Action": "cloudformation:DescribeStacks",
"Resource": (
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
f"{config['cloudformation_stack_name']}/*"
),
},
{
"Effect": "Allow",
"Action": [
"s3:Abort*",
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging",
],
"Resource": [bucket_arn, f"{bucket_arn}/*"],
},
{
"Sid": "InvalidateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
],
"Resource": distribution_arn,
},
]
)
return {"Version": "2012-10-17", "Statement": statements}
def deploy_policy(environment: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}"
distribution_arn = (
"arn:aws:cloudfront::396287094661:distribution/"
f"{distribution_id(environment)}"
)
return {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadDeploymentBucket",
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation",
"s3:GetBucketVersioning",
"s3:ListBucket",
"s3:ListBucketVersions",
],
"Resource": bucket_arn,
},
{
"Sid": "PublishAndRollbackSiteObjects",
"Effect": "Allow",
"Action": [
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
],
"Resource": f"{bucket_arn}/*",
},
{
"Sid": "InvalidateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
],
"Resource": distribution_arn,
},
],
}
def tag_change(environment: str, address: str) -> dict[str, Any]:
manager = {
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"]
}
before_tags = {
"Environment": environment,
"ManagedBy": "cdk",
"Project": "shoc-frontend",
}
after_tags = {
"Environment": environment,
"ManagedBy": "terraform",
"Ownership": "terraform",
"Project": "shoc-frontend",
}
if address == ROLE:
before_tags.update(manager)
after_tags.update(manager)
if address == BUCKET:
before_tags["aws-cdk:auto-delete-objects"] = "true"
before: dict[str, Any] = {
"tags": before_tags,
"tags_all": before_tags,
}
after: dict[str, Any] = {
"tags": after_tags,
"tags_all": after_tags,
}
if address == DISTRIBUTION:
before["id"] = distribution_id(environment)
after["id"] = distribution_id(environment)
return {"actions": ["update"], "before": before, "after": after}
def policy_change(environment: str, address: str) -> dict[str, Any]:
before_policy = (
pre_adoption_bucket_policy(environment)
if address == BUCKET_POLICY
else pre_adoption_deploy_policy(environment)
)
after_policy = (
bucket_policy(environment)
if address == BUCKET_POLICY
else deploy_policy(environment)
)
return {
"actions": ["update"],
"before": {"policy": json.dumps(before_policy)},
"after": {"policy": json.dumps(after_policy)},
}
def make_plan(
environment: str,
*,
mode: str = "import",
controlled_updates: set[str] | None = None,
) -> dict[str, Any]:
resources: list[dict[str, Any]] = []
updates = controlled_updates or set()
for address, resource_type in REQUIRED_RESOURCES[environment].items():
if mode == "import":
change: dict[str, Any] = {
"actions": ["no-op"],
"importing": {"id": import_id(environment, address)},
}
elif mode == "post-import":
change = {"actions": ["no-op"]}
elif address in updates:
change = (
tag_change(environment, address)
if address in TAG_ADDRESSES
else policy_change(environment, address)
)
else:
change = {"actions": ["no-op"]}
if address == DISTRIBUTION:
change["after"] = {"id": distribution_id(environment)}
resources.append(
{
"address": address,
"mode": "managed",
"type": resource_type,
"change": change,
}
)
return {"resource_changes": resources}
def resource(plan: dict[str, Any], address: str) -> dict[str, Any]:
return next(
item for item in plan["resource_changes"] if item["address"] == address
)
def run_checker(
plan: dict[str, Any],
environment: str,
*allowed_updates: str,
post_import: bool = False,
) -> subprocess.CompletedProcess[str]:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "plan.json"
path.write_text(json.dumps(plan), encoding="utf-8")
command = [
sys.executable,
str(SCRIPT),
str(path),
"--environment",
environment,
]
if post_import:
command.append("--post-import-no-op")
for address in allowed_updates:
command.extend(["--allow-update-address", address])
return subprocess.run(
command,
check=False,
capture_output=True,
text=True,
)
class ImportPlanCheckerTests(unittest.TestCase):
def assert_passes(
self,
plan: dict[str, Any],
environment: str,
*allowed_updates: str,
post_import: bool = False,
) -> None:
result = run_checker(
plan,
environment,
*allowed_updates,
post_import=post_import,
)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
def assert_fails(
self,
plan: dict[str, Any],
environment: str,
*allowed_updates: str,
post_import: bool = False,
) -> None:
result = run_checker(
plan,
environment,
*allowed_updates,
post_import=post_import,
)
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
source = (
REPOSITORY
/ "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
expected = """ spa_rewrite_code = join("\\n", [
"function handler(event) {",
" var request = event.request;",
" var uri = request.uri;",
" // No file extension after the last slash -> a client-side route.",
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
" request.uri = '/index.html';",
" }",
" return request;",
"}",
])"""
self.assertIn(expected, source)
def test_only_dev_has_a_live_root(self) -> None:
live_roots = sorted(
path.name
for path in (REPOSITORY / "terraform/live").iterdir()
if path.is_dir() and path.name != "modules"
)
self.assertEqual(["dev"], live_roots)
def test_dev_root_pins_import_phase_in_code(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
self.assertRegex(source, r"\n\s+adoption_complete\s+= false\n")
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
self.assertNotIn('variable "adoption_complete"', source)
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
self.assertNotIn(
"variable ",
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
root_file,
)
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
expected = {
"dev": (
"local.hosted_zone_id",
"local.certificate_arn",
"local.github_oidc_arn",
"local.cache_policy_id",
),
}
for environment, values in expected.items():
source = (
REPOSITORY / f"terraform/live/{environment}/main.tf"
).read_text(encoding="utf-8")
for name, value in zip(
(
"hosted_zone_id",
"certificate_arn",
"github_oidc_provider_arn",
"cache_policy_id",
),
values,
strict=True,
):
self.assertIn(f"{name}", source)
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
self.assertNotRegex(
source,
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
)
def test_exact_import_plan_passes_for_every_environment(self) -> None:
for environment in REQUIRED_RESOURCES:
with self.subTest(environment=environment):
self.assert_passes(make_plan(environment), environment)
def test_import_missing_extra_wrong_type_and_cross_environment_fail(self) -> None:
for mutation in ("missing", "extra", "wrong-type", "cross-environment"):
plan = make_plan("dev")
if mutation == "missing":
plan["resource_changes"].pop()
elif mutation == "extra":
plan["resource_changes"].append(
{
"address": "module.inventory.aws_route53_zone.site",
"mode": "managed",
"type": "aws_route53_zone",
"change": {
"actions": ["no-op"],
"importing": {"id": "Z00000000000000000000"},
},
}
)
elif mutation == "wrong-type":
plan["resource_changes"][0]["type"] = "aws_s3_object"
else:
resource(plan, DISTRIBUTION)["change"]["importing"]["id"] = (
REQUIRED_IMPORT_IDS["staging"][DISTRIBUTION]
)
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev")
def test_import_rejects_mutation_and_invalid_metadata(self) -> None:
for actions in (["create"], ["update"], ["delete"], ["delete", "create"]):
plan = make_plan("dev")
plan["resource_changes"][0]["change"]["actions"] = actions
with self.subTest(actions=actions):
self.assert_fails(plan, "dev")
plan = make_plan("dev")
plan["resource_changes"][0]["change"]["importing"] = {"id": ""}
self.assert_fails(plan, "dev")
def test_post_import_no_op_passes(self) -> None:
self.assert_passes(
make_plan("staging", mode="post-import"),
"staging",
post_import=True,
)
def test_post_import_rejects_import_metadata_and_update(self) -> None:
plan = make_plan("dev", mode="post-import")
plan["resource_changes"][0]["change"]["importing"] = {"id": "unexpected"}
self.assert_fails(plan, "dev", post_import=True)
plan = make_plan("dev", mode="post-import")
plan["resource_changes"][0]["change"]["actions"] = ["update"]
self.assert_fails(plan, "dev", post_import=True)
def test_every_allowed_controlled_diff_passes(self) -> None:
for environment in REQUIRED_RESOURCES:
for address in CONTROLLED_UPDATE_ADDRESSES:
with self.subTest(environment=environment, address=address):
self.assert_passes(
make_plan(
environment,
mode="controlled",
controlled_updates={address},
),
environment,
address,
)
def test_full_exact_controlled_allowlist_passes(self) -> None:
addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES))
self.assert_passes(
make_plan(
"dev",
mode="controlled",
controlled_updates=set(addresses),
),
"dev",
*addresses,
)
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
self.assert_fails(plan, "dev", ROLE)
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
self.assert_fails(plan, "dev", ROLE)
def test_tag_update_requires_complete_adopted_tag_sets(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={BUCKET})
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
self.assert_fails(plan, "dev", BUCKET)
def test_role_trust_change_is_rejected(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
role = resource(plan, ROLE)["change"]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
self.assert_fails(plan, "dev", ROLE)
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
for mutation in ("principal", "extra"):
plan = make_plan(
"dev",
mode="controlled",
controlled_updates={BUCKET_POLICY},
)
policy = copy.deepcopy(bucket_policy("dev"))
if mutation == "principal":
policy["Statement"][0]["Principal"] = {"AWS": "*"}
else:
policy["Statement"].append(
{
"Effect": "Allow",
"Principal": {"AWS": "*"},
"Action": "s3:*",
"Resource": "*",
}
)
resource(plan, BUCKET_POLICY)["change"]["after"]["policy"] = json.dumps(
policy
)
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", BUCKET_POLICY)
def test_deploy_policy_rejects_resource_action_and_extra_statement(self) -> None:
for mutation in ("resource", "action", "extra"):
plan = make_plan(
"staging",
mode="controlled",
controlled_updates={DEPLOY_POLICY},
)
policy = copy.deepcopy(deploy_policy("staging"))
if mutation == "resource":
policy["Statement"][0]["Resource"] = "*"
elif mutation == "action":
policy["Statement"][0]["Action"].append("iam:PassRole")
else:
policy["Statement"].append(
{
"Sid": "Extra",
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*",
}
)
resource(plan, DEPLOY_POLICY)["change"]["after"]["policy"] = json.dumps(
policy
)
with self.subTest(mutation=mutation):
self.assert_fails(plan, "staging", DEPLOY_POLICY)
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
for environment in REQUIRED_RESOURCES:
for address in (BUCKET_POLICY, DEPLOY_POLICY):
plan = make_plan(
environment,
mode="controlled",
controlled_updates={address},
)
change = resource(plan, address)["change"]
before = json.loads(change["before"]["policy"])
before["Statement"].append(
{
"Sid": "UnexpectedDrift",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
}
)
change["before"]["policy"] = json.dumps(before)
with self.subTest(environment=environment, address=address):
self.assert_fails(plan, environment, address)
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
for field, value in (
("after_unknown", {"tags": {"ManagedBy": True}}),
("replace_paths", [["tags"]]),
):
plan = make_plan(
"dev",
mode="controlled",
controlled_updates={ROLE},
)
resource(plan, ROLE)["change"][field] = value
with self.subTest(field=field):
self.assert_fails(plan, "dev", ROLE)
def test_nonallowlisted_update_and_unused_allowlist_fail(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
self.assert_fails(plan, "dev", BUCKET_POLICY)
plan = make_plan("dev", mode="controlled", controlled_updates=set())
self.assert_fails(plan, "dev", ROLE)
if __name__ == "__main__":
unittest.main()

24
scripts/upload-sourcemaps.sh Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
: "${SENTRY_AUTH_TOKEN:?SENTRY_AUTH_TOKEN is required for source-map upload}"
SENTRY_ORG="${SENTRY_ORG:-seahaven}"
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
exit 1
fi
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
RELEASE="shoc-frontend@${COMMIT_SHA}"
npm exec --no -- sentry-cli sourcemaps upload \
--org "${SENTRY_ORG}" \
--project "${SENTRY_PROJECT}" \
--release "${RELEASE}" \
--url-prefix "~/" \
--validate \
dist

View file

@ -37,6 +37,8 @@ export const API_PATHS = {
media: (id: string | number) => `workorders/${id}/media`,
mediaItem: (id: string | number, mediaId: string | number) =>
`workorders/${id}/media/${mediaId}`,
mediaContent: (id: string | number, mediaId: string | number) =>
`workorders/${id}/media/${mediaId}/content`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>

View file

@ -3,18 +3,9 @@ import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import queryClient from "@/lib/query/query-client";
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
function clearSessionAndRedirectToLogin(): void {
clearAuth();
stopQueryBroadcast(queryClient);
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login");
}
}
import { getAccessToken } from "@/lib/auth/auth-storage";
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
import { traceHttpOperation } from "@/observability/http-transaction";
function isAuthLoginRequest(request: Request): boolean {
return request.url.includes(API_PATHS.authentication.login);
@ -51,35 +42,99 @@ export const api: KyInstance = ky.create({
return response;
}
clearSessionAndRedirectToLogin();
expireSessionAndRedirectToLogin();
return response;
},
],
},
});
export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete";
export async function apiRequestRaw(
method: ApiRawMethod,
url: string,
options?: Options,
entryPoint?: string,
): Promise<Response> {
return traceHttpOperation(
{
method: method.toUpperCase(),
url,
entryPoint: entryPoint ?? `api.${method}`,
},
async ({ reportStatus }) => {
const response = await api[method](url, options);
reportStatus(response.status);
return response;
},
);
}
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
return api.get(url, options).json<T>();
return traceHttpOperation(
{ method: "GET", url, entryPoint: "api.apiGet" },
async ({ reportStatus }) => {
const response = await api.get(url, options);
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.post(url, { ...options, json: body }).json<T>();
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPost" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
return api.post(url, { ...options, body }).json<T>();
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPostForm" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.put(url, { ...options, json: body }).json<T>();
return traceHttpOperation(
{ method: "PUT", url, entryPoint: "api.apiPut" },
async ({ reportStatus }) => {
const response = await api.put(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.patch(url, { ...options, json: body }).json<T>();
return traceHttpOperation(
{ method: "PATCH", url, entryPoint: "api.apiPatch" },
async ({ reportStatus }) => {
const response = await api.patch(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
return api.delete(url, options).json<T>();
return traceHttpOperation(
{ method: "DELETE", url, entryPoint: "api.apiDelete" },
async ({ reportStatus }) => {
const response = await api.delete(url, options);
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPostNoContent(
@ -87,31 +142,43 @@ export async function apiPostNoContent(
body?: unknown,
options?: Options,
): Promise<void> {
const response = await api.post(url, { ...options, json: body });
if (response.status === 204) {
return;
}
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPostNoContent" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, json: body });
reportStatus(response.status);
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
JSON.parse(text);
},
);
}
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
const response = await api.delete(url, options);
if (response.status === 204) {
return;
}
return traceHttpOperation(
{ method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" },
async ({ reportStatus }) => {
const response = await api.delete(url, options);
reportStatus(response.status);
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
JSON.parse(text);
},
);
}
export { mapHttpStatusToMessage };

View file

@ -0,0 +1,103 @@
import { getAccessToken } from "@/lib/auth/auth-storage";
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
import { env } from "@/lib/env";
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
export interface UploadFormResult {
ok: boolean;
status: number;
data: unknown;
}
export interface UploadFormOptions {
signal?: AbortSignal;
onProgress?: (percent: number) => void;
}
function parseJsonBody(text: string): unknown {
if (!text.trim()) {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
function createAbortError(): DOMException {
return new DOMException("The upload was aborted.", "AbortError");
}
/**
* hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept
* streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies
* as a network error before reaching the API. XHR reports progress on every transport.
*/
export function uploadFormWithProgress(
path: string,
body: FormData,
options?: UploadFormOptions,
): Promise<UploadFormResult> {
return new Promise((resolve, reject) => {
if (options?.signal?.aborted) {
reject(createAbortError());
return;
}
const request = new XMLHttpRequest();
request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`);
request.setRequestHeader("Accept", "application/json");
const token = getAccessToken();
if (token) {
request.setRequestHeader("Authorization", token);
}
const abortRequest = () => {
request.abort();
};
options?.signal?.addEventListener("abort", abortRequest);
const detachSignal = () => {
options?.signal?.removeEventListener("abort", abortRequest);
};
const { onProgress } = options ?? {};
if (onProgress) {
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
}
request.addEventListener("load", () => {
detachSignal();
const { status } = request;
if (status === 401) {
expireSessionAndRedirectToLogin();
}
resolve({
ok: status >= 200 && status < 300,
status,
data: parseJsonBody(request.responseText),
});
});
request.addEventListener("error", () => {
detachSignal();
reject(new Error("Upload failed. Check your connection and try again."));
});
request.addEventListener("abort", () => {
detachSignal();
reject(createAbortError());
});
request.send(body);
});
}

View file

@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { toast } from "react-toastify";
import { Text } from "@/components/ui/text";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
@ -20,9 +21,9 @@ import {
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderExtraDocTypeAllowed,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
workOrderMediaAcceptAttribute,
workOrderExtraDocsAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = {
isReadOnly: boolean;
pending?: boolean;
avetaRequired?: boolean;
workOrderId?: string | number;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
};
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
const tab = window.open("about:blank", "_blank");
if (!tab) {
toast.error("Unable to open this document. Please allow pop-ups and try again.");
return;
}
try {
const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId);
const objectUrl = URL.createObjectURL(blob);
tab.opener = null;
tab.location.href = objectUrl;
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
} catch {
tab.close();
toast.error("Unable to open this document.");
}
}
function ExtraDocRow({
file,
isReadOnly,
pending,
workOrderId,
onRemove,
onCategorize,
}: {
file: MediaFile;
isReadOnly: boolean;
pending: boolean;
workOrderId?: string | number;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
}) {
@ -75,15 +98,20 @@ function ExtraDocRow({
className="shrink-0"
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
/>
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate hover:underline"
<button
type="button"
disabled={workOrderId == null}
onClick={() => {
if (workOrderId == null) {
return;
}
void openExtraDocContent(workOrderId, file.id);
}}
className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50"
style={{ fontSize: 12, color: "var(--foreground)" }}
>
{file.name}
</a>
</button>
{showCategory ? (
<select
aria-label={`Category for ${file.name}`}
@ -134,6 +162,7 @@ export function SlideOverExtrasTab({
isReadOnly,
pending = false,
avetaRequired = false,
workOrderId,
onUpload,
onRemove,
onCategorize,
@ -145,8 +174,8 @@ export function SlideOverExtrasTab({
if (isReadOnly || pending || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderMediaTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
if (!isWorkOrderExtraDocTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
@ -193,7 +222,7 @@ export function SlideOverExtrasTab({
event.preventDefault();
addFiles(event.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
style={{
minHeight: 100,
border: "1px dashed var(--color-border)",
@ -208,13 +237,14 @@ export function SlideOverExtrasTab({
{pending ? "Uploading…" : "Upload documents, photos or videos"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept={workOrderMediaAcceptAttribute()}
accept={workOrderExtraDocsAcceptAttribute()}
multiple
hidden
disabled={pending}
@ -238,6 +268,7 @@ export function SlideOverExtrasTab({
file={file}
isReadOnly={isReadOnly}
pending={pending}
workOrderId={workOrderId}
onRemove={onRemove}
onCategorize={onCategorize}
/>

View file

@ -16,6 +16,7 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { DocBadge } from "./slide-over-doc-badge";
@ -31,6 +32,75 @@ type SlideOverInfoTabEditViewProps = {
onRequestComplete?: () => void;
};
function SlideOverScheduleOnField({
draft,
onDraftChange,
}: {
draft: WorkOrderTableRow;
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
}) {
const hasSchedule =
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
return (
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onSave={(scheduledOn) =>
onDraftChange({
scheduledOn,
scheduleWeekOnly: false,
targetWeek: scheduledOn ? "" : draft.targetWeek,
scheduleEnd: "",
schedMode: "date",
isPastDue: false,
...deriveDayFields(scheduledOn),
})
}
onSaveWeek={(weekStart) =>
onDraftChange({
scheduledOn: "",
targetWeek: weekStart,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
isPastDue: false,
...deriveDayFields(""),
})
}
onSaveRange={(start, end) =>
onDraftChange({
scheduledOn: start,
scheduleEnd: end,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
isPastDue: false,
...deriveDayFields(start),
})
}
onClear={
hasSchedule
? () =>
onDraftChange({
scheduledOn: "",
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: undefined,
isPastDue: false,
...deriveDayFields(""),
})
: undefined
}
/>
);
}
export function SlideOverInfoTabEditView({
draft,
users,
@ -50,6 +120,9 @@ export function SlideOverInfoTabEditView({
rescheduleCount={draft.rescheduleCount}
carriedOver={draft.carriedOver}
originalDate={draft.originalDate}
originalWeek={draft.originalWeek}
scheduledOn={draft.scheduledOn}
status={draft.status}
currentId={draft.id}
allOrders={allOrders}
onSave={(woNumber) => onDraftChange({ woNumber })}
@ -96,23 +169,7 @@ export function SlideOverInfoTabEditView({
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
onSave={(scheduledOn) =>
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
}
onSaveWeek={(weekStart) =>
onDraftChange({
scheduledOn: "",
targetWeek: weekStart,
scheduleWeekOnly: true,
isPastDue: false,
})
}
/>
<SlideOverScheduleOnField draft={draft} onDraftChange={onDraftChange} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<DueDateCell

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
@ -9,9 +9,13 @@ import type {
useDeleteWorkOrderMedia,
useUpdateWorkOrderMediaCategory,
useUploadWorkOrderMedia,
UploadWorkOrderMediaInput,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
import {
isCompletionSurfaceMedia,
isExtrasSurfaceMedia,
type MediaSurfaceOwnership,
} from "@/domain/work-orders/utils/partition-slide-over-media";
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = {
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
};
function addOwnedId(previous: Set<string>, id: string | undefined): Set<string> {
if (!id) return previous;
const next = new Set(previous);
next.add(id);
return next;
}
function dropOwnedId(previous: Set<string>, id: string): Set<string> {
if (!previous.has(id)) return previous;
const next = new Set(previous);
next.delete(id);
return next;
}
const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:";
function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership {
if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
try {
const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`);
if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown };
return {
completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []),
extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []),
};
} catch {
return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
}
}
function saveOwnership(
workOrderId: string | number | undefined,
ownership: MediaSurfaceOwnership,
): void {
if (workOrderId == null) return;
try {
sessionStorage.setItem(
`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`,
JSON.stringify({
completion: [...ownership.completionOwnedIds],
extra: [...ownership.extraOwnedIds],
}),
);
} catch {
return;
}
}
export function useSlideOverMediaState({
detailForRow,
uploadMediaMutation,
deleteMediaMutation,
categorizeMediaMutation,
}: UseSlideOverMediaStateArgs) {
const workOrderId = detailForRow?.info.id;
const [mediaBatchPending, setMediaBatchPending] = useState(false);
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
loadOwnership(workOrderId),
);
useEffect(() => {
setOwnershipState(loadOwnership(workOrderId));
}, [workOrderId]);
const updateOwnership = useCallback(
(update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => {
setOwnershipState((current) => {
const next = update(current);
saveOwnership(workOrderId, next);
return next;
});
},
[workOrderId],
);
const persistCompletionUpload = useCallback(
async (input: UploadWorkOrderMediaInput) => {
const item = await uploadMediaMutation.mutateAsync(input);
updateOwnership((current) => ({
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id),
}));
return item;
},
[updateOwnership, uploadMediaMutation],
);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
useMediaUploadSession(detailForRow, persistCompletionUpload);
const completionMedia = useMemo(
() =>
uploads.filter(
(item) =>
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
item.uploadStatus === "uploading" ||
item.uploadStatus === "failed",
),
[uploads],
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
[uploads, ownership],
);
const extrasUploads = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter(
(item) => item.category === "Extra" || item.category === "Aveta",
),
boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) =>
isExtrasSurfaceMedia(item, ownership),
),
[detailForRow?.media],
[detailForRow?.media, ownership],
);
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
@ -68,7 +145,11 @@ export function useSlideOverMediaState({
try {
for (const file of files) {
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
updateOwnership((current) => ({
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
}));
}
} finally {
setMediaBatchPending(false);
@ -81,7 +162,20 @@ export function useSlideOverMediaState({
toast.error("Missing work order version. Refresh and try again.");
return;
}
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
categorizeMediaMutation.mutate(
{ mediaId, category, workOrderVersion },
{
onSuccess: () => {
updateOwnership((current) => ({
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
extraOwnedIds:
category === "Extra"
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
}));
},
},
);
};
const removeMedia = (id: string) => {

View file

@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({
>
<SlideOverExtrasTab
uploads={state.extrasUploads}
workOrderId={infoSource?.id}
avetaRequired={resolveSlideOverAvetaRequired(
state.editing,
infoSource?.avetaRequired,

View file

@ -2,6 +2,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
export function buildInlineDraftCreateInput(
draft: WorkOrderTableDraft,
@ -36,13 +37,19 @@ export function buildInlineDraftCreateInput(
vendorNotes: "",
apptStart: draft.apptStart,
apptEnd: draft.apptEnd,
status: draft.status || "Incomplete",
status: resolveCreationStatus({
status: draft.status || "Incomplete",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly,
}),
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,
additionalContacts: draft.additionalContacts ?? [],
scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
scheduleEnd: draft.scheduleEnd || "",
schedMode: draft.schedMode,
isAddOnOverride: draft.isAddOnOverride,
};
}

View file

@ -28,7 +28,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80"
className="group/cell flex w-full min-w-0 items-center gap-1.5 rounded transition-opacity hover:opacity-80"
style={
highlight
? {
@ -43,13 +43,13 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
{selected ? (
<DispatcherPill user={selected} />
) : (
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
<span className="flex min-w-0 items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
<Users
size={13}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
Select dispatcher
<span className="truncate whitespace-nowrap">Select dispatcher</span>
</span>
)}
<HoverChevron />

View file

@ -0,0 +1,60 @@
import { CalendarDays, Calendar as CalendarIcon, CalendarRange } from "lucide-react";
import { fmtDateRange, fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
import { HighlightText } from "./highlight-text";
type DateCellValueArgs = {
showRange: boolean;
showWeek: boolean;
value: string;
scheduleEnd: string;
targetWeek: string;
q?: string;
highlight?: boolean;
};
export function DateCellValue({
showRange,
showWeek,
value,
scheduleEnd,
targetWeek,
q,
highlight,
}: DateCellValueArgs) {
if (showRange) {
return (
<>
<CalendarRange size={11} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
{fmtDateRange(value, scheduleEnd)}
</span>
</>
);
}
if (showWeek) {
return (
<>
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
{fmtWeekOf(targetWeek)}
</span>
</>
);
}
return (
<>
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</>
);
}

View file

@ -1,10 +1,13 @@
import { useState } from "react";
import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react";
import { ChevronDown, X } from "lucide-react";
import { Popover } from "@mui/material";
import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
import { HighlightText } from "./highlight-text";
import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state";
import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs";
import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel";
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
type DateCellProps = {
value: string;
@ -15,6 +18,9 @@ type DateCellProps = {
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date";
scheduleEnd?: string;
schedMode?: WizardSchedMode;
onSaveRange?: (start: string, end: string) => void;
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
onClear?: () => void;
/** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */
@ -30,14 +36,34 @@ export function DateCell({
targetWeek = "",
onSaveWeek,
defaultMode,
scheduleEnd = "",
schedMode,
onSaveRange,
onClear,
srStatusSuffix,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
const open = Boolean(anchorEl);
const allowRange = Boolean(allowWeekMode && onSaveRange);
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
{
open,
value,
scheduleEnd,
allowRange,
allowWeekMode,
targetWeek,
schedMode,
defaultMode,
},
);
const showWeek = allowWeekMode && !value && !!targetWeek;
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
const modes: WizardSchedMode[] = allowRange
? ["week", "date", "range"]
: allowWeekMode
? ["week", "date"]
: [];
return (
<>
@ -60,29 +86,15 @@ export function DateCell({
}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{showWeek ? (
<>
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
{fmtWeekOf(targetWeek)}
</span>
</>
) : (
<>
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</>
)}
<DateCellValue
showRange={showRange}
showWeek={showWeek}
value={value}
scheduleEnd={scheduleEnd}
targetWeek={targetWeek}
q={q}
highlight={highlight}
/>
</span>
{Boolean(srStatusSuffix) && <span className="sr-only">{srStatusSuffix}</span>}
<ChevronDown
@ -92,36 +104,35 @@ export function DateCell({
/>
</button>
<Popover
open={Boolean(anchorEl)}
open={open}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
>
{Boolean(allowWeekMode) && (
<div className="mb-2.5 flex items-center gap-1.5">
{(["week", "date"] as const).map((m) => {
const active = cellMode === m;
return (
<button
key={m}
type="button"
onClick={() => setCellMode(m)}
className="rounded-full border px-3 text-[11.5px] font-medium"
style={{
height: 26,
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
}}
>
{m === "week" ? "Week" : "Specific date"}
</button>
);
})}
{Boolean(onClear) && (
<div className="mb-1.5 flex justify-end">
<button
type="button"
onClick={() => {
onClear?.();
setAnchorEl(null);
}}
className="inline-flex items-center gap-0.5 text-xs font-medium transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
<X size={12} />
Clear
</button>
</div>
)}
<WizardDateModeTabs
modes={modes}
cellMode={cellMode}
allowRange={allowRange}
onSelect={setCellMode}
/>
{allowWeekMode && cellMode === "week" ? (
<WeekPicker
selected={!value ? targetWeek : ""}
@ -130,6 +141,16 @@ export function DateCell({
setAnchorEl(null);
}}
/>
) : allowRange && cellMode === "range" ? (
<WizardDateRangePanel
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onRangeClick={handleRangeClick}
onDone={(start, end) => {
onSaveRange?.(start, end);
setAnchorEl(null);
}}
/>
) : (
<MiniCalendar
value={value}
@ -139,21 +160,6 @@ export function DateCell({
}}
/>
)}
{Boolean(onClear) && (
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
<button
type="button"
onClick={() => {
onClear?.();
setAnchorEl(null);
}}
className="text-[11.5px] font-medium transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
× Clear
</button>
</div>
)}
</Popover>
</>
);

View file

@ -13,6 +13,9 @@ type EditableWONumberCellProps = {
carriedOver?: number;
isAddOn?: boolean;
originalDate?: string;
originalWeek?: string;
scheduledOn?: string;
status?: string;
q?: string;
currentId: string | number;
allOrders: WorkOrderTableRow[];
@ -37,6 +40,9 @@ export function EditableWONumberCell({
carriedOver = 0,
isAddOn = false,
originalDate,
originalWeek,
scheduledOn,
status,
q,
currentId,
allOrders,
@ -118,7 +124,13 @@ export function EditableWONumberCell({
</span>
<AddOnPill when={isAddOn} />
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
<CarriedOverCounterPill count={carriedOver} />
<CarriedOverCounterPill
count={carriedOver}
originalWeek={originalWeek}
originalDate={originalDate}
scheduledOn={scheduledOn}
status={status}
/>
</div>
);
}
@ -155,7 +167,13 @@ export function EditableWONumberCell({
)}
<AddOnPill when={isAddOn} />
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
<CarriedOverCounterPill count={carriedOver} />
<CarriedOverCounterPill
count={carriedOver}
originalWeek={originalWeek}
originalDate={originalDate}
scheduledOn={scheduledOn}
status={status}
/>
</div>
);
}

View file

@ -71,6 +71,10 @@ export function SeverityField({ value, onSelect }: SeverityFieldProps) {
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
>
Severity (from APM)
<Text as="span" variant="label" tone="error">
{" "}
*
</Text>
</Text>
<Text
as="p"

View file

@ -2,16 +2,19 @@ import { useState } from "react";
import { AlertTriangle, Check } from "lucide-react";
import { Popover } from "@mui/material";
import { Text } from "@/components/ui/text";
import {
ALL_WIZARD_STATUSES,
type WizardWOStatus,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
tableRowToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import {
BOARD_INLINE_STATUSES,
PAST_DUE_BADGE_TOOLTIP,
PAST_DUE_POPOVER_BODY,
PAST_DUE_RESCHEDULE_CTA,
getStatusOptionState,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label";
@ -24,6 +27,7 @@ type StatusCellProps = {
onPastDueAttempt?: () => void;
onRequestComplete?: () => void;
readOnly?: boolean;
statusOptions?: readonly WizardWOStatus[];
};
function ReadOnlyStatusLabel({ status }: { status: string }) {
@ -64,7 +68,7 @@ function PastDueStatusCell({
onPastDueAttempt?.();
setAnchorEl(e.currentTarget);
}}
title="This WO missed its deadline. Update Schedule On to unlock status updates."
title={PAST_DUE_BADGE_TOOLTIP}
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
@ -97,7 +101,7 @@ function PastDueStatusCell({
color: "var(--status-pending-text)",
}}
>
This work order is past due. Update Schedule On before the status can be updated.
{PAST_DUE_POPOVER_BODY}
</Text>
<button
type="button"
@ -108,7 +112,7 @@ function PastDueStatusCell({
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Update Schedule On →
{PAST_DUE_RESCHEDULE_CTA}
</button>
</div>
</div>
@ -124,6 +128,7 @@ export function StatusCell({
onPastDueAttempt,
onRequestComplete,
readOnly = false,
statusOptions = BOARD_INLINE_STATUSES,
}: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const closabilityGaps = rowClosabilityGaps(wo);
@ -162,22 +167,19 @@ export function StatusCell({
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }}
>
{ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
const missingTitle = isCompletedBlocked
? formatClosabilityMissingTitle(closabilityGaps)
: undefined;
{statusOptions.map((status) => {
const option = getStatusOptionState(wo, status, closabilityGaps);
return (
<button
key={status}
type="button"
disabled={isCompletedBlocked}
title={missingTitle}
disabled={option.disabled}
title={option.title}
onClick={() => {
if (isCompletedBlocked) {
if (option.disabled) {
return;
}
if (status === "Completed" && wo.status !== "Completed") {
@ -189,9 +191,9 @@ export function StatusCell({
setAnchorEl(null);
}}
className={
isCompletedBlocked
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
option.disabled
? "flex w-full cursor-not-allowed items-center justify-between gap-2 rounded px-2 py-1.5 opacity-50"
: "flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
}
>
<WoTableStatusLabel status={status} />

View file

@ -18,14 +18,32 @@ type TypeCellProps = {
value: WOType | "";
severity?: WorkOrderSeverity | null;
q?: string;
viewOnly?: boolean;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
};
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
if (value) {
return <WoTableTypeLabel type={value} q={q} />;
}
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
}
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
const [resetSeverity, setResetSeverity] = useState(false);
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
if (viewOnly) {
return (
<div className="flex w-full items-center gap-1">
<TypeCellValue value={value} q={q} />
{Boolean(showSeverityBadge && severity != null) && <SeverityBadge severity={severity!} />}
</div>
);
}
function pick(t: WOType) {
setAnchorEl(null);
@ -43,23 +61,19 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
onSave(t, needsSeverity ? severity : null);
}
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
return (
<>
<button
type="button"
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
title={value ? undefined : "Select type of WO"}
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
>
{value ? (
<WoTableTypeLabel type={value} q={q} />
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
<TypeCellValue value={value} q={q} />
{Boolean(showSeverityBadge && severity != null) && (
<SeverityBadge
severity={severity!}

View file

@ -1,6 +1,14 @@
import { useEffect, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import {
@ -76,6 +84,10 @@ export function TypeSeverityModal({
>
<DialogTitle
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingRight: 1,
fontFamily: "var(--font-display)",
fontSize: 18,
fontWeight: 600,
@ -83,6 +95,9 @@ export function TypeSeverityModal({
}}
>
Type &amp; severity
<IconButton aria-label="Close" onClick={() => onOpenChange(false)} size="small">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent>
<div className="space-y-3">
@ -93,6 +108,10 @@ export function TypeSeverityModal({
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
>
Type
<Text as="span" variant="label" tone="error">
{" "}
*
</Text>
</Text>
<div className="flex flex-wrap gap-1.5">
{WO_TYPES.map((t) => (

View file

@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import {
buildCompanyOptions,
buildTechnicianOptions,
phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
type UseVendorDialogFormArgs = {
open: boolean;
onOpenChange: (open: boolean) => void;
vendorId: string | number;
company: string;
tech: string;
techPhone: string;
vendors: VendorDropdownItem[];
onSave: (patch: VendorPatch) => void;
};
export function useVendorDialogForm({
open,
onOpenChange,
vendorId,
company,
tech,
techPhone,
vendors,
onSave,
}: UseVendorDialogFormArgs) {
const createVendor = useCreateVendor();
const [selectedCompany, setSelectedCompany] = useState(company);
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const allVendors = useMemo(
() => mergeVendorDropdownItems(vendors, addedVendors),
[vendors, addedVendors],
);
const companyOptions = useMemo(
() => buildCompanyOptions(allVendors, company),
[allVendors, company],
);
const technicianOptions = useMemo(
() => buildTechnicianOptions(allVendors, selectedCompany, tech, company),
[allVendors, selectedCompany, tech, company],
);
useEffect(() => {
if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
const initialOptions = buildTechnicianOptions(vendors, company, tech, company);
setSelectedCompany(company);
setSelectedKey(key);
setT(tech);
setPhone(techPhone || phoneForOption(tech, initialOptions));
setNotes("");
setAddedVendors([]);
setPendingCreates({});
setSaveError(null);
}
// invariant: rebuilding options after Add must not clear staged technician drafts.
}, [open, tech, techPhone, vendors, company, vendorId]);
const selected = technicianOptions.find((option) => option.key === selectedKey);
const resolvedCompany = selectedCompany;
const selectedVendor =
selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany);
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company: selectedCompany,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const canSubmit = computeVendorDialogCanSubmit({
current: {
company: selectedCompany,
selectedKey,
phone,
notes,
pendingCreateCount: Object.keys(pendingCreates).length,
},
baseline: {
company,
selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors),
phone:
techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)),
notes: "",
pendingCreateCount: 0,
},
});
const { handleSave } = useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
onSave,
onOpenChange,
setSaveError,
canSubmit,
});
return {
companyOptions,
technicianOptions,
selected,
t,
phone,
notes,
resolvedCompany,
selectedVendor,
selectedCompany,
selectedKey,
anchorCompanyName,
companyVendor,
saveError,
canSubmit,
isPending: createVendor.isPending,
handleSave,
handleCompanyPick: (nextCompany: string) => {
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
setSelectedCompany(nextCompany);
if (selectedOption?.company !== nextCompany) {
setAddedVendors([]);
setPendingCreates({});
setSelectedKey("");
setT("");
setPhone("");
}
},
handlePick: (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
setSelectedKey(key);
setT(option?.name ?? key);
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
},
handleTechnicianCreated: (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => {
setAddedVendors((current) => [...current, created]);
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
setSelectedKey(String(created.id));
setT(created.contactName);
setPhone(createdPhone);
},
setPhone,
setNotes,
};
}

View file

@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = {
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void;
canSubmit: boolean;
};
export function useVendorDialogSave({
@ -33,6 +34,7 @@ export function useVendorDialogSave({
onSave,
onOpenChange,
setSaveError,
canSubmit,
}: UseVendorDialogSaveArgs) {
const resolveSaveVendor = () =>
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
@ -44,6 +46,7 @@ export function useVendorDialogSave({
selectedVendor;
const handleSave = async () => {
if (!canSubmit) return;
setSaveError(null);
let saveVendor = resolveSaveVendor();
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
@ -65,8 +68,8 @@ export function useVendorDialogSave({
onSave({
vendorId: saveVendor ? String(saveVendor.id) : "",
company: resolvedCompany || anchorCompanyName,
tech: saveVendor?.contactName ?? tech,
techPhone: phone,
tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
techPhone: selectedKey ? phone : "",
});
onOpenChange(false);
};

View file

@ -1,4 +1,4 @@
import { Wrench } from "lucide-react";
import { Building2, Wrench } from "lucide-react";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import type {
CompanyOption,
TechnicianOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
type VendorDialogBodyProps = {
selectedCompany: string;
companyOptions: CompanyOption[];
selectedKey: string;
technicianOptions: TechnicianOption[];
anchorCompanyName: string;
@ -23,6 +28,7 @@ type VendorDialogBodyProps = {
resolvedCompany: string;
selectedVendor: VendorDropdownItem | undefined;
saveError: string | null;
onCompanyPick: (company: string) => void;
onPick: (key: string) => void;
onPhoneChange: (phone: string) => void;
onNotesChange: (notes: string) => void;
@ -34,6 +40,8 @@ type VendorDialogBodyProps = {
};
export function VendorDialogBody({
selectedCompany,
companyOptions,
selectedKey,
technicianOptions,
anchorCompanyName,
@ -44,6 +52,7 @@ export function VendorDialogBody({
resolvedCompany,
selectedVendor,
saveError,
onCompanyPick,
onPick,
onPhoneChange,
onNotesChange,
@ -53,17 +62,46 @@ export function VendorDialogBody({
<>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<FieldLabel required>Company</FieldLabel>
<WizardFieldSelect
value={selectedCompany}
placeholder="Select company …"
icon={
<Building2
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search company…"
options={companyOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={onCompanyPick}
/>
</div>
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company info</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Technician (optional)</FieldLabel>
<WizardFieldSelect
value={selectedKey}
placeholder="Search technician …"
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
disabled={!selectedCompany}
icon={
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
label: option.name,
}))}
onPick={onPick}
/>
@ -80,7 +118,7 @@ export function VendorDialogBody({
{Boolean(tech) && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<FieldLabel>Technician Phone (optional)</FieldLabel>
<input
type="tel"
value={phone}
@ -91,15 +129,8 @@ export function VendorDialogBody({
</div>
)}
{Boolean(resolvedCompany) && (
<div>
<FieldLabel>Company</FieldLabel>
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
</div>
)}
<div>
<FieldLabel>Notes</FieldLabel>
<FieldLabel>Notes (optional)</FieldLabel>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}

View file

@ -0,0 +1,26 @@
export type VendorDialogSubmitSnapshot = {
company: string;
selectedKey: string;
phone: string;
notes: string;
pendingCreateCount: number;
};
type VendorDialogCanSubmitInput = {
current: VendorDialogSubmitSnapshot;
baseline: VendorDialogSubmitSnapshot;
};
export function computeVendorDialogCanSubmit({
current,
baseline,
}: VendorDialogCanSubmitInput): boolean {
const valid = current.company.trim() !== "";
const hasNewTechs = current.pendingCreateCount > 0;
const dirty =
current.company !== baseline.company ||
current.selectedKey !== baseline.selectedKey ||
current.phone !== baseline.phone ||
current.pendingCreateCount !== baseline.pendingCreateCount;
return valid && (dirty || hasNewTechs);
}

View file

@ -1,4 +1,4 @@
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
import { ExternalLink, Mail, MapPin } from "lucide-react";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
@ -30,10 +30,7 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
</span>
)}
</div>
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
{Boolean(vendor?.tradeSpecialties) && (
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
)}
{Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
{Boolean(email) && <Detail icon={Mail} text={email} />}
{vendor != null && Boolean(vendor.address) && (
<a

View file

@ -5,6 +5,13 @@ type VendorDialogFooterProps = {
onCancel: () => void;
onSave: () => void;
viewOnly?: boolean;
canSubmit?: boolean;
};
const lookDisabledSx = {
opacity: 0.5,
cursor: "not-allowed",
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
};
export function VendorDialogFooter({
@ -12,6 +19,7 @@ export function VendorDialogFooter({
onCancel,
onSave,
viewOnly = false,
canSubmit = false,
}: VendorDialogFooterProps) {
if (viewOnly) {
return (
@ -36,7 +44,13 @@ export function VendorDialogFooter({
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={isPending}
onClick={() => void onSave()}
aria-disabled={!canSubmit && !isPending}
// why: AAP Save stays clickable; native disabled would change the no-op look-disabled gate.
sx={canSubmit || isPending ? undefined : lookDisabledSx}
onClick={() => {
if (!canSubmit) return;
void onSave();
}}
>
{isPending ? "Saving…" : "Save"}
</Button>

View file

@ -10,10 +10,44 @@ export type TechnicianOption = {
vendor?: VendorDropdownItem;
};
export type CompanyOption = {
key: string;
label: string;
vendor?: VendorDropdownItem;
};
export function buildCompanyOptions(
vendors: VendorDropdownItem[],
currentCompany: string,
): CompanyOption[] {
const companies = new Map<string, CompanyOption>();
for (const vendor of vendors) {
const companyName = vendor.companyName.trim();
if (companyName && !companies.has(companyName)) {
companies.set(companyName, {
key: companyName,
label: companyName,
vendor,
});
}
}
if (currentCompany && !companies.has(currentCompany)) {
companies.set(currentCompany, {
key: currentCompany,
label: currentCompany,
});
}
return Array.from(companies.values());
}
export function buildTechnicianOptions(
vendors: VendorDropdownItem[],
selectedCompany: string,
tech: string,
company: string,
currentCompany: string,
): TechnicianOption[] {
const options = new Map<string, TechnicianOption>();
@ -25,6 +59,9 @@ export function buildTechnicianOptions(
};
for (const vendor of vendors) {
if (vendor.companyName !== selectedCompany) {
continue;
}
const contact = vendor.contactName.trim() || vendor.companyName.trim();
add({
key: String(vendor.id),
@ -36,17 +73,16 @@ export function buildTechnicianOptions(
});
}
if (tech) {
if (tech && selectedCompany === currentCompany) {
const vendor =
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
vendors.find((v) => v.contactName === tech) ??
vendors.find((v) => v.companyName === company);
vendors.find((v) => v.contactName === tech && v.companyName === currentCompany) ??
vendors.find((v) => v.contactName === tech);
const key = vendor ? String(vendor.id) : tech;
add({
key,
label: company && company !== tech ? `${tech} · ${company}` : tech,
label: tech,
name: tech,
company: company || tech,
company: currentCompany,
phone: "",
vendor,
});

View file

@ -1,22 +1,11 @@
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Text } from "@/components/ui/text";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import {
buildTechnicianOptions,
phoneForOption,
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
import {
mergeVendorDropdownItems,
resolveVendorCompanyAnchor,
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
@ -44,85 +33,15 @@ export function VendorDialog({
viewOnly = false,
onSave,
}: VendorDialogProps) {
const createVendor = useCreateVendor();
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
);
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const allVendors = useMemo(
() => mergeVendorDropdownItems(vendors, addedVendors),
[vendors, addedVendors],
);
const technicianOptions = useMemo(
() => buildTechnicianOptions(allVendors, tech, company),
[allVendors, tech, company],
);
useEffect(() => {
if (open) {
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
setSelectedKey(key);
setT(tech);
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
setNotes("");
setAddedVendors([]);
setPendingCreates({});
setSaveError(null);
}
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
}, [open, tech, techPhone, vendors, company, vendorId]);
const selected = technicianOptions.find((option) => option.key === selectedKey);
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
const selectedVendor =
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
company,
resolvedCompany,
selectedVendor,
vendors: allVendors,
});
const handlePick = (key: string) => {
const option = technicianOptions.find((o) => o.key === key);
setSelectedKey(key);
setT(option?.name ?? key);
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
};
const handleTechnicianCreated = (
created: VendorDropdownItem,
createdPhone: string,
payload: VendorFormValues,
) => {
setAddedVendors((current) => [...current, created]);
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
setSelectedKey(String(created.id));
setT(created.contactName);
setPhone(createdPhone);
};
const { handleSave } = useVendorDialogSave({
allVendors,
pendingCreates,
selectedKey,
selectedVendor,
resolvedCompany,
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
onSave,
const form = useVendorDialogForm({
open,
onOpenChange,
setSaveError,
vendorId,
company,
tech,
techPhone,
vendors,
onSave,
});
return (
@ -134,43 +53,47 @@ export function VendorDialog({
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 480 },
style: { borderRadius: 12, maxWidth: 560 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Technician
Vendor
</DialogTitle>
<Text as="p" className="sr-only">
Search and select the technician
Search and select the vendor company
</Text>
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
<VendorDialogBody
selectedKey={selectedKey}
technicianOptions={technicianOptions}
anchorCompanyName={anchorCompanyName}
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
tech={selected?.name ?? t}
phone={phone}
notes={notes}
resolvedCompany={resolvedCompany}
selectedVendor={selectedVendor}
saveError={saveError}
onPick={handlePick}
onPhoneChange={setPhone}
onNotesChange={setNotes}
onTechnicianCreated={handleTechnicianCreated}
selectedCompany={form.selectedCompany}
companyOptions={form.companyOptions}
selectedKey={form.selectedKey}
technicianOptions={form.technicianOptions}
anchorCompanyName={form.anchorCompanyName}
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
tech={form.selected?.name ?? form.t}
phone={form.phone}
notes={form.notes}
resolvedCompany={form.resolvedCompany}
selectedVendor={form.selectedVendor}
saveError={form.saveError}
onCompanyPick={form.handleCompanyPick}
onPick={form.handlePick}
onPhoneChange={form.setPhone}
onNotesChange={form.setNotes}
onTechnicianCreated={form.handleTechnicianCreated}
/>
</fieldset>
<VendorDialogFooter
viewOnly={viewOnly}
isPending={createVendor.isPending}
isPending={form.isPending}
canSubmit={form.canSubmit}
onCancel={() => onOpenChange(false)}
onSave={handleSave}
onSave={form.handleSave}
/>
</DialogContent>
</Dialog>

View file

@ -1,5 +1,10 @@
import { RefreshCw } from "lucide-react";
import { Tooltip } from "@mui/material";
import {
formatCarriedOverHover,
resolveCarryOverSourceWeek,
shouldShowCarriedOverPill,
} from "@/domain/work-orders/utils/carry-over-display";
import { formatRescheduleHover } from "@/domain/work-orders/utils/reschedule-indicator";
const RESCHEDULE_PILL_STYLE = {
@ -46,17 +51,30 @@ export function RescheduleCounterPill({
);
}
export function CarriedOverCounterPill({ count }: { count: number }) {
if (count < 2) {
export function CarriedOverCounterPill({
count,
originalWeek,
originalDate,
scheduledOn,
status,
}: {
count: number;
originalWeek?: string;
originalDate?: string;
scheduledOn?: string;
status?: string;
}) {
if (!shouldShowCarriedOverPill(count, status)) {
return null;
}
const label = `Carried over ${count} times`;
const sourceWeek = resolveCarryOverSourceWeek({ originalWeek, originalDate, scheduledOn });
const hover = formatCarriedOverHover(count, sourceWeek);
return (
<Tooltip title="Carried over" arrow placement="top">
<Tooltip title={hover} arrow placement="top">
<span
aria-label={label}
aria-label={hover}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={MUTED_PILL_STYLE}
>

View file

@ -18,11 +18,15 @@ type NotifyNavigate = {
onNavigate: () => void;
};
function showRescheduleToast(message: string, onNavigate: () => void) {
function showRescheduleToast(
message: string,
onNavigate: () => void,
linkLabel = "Click to view it.",
) {
const toastId = toast(
createElement(RescheduleToast, {
message,
linkLabel: "Click to view it.",
linkLabel,
onNavigate: () => {
toast.dismiss(toastId);
onNavigate();
@ -35,6 +39,13 @@ function showRescheduleToast(message: string, onNavigate: () => void) {
}
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
if (!weekStart) {
return showRescheduleToast(
"Work order moved to Unscheduled.",
onNavigate,
"View in Advanced Filters.",
);
}
return showRescheduleToast(
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
onNavigate,

View file

@ -0,0 +1,33 @@
import { toast } from "react-toastify";
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import {
formatSeverityEscalationToast,
shouldAnnounceSeverityEscalation,
} from "@/domain/work-orders/utils/severity-escalation";
type PersistPatchFn = (
id: string | number,
patch: WorkOrderTablePatch,
options?: { onSuccess?: () => void },
) => void;
export function saveExistingRowType(
onPatch: PersistPatchFn,
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
type: WOType,
severity: WorkOrderSeverity | null,
) {
const patch = { type, severity };
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
onPatch(row.id, patch);
return;
}
onPatch(row.id, patch, {
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
});
}

View file

@ -17,6 +17,10 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
dispatcherId: draft.dispatcherId,
dispatcherName: "",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly: draft.scheduleWeekOnly,
scheduleEnd: draft.scheduleEnd,
schedMode: draft.schedMode,
vendorId: draft.vendorId,
completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,

View file

@ -2,6 +2,7 @@ import { useMemo } from "react";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
applySchedulePatch,
applyScheduleRangePatch,
type WorkOrderTablePatchFn,
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
@ -9,6 +10,27 @@ import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
import type { WoTableRowHandlers } from "./wo-table-row";
function revealOrNotifyScheduledDay(args: {
id: string | number;
date: string;
weekMonday: string;
onNavigateToDay: (dateIso: string) => void;
revealScheduledRow: (id: string | number) => void;
}) {
const { id, date, weekMonday, onNavigateToDay, revealScheduledRow } = args;
if (shouldNotifyCrossWeek(date, weekMonday)) {
rescheduleNotify(date, {
onNavigate: () => {
onNavigateToDay(date);
revealScheduledRow(id);
},
});
return;
}
onNavigateToDay(date);
revealScheduledRow(id);
}
type UseWoTableRowHandlersOptions = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
weekMonday: string;
@ -81,29 +103,45 @@ export function useWoTableRowHandlers({
},
onScheduleDate: (id: string | number, date: string) => {
onPatch(id, applySchedulePatch(date, false));
if (shouldNotifyCrossWeek(date, weekMonday)) {
rescheduleNotify(date, {
onNavigate: () => {
onNavigateToDay(date);
revealScheduledRow(id);
},
});
return;
}
onNavigateToDay(date);
revealScheduledRow(id);
revealOrNotifyScheduledDay({
id,
date,
weekMonday,
onNavigateToDay,
revealScheduledRow,
});
},
onScheduleRange: (id: string | number, start: string, end: string) => {
onPatch(id, applyScheduleRangePatch(start, end));
revealOrNotifyScheduledDay({
id,
date: start,
weekMonday,
onNavigateToDay,
revealScheduledRow,
});
},
onScheduleWeek: (id: string | number, week: string) => {
onPatch(id, {
scheduledOn: "",
targetWeek: week,
scheduleWeekOnly: true,
dayGroup: "",
dayLabel: "",
});
const isClear = week === "";
onPatch(
id,
isClear
? applySchedulePatch("", false)
: {
scheduledOn: "",
targetWeek: week,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
dayGroup: "",
dayLabel: "",
},
);
weekMoveNotify(week, {
onNavigate: () => {
onNavigateToDay(week);
if (!isClear) {
onNavigateToDay(week);
}
onOpenAdvancedFilters?.();
},
});

View file

@ -13,6 +13,10 @@ import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
import { PMTypeCell } from "./cells/pm-type-cell";
import {
CREATION_INLINE_STATUSES,
withAutoScheduleStatus,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell";
@ -74,7 +78,8 @@ function InlineScheduleOnCell({
weekDefault: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
}) {
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
const hasSchedule =
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
return (
<td className="group/cell" style={{ padding: "0 14px" }}>
<DateCell
@ -83,31 +88,57 @@ function InlineScheduleOnCell({
targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onSave={(date) =>
onPatch({
scheduledOn: date,
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
})
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: date,
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: "date",
isAddOnOverride: false,
}),
)
}
onSaveWeek={(week) =>
onPatch({
scheduledOn: "",
targetWeek: week,
scheduleWeekOnly: true,
isAddOnOverride: false,
})
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: "",
targetWeek: week,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
isAddOnOverride: false,
}),
)
}
onSaveRange={(start, end) =>
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: start,
scheduleEnd: end,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
}),
)
}
onClear={
hasSchedule
? () =>
onPatch({
scheduledOn: "",
targetWeek: "",
scheduleWeekOnly: false,
isAddOnOverride: false,
})
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: "",
targetWeek: "",
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: undefined,
isAddOnOverride: false,
}),
)
: undefined
}
/>
@ -215,6 +246,7 @@ export function WoTableInlineRowCells({
<td className="group/cell" style={{ padding: "0 14px" }}>
<StatusCell
wo={draftRow as never}
statusOptions={CREATION_INLINE_STATUSES}
onChangeStatus={(status) => onPatch({ status })}
onOpenSlideOver={() => {}}
onRequestComplete={onRequestComplete}

View file

@ -1,6 +1,9 @@
import type { CSSProperties } from "react";
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
import { saveExistingRowType } from "./save-existing-row-type";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { DateCell } from "./cells/date-cell";
import { DueDateCell } from "./cells/due-date-cell";
@ -40,6 +43,9 @@ export function WoTableRowCells({
handlers,
onPatchRow,
}: WoTableRowCellsProps) {
const hasSchedule =
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
return (
<>
<td
@ -82,6 +88,9 @@ export function WoTableRowCells({
carriedOver={row.carriedOver}
isAddOn={row.isAddOn}
originalDate={row.originalDate}
originalWeek={row.originalWeek}
scheduledOn={row.scheduledOn}
status={row.status}
q={search}
currentId={row.id}
allOrders={allRows}
@ -99,7 +108,8 @@ export function WoTableRowCells({
value={row.type}
severity={row.severity}
q={search}
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)}
/>
</td>
<td
@ -111,9 +121,7 @@ export function WoTableRowCells({
dispatcherId={row.dispatcherId}
users={users}
highlight={!row.dispatcherId}
onSave={(id, name) =>
handlers.onPatch(row.id, { dispatcherId: id, dispatcherName: name })
}
onSave={(id, name) => applyAssignedToTableSave(handlers.onPatch, row.id, id, name)}
/>
</td>
<td
@ -127,8 +135,12 @@ export function WoTableRowCells({
highlight={forceScheduleHighlight}
allowWeekMode
targetWeek={row.targetWeek}
scheduleEnd={row.scheduleEnd}
schedMode={row.schedMode}
onSave={(date) => handlers.onScheduleDate(row.id, date)}
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)}
onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined}
/>
</td>
<td

View file

@ -0,0 +1,40 @@
import { GripVertical } from "lucide-react";
type WoTableRowReorderHandleProps = {
disabled: boolean;
isDragging: boolean;
woNumber: string;
onArrowReorder: (delta: -1 | 1) => void;
};
export function WoTableRowReorderHandle({
disabled,
isDragging,
woNumber,
onArrowReorder,
}: WoTableRowReorderHandleProps) {
return (
<span
role="button"
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
aria-label={woNumber ? `Reorder work order ${woNumber}` : "Reorder work order"}
className="flex items-center justify-center"
style={{
cursor: isDragging ? "grabbing" : "grab",
color: "var(--color-text-muted)",
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) {
return;
}
event.preventDefault();
event.stopPropagation();
onArrowReorder(event.key === "ArrowUp" ? -1 : 1);
}}
>
<GripVertical size={14} aria-hidden />
</span>
);
}

View file

@ -1,4 +1,3 @@
import { GripVertical } from "lucide-react";
import type {
LocationOption,
VendorDropdownItem,
@ -7,6 +6,7 @@ import type {
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderTablePatchOptions } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import { neighborIdForReorder } from "@/domain/work-orders/utils/board-row-order-storage";
import {
canApplyWorkOrderTablePatch,
canMutateWorkOrderFlag,
@ -16,6 +16,7 @@ import {
import { FlagCell } from "./cells/flag-cell";
import { WoTableRowActions } from "./wo-table-row-actions";
import { WoTableRowCells } from "./wo-table-row-cells";
import { WoTableRowReorderHandle } from "./wo-table-row-reorder-handle";
import { WoTableRowServiceCells } from "./wo-table-row-service-cells";
import {
MUTATION_CELL_STYLE,
@ -37,6 +38,7 @@ export type WoTableRowHandlers = {
) => void;
onScheduleDate: (id: string | number, date: string) => void;
onScheduleWeek: (id: string | number, week: string) => void;
onScheduleRange: (id: string | number, start: string, end: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onOpenSlideOver: (row: WorkOrderTableRow) => void;
onRequestComplete?: (row: WorkOrderTableRow) => void;
@ -65,6 +67,31 @@ type WoTableRowProps = {
handlers: WoTableRowHandlers;
};
function guardScheduleHandlers(
row: WorkOrderTableRow,
handlers: WoTableRowHandlers,
): WoTableRowHandlers {
return {
...handlers,
onPatch: (id, patch, options) => {
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
handlers.onPatch(id, patch, options);
},
onScheduleDate: (id, date) => {
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
handlers.onScheduleDate(id, date);
},
onScheduleWeek: (id, week) => {
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
handlers.onScheduleWeek(id, week);
},
onScheduleRange: (id, start, end) => {
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return;
handlers.onScheduleRange(id, start, end);
},
};
}
export function WoTableRow({
row,
allRows,
@ -87,21 +114,7 @@ export function WoTableRow({
const isCoreLocked = isWorkOrderCoreLocked(row.status);
const closedForEdit = isCanceled || isCoreLocked;
const guardedHandlers: WoTableRowHandlers = {
...handlers,
onPatch: (id, patch, options) => {
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
handlers.onPatch(id, patch, options);
},
onScheduleDate: (id, date) => {
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
handlers.onScheduleDate(id, date);
},
onScheduleWeek: (id, week) => {
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
handlers.onScheduleWeek(id, week);
},
};
const guardedHandlers = guardScheduleHandlers(row, handlers);
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
@ -155,15 +168,21 @@ export function WoTableRow({
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
onClick={(e) => e.stopPropagation()}
>
<span
className="flex items-center justify-center"
style={{
cursor: isCanceled ? "default" : isDragging ? "grabbing" : "grab",
color: "var(--color-text-muted)",
<WoTableRowReorderHandle
disabled={isCanceled}
isDragging={isDragging}
woNumber={row.woNumber}
onArrowReorder={(delta) => {
const overId = neighborIdForReorder(
allRows,
row,
delta,
(item) => !isWorkOrderFullyLocked(item.status),
);
if (overId == null) return;
handlers.onReorder(row.id, overId);
}}
>
<GripVertical size={14} />
</span>
/>
</td>
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
<FlagCell
@ -182,7 +201,7 @@ export function WoTableRow({
forceScheduleHighlight={forceScheduleHighlight}
forceDueDateHighlight={forceDueDateHighlight}
mutationCellStyle={coreLockStyle}
viewOnly={isCoreLocked}
viewOnly={closedForEdit}
canceledLockStyle={canceledLockStyle}
handlers={guardedHandlers}
onPatchRow={patchRow}
@ -193,7 +212,7 @@ export function WoTableRow({
pmTypes={pmTypes}
search={search}
mutationCellStyle={coreLockStyle}
viewOnly={isCoreLocked}
viewOnly={closedForEdit}
canceledLockStyle={canceledLockStyle}
handlers={guardedHandlers}
onPatchRow={patchRow}

View file

@ -12,7 +12,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useDeleteWorkOrderMedia,
useUpdateWorkOrderMediaCategory,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
@ -35,14 +34,12 @@ function resolveWorkOrderVersion(
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
const uploadMutation = useUploadWorkOrderMedia(row.id);
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
const pending =
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
const pending = uploadMutation.isPending || deleteMutation.isPending;
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
const requireVersion = (): string | null => {
@ -69,14 +66,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
<ImageIcon size={18} className="shrink-0" style={{ color: "var(--foreground)" }} />
<DialogTitle
sx={{ p: 0, fontSize: 16, lineHeight: 1.25, color: "var(--color-text-primary)" }}
>
Photos &amp; Videos
</DialogTitle>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-muted)" }}>
#{row.woNumber}
</span>
</div>
<Text className="sr-only" variant="description">
Upload photos and videos for this work order, then assign Before, After, or Extra.
Upload photos and videos for this work order.
</Text>
<div className="mt-4">
@ -101,11 +102,6 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
if (!workOrderVersion) return;
deleteMutation.mutate({ mediaId: id, workOrderVersion });
}}
onCategorize={(id, category) => {
const workOrderVersion = requireVersion();
if (!workOrderVersion) return;
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
}}
onRetry={retryUpload}
onCancel={cancelUpload}
readOnly={isReadOnly}

View file

@ -8,12 +8,14 @@ import {
type MediaUploaderDropzoneProps = {
inputRef: RefObject<HTMLInputElement | null>;
pending: boolean;
categorizeAfterUpload?: boolean;
onFilesSelected: (files: FileList | null) => void;
};
export function MediaUploaderDropzone({
inputRef,
pending,
categorizeAfterUpload = false,
onFilesSelected,
}: MediaUploaderDropzoneProps) {
return (
@ -27,7 +29,7 @@ export function MediaUploaderDropzone({
e.preventDefault();
onFilesSelected(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
@ -39,8 +41,8 @@ export function MediaUploaderDropzone({
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
upload
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
{categorizeAfterUpload ? " · categorize after upload" : ""}
</span>
</button>
<input

View file

@ -17,12 +17,13 @@ export function MediaUploaderGridItemMeta({
onCategorize,
}: MediaUploaderGridItemMetaProps) {
const failed = file.uploadStatus === "failed";
const managesPurpose = Boolean(onCategorize);
const needsCategory =
!readOnly &&
!file.isLegacy &&
file.uploadStatus !== "uploading" &&
file.uploadStatus !== "failed" &&
Boolean(onCategorize);
managesPurpose;
return (
<div className="space-y-1 px-1 py-1">
@ -34,10 +35,10 @@ export function MediaUploaderGridItemMeta({
{file.errorMessage || "Upload failed"}
</div>
)}
{needsCategory ? (
{needsCategory && (
<select
aria-label={`Category for ${file.name}`}
value={file.purpose ?? ""}
value={file.purpose === "Extra" ? "" : (file.purpose ?? "")}
disabled={pending}
onChange={(event) => {
const value = event.target.value as UploadableMediaCategory;
@ -57,7 +58,8 @@ export function MediaUploaderGridItemMeta({
</option>
))}
</select>
) : (
)}
{Boolean(!needsCategory && managesPurpose) && (
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
{file.purpose || (file.isLegacy ? "Legacy" : "")}
</div>

View file

@ -91,7 +91,12 @@ export function MediaUploader({
return (
<div>
{!readOnly && (
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
<MediaUploaderDropzone
inputRef={inputRef}
pending={pending}
categorizeAfterUpload={Boolean(onCategorize)}
onFilesSelected={addFiles}
/>
)}
<MediaUploaderGrid
uploads={uploads}

View file

@ -9,6 +9,7 @@ type UseWizardDateFieldStateArgs = {
allowWeekMode?: boolean;
targetWeek: string;
schedMode?: WizardSchedMode;
defaultMode?: "week" | "date";
};
function resolveInitialMode({
@ -17,9 +18,10 @@ function resolveInitialMode({
allowWeekMode,
value,
targetWeek,
defaultMode,
}: Pick<
UseWizardDateFieldStateArgs,
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek"
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek" | "defaultMode"
>): WizardSchedMode {
if (allowRange && schedMode === "range") {
return "range";
@ -27,6 +29,9 @@ function resolveInitialMode({
if (allowWeekMode && !value && targetWeek) {
return "week";
}
if (allowWeekMode && defaultMode) {
return defaultMode;
}
return "date";
}
@ -39,6 +44,7 @@ export function useWizardDateFieldState({
allowWeekMode,
targetWeek,
schedMode,
defaultMode,
}: UseWizardDateFieldStateArgs) {
const initialMode = resolveInitialMode({
allowRange,
@ -46,6 +52,7 @@ export function useWizardDateFieldState({
allowWeekMode,
value,
targetWeek,
defaultMode,
});
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);

View file

@ -13,6 +13,7 @@ import {
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
import { withAutoScheduleStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
@ -146,7 +147,11 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
<div>
<WizLabelRow
optional
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
onClear={
hasWizardSchedule(draft)
? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch()))
: undefined
}
>
Schedule On
</WizLabelRow>
@ -158,34 +163,40 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode}
onChange={(iso) =>
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
isAddOnOverride: false,
})
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: iso,
scheduleWeekOnly: false,
scheduleEnd: "",
schedMode: "date",
targetWeek: "",
isAddOnOverride: false,
}),
)
}
onChangeWeek={(w) =>
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
isAddOnOverride: false,
})
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
scheduleEnd: "",
schedMode: "week",
targetWeek: w,
isAddOnOverride: false,
}),
)
}
onChangeRange={(s, e) =>
onPatch({
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
})
onPatch(
withAutoScheduleStatus(draft, {
scheduledOn: s,
scheduleEnd: e,
schedMode: "range",
scheduleWeekOnly: false,
targetWeek: "",
isAddOnOverride: false,
}),
)
}
/>
</div>

View file

@ -1,9 +1,7 @@
import { useState } from "react";
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
ALL_WIZARD_STATUSES,
TIME_SLOTS,
WIZARD_STATUS_DOTS,
type WizardWOStatus,
@ -15,12 +13,15 @@ import {
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
formatClosabilityMissingTitle,
getClosabilityGaps,
wizardDraftToClosabilityInput,
} from "@/domain/work-orders/utils/wo-closability";
import {
CREATION_INLINE_STATUSES,
getStatusOptionState,
withAutoScheduleStatus,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
@ -173,15 +174,15 @@ function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFields
);
}
function buildWizardStatusOptions(closabilityGaps: string[]) {
return ALL_WIZARD_STATUSES.map((status) => {
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
function buildWizardStatusOptions(draft: WorkOrderWizardDraft, closabilityGaps: string[]) {
return CREATION_INLINE_STATUSES.map((status) => {
const option = getStatusOptionState(draft, status, closabilityGaps);
return {
key: status,
label: status,
left: <ColorDot color={WIZARD_STATUS_DOTS[status]} />,
disabled: isCompletedBlocked,
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
disabled: option.disabled,
title: option.title,
};
});
}
@ -192,7 +193,6 @@ export function WizardStepVendorTime({
onUpdate,
onPatch,
}: WizardStepVendorTimeProps) {
const [confirmComplete, setConfirmComplete] = useState(false);
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
@ -272,27 +272,16 @@ export function WizardStepVendorTime({
placeholder="Select status…"
search={false}
icon={<ColorDot color={statusDotColor} />}
options={buildWizardStatusOptions(closabilityGaps)}
options={buildWizardStatusOptions(draft, closabilityGaps)}
onPick={(status) => {
if (status === "Completed" && closabilityGaps.length > 0) return;
if (status === "Completed" && draft.status !== "Completed") {
setConfirmComplete(true);
const option = getStatusOptionState(draft, status as WizardWOStatus, closabilityGaps);
if (option.disabled) {
return;
}
onUpdate("status", status as WizardWOStatus);
onPatch(withAutoScheduleStatus(draft, { status: status as WizardWOStatus }));
}}
/>
</div>
<WorkOrderCompleteDialog
open={confirmComplete}
isPending={false}
onDialogClose={() => setConfirmComplete(false)}
onKeepOpen={() => setConfirmComplete(false)}
onConfirm={() => {
onUpdate("status", "Completed");
setConfirmComplete(false);
}}
/>
</div>
);
}

View file

@ -0,0 +1,6 @@
import { getAuthUser } from "@/lib/auth/auth-storage";
export function resolveBoardRowOrderOwnerId(): string {
const user = getAuthUser();
return user?.id == null ? "" : String(user.id);
}

View file

@ -1,25 +1,13 @@
import { useMemo } from "react";
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { resolveBoardRowOrderOwnerId } from "@/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id";
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
import { useWorkOrdersTableDayView } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-day-view";
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import {
pickBoardPager,
pageSelectedDayGroups,
type DayPageSize,
} from "@/domain/work-orders/utils/day-pagination";
import {
buildSelectedDayGroup,
isDayScopedSearchEmpty,
} from "@/domain/work-orders/utils/week-day-nav-utils";
import {
filterByBarAndSearch,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
import { pickBoardPager, type DayPageSize } from "@/domain/work-orders/utils/day-pagination";
import { filterByBarAndSearch } from "@/domain/work-orders/utils/table-filter-utils";
export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
@ -33,12 +21,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
useWorkOrdersBoardSource(filters);
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
const ownerId = resolveBoardRowOrderOwnerId();
const local = useWorkOrdersTableLocalState({
items: weeklyItems,
weekMonday: filters.weekMonday,
advActive,
isFetching,
ownerId,
});
const { displayRows, ...localUi } = local;
@ -54,57 +44,22 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
return displayRows;
}, [displayRows, filters.barFilters, filters.search, advActive]);
const { scheduled, unscheduled } = useMemo(() => {
if (advActive) {
return splitScheduledUnscheduled(advResults);
}
return splitScheduledUnscheduled(filtered);
}, [advActive, advResults, filtered]);
const dayGroups = useMemo(
() =>
advActive
? []
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
);
const pagination = useWorkOrdersDayPagination({
weekMonday: filters.weekMonday,
selectedDayIso: filters.selectedDayIso,
const dayView = useWorkOrdersTableDayView({
filters,
advActive,
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
filtered,
advResults,
onRevealed: local.triggerFlash,
});
const pagedDay = useMemo(
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
[dayGroups, pagination.page, pagination.pageSize],
);
const searchActive = filters.search.trim() !== "";
const displayGroups = pagedDay.groups;
const dayTotalCount = pagedDay.totalCount;
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
const noSearchResults =
!advActive &&
isDayScopedSearchEmpty({
searchActive,
unassignedCount: 0,
dayItemCount: dayTotalCount,
unassignedQueueReady: true,
});
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
const todayKey =
filters.weekMonday === currentWeekMonday()
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
: null;
const selectedRow = useMemo(
() => displayRows.find((row) => String(row.id) === String(local.selectedId)) ?? null,
[displayRows, local.selectedId],
);
const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount;
const scopeTotalCount = advActive
? (data?.totalCount ?? advResults.length)
: dayView.dayTotalCount;
useSyncOutOfRangeSearchPage({
enabled: advActive,
ready: !isLoading,
@ -113,11 +68,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
totalCount: scopeTotalCount,
setPage: filters.setPage,
});
const listTotalCount = advActive ? advResults.length : dayVisibleCount;
const listTotalCount = advActive ? advResults.length : dayView.dayVisibleCount;
const pager = pickBoardPager({
advActive,
isLoading,
noSearchResults,
noSearchResults: dayView.noSearchResults,
advResultCount: advResults.length,
search: {
page: filters.page,
@ -125,11 +80,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
totalCount: scopeTotalCount,
},
day: {
pageSize: pagination.pageSize,
pageIndex: pagedDay.pageIndex,
pageCount: pagedDay.pageCount,
itemCount: dayTotalCount,
footerLabel: displayGroups[0]?.label ?? "",
pageSize: dayView.pagination.pageSize,
pageIndex: dayView.pagedDay.pageIndex,
pageCount: dayView.pagedDay.pageCount,
itemCount: dayView.dayTotalCount,
footerLabel: dayView.displayGroups[0]?.label ?? "",
},
});
@ -145,12 +100,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
unassignedTotalCount: 0,
unassignedError: null,
unassignedLoading: false,
unscheduled,
displayGroups,
searchActive,
noSearchResults,
isEmptyWeek,
todayKey,
unscheduled: dayView.unscheduled,
displayGroups: dayView.displayGroups,
searchActive: dayView.searchActive,
noSearchResults: dayView.noSearchResults,
isEmptyWeek: dayView.isEmptyWeek,
todayKey: dayView.todayKey,
...localUi,
selectedRow,
scopeTotalCount,
@ -167,14 +122,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
filters.setPageSize(next);
return;
}
pagination.setPageSize(next);
dayView.pagination.setPageSize(next);
},
goToPrevDayPage: advActive
? () => filters.setPage(Math.max(0, pager.pageIndex - 1))
: pagination.goToPrevPage,
: dayView.pagination.goToPrevPage,
goToNextDayPage: advActive
? () => filters.setPage(Math.min(pager.pageIndex + 1, Math.max(0, pager.pageCount - 1)))
: pagination.goToNextPage,
revealScheduledRow: pagination.revealRow,
: dayView.pagination.goToNextPage,
revealScheduledRow: dayView.pagination.revealRow,
};
}

View file

@ -0,0 +1,90 @@
import { useMemo } from "react";
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
import {
buildSelectedDayGroup,
isDayScopedSearchEmpty,
} from "@/domain/work-orders/utils/week-day-nav-utils";
import { splitScheduledUnscheduled } from "@/domain/work-orders/utils/table-filter-utils";
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
export type UseWorkOrdersTableDayViewParams = {
filters: FiltersReturn;
advActive: boolean;
filtered: WorkOrderTableRow[];
advResults: WorkOrderTableRow[];
onRevealed: (id: string | number) => void;
};
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
export function useWorkOrdersTableDayView({
filters,
advActive,
filtered,
advResults,
onRevealed,
}: UseWorkOrdersTableDayViewParams) {
const { scheduled, unscheduled } = useMemo(() => {
if (advActive) {
return splitScheduledUnscheduled(advResults);
}
return splitScheduledUnscheduled(filtered);
}, [advActive, advResults, filtered]);
const dayGroups = useMemo(
() =>
advActive
? []
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
);
const pagination = useWorkOrdersDayPagination({
weekMonday: filters.weekMonday,
selectedDayIso: filters.selectedDayIso,
advActive,
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
onRevealed,
});
const pagedDay = useMemo(
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
[dayGroups, pagination.page, pagination.pageSize],
);
const searchActive = filters.search.trim() !== "";
const displayGroups = pagedDay.groups;
const dayTotalCount = pagedDay.totalCount;
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
const noSearchResults =
!advActive &&
isDayScopedSearchEmpty({
searchActive,
unassignedCount: 0,
dayItemCount: dayTotalCount,
unassignedQueueReady: true,
});
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
const todayKey =
filters.weekMonday === currentWeekMonday()
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
: null;
return {
scheduled,
unscheduled,
displayGroups,
searchActive,
noSearchResults,
isEmptyWeek,
todayKey,
dayTotalCount,
dayVisibleCount,
pagination,
pagedDay,
};
}

View file

@ -1,12 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-toastify";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { applyBoardRowOrderOnScheduleChange } from "@/domain/work-orders/utils/apply-board-row-order-on-schedule-change";
import { applyCarryOverDisplayDayFields } from "@/domain/work-orders/utils/carry-over-display";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import { isWorkOrderFullyLocked } from "@/domain/work-orders/utils/work-order-lock";
import {
applyRowOrderByDay,
boardRowOrderStorageKey,
loadBoardRowOrder,
mergeDayOrder,
removeIdFromRowOrder,
reorderIdsInList,
saveBoardRowOrder,
type BoardRowOrderByDay,
@ -23,6 +27,7 @@ export type UseWorkOrdersTableLocalStateParams = {
weekMonday: string;
advActive: boolean;
isFetching: boolean;
ownerId?: string;
};
function usePrunePatchesOnFetchSettle(
@ -44,6 +49,7 @@ export function useWorkOrdersTableLocalState({
weekMonday,
advActive,
isFetching,
ownerId = "",
}: UseWorkOrdersTableLocalStateParams) {
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
@ -51,8 +57,19 @@ export function useWorkOrdersTableLocalState({
const ui = useWorkOrdersTableUiState(weekMonday);
useEffect(() => {
setRowOrderByDay(loadBoardRowOrder(weekMonday));
}, [weekMonday]);
setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
}, [ownerId, weekMonday]);
useEffect(() => {
const storageKey = boardRowOrderStorageKey(weekMonday, ownerId);
const onStorage = (event: StorageEvent) => {
if (event.key !== storageKey) return;
setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
toast.info("Board order was updated in another session. Refreshed.");
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [ownerId, weekMonday]);
const apiRows = useMemo(
() => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
@ -62,8 +79,11 @@ export function useWorkOrdersTableLocalState({
const displayRows = useMemo(() => {
const apiIds = new Set(apiRows.map((r) => String(r.id)));
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
return applyRowOrderByDay([...apiRows, ...extra.map(enrichTableRow)], rowOrderByDay);
}, [apiRows, localRows, rowOrderByDay]);
const merged = [...apiRows, ...extra.map(enrichTableRow)].map((row) =>
applyCarryOverDisplayDayFields(row, weekMonday),
);
return applyRowOrderByDay(merged, rowOrderByDay);
}, [apiRows, localRows, rowOrderByDay, weekMonday]);
const clearRowPatch = useCallback((id: string | number) => {
const key = String(id);
@ -100,17 +120,17 @@ export function useWorkOrdersTableLocalState({
if (patch.scheduledOn === undefined || advActive) return;
const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
setRowOrderByDay((prev) => {
const next = removeIdFromRowOrder(prev, key);
if (next === prev) return prev;
saveBoardRowOrder(weekMonday, next);
return next;
});
setRowOrderByDay((prev) =>
applyBoardRowOrderOnScheduleChange(prev, {
weekMonday,
ownerId,
id: key,
scheduledOn: patch.scheduledOn!,
previousDayGroup,
}),
);
},
[advActive, displayRows, weekMonday, localRows],
[advActive, displayRows, localRows, ownerId, weekMonday],
);
const reorderRows = useCallback(
@ -120,6 +140,7 @@ export function useWorkOrdersTableLocalState({
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
const overRow = displayRows.find((r) => String(r.id) === overKey);
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) return;
if (isWorkOrderFullyLocked(dragRow.status) || isWorkOrderFullyLocked(overRow.status)) return;
const dayGroup = dragRow.dayGroup;
const dayIds = displayRows
@ -130,11 +151,11 @@ export function useWorkOrdersTableLocalState({
setRowOrderByDay((prev) => {
const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) };
if (!advActive) saveBoardRowOrder(weekMonday, next);
if (!advActive) saveBoardRowOrder(weekMonday, next, ownerId);
return next;
});
},
[advActive, displayRows, weekMonday],
[advActive, displayRows, ownerId, weekMonday],
);
const { triggerFlash } = ui;

View file

@ -46,11 +46,13 @@ export function useLogout(): UseMutationResult<void, Error, void> {
mutationFn: authApi.logout,
onSuccess: () => {
stopQueryBroadcast(queryClient);
queryClient.setQueryData(queryKeys.auth.session(), null);
toast.info("You have been logged out");
navigate("/login");
},
onError: (error: Error) => {
stopQueryBroadcast(queryClient);
queryClient.setQueryData(queryKeys.auth.session(), null);
toast.error(error.message || "Logout failed");
navigate("/login");
},

View file

@ -1,6 +1,6 @@
import { HTTPError } from "ky";
import { API_PATHS } from "@/api/api-paths";
import { api, apiGet, apiPost } from "@/api/api";
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
@ -91,7 +91,12 @@ export const upliftsApi = {
): Promise<void> => {
let response: Response;
try {
response = await api.get(evidenceUrl(id));
response = await apiRequestRaw(
"get",
evidenceUrl(id),
undefined,
"upliftsApi.downloadEvidence",
);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);

View file

@ -1,4 +1,4 @@
import { api, apiGet, apiPut } from "@/api/api";
import { apiGet, apiPut, apiRequestRaw } from "@/api/api";
import type {
SiteVendorPreference,
VendorAssignmentResult,
@ -27,7 +27,13 @@ type InsightParams = {
};
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
const response = await apiRequestRaw(
"get",
`${base}/${path}`,
{ searchParams: searchParams(params) },
"vendorOperationsApi.saveDownload",
);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;

View file

@ -1,6 +1,7 @@
import { API_PATHS } from "@/api/api-paths";
import { env } from "@/lib/env";
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
import { traceHttpOperation } from "@/observability/http-transaction";
import type {
VendorPortalComment,
VendorPortalDispatchDetail,
@ -38,30 +39,45 @@ function uploadVendorDocument({
purpose,
onProgress,
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
request.setRequestHeader("X-Vendor-Token", token);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
request.addEventListener("load", () => {
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
if (request.status < 200 || request.status >= 300) {
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
return;
}
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
});
request.addEventListener("error", () => reject(new Error("Upload failed")));
const body = new FormData();
body.append("file", file);
if (replacesDocumentId !== undefined) {
body.append("replacesDocumentId", String(replacesDocumentId));
}
body.append("purpose", purpose ?? "Completion");
request.send(body);
});
return traceHttpOperation(
{
method: "POST",
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`),
entryPoint: "vendorPortalApi.uploadDocument",
actorType: "vendor_link",
},
({ reportStatus }) =>
new Promise<VendorPortalDocument>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
request.setRequestHeader("X-Vendor-Token", token);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
request.addEventListener("load", () => {
reportStatus(request.status);
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
if (request.status < 200 || request.status >= 300) {
reject(
new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)),
);
return;
}
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
});
request.addEventListener("error", () => {
reportStatus(request.status);
reject(new Error("Upload failed"));
});
const body = new FormData();
body.append("file", file);
if (replacesDocumentId !== undefined) {
body.append("replacesDocumentId", String(replacesDocumentId));
}
body.append("purpose", purpose ?? "Completion");
request.send(body);
}),
);
}
async function downloadVendorDocument(
@ -69,19 +85,32 @@ async function downloadVendorDocument(
dispatchId: string | number,
document: VendorPortalDocument,
): Promise<void> {
const response = await fetch(
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
{ headers: { "X-Vendor-Token": token } },
await traceHttpOperation(
{
method: "GET",
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
entryPoint: "vendorPortalApi.downloadDocument",
actorType: "vendor_link",
},
async ({ reportStatus }) => {
const response = await fetch(
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
{ headers: { "X-Vendor-Token": token } },
);
reportStatus(response.status);
if (!response.ok)
throw new Error(
response.status === 423 ? "File is still being scanned" : "Download failed",
);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = document.originalFileName;
anchor.click();
URL.revokeObjectURL(url);
},
);
if (!response.ok)
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = document.originalFileName;
anchor.click();
URL.revokeObjectURL(url);
}
async function vendorPortalCall<T>(
@ -89,30 +118,41 @@ async function vendorPortalCall<T>(
path: string,
options?: { method?: string; body?: unknown },
): Promise<T> {
const response = await fetch(resolveVendorPortalUrl(path), {
method: options?.method ?? "GET",
headers: {
"Content-Type": "application/json",
"X-Vendor-Token": token,
return traceHttpOperation<T>(
{
method: options?.method ?? "GET",
url: resolveVendorPortalUrl(path),
entryPoint: "vendorPortalApi.call",
actorType: "vendor_link",
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
async ({ reportStatus }) => {
const response = await fetch(resolveVendorPortalUrl(path), {
method: options?.method ?? "GET",
headers: {
"Content-Type": "application/json",
"X-Vendor-Token": token,
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
reportStatus(response.status);
if (response.status === 401) {
throw new Error("Invalid or expired token");
}
if (response.status === 401) {
throw new Error("Invalid or expired token");
}
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok) {
const message =
(typeof payload.message === "string" ? payload.message : undefined) ??
(typeof payload.Message === "string" ? payload.Message : undefined) ??
`HTTP ${response.status}`;
throw new Error(message);
}
if (!response.ok) {
const message =
(typeof payload.message === "string" ? payload.message : undefined) ??
(typeof payload.Message === "string" ? payload.Message : undefined) ??
`HTTP ${response.status}`;
throw new Error(message);
}
return (payload.data ?? payload.Data ?? payload) as T;
return (payload.data ?? payload.Data ?? payload) as T;
},
);
}
function isNoApprovalRequired(payload: unknown): boolean {

View file

@ -1,7 +1,9 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { api, apiGet, apiPost } from "@/api/api";
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
import { traceHttpOperation } from "@/observability/http-transaction";
import {
BoardPatchConflictError,
BoardPatchValidationError,
@ -33,10 +35,10 @@ async function readResponseJson(response: Response): Promise<unknown> {
}
}
function throwDocumentHttpError(response: Response, data: unknown): never {
function throwDocumentHttpError(status: number, data: unknown): never {
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
if (status === 409) {
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
@ -45,14 +47,14 @@ function throwDocumentHttpError(response: Response, data: unknown): never {
);
}
if (response.status === 422) {
if (status === 422) {
throw new BoardPatchValidationError(
typeof record.code === "string" ? record.code : "ValidationError",
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
);
}
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
throw new ApiError(mapHttpStatusToMessage(status, data), status);
}
export const workOrderBoardDocumentsApi = {
@ -81,10 +83,12 @@ export const workOrderBoardDocumentsApi = {
commentId: string | number,
text: string,
): Promise<WorkOrderComment> => {
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
json: { text },
throwHttpErrors: false,
});
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.commentItem(workOrderId, commentId),
{ json: { text }, throwHttpErrors: false },
"workOrderBoardDocumentsApi.updateComment",
);
const data = await readResponseJson(response);
if (response.ok) {
@ -94,7 +98,7 @@ export const workOrderBoardDocumentsApi = {
return mapBoardDetailComment(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(response, data);
throwDocumentHttpError(response.status, data);
},
/** @deprecated Prefer createComment — modern board endpoint. */
@ -119,21 +123,26 @@ export const workOrderBoardDocumentsApi = {
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
): Promise<WorkOrderBoardMediaItem> => {
const formData = buildMediaFormData(file, category);
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
body: formData,
throwHttpErrors: false,
signal: options?.signal,
onUploadProgress: options?.onProgress
? (progress) => {
options.onProgress?.(Math.round(progress.percent * 100));
}
: undefined,
});
const data = await readResponseJson(response);
if (response.ok) {
const { ok, status, data } = await traceHttpOperation(
{
method: "POST",
url: API_PATHS.workOrder.media(workOrderId),
entryPoint: "workOrderBoardDocumentsApi.uploadMedia",
},
async ({ reportStatus }) => {
const result = await uploadFormWithProgress(
API_PATHS.workOrder.media(workOrderId),
formData,
{ signal: options?.signal, onProgress: options?.onProgress },
);
reportStatus(result.status);
return result;
},
);
if (ok) {
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(response, data);
throwDocumentHttpError(status, data);
},
updateMediaCategory: async (
@ -152,15 +161,17 @@ export const workOrderBoardDocumentsApi = {
const formData = new FormData();
formData.append("category", category);
formData.append("workOrderVersion", workOrderVersion);
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
body: formData,
throwHttpErrors: false,
});
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.mediaItem(workOrderId, mediaId),
{ body: formData, throwHttpErrors: false },
"workOrderBoardDocumentsApi.updateMediaCategory",
);
const data = await readResponseJson(response);
if (response.ok) {
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(response, data);
throwDocumentHttpError(response.status, data);
},
deleteMedia: async (
@ -183,10 +194,12 @@ export const workOrderBoardDocumentsApi = {
);
}
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
searchParams: { workOrderVersion },
throwHttpErrors: false,
});
const response = await apiRequestRaw(
"delete",
API_PATHS.workOrder.mediaItem(workOrderId, numericId),
{ searchParams: { workOrderVersion }, throwHttpErrors: false },
"workOrderBoardDocumentsApi.deleteMedia",
);
if (response.status === 204) {
return;
@ -196,7 +209,30 @@ export const workOrderBoardDocumentsApi = {
if (response.ok) {
return;
}
throwDocumentHttpError(response, data);
throwDocumentHttpError(response.status, data);
},
getMediaContent: async (
workOrderId: string | number,
mediaId: string | number,
): Promise<Blob> => {
const response = await apiRequestRaw(
"get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
{ credentials: "include", throwHttpErrors: false },
"workOrderBoardDocumentsApi.getMediaContent",
);
if (response.ok) {
return response.blob();
}
if (response.status === 404) {
throw new ApiError(mapHttpStatusToMessage(404), 404);
}
const data = await readResponseJson(response);
throwDocumentHttpError(response.status, data);
},
uploadCompletionDoc: async (
@ -216,14 +252,16 @@ export const workOrderBoardDocumentsApi = {
}
const formData = buildCompletionDocFormData(params);
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
body: formData,
throwHttpErrors: false,
});
const response = await apiRequestRaw(
"post",
API_PATHS.workOrder.completionDoc(workOrderId),
{ body: formData, throwHttpErrors: false },
"workOrderBoardDocumentsApi.uploadCompletionDoc",
);
const data = await readResponseJson(response);
if (response.ok) {
return mapCompletion(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(response, data);
throwDocumentHttpError(response.status, data);
},
};

View file

@ -1,6 +1,6 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { api, apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
BoardPatchConflictError,
@ -314,10 +314,12 @@ export const workOrdersApi = {
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> => {
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
json: payload,
throwHttpErrors: false,
});
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.boardPatch(id),
{ json: payload, throwHttpErrors: false },
"workOrdersApi.patchBoardField",
);
let data: unknown;
try {

View file

@ -21,3 +21,11 @@ export class BoardPatchValidationError extends Error {
this.code = code;
}
}
/** why: backend field names must never be exposed through user-facing board toasts. */
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
if (error.code === "DispatchRequired") {
return "Select a vendor company before saving.";
}
return error.message;
}

View file

@ -12,6 +12,7 @@ import type {
WorkOrderComment,
} from "@/domain/work-orders/types/work-order-detail";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name";
import type {
FileCategory,
MediaCategory,
@ -220,7 +221,7 @@ export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
category: mapMediaCategory(item.category ?? item.Category),
url: readString(item, "url", "Url"),
fileName: readString(item, "fileName", "FileName") || "file",
fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"),
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
isLegacy: isLegacyFlag || (id != null && id <= 0),
};

View file

@ -43,6 +43,9 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
"isAddOn",
"primaryDispatchStatus",
/** Client-only Schedule On range overlay — API persists range start as scheduledDate. */
"scheduleEnd",
"schedMode",
]);
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
@ -84,7 +87,7 @@ export function buildBoardPatchPayload(
if (row.dispatchRowVersion) {
payload.dispatchVersion = row.dispatchRowVersion;
}
if (row.primaryDispatchId != null) {
if (row.primaryDispatchId != null && row.primaryDispatchId > 0) {
payload.primaryDispatchId = row.primaryDispatchId;
}
}

View file

@ -1,5 +1,5 @@
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
import type { WizardWOStatus, WOType } from "./work-order-wizard";
import type { WizardSchedMode, WizardWOStatus, WOType } from "./work-order-wizard";
import type { WorkOrderSeverity } from "./work-order-severity";
import type { UpliftSummary } from "./work-order-uplift";
@ -65,6 +65,10 @@ export interface WorkOrderTableRow {
originalDate: string;
isPastDue: boolean;
scheduleWeekOnly: boolean;
/** Optimistic range end; API persists only `scheduledOn` (range start). */
scheduleEnd?: string;
/** Last Schedule On mode used in this session. */
schedMode?: WizardSchedMode;
mediaCount: number;
title: string;
priority: string;
@ -105,6 +109,8 @@ export interface WorkOrderTableDraft {
additionalContacts: WorkOrderAdditionalContact[];
scheduleWeekOnly: boolean;
targetWeek: string;
scheduleEnd?: string;
schedMode?: WizardSchedMode;
/** Manual Add-On override when schedule is empty at inline create. */
isAddOnOverride: boolean;
}
@ -137,6 +143,8 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
additionalContacts: [],
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: undefined,
isAddOnOverride: false,
};

View file

@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import {
boardPatchValidationMessage,
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
@ -19,9 +20,15 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import {
maybeAutoSchedulePatch,
mergeAutoSchedulePatch,
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
import { queryKeys } from "@/infra/query-key/query-key";
export { maybeAutoSchedulePatch };
export type { WorkOrderTablePatch };
export type WorkOrderTablePatchOptions = {
@ -124,10 +131,16 @@ export function useWorkOrderTableMutations(
const patchMutation = useMutation({
meta: { suppressErrorToast: true },
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
mutationFn: ({
id,
persistPatch,
}: {
id: string | number;
patch: WorkOrderTablePatch;
persistPatch: WorkOrderTablePatch;
}) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
onMutate: async ({ id, persistPatch }) => {
onPatch(id, persistPatch);
},
onSuccess: (result, { id }) => {
if (result.kind === "board") {
@ -149,7 +162,7 @@ export function useWorkOrderTableMutations(
toast.error(error.message || "Conflict — refreshed with latest data");
} else if (error instanceof BoardPatchValidationError) {
clearPatch(id);
toast.error(error.message);
toast.error(boardPatchValidationMessage(error));
} else {
clearPatch(id);
toast.error(error.message || "Failed to update work order");
@ -158,15 +171,21 @@ export function useWorkOrderTableMutations(
},
});
const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) =>
mergeAutoSchedulePatch(getRow?.(id), patch);
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
void patchMutation.mutateAsync({ id, patch }).then(
const persistPatch = resolvePatch(id, patch);
void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
() => options?.onSuccess?.(),
(error: Error) => options?.onError?.(error),
);
};
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
patchMutation.mutateAsync({ id, patch });
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
const persistPatch = resolvePatch(id, patch);
return patchMutation.mutateAsync({ id, patch, persistPatch });
};
return {
patchField,
@ -189,19 +208,9 @@ export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersA
);
}
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
const hasDate = !!row.scheduledOn;
const hasDispatcher = !!row.dispatcherId;
if (hasDate && hasDispatcher && row.status === "Incomplete") {
return { status: "Scheduled" };
}
return {};
}
/**
* Build a schedule table patch. Clearing the schedule optimistically clears
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response.
*/
export function applySchedulePatch(
scheduledOn: string,
@ -212,6 +221,8 @@ export function applySchedulePatch(
scheduledOn: "",
targetWeek: scheduledOn,
scheduleWeekOnly: true,
scheduleEnd: "",
schedMode: "week",
...deriveDayFields(""),
};
}
@ -221,6 +232,8 @@ export function applySchedulePatch(
scheduledOn: "",
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: undefined,
isAddOn: false,
...deriveDayFields(""),
};
@ -229,6 +242,17 @@ export function applySchedulePatch(
scheduledOn: trimmed,
scheduleWeekOnly: false,
targetWeek: "",
scheduleEnd: "",
schedMode: "date",
...deriveDayFields(trimmed),
};
}
/** Range lands on the first day; end/mode stay optimistic (no board API field). */
export function applyScheduleRangePatch(start: string, end: string): WorkOrderTablePatch {
return {
...applySchedulePatch(start, false),
scheduleEnd: end,
schedMode: "range",
};
}

View file

@ -0,0 +1,46 @@
import type { BoardRowOrderByDay } from "@/domain/work-orders/utils/board-row-order-storage";
import {
moveIdToDayOrder,
removeIdFromRowOrder,
saveBoardRowOrder,
transferIdToDestinationWeekOrder,
} from "@/domain/work-orders/utils/board-row-order-storage";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
export type ApplyBoardRowOrderOnScheduleChangeInput = {
weekMonday: string;
ownerId: string;
id: string;
scheduledOn: string;
previousDayGroup?: string;
};
export function applyBoardRowOrderOnScheduleChange(
orderByDay: BoardRowOrderByDay,
input: ApplyBoardRowOrderOnScheduleChangeInput,
): BoardRowOrderByDay {
const { weekMonday, ownerId, id, scheduledOn, previousDayGroup } = input;
const nextDayGroup = deriveDayFields(scheduledOn).dayGroup;
if (!previousDayGroup || previousDayGroup === nextDayGroup) {
return orderByDay;
}
const destinationWeekMonday = mondayOf(scheduledOn.slice(0, 10));
if (destinationWeekMonday !== weekMonday) {
transferIdToDestinationWeekOrder({
sourceWeekMonday: weekMonday,
destinationWeekMonday,
ownerId,
id,
nextDayGroup,
});
return removeIdFromRowOrder(orderByDay, id);
}
const next = moveIdToDayOrder(orderByDay, id, nextDayGroup);
if (next !== orderByDay) {
saveBoardRowOrder(weekMonday, next, ownerId);
}
return next;
}

View file

@ -1,3 +1,4 @@
import { SEVERITY_LEVELS } from "@/domain/work-orders/types/work-order-severity";
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
export type AuditActionSource = Pick<
@ -26,11 +27,51 @@ export type AuditActionRuleContext = {
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
function formatSeverityAuditLabel(raw: string): string {
const n = Number(raw.trim());
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
return SEVERITY_LEVELS[n].label;
}
const sevMatch = raw.trim().match(/^sev\s*([1-5])$/i);
if (sevMatch) {
return `SEV ${sevMatch[1]}`;
}
return raw;
}
function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
const matches =
fieldKey === "type" ||
fieldKey === "workordertype" ||
fieldKey === "wotype" ||
actionKey === "typechanged" ||
actionKey.includes("workordertype");
if (!matches || !newValue.text) return null;
return `Changed Type to ${wrapAuditValue(newValue)}`;
}
function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
if (!matches || !newValue.text) return null;
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
return `Changed Severity to ${wrapAuditValue(labeled)}`;
}
function tryStatusRule({
fieldKey,
actionKey,
oldValue,
newValue,
}: AuditActionRuleContext): string | null {
const matches =
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
if (!matches || !newValue.text) return null;
if (oldValue.text) {
return `Changed status from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
}
return `Changed status to ${wrapAuditValue(newValue)}`;
}
@ -119,6 +160,8 @@ function tryAssignedToRule({
/** Applied in order; the first non-null result wins. */
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
tryStatusRule,
tryTypeRule,
trySeverityRule,
tryApptRule,
tryScheduleRule,
tryCarriedOverRule,

View file

@ -2,7 +2,12 @@ export type BoardRowOrderByDay = Record<string, string[]>;
const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:";
export function boardRowOrderStorageKey(weekMonday: string): string {
export function boardRowOrderStorageKey(weekMonday: string, ownerId = ""): string {
const owner = ownerId.trim() || "local";
return `${STORAGE_PREFIX}${owner}:${weekMonday}`;
}
export function legacyBoardRowOrderStorageKey(weekMonday: string): string {
return `${STORAGE_PREFIX}${weekMonday}`;
}
@ -17,13 +22,9 @@ function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay {
return Object.values(value).every(isStringArray);
}
export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
if (typeof window === "undefined" || !weekMonday) {
return {};
}
function readStoredOrder(key: string): BoardRowOrderByDay {
try {
const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday));
const raw = localStorage.getItem(key);
if (!raw) {
return {};
}
@ -34,13 +35,29 @@ export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
}
}
export function saveBoardRowOrder(weekMonday: string, orderByDay: BoardRowOrderByDay): void {
export function loadBoardRowOrder(weekMonday: string, ownerId = ""): BoardRowOrderByDay {
if (typeof window === "undefined" || !weekMonday) {
return {};
}
const scoped = readStoredOrder(boardRowOrderStorageKey(weekMonday, ownerId));
if (Object.keys(scoped).length) {
return scoped;
}
return readStoredOrder(legacyBoardRowOrderStorageKey(weekMonday));
}
export function saveBoardRowOrder(
weekMonday: string,
orderByDay: BoardRowOrderByDay,
ownerId = "",
): void {
if (typeof window === "undefined" || !weekMonday) {
return;
}
try {
localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay));
localStorage.setItem(boardRowOrderStorageKey(weekMonday, ownerId), JSON.stringify(orderByDay));
} catch {
// Quota exceeded or storage blocked — keep in-memory order only.
}
@ -105,6 +122,27 @@ export function applyRowOrderByDay<T extends { id: string | number; dayGroup: st
});
}
/** Same-day neighbor for keyboard reorder; null at list ends or if the row is missing. */
export function neighborIdForReorder<T extends { id: string | number; dayGroup: string }>(
rows: T[],
row: T,
delta: -1 | 1,
isReorderTarget: (item: T) => boolean = () => true,
): string | number | null {
const dayRows = rows.filter((item) => item.dayGroup === row.dayGroup);
const index = dayRows.findIndex((item) => String(item.id) === String(row.id));
if (index < 0) {
return null;
}
for (let cursor = index + delta; cursor >= 0 && cursor < dayRows.length; cursor += delta) {
const candidate = dayRows[cursor];
if (candidate && isReorderTarget(candidate)) {
return candidate.id;
}
}
return null;
}
export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null {
const dragIdx = ids.indexOf(dragId);
const overIdx = ids.indexOf(overId);
@ -138,6 +176,60 @@ export function removeIdFromRowOrder(
return changed ? next : orderByDay;
}
/** Drop the id from every day, then append it to the destination day. */
export type TransferIdToDestinationWeekOrderInput = {
sourceWeekMonday: string;
destinationWeekMonday: string;
ownerId: string;
id: string;
nextDayGroup: string;
};
/** Persist a moved row under the destination week when schedule crosses week boundaries. */
export function transferIdToDestinationWeekOrder({
sourceWeekMonday,
destinationWeekMonday,
ownerId,
id,
nextDayGroup,
}: TransferIdToDestinationWeekOrderInput): void {
if (!sourceWeekMonday || !destinationWeekMonday || !nextDayGroup) {
return;
}
if (sourceWeekMonday === destinationWeekMonday) {
return;
}
const sourceOrder = loadBoardRowOrder(sourceWeekMonday, ownerId);
const sourceWithoutId = removeIdFromRowOrder(sourceOrder, id);
if (sourceWithoutId !== sourceOrder) {
saveBoardRowOrder(sourceWeekMonday, sourceWithoutId, ownerId);
}
const destinationOrder = loadBoardRowOrder(destinationWeekMonday, ownerId);
saveBoardRowOrder(
destinationWeekMonday,
moveIdToDayOrder(destinationOrder, id, nextDayGroup),
ownerId,
);
}
export function moveIdToDayOrder(
orderByDay: BoardRowOrderByDay,
id: string,
nextDayGroup: string,
): BoardRowOrderByDay {
if (!nextDayGroup) {
return removeIdFromRowOrder(orderByDay, id);
}
const without = removeIdFromRowOrder(orderByDay, id);
const dest = without[nextDayGroup] ?? [];
if (dest.includes(id)) {
return without;
}
return { ...without, [nextDayGroup]: [...dest, id] };
}
/**
* Merge a visible day's new order into stored order: visible ids take the new sequence,
* ids that were stored but not currently visible stay after the visible block (preserving

View file

@ -0,0 +1,109 @@
import { compactWeekLabel } from "@/domain/work-orders/utils/list-week-utils";
import { formatRescheduleCountLabel } from "@/domain/work-orders/utils/reschedule-indicator";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const WEEKDAY_COUNT = 7;
const FRIDAY_OFFSET = 4;
export type CarryOverRow = {
scheduledOn: string;
status?: string;
carriedOver?: number;
};
export function isTerminalCarryOverStatus(status?: string): boolean {
return status === "Completed" || status === "Canceled";
}
export function shouldShowCarriedOverPill(count: number, status?: string): boolean {
return count >= 2 && !isTerminalCarryOverStatus(status);
}
export function toIsoDate(value?: string): string {
if (!value) {
return "";
}
const iso = value.slice(0, 10);
return ISO_DATE.test(iso) ? iso : "";
}
export function resolveCarryOverSourceWeek(input: {
originalWeek?: string;
originalDate?: string;
scheduledOn?: string;
}): string {
const originalWeek = toIsoDate(input.originalWeek);
if (originalWeek) {
return mondayOf(originalWeek);
}
const originalDate = toIsoDate(input.originalDate);
if (originalDate) {
return mondayOf(originalDate);
}
const scheduledOn = toIsoDate(input.scheduledOn);
return scheduledOn ? mondayOf(scheduledOn) : "";
}
export function formatCarriedOverHover(count: number, sourceWeekIso?: string): string {
const times = formatRescheduleCountLabel(count);
const base = `Carried over ${times} — this WO was not completed and moved to the next period`;
const sourceLabel = sourceWeekIso ? compactWeekLabel(sourceWeekIso) : "";
if (!sourceLabel) {
return base;
}
return `${base}. Source week: ${sourceLabel}.`;
}
/** Non-terminal WOs scheduled before the viewed week are display carry-overs. */
export function isCarryOverDisplayRow(row: CarryOverRow, weekMonday: string): boolean {
if (isTerminalCarryOverStatus(row.status)) {
return false;
}
const scheduled = toIsoDate(row.scheduledOn);
return Boolean(scheduled) && scheduled < weekMonday;
}
export function mapCarryOverDisplayDay(scheduledOn: string, weekMonday: string): string {
const scheduled = toIsoDate(scheduledOn);
if (!scheduled || !ISO_DATE.test(weekMonday)) {
return "";
}
const sourceMonday = mondayOf(scheduled);
const offsetMs =
new Date(`${scheduled}T12:00:00`).getTime() - new Date(`${sourceMonday}T12:00:00`).getTime();
const offset = Math.round(offsetMs / 86_400_000);
if (offset < 0 || offset >= WEEKDAY_COUNT) {
return weekMonday;
}
if (offset > FRIDAY_OFFSET) {
return weekMonday;
}
return addDaysIso(weekMonday, offset);
}
export function rowMatchesCarryOverDay(
row: CarryOverRow,
selectedDayIso: string,
weekMonday: string,
): boolean {
if (!isCarryOverDisplayRow(row, weekMonday)) {
return false;
}
return mapCarryOverDisplayDay(row.scheduledOn, weekMonday) === selectedDayIso;
}
/** Aligns UI day keys with the mapped weekday so reorder stays on the displayed tab. */
export function applyCarryOverDisplayDayFields<
T extends CarryOverRow & { dayGroup: string; dayLabel: string },
>(row: T, weekMonday: string): T {
if (!isCarryOverDisplayRow(row, weekMonday)) {
return row;
}
const displayDay = mapCarryOverDisplayDay(row.scheduledOn, weekMonday);
if (!displayDay) {
return row;
}
return { ...row, ...deriveDayFields(displayDay) };
}

View file

@ -0,0 +1,10 @@
/**
* why: uploads are stored as `{guid}_{original name}`, so the raw storage name shown in media
* tiles reads as an id. The original name (and its extension) survive after the prefix.
*/
const STORED_NAME_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i;
export function toDisplayFileName(fileName: string): string {
const withoutPrefix = fileName.replace(STORED_NAME_PREFIX, "").trim();
return withoutPrefix || fileName;
}

View file

@ -0,0 +1,44 @@
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
const COMPLETION_PURPOSES = new Set(["Before", "After", "Completion"]);
export type MediaSurfaceOwnership = {
completionOwnedIds: ReadonlySet<string>;
extraOwnedIds: ReadonlySet<string>;
};
const EMPTY_OWNERSHIP: MediaSurfaceOwnership = {
completionOwnedIds: new Set(),
extraOwnedIds: new Set(),
};
function isPhotoOrVideo(item: MediaFile): boolean {
return item.fileType === "image" || item.fileType === "video";
}
function isInFlight(item: MediaFile): boolean {
return item.uploadStatus === "uploading" || item.uploadStatus === "failed";
}
/** Extra photo/video from Completion Doc POST (no category) until Extra is chosen. */
export function isCompletionSurfaceMedia(
item: MediaFile,
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
): boolean {
if (isInFlight(item)) return true;
if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true;
if (ownership.completionOwnedIds.has(item.id)) return true;
if (ownership.extraOwnedIds.has(item.id)) return false;
return item.purpose === "Extra" && isPhotoOrVideo(item);
}
export function isExtrasSurfaceMedia(
item: MediaFile,
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
): boolean {
if (item.purpose === "Aveta") return true;
if (item.purpose !== "Extra") return false;
if (ownership.completionOwnedIds.has(item.id)) return false;
if (ownership.extraOwnedIds.has(item.id)) return true;
return !isPhotoOrVideo(item);
}

View file

@ -0,0 +1,20 @@
import {
SEVERITY_LEVELS,
type WorkOrderSeverity,
} from "@/domain/work-orders/types/work-order-severity";
function isCriticalSeverity(severity: WorkOrderSeverity | null | undefined): severity is 1 | 2 {
return severity === 1 || severity === 2;
}
/** AAP: toast only when severity newly enters SEV 1/2 — not on re-save of the same value. */
export function shouldAnnounceSeverityEscalation(
previous: WorkOrderSeverity | null | undefined,
next: WorkOrderSeverity | null | undefined,
): next is 1 | 2 {
return isCriticalSeverity(next) && !isCriticalSeverity(previous);
}
export function formatSeverityEscalationToast(woNumber: string, severity: 1 | 2): string {
return `WO #${woNumber} escalated to ${SEVERITY_LEVELS[severity].label}.`;
}

View file

@ -19,6 +19,8 @@ export const SLIDE_OVER_EDIT_KEYS = [
"scheduledOn",
"targetWeek",
"scheduleWeekOnly",
"scheduleEnd",
"schedMode",
"dueDate",
"completedDate",
"apptTime",

View file

@ -7,6 +7,7 @@ import type {
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates";
import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
@ -96,7 +97,7 @@ export function filterByBarAndSearch(
if (row.scheduledOn) {
if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
return false;
return isCarryOverDisplayRow(row, bar.weekMonday);
}
} else if (row.targetWeek) {
if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {

View file

@ -2,6 +2,7 @@ import type {
WorkOrderDayGroup,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { rowMatchesCarryOverDay } from "@/domain/work-orders/utils/carry-over-display";
import { isLiveWeek } from "@/domain/work-orders/utils/list-week-utils";
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
@ -65,10 +66,17 @@ export function formatDayTabLabel(iso: string): string {
}
export function rowMatchesSelectedDay(
row: Pick<WorkOrderTableRow, "scheduledOn">,
row: Pick<WorkOrderTableRow, "scheduledOn" | "status" | "carriedOver">,
selectedDayIso: string,
weekMonday?: string,
): boolean {
return Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso;
if (Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso) {
return true;
}
if (!weekMonday) {
return false;
}
return rowMatchesCarryOverDay(row, selectedDayIso, weekMonday);
}
export function buildSelectedDayGroup(
@ -83,6 +91,6 @@ export function buildSelectedDayGroup(
key: `${month}/${day}`,
label: formatDayTabLabel(date),
date,
items: scheduled.filter((row) => rowMatchesSelectedDay(row, date)),
items: scheduled.filter((row) => rowMatchesSelectedDay(row, date, weekMonday)),
};
}

View file

@ -0,0 +1,195 @@
import {
ALL_WIZARD_STATUSES,
type WizardWOStatus,
} from "@/domain/work-orders/types/work-order-wizard";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import {
PENDING_UPLIFT_GAP,
UPLIFT_PENDING_COMPLETED_TITLE,
} from "@/domain/work-orders/utils/wo-closability";
/** AAP `ALL_STATUSES` — Canceled is kebab-only. */
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES;
/** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */
export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
"Incomplete",
"Pending",
"Scheduled",
];
export const SCHEDULED_NEEDS_DATE_MSG =
"Set a Schedule On date before marking this work order as Scheduled.";
export const PAST_DUE_BADGE_TOOLTIP =
"This WO missed its service window. Reschedule to unlock status updates.";
export const PAST_DUE_POPOVER_BODY =
"This work order is past due. A new date must be scheduled before the status can be updated.";
export const PAST_DUE_RESCHEDULE_CTA = "Reschedule →";
export const COMPLETED_DOC_BLOCK_MSG =
"Generate or mark the completion document as not required before completing this work order";
export const COMPLETED_MEDIA_BLOCK_MSG =
"Upload at least one completion photo before completing this work order.";
const COMPLETE_ACTION = "completing this work order";
const EMPTY_FIELD_TOKENS = new Set(["", "-", "—", "N/A", "n/a"]);
const PRE_SCHEDULE_STATUSES = new Set<string>(["Incomplete", "Pending", ""]);
const FIELD_GAP_TO_AAP_LABEL: [string, string][] = [
["Dispatcher", "Assigned To"],
["Company", "Vendor"],
["Completed Date", "Completed Date"],
["Service", "Service"],
];
export type ScheduleDateInput = {
scheduledOn?: string;
scheduleWeekOnly?: boolean;
schedMode?: string;
};
export type StatusOptionState = {
disabled: boolean;
title?: string;
};
export function isLifecycleFieldEmpty(value?: string): boolean {
if (value == null) {
return true;
}
const trimmed = value.trim();
return EMPTY_FIELD_TOKENS.has(trimmed);
}
/** AAP `hasScheduleDate`: concrete day/range only — week-only does not count. */
export function hasConcreteScheduleDate(row: ScheduleDateInput): boolean {
if (row.scheduleWeekOnly === true || row.schedMode === "week") {
return false;
}
return !isLifecycleFieldEmpty(row.scheduledOn);
}
export function isPreScheduleStatus(status: string): boolean {
return PRE_SCHEDULE_STATUSES.has(status);
}
export function formatAapCompletedBlockTitle(gaps: string[]): string | undefined {
if (gaps.length === 0) {
return undefined;
}
const fields = FIELD_GAP_TO_AAP_LABEL.filter(([gap]) => gaps.includes(gap)).map(
([, label]) => label,
);
if (fields.length === 1 && fields[0] === "Service") {
return `Select a service before ${COMPLETE_ACTION}.`;
}
if (fields.length > 0) {
return `Fill in ${fields.join(", ")} before ${COMPLETE_ACTION}.`;
}
if (gaps.includes("WO number")) {
return `Assign a real APM number (not SH) before ${COMPLETE_ACTION}.`;
}
if (gaps.includes(PENDING_UPLIFT_GAP)) {
return UPLIFT_PENDING_COMPLETED_TITLE;
}
if (gaps.includes("Completion document")) {
return COMPLETED_DOC_BLOCK_MSG;
}
if (gaps.includes("At least 1 photo")) {
return COMPLETED_MEDIA_BLOCK_MSG;
}
return undefined;
}
export function getStatusOptionState(
row: ScheduleDateInput,
status: WizardWOStatus,
closabilityGaps: string[],
): StatusOptionState {
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
}
if (status === "Completed") {
const title = formatAapCompletedBlockTitle(closabilityGaps);
if (title) {
return { disabled: true, title };
}
}
return { disabled: false };
}
/** AAP `maybeAutoSchedule` — no dispatcher requirement. */
export function maybeAutoSchedulePatch(
row: ScheduleDateInput & { status?: string },
): WorkOrderTablePatch {
const hasDate = hasConcreteScheduleDate(row);
if (hasDate && isPreScheduleStatus(row.status ?? "")) {
return { status: "Scheduled" };
}
if (!hasDate && row.status === "Scheduled") {
return { status: "Pending" };
}
return {};
}
function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean {
return (
patch.status !== undefined ||
patch.scheduledOn !== undefined ||
patch.scheduleWeekOnly !== undefined ||
patch.targetWeek !== undefined
);
}
export function mergeAutoSchedulePatch(
row: WorkOrderTableRow | undefined,
patch: WorkOrderTablePatch,
): WorkOrderTablePatch {
if (!row || !patchTouchesScheduleOrStatus(patch)) {
return patch;
}
const merged = {
status: patch.status ?? row.status,
scheduledOn: patch.scheduledOn !== undefined ? patch.scheduledOn : row.scheduledOn,
scheduleWeekOnly:
patch.scheduleWeekOnly !== undefined ? patch.scheduleWeekOnly : row.scheduleWeekOnly,
};
return { ...patch, ...maybeAutoSchedulePatch(merged) };
}
/** Apply schedule fields and the AAP auto-status for that next schedule. */
export function withAutoScheduleStatus<T extends ScheduleDateInput & { status?: string }>(
current: T,
schedulePatch: Partial<T> & ScheduleDateInput,
): Partial<T> {
const next = {
status: schedulePatch.status ?? current.status ?? "",
scheduledOn:
schedulePatch.scheduledOn !== undefined ? schedulePatch.scheduledOn : current.scheduledOn,
scheduleWeekOnly:
schedulePatch.scheduleWeekOnly !== undefined
? schedulePatch.scheduleWeekOnly
: current.scheduleWeekOnly,
};
return { ...schedulePatch, ...maybeAutoSchedulePatch(next) };
}
/** AAP create: apply maybeAutoSchedule to the draft before POST. */
export function resolveCreationStatus(row: ScheduleDateInput & { status: string }): WizardWOStatus {
const autoStatus = maybeAutoSchedulePatch(row).status;
if (autoStatus === "Scheduled" || autoStatus === "Pending") {
return autoStatus;
}
return (row.status || "Incomplete") as WizardWOStatus;
}

View file

@ -80,3 +80,12 @@ export function applyVendorTableSave(
onSuccess: isAssignedVendorId(fields.vendorId) ? notifyVendorAssigned : notifyWorkOrderSaved,
});
}
export function applyAssignedToTableSave(
onPatch: PersistPatchFn,
id: string | number,
dispatcherId: string,
dispatcherName: string,
) {
onPatch(id, { dispatcherId, dispatcherName }, { onSuccess: notifyWorkOrderSaved });
}

View file

@ -1,9 +1,38 @@
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
/** Backend RequestSizeLimit for media and completion-doc uploads. */
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000;
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
const MEDIA_MIME_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"video/mp4",
"video/quicktime",
]);
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
const EXTRA_DOC_MIME_TYPES = new Set([
...MEDIA_MIME_TYPES,
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]);
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
function fileExtension(file: File): string {
const name = file.name.toLowerCase();
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot) : "";
}
function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>): boolean {
const mimeOk = Boolean(file.type) && mimes.has(file.type.toLowerCase());
const extOk = extensions.has(fileExtension(file));
return mimeOk || extOk;
}
export function isWorkOrderFileTooLarge(
file: File,
@ -13,24 +42,25 @@ export function isWorkOrderFileTooLarge(
}
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
const name = file.name.toLowerCase();
const dot = name.lastIndexOf(".");
const ext = dot >= 0 ? name.slice(dot) : "";
const extOk = ALLOWED_EXTENSIONS.has(ext);
return mimeOk || extOk;
return isTypeAllowed(file, MEDIA_MIME_TYPES, MEDIA_EXTENSIONS);
}
export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
return isTypeAllowed(file, EXTRA_DOC_MIME_TYPES, EXTRA_DOC_EXTENSIONS);
}
export function workOrderMediaAcceptAttribute(): string {
return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
}
export function workOrderExtraDocsAcceptAttribute(): string {
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
}
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
return `${Math.round(maxBytes / 1_000_000)} MB`;
}
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
return isWorkOrderCompletionSurfaceReadOnly(status);

View file

@ -0,0 +1,12 @@
import { clearAuth } from "@/lib/auth/auth-storage";
import queryClient from "@/lib/query/query-client";
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
export function expireSessionAndRedirectToLogin(): void {
clearAuth();
stopQueryBroadcast(queryClient);
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
window.location.replace("/login");
}
}

View file

@ -1,3 +1,7 @@
import * as Sentry from "@sentry/react";
import "@/observability/sentry";
import React from "react";
import ReactDOM from "react-dom/client";
import { AppRoot } from "@/app-root";
@ -10,7 +14,11 @@ if (!rootElement) {
throw new Error("Root element #root not found");
}
ReactDOM.createRoot(rootElement).render(
ReactDOM.createRoot(rootElement, {
onCaughtError: Sentry.reactErrorHandler(),
onRecoverableError: Sentry.reactErrorHandler(),
onUncaughtError: Sentry.reactErrorHandler(),
}).render(
<React.StrictMode>
<AppProviders>
<AppRoot />

View file

@ -0,0 +1,111 @@
import { setHttpStatus, spanToJSON, startSpan, type Span } from "@sentry/react";
import { isHTTPError } from "ky";
const INTEGER_SEGMENT = /^\d+$/;
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HEX_SEGMENT = /^[0-9a-f]+$/i;
const TOKEN_SEGMENT = /^[0-9a-z._~-]+$/i;
const OPAQUE_HEX_MIN_LENGTH = 8;
const OPAQUE_TOKEN_MIN_LENGTH = 16;
function isIdentifierSegment(segment: string): boolean {
if (segment.length === 0) {
return false;
}
if (INTEGER_SEGMENT.test(segment)) {
return true;
}
if (UUID_SEGMENT.test(segment)) {
return true;
}
if (segment.length >= OPAQUE_HEX_MIN_LENGTH && HEX_SEGMENT.test(segment)) {
return true;
}
return (
segment.length >= OPAQUE_TOKEN_MIN_LENGTH &&
TOKEN_SEGMENT.test(segment) &&
/\d/.test(segment) &&
/[a-z]/i.test(segment)
);
}
export function normalizeHttpRoute(input: string): string {
let path: string;
try {
path = new URL(input).pathname;
} catch {
path = input.split("#")[0] ?? input;
path = path.split("?")[0] ?? path;
}
return path
.split("/")
.map((segment) => (isIdentifierSegment(segment) ? ":id" : segment))
.join("/");
}
export type HttpTraceOptions = {
method: string;
url: string;
entryPoint: string;
actorType?: string;
};
export type HttpTraceContext = {
reportStatus: (status: number) => void;
};
export async function traceHttpOperation<T>(
options: HttpTraceOptions,
callback: (context: HttpTraceContext) => Promise<T>,
): Promise<T> {
const route = normalizeHttpRoute(options.url);
return startSpan(
{
name: `${options.method.toUpperCase()} ${route}`,
op: "http.client",
kind: 2,
forceTransaction: true,
attributes: {
"http.request.method": options.method.toUpperCase(),
"http.route": route,
"code.function": options.entryPoint,
"operation.type": "http.client",
...(options.actorType ? { "actor.type": options.actorType } : {}),
},
},
async (span: Span) => {
const spanJson = spanToJSON(span);
if (spanJson.trace_id) {
span.setAttribute("trace_id", spanJson.trace_id);
}
if (spanJson.span_id) {
span.setAttribute("span_id", spanJson.span_id);
span.setAttribute("transaction_id", spanJson.span_id);
}
let statusReported = false;
const reportStatus = (status: number): void => {
if (statusReported) {
return;
}
statusReported = true;
setHttpStatus(span, status);
};
try {
const result = await callback({ reportStatus });
if (!statusReported && result instanceof Response) {
reportStatus(result.status);
}
return result;
} catch (error) {
if (!statusReported) {
reportStatus(isHTTPError(error) ? error.response.status : 500);
}
throw error;
}
},
);
}

Some files were not shown because too many files have changed in this diff Show more