Compare commits

...

80 commits

Author SHA1 Message Date
Alexandre Brandizzi
38bbb4a575
Merge ff1a946e74 into e51551c160 2026-09-19 17:36:45 -03:00
Adam Moussa
e51551c160
Merge pull request #239 from Sea-Haven-Industries/chore/parallel-ci-gates
Some checks failed
Frontend checks / static (push) Has been cancelled
Frontend checks / build (push) Has been cancelled
Frontend checks / unit (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Deploy Web / Resolve target (push) Has been cancelled
Frontend checks / ci-complete (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled
ci: fan out quality gates from this repository
2026-09-19 20:32:50 +00:00
Cursor Agent
cc7ecb9b5a
fix(ci): classify G13 per queued PR on merge_group
Run live isolation on the merge-queue event so ci-complete actually
gates mixed change sets, without treating the group union as one PR.
2026-09-19 20:20:30 +00:00
Adam Moussa
ec9812ae8d fix(style): correct line-wrapping formatting errors 2026-09-19 19:47:08 +00:00
af0ae60b80 ci: shard unit tests four ways 2026-09-19 15:41:10 -04:00
8ad7438f26 ci: fan out quality gates from this repository 2026-09-19 15:22:38 -04:00
Alexandre Brandizzi
3e5fba4d2b
Merge pull request #225 from Sea-Haven-Industries/feat/ab/sh-292-notification-center
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 Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-292: Notification Center panel and feed
2026-09-18 23:33:59 +00:00
Alexandre Brandizzi
cbfed3dde8
Merge pull request #226 from Sea-Haven-Industries/feat/ab/sh-209-uplift-detail-modal
SH-209: Align the Uplift Detail modal with the spec
2026-09-18 23:33:55 +00:00
Alexandre Brandizzi
f25a3dd628
Merge pull request #228 from Sea-Haven-Industries/fix/ab/sh-379-poc-persist
Persist manual POC edits via dedicated POC endpoint (SH-379)
2026-09-18 23:33:52 +00:00
Adam Moussa
41b26692c3
Merge pull request #238 from Sea-Haven-Industries/chore/pr-template-and-cleanup
chore(repo): add PR template, fix README conventions, drop tracked scratch files
2026-09-18 23:30:58 +00:00
1698d53d38
chore(repo): add PR template, fix README conventions, drop tracked scratch files
The org PR template pre-filled Summary / Validation / Tests / Notes here while
PRs in this repository use Summary / Changes and value / Ticket. A repo template
now overrides the org one, and the review framework gains the description
contract plus a note on the divergence from the org pr-policy workflow, which
is not wired in.

README: the CI badge tracked the retired dev branch; branches come from main,
not dev; the fix/ prefix replaces bug/; staging exists alongside dev; and PRs
now merge through the merge queue.

Cleanup: four PR description drafts under tmp/ were tracked; they are removed
and /tmp/ is ignored.
2026-09-18 19:19:55 -04:00
Adam Moussa
73e3362fcf
Merge pull request #237 from Sea-Haven-Industries/renovate/reconfigure
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 Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
chore(renovate): widen the schedule, manage workflow actions, surface actionlint
2026-09-18 23:09:28 +00:00
Adam Moussa
e611f193bd
ci: run required checks on merge_group for the merge queue (#236)
governance and Build and test are the required checks on main. A merge queue
only counts checks that ran on the merge_group event, so the workflow now
triggers on it. The governance gate reads the merge group's own base SHA
because github.event.before is empty there.
2026-09-18 19:03:12 -04:00
bebd517290
chore(renovate): widen the schedule, manage workflow actions, surface actionlint
The org window (before 6am on Monday) has produced only one security PR in
this repository since the overlay landed, so the overlay now opens every
weekday morning. The github-actions manager is enabled so the digest-pinned
actions in the workflows follow the org's pinning and grouping rules. A regex
manager tracks the actionlint release installed by the governance job; it is
held for dashboard approval because the SHA256 pin next to it has to be
updated by hand.
2026-09-18 18:50:49 -04:00
Alexandre Brandizzi
7c470d389c
Merge pull request #231 from Sea-Haven-Industries/fix/ab/sh-380-completion-pdf-post-upload-error
fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380)
2026-09-18 19:47:47 -03:00
Alexandre Brandizzi
de7066d31b
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:33:59 -03:00
Alexandre Brandizzi
2c47b4a9ca
Merge pull request #212 from Sea-Haven-Industries/feat/ab/sh-304-services-registry-ui
SH-304: Add Services registry management UI
2026-09-18 19:33:33 -03:00
Alexandre Brandizzi
7fdf85fc47
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 19:21:21 -03:00
Alexandre Brandizzi
7eb7e4a730
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:21:18 -03:00
Alexandre Brandizzi
9bba6402e0
Merge pull request #230 from Sea-Haven-Industries/feat/ab/sh-382-extra-doc-view
fix(work-orders): view extra documents (SH-382)
2026-09-18 19:20:59 -03:00
Alexandre Brandizzi
7c1243eec3
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 19:09:49 -03:00
Alexandre Brandizzi
744d7af7bb
Merge branch 'main' into feat/ab/sh-382-extra-doc-view 2026-09-18 19:09:47 -03:00
Alexandre Brandizzi
e3afa182d6
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:09:44 -03:00
Alexandre Brandizzi
d7ee290b1d
Merge pull request #202 from Sea-Haven-Industries/fix/ab/sh-364-inline-technician-registration
Some checks failed
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 Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Terraform CI / terraform (push) Has been cancelled
SH-364, SH-365, SH-366: register inline technicians from work-order flows
2026-09-18 19:09:19 -03:00
Alexandre Brandizzi
4d3dd82afb
Merge branch 'main' into fix/ab/sh-364-inline-technician-registration 2026-09-18 18:58:01 -03:00
Alexandre Brandizzi
29caecd449
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 18:57:58 -03:00
Alexandre Brandizzi
e1b03dc151
Merge branch 'main' into feat/ab/sh-382-extra-doc-view 2026-09-18 18:57:56 -03:00
Alexandre Brandizzi
5766616cc0
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 18:57:53 -03:00
Adam Moussa
148f5f1823
Merge pull request #235 from Sea-Haven-Industries/chore/drop-pointer-cd-leftovers
chore(cd): drop leftover pointer-cd scripts and cutover docs
2026-09-18 17:25:35 -04:00
0c867e2cb4
chore(cd): drop leftover pointer-cd scripts and cutover docs
GitHub Actions already owns SPA bytes, so the unused pointer scripts
and the stale greenlight checklist should not stay in tree.
2026-09-18 17:13:32 -04:00
Adam Moussa
2600234b89
Merge pull request #233 from Sea-Haven-Industries/chore/staging-adoption-complete
Some checks are pending
Terraform CI / terraform (push) Waiting to run
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 Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
chore(terraform): complete staging SPA adoption
2026-09-18 16:57:34 -04:00
Adam Moussa
1199252673
Merge branch 'main' into chore/staging-adoption-complete 2026-09-18 16:45:04 -04:00
Adam Moussa
daa590622b
Merge pull request #234 from Sea-Haven-Industries/fix/skip-sourcemaps-without-sentry-cli
fix(cd): skip source-map upload when sentry-cli is absent
2026-09-18 16:41:31 -04:00
ae852f15ea
fix(cd): skip source-map upload when sentry-cli is absent
August SPA trees have no @sentry/cli. Skipping that step lets a
workflow_dispatch of the live staging SHA finish the S3 sync.
2026-09-18 16:27:25 -04:00
Adam Moussa
fa9e60ce0b
Merge branch 'main' into chore/staging-adoption-complete 2026-09-18 16:23:41 -04:00
Adam Moussa
6f4b3d0e43
Merge pull request #232 from Sea-Haven-Industries/fix/deploy-scripts-from-workflow-sha
fix(cd): run deploy scripts from the workflow SHA
2026-09-18 16:23:16 -04:00
7b37529006
chore(terraform): complete staging SPA adoption
Pin staging adoption_complete to true so tags and the bucket policy
match the already-adopted dev root.
2026-09-18 16:14:10 -04:00
c99273d3fc
fix(cd): run deploy scripts from the workflow SHA
An old content ref has no upload/verify scripts. Copy those from
github.workflow_sha so dispatch of a live SHA can finish.
2026-09-18 16:09:58 -04:00
Alexandre Brandizzi
8d50c6f75e fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380)
The completion-doc upload endpoint sets DocStatus=Yes and bumps the work-order row
version in one transaction. Both signed-PDF upload paths then re-ran a "docStatus=Yes"
work-order patch using the pre-upload row version (the comp-doc dialog re-ran the whole
Generate flow; the slide-over posted docStatus directly). That patch always 409'd — and
in the dialog it also demanded a Date Work Completed — so a successful upload surfaced an
error to the user.

Both paths now stop after the upload settles: the mutation's onSuccess refreshes the
board and the UI reflects the generated state locally, with no second docStatus patch.
2026-09-18 16:59:24 -03:00
Adam Moussa
95f0342271
Merge pull request #229 from Sea-Haven-Industries/dev
Some checks are pending
Terraform CI / terraform (push) Waiting to run
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 Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
chore: promote GitHub-owned SPA onto main
2026-09-18 15:44:21 -04:00
Alexandre Brandizzi
1a7a4ba58f fix(work-orders): open extra documents without credentialed CORS (SH-382)
Viewing a work-order extra document called the authorized content endpoint
with `credentials: "include"`. The API replies `Access-Control-Allow-Origin: *`,
and in credentialed mode the browser rejects a wildcard origin outright, so the
fetch threw, `openExtraDocContent` fell into its catch, closed the tab and
toasted "Unable to open this document." — QA CT-03.

The endpoint authenticates with the bearer token the ky beforeRequest hook
attaches, not cookies, so credentials mode was dead weight. List, upload and
delete never set it, which is why only viewing failed. Drop the option and
guard the request shape in a test (JSDOM cannot enforce CORS).
2026-09-18 16:35:01 -03:00
0bf834bb8e
fix(cd): keep .env.production Sentry DSN when the GitHub var is empty
An unset vars.VITE_SENTRY_DSN becomes an empty env value and Vite will
not let .env.production overwrite it, which would ship with Sentry off.
2026-09-18 15:26:55 -04:00
7ed0743b90
fix(terraform): pin SSM deploy parameter tier and data_type
HCP tried to replace the live parameters on refresh because those
attributes were computed. The apply role cannot DeleteParameter.
2026-09-18 15:09:56 -04:00
Adam Moussa
bd79617aa2
Merge branch 'main' into dev 2026-09-18 15:08:29 -04:00
ceecab5438
fix(cd): ignore githubdeploy description and retire leftover pointer CD
SCP denies UpdateRoleDescription on githubdeploy-*, so HCP apply cannot
rewrite the role description. Pointer workflows must not land on main.
2026-09-18 15:04:51 -04:00
Adam Moussa
c96a259365
refactor(cd): ship SPA content from GitHub on main (#220)
Some checks failed
Deploy dev content / Deploy shoc-frontend-new-dev through Terraform (push) Has been cancelled
* ci(cd): convert SPA hosting to handbook HCP and GitHub content CD

Give HCP the bucket and CloudFront with an empty origin path. GitHub owns
bucket-root sync and invalidation so merge-to-main and a human staging tag
can deploy without creating HCP runs. G13 fails PRs that mix terraform/
with deployable application files.

* ci: run Frontend checks and Terraform CI on PRs to main and dev

Match backend 148 so a PR targeting origin/dev still gets the required
checks. Push remains main only.

* refactor(terraform): keep live/dev and live/staging as HCP roots

Leave the adopted working directories in place so this CD PR does not
retarget two live HCP workspaces. Flattening stays a later change.

* style: prettier terraform-validate.mjs

* fix(terraform): pin githubdeploy assume-role policy in import checker

Reject controlled role updates whose trust document is not the rendered
GitHub OIDC policy, matching the bucket-policy pin.
2026-09-18 14:30:20 -04:00
Adam Moussa
ff79c9d56e
Merge pull request #221 from Sea-Haven-Industries/dev
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
chore: promote current dev onto main
2026-09-18 14:25:41 -04:00
Codex Review Integration
84568dd737 refactor(workorders): extract board patch api module
work-orders-api.ts exceeded the 500-line godfile cap after adding
updatePoc. Move patchBoardField/updatePoc and their shared response
handling into work-order-board-patch-api.ts and re-export through
workOrdersApi, mirroring workOrderBoardDocumentsApi.
2026-09-18 15:03:59 -03:00
Codex Review Integration
3b1da21abb fix(workorders): preserve partial POC edits 2026-09-18 15:00:01 -03:00
Codex Review Integration
5b0c758a9f fix(workorders): persist manual POC edits via dedicated POC endpoint (SH-379)
The UI dropped manual POC edits before they reached the API: the patch
mapper listed pocName/pocPhone/pocNotes as local-only keys and the
slide-over draft excluded them from Save, so the optimistic edit vanished
on refetch and the completion freeze captured the Site contact instead.

- Emit one composite POC op from table patches and route it through a new
  workOrdersApi.updatePoc (PATCH workorders/{id}/poc), reusing the board
  patch row/error contract (409 conflict with currentState, 422 validation).
- Include POC scalars in slide-over edit keys so dirty state and Save carry
  them; site dialog and slide-over now both persist POC edits.
2026-09-18 14:44:06 -03:00
Codex Review Integration
33d0ab4fd6 test(uplifts): scope modal permission and date assertions to what they prove
The permission spec asserts the work order renders from the queue item
and, separately, that only the approved-on-WO breakdown degrades to
Unavailable. The Requested At assertion derives the local calendar day
so it holds in every runner time zone.
2026-09-18 13:02:11 -03:00
Codex Review Integration
3c84d851c0 feat(uplifts): align uplift detail modal with SH-209
Two sections, Work order and Uplift request, divided by a single rule.
Work order shows Service, Vendor / Technician, Assigned Dispatcher and
Scheduled from the queue item, so it no longer fetches the work order
and no longer degrades to Unavailable for account-scoped staff. Uplift
request shows Requested By, Requested At, the justification in a
bordered block, the approved-on-WO breakdown and a horizontal
attachment row whose chips open the evidence in a new tab. Every field
renders data or an explicit placeholder. The footer shows Reject and
Approve for pending uplifts, Revoke for approved ones and nothing for
read-only records; closing is the header X.
2026-09-18 12:53:55 -03:00
Alexandre Brandizzi
c9562650ca
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-18 11:59:16 -03:00
Alexandre Brandizzi
e780bafdd8
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-18 11:59:13 -03:00
Codex Review Integration
28ff14d457 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-304-services-registry-ui
# Conflicts:
#	src/test/lib/auth/user-utils.test.ts
2026-09-17 15:34:52 -03:00
Codex Review Integration
8a0a134238 style: format wizard-vendor-company-fields with prettier 2026-09-17 15:31:15 -03:00
Codex Review Integration
abe28a3a7f Merge remote-tracking branch 'origin/dev' into fix/ab/sh-364-inline-technician-registration
# Conflicts:
#	src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts
#	src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx
#	src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx
#	src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx
2026-09-17 15:27:39 -03:00
Alexandre Brandizzi
9ed8099e71
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 15:16:25 -03:00
Alexandre Brandizzi
361f6b65a2
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 15:10:25 -03:00
Alexandre Brandizzi
534d7ce4d6
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 15:02:19 -03:00
Codex Review Integration
bbdf418ee3 refactor(services): extract useServiceEditor to satisfy maintainability gate
useServicesRegistryController exceeded the changed-file max-lines-per-function
budget (158 > 150). Move the add/edit form lifecycle into a useServiceEditor
sub-hook; the controller's public return shape and behavior are unchanged.
2026-09-17 14:55:05 -03:00
Alexandre Brandizzi
65d55d7c9f
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 14:48:26 -03:00
Alexandre Brandizzi
ff4ecb37e4
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:48:16 -03:00
Alexandre Brandizzi
0eeae02785
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 14:34:43 -03:00
Alexandre Brandizzi
0302b2828f
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:34:33 -03:00
Alexandre Brandizzi
3dbf3df5d5
Merge branch 'dev' into fix/ab/sh-364-inline-technician-registration 2026-09-17 14:26:40 -03:00
Alexandre Brandizzi
3011cb1eda
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:26:30 -03:00
Codex Review Integration
90a3c24846 fix(services): source Trade picker from canonical catalog (SH-304)
The Trade select was fed from the editable DropdownOptions "Trade"
category, whose legacy seed values ("Plumbing", "General", ...) are not
in the backend TradeCatalog. ServicesRegistryService.RequireCanonicalTrade
rejects any non-canonical trade with 400 TradeInvalid, so Add Service and
Save Changes failed for legacy names and an admin editing the Trade
dropdown could break service creation at any time.

Source the picker from the server-owned canonical Trade reference on
Vendor/facets (the same list the vendor roster form uses), key the icon
defaults to the canonical names, and keep the edited service's stored
trade selectable if it was later deactivated so editing never silently
blanks the field.
2026-09-17 14:14:48 -03:00
Codex Review Integration
c40f0c4a37 Merge remote-tracking branch 'origin/dev' into fix/ab/sh-364-inline-technician-registration
# Conflicts:
#	src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts
2026-09-17 13:26:17 -03:00
Codex Review Integration
7dd7ea497e Merge remote-tracking branch 'origin/dev' into feat/ab/sh-304-services-registry-ui
# Conflicts:
#	src/api/api-paths.ts
#	src/domain/services/api/services-api.ts
#	src/domain/services/mappers/service-mapper.ts
#	src/domain/services/types/service.ts
#	src/infra/query-key/query-key.ts
#	src/test/domain/services/api/services-api.test.ts
2026-09-17 13:24:08 -03:00
Codex Review Integration
f39328576e fix(services): replace status filter with active/inactive tabs (SH-304) 2026-09-17 02:01:08 -03:00
Codex Review Integration
2fb5d4a965 fix(services): satisfy registry maintainability gate 2026-09-16 22:50:09 -03:00
Codex Review Integration
9c360fc95b fix(services): address registry review findings 2026-09-16 22:30:32 -03:00
Codex Review Integration
0b229fd054 fix(work-orders): validate inline technician phone before roster save
The inline technician rows on the wizard's Vendor & time step had no
client-side phone check, so a number that does not normalize to ten digits
failed the roster PATCH and blocked the whole Create with the raw backend
message in a toast. Validate each staged phone with the same
isValidNorthAmericanPhone predicate the vendor roster schema uses, so the
inline path matches the modal path. A blank phone stays valid.
2026-09-16 19:45:07 -03:00
Codex Review Integration
1c66f8d60e fix(services): cache completion template options 2026-09-16 19:13:28 -03:00
Codex Review Integration
b449df97e6 fix(services): use completion document templates 2026-09-16 19:13:00 -03:00
Codex Review Integration
0b63d882ab refactor(services): split registry management view 2026-09-16 19:05:02 -03:00
Codex Review Integration
c9ab1f3598 feat(services): add registry management page 2026-09-16 18:42:31 -03:00
Codex Review Integration
712d8e0dc6 fix(work-orders): clear stale technician drafts on selection changes 2026-09-16 16:50:26 -03:00
Codex Review Integration
ee95847354 fix(work-orders): register inline technicians through the additive vendor roster 2026-09-16 13:16:44 -03:00
112 changed files with 5744 additions and 2720 deletions

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,22 @@
<!--
Title: type(scope): description (SH-123)
type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, release.
Product work carries its SH key at the end of the title. Platform or security work carries PLAT or SEC.
Docs-only and configuration-only chores may omit the key.
Branch: feature/, fix/, hotfix/, chore/, docs/, refactor/, release/ plus a kebab-case description. No Jira keys in branch names.
Scope: one logical change per PR. If the title needs "and", split it.
Body: verifiable facts about the change. No validation transcripts, no deployment notes, no AI attribution footers.
The layout below is this repository's contract (REVIEW_AND_PR_FRAMEWORK.md, section 8). Keep the three headings.
-->
## Summary
<!-- What changed and why, in plain language. Two to four sentences. -->
## Changes and value
<!-- Grouped by area, each with the value it delivers. Bullets, bold lead-in per bullet. -->
## Ticket
<!-- The board key(s) this PR delivers, one per line. "None" when nothing applies. -->

21
.github/renovate.json vendored
View file

@ -1,6 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["npm", "custom.regex", "terraform"],
"enabledManagers": ["npm", "custom.regex", "terraform", "github-actions"],
"schedule": ["before 6am every weekday"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict",
"customManagers": [
@ -14,6 +15,17 @@
"datasourceTemplate": "npm",
"depNameTemplate": "@playwright/test",
"versioningTemplate": "npm"
},
{
"customType": "regex",
"description": [
"actionlint release installed by the governance job; its SHA256 pin must be updated by hand, so this only surfaces the update on the dashboard"
],
"managerFilePatterns": ["/^\\.github/workflows/ci\\.ya?ml$/"],
"matchStrings": ["ACTIONLINT_VERSION: \"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""],
"datasourceTemplate": "github-releases",
"depNameTemplate": "rhysd/actionlint",
"extractVersionTemplate": "^v(?<version>.*)$"
}
],
"packageRules": [
@ -36,6 +48,13 @@
"dependencyDashboardApproval": true,
"groupName": "playwright"
},
{
"description": [
"Do not open actionlint PRs until approved; the SHA256 pin in ci.yaml has to change with the version"
],
"matchPackageNames": ["rhysd/actionlint"],
"dependencyDashboardApproval": true
},
{
"description": ["Keep MUI packages together"],
"matchPackageNames": ["@mui/**"],

View file

@ -2,37 +2,99 @@ name: Frontend checks
on:
pull_request:
branches: [main, dev, staging]
branches: [main, dev]
# The merge queue builds main plus the queued pull requests on a temporary
# branch and only counts checks that ran on the merge_group event.
merge_group:
push:
branches: [main, dev, staging]
branches: [main]
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build-and-test:
name: Build and test
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
with:
node-version: "24"
static:
name: static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run format:check
- run: npm run lint
build:
name: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run build
unit:
name: unit
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm test -- --shard=${{ matrix.shard }}/4
visual:
name: Visual regression
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14
governance:
# Repo-owned guarantee that every frontend quality gate runs from this
# 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, Terraform fmt/validate, Terraform import-plan and
# release-plan guards, isolation tests, HCP run guard, CloudFront verify,
# and GitHub workflow shell). If the reusable workflow is later confirmed
# to run every gate, this job can be slimmed to `npm run governance`.
# Repo-owned gates: godfile ratchet, changed-file maintainability,
# Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront
# verify, GitHub workflow shell, isolation classifier tests, and live G13
# (pull_request, merge_group per queued PR, and local). Format, lint, build,
# and unit tests run
# in the parallel jobs above, not here.
#
# GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>)
# merge group -> the group's own base (github.event.merge_group.base_sha)
# push-> the previous commit on the branch (github.event.before)
# manual -> dev, for exact-head recovery runs
# manual -> main, for exact-head recovery runs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -45,14 +107,17 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
base="${PR_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "merge_group" && -n "${MERGE_GROUP_BASE_SHA}" ]]; then
base="${MERGE_GROUP_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}"
else
base="origin/dev"
base="origin/main"
fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- name: Set up Terraform
@ -78,29 +143,29 @@ jobs:
tar -xzf actionlint.tar.gz actionlint
sudo mv actionlint /usr/local/bin/actionlint
- run: npm ci
- run: npm run verify
- run: npm run governance
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
visual-regression:
name: Visual regression
ci-complete:
name: ci-complete
if: always()
needs: [static, build, unit, visual, governance]
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run test:e2e:visual
- name: Upload visual diff artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-regression-diffs
path: |
test-results/visual
playwright-report-visual
if-no-files-found: ignore
retention-days: 14
- name: All required jobs passed
shell: bash
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
set -euo pipefail
failed=0
for result in ${RESULTS}; do
if [[ "${result}" != "success" ]]; then
failed=1
fi
done
if [[ "${failed}" -ne 0 ]]; then
echo "Required jobs did not all succeed: ${RESULTS}"
exit 1
fi

View file

@ -1,148 +0,0 @@
name: Deploy staging
# Standalone staging deployment (push to `staging` / manual dispatch), NOT a
# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging
# trusts the exact GitHub-environment OIDC subject, which requires the deploy
# job to declare `environment: staging` and run in this repo, with the
# non-secret role ARN pinned below (created by the staging stack itself).
#
# Order is fixed: full `npm run verify` gates run BEFORE any deploy step.
# No secrets are used — OIDC + the static role ARN are the only credentials.
on:
push:
branches: [staging]
workflow_dispatch: {}
permissions:
id-token: write
contents: read
concurrency:
group: deploy-staging
cancel-in-progress: false
jobs:
deploy:
name: Deploy to staging
# Deploy only the exact staging branch ref, never a tag or other ref
# (workflow_dispatch can be invoked from arbitrary refs).
if: github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
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
with:
fetch-depth: 0
- name: Resolve governance comparison ref
id: governance-ref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}"
else
base="origin/dev"
fi
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
# Node 24 bundles npm 11 (lockfileVersion 3); the packageManager pin
# (npm@11.16.0) matches this CI environment.
- name: Quality gates (full verify before any deploy)
run: npm ci && npm run verify
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
- name: Assume staging 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-staging
aws-region: us-east-1
# 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
stack_output() {
aws cloudformation describe-stacks \
--stack-name shoc-frontend-staging \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
--output text
}
BUCKET="$(stack_output BucketName)"
DIST_ID="$(stack_output DistributionId)"
DIST_DOMAIN="$(stack_output DistributionDomainName)"
SITE_URL="$(stack_output SiteUrl)"
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then
echo "::error::Could not resolve bucket/distribution from stack outputs." >&2
exit 1
fi
echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}"
aws s3api head-bucket --bucket "${BUCKET}"
echo "Bucket exists."
# The distribution is proven to exist and serve by the HTTPS check
# below: the custom domain is an alias to this distribution, and the
# deploy role deliberately carries no cloudfront:GetDistribution
# (least privilege; the dev template is shared and must not drift).
if grep -Rq "api.dev.seahaven.com" dist/; then
echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2
grep -Rl "api.dev.seahaven.com" dist/ >&2 || true
exit 1
fi
echo "Built assets carry no dev API URL."
grep -Rq "api.staging.seahaven.com" dist/
echo "Built assets reference the staging API URL."
# Verify the actual post-invalidation HTML and its referenced assets,
# not only the local build or a generic endpoint response.
remote_dir="$(mktemp -d)"
trap 'rm -rf "${remote_dir}"' EXIT
for i in 1 2 3 4 5 6; do
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then
break
fi
echo "Endpoint not ready (attempt ${i}); retrying in 20s..."
sleep 20
done
test -s "${remote_dir}/index.html"
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \
| sed -E 's/^(src|href)="([^"]+)"$/\2/' \
| sort -u > "${remote_dir}/asset-paths.txt"
test -s "${remote_dir}/asset-paths.txt"
while IFS= read -r asset_path; do
curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \
>> "${remote_dir}/assets.txt"
done < "${remote_dir}/asset-paths.txt"
if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then
echo "::error::Deployed assets contain the dev API URL." >&2
exit 1
fi
grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt"
echo "Deployed staging assets reference only the staging API URL."

316
.github/workflows/deploy-web.yaml vendored Normal file
View file

@ -0,0 +1,316 @@
name: Deploy Web
# SPA CD. GitHub Actions builds dist/ and syncs it to the S3 origin bucket
# root, then invalidates CloudFront. Terraform owns the bucket and the
# distribution and never touches content.
#
# push to main -> dev, at github.sha
# release: published -> staging, at vX.Y.Z-staging (must be on main)
# workflow_dispatch -> chosen environment at a chosen ref
#
# Releases are cut by a human with
# `gh release create vX.Y.Z-staging --target main --generate-notes`.
# A workflow cannot do it: releases created with GITHUB_TOKEN do not fire
# `release: published`. Core vX.Y.Z waits until a prod distribution exists.
#
# Bucket and distribution come from SSM after assuming the Environment's
# DEPLOY_ROLE_ARN. Nothing here creates an HCP run. Quality gates live in CI.
#
# The SPA checkout is the resolved content ref. Deploy scripts are copied
# from github.workflow_sha so workflow_dispatch of an older SHA still runs
# the current upload/verify path.
on:
push:
branches: [main]
paths-ignore:
- "terraform/**"
- "docs/**"
- "**/*.md"
- ".github/workflows/ci.yaml"
- ".github/workflows/deploy-web.yaml"
release:
types: [published]
workflow_dispatch:
inputs:
environment:
description: "Target Environment"
required: true
type: choice
options: [dev, staging]
ref:
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
required: false
type: string
default: ""
permissions:
contents: read
jobs:
target:
name: Resolve target
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
environment: ${{ steps.resolve.outputs.environment }}
ref: ${{ steps.resolve.outputs.ref }}
steps:
- id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
GITHUB_REF_NAME_IN: ${{ github.ref }}
GITHUB_SHA_IN: ${{ github.sha }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
push)
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
echo "push deploys only run from main" >&2
exit 1
fi
environment=dev
ref="${GITHUB_SHA_IN}"
;;
release)
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-staging$ ]]; then
echo "release tag ${RELEASE_TAG} is not vX.Y.Z-staging; refusing until a prod distribution exists." >&2
exit 1
fi
environment=staging
ref="${RELEASE_TAG}"
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
exit 1
fi
;;
workflow_dispatch)
environment="${INPUT_ENVIRONMENT}"
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
;;
*)
echo "unsupported event ${EVENT_NAME}" >&2
exit 1
;;
esac
{
echo "environment=${environment}"
echo "ref=${ref}"
} >> "${GITHUB_OUTPUT}"
echo "Deploying ${ref} to ${environment}"
deploy:
name: Deploy SPA to ${{ needs.target.outputs.environment }}
needs: target
runs-on: ubuntu-latest
timeout-minutes: 45
environment: ${{ needs.target.outputs.environment }}
concurrency:
group: deploy-web-${{ needs.target.outputs.environment }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.target.outputs.ref }}
persist-credentials: false
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "Building ${sha}"
- name: Checkout workflow deploy scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
sparse-checkout: |
scripts
sparse-checkout-cone-mode: true
path: .workflow-scripts
- name: Install workflow deploy scripts
run: |
set -euo pipefail
test -f .workflow-scripts/scripts/upload-sourcemaps.sh
test -f .workflow-scripts/scripts/verify-cloudfront-release.sh
test -f .workflow-scripts/scripts/summarize-cloudfront-live-state.sh
mkdir -p scripts
cp .workflow-scripts/scripts/upload-sourcemaps.sh scripts/
cp .workflow-scripts/scripts/verify-cloudfront-release.sh scripts/
cp .workflow-scripts/scripts/summarize-cloudfront-live-state.sh scripts/
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Build SPA
env:
TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }}
VITE_APP_COMMIT_SHA: ${{ steps.commit.outputs.sha }}
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ needs.target.outputs.environment }}
VITE_SENTRY_RELEASE: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
# vars.VITE_SENTRY_DSN is unset today. An empty env value would
# override .env.production and disable Sentry (Vite does not let
# .env overwrite an existing variable).
if [ -n "${VITE_SENTRY_DSN:-}" ]; then
export VITE_SENTRY_DSN
else
unset VITE_SENTRY_DSN
fi
case "${TARGET_ENVIRONMENT}" in
dev)
export VITE_API_URL="https://api.dev.seahaven.com/api"
forbidden="api.staging.seahaven.com"
required="api.dev.seahaven.com"
;;
staging)
export VITE_API_URL="https://api.staging.seahaven.com/api"
forbidden="api.dev.seahaven.com"
required="api.staging.seahaven.com"
;;
*)
echo "unsupported environment ${TARGET_ENVIRONMENT}" >&2
exit 1
;;
esac
npm ci
npm run build
test -f dist/index.html
if grep -Rq "${forbidden}" dist/; then
echo "Built assets contain the forbidden URL ${forbidden}." >&2
exit 1
fi
if grep -Rq "localhost:5141" dist/; then
echo "Built assets contain the Vite proxy target localhost:5141." >&2
exit 1
fi
grep -Rq "${required}" dist/
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
echo "VITE_API_URL=${VITE_API_URL}" >> "${GITHUB_ENV}"
echo "dist/index.html sha256=${index_sha}"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
VITE_APP_COMMIT_SHA: ${{ steps.commit.outputs.sha }}
- name: Strip source maps from dist/
run: |
set -euo pipefail
find dist -name '*.map' -delete
if find dist -name '*.map' | grep -q .; then
echo "SPA source maps must not ship in dist/" >&2
exit 1
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: spa-dist-${{ needs.target.outputs.environment }}-${{ steps.commit.outputs.sha }}
path: dist/
if-no-files-found: error
retention-days: 7
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
env:
TARGET_ENVIRONMENT: ${{ needs.target.outputs.environment }}
run: |
set -euo pipefail
prefix="/shoc-frontend-new/${TARGET_ENVIRONMENT}/deploy"
BUCKET=$(aws ssm get-parameter --name "${prefix}/bucket" --query Parameter.Value --output text)
DIST_ID=$(aws ssm get-parameter --name "${prefix}/distribution-id" --query Parameter.Value --output text)
DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text)
# Refuse to touch the bucket until Terraform has moved every origin
# to the bucket root. The previous CD pointed origins at
# /releases/<label>; syncing and pruning under that layout would
# serve a broken site or delete the live prefix.
origin_paths="$(aws cloudfront get-distribution --id "${DIST_ID}" \
--query 'Distribution.DistributionConfig.Origins.Items[].OriginPath' --output text | tr -d '[:space:]')"
if [ -n "${origin_paths}" ]; then
echo "Distribution ${DIST_ID} still has a non-empty origin path (${origin_paths})." >&2
echo "Wait for the HCP apply that moves the origin to the bucket root, then re-run." >&2
exit 1
fi
{
echo "bucket=${BUCKET}"
echo "distribution_id=${DIST_ID}"
echo "site_url=https://${DOMAIN}"
} >> "${GITHUB_OUTPUT}"
- name: Sync dist/ to the bucket root
env:
SITE_BUCKET: ${{ steps.deploy.outputs.bucket }}
run: |
set -euo pipefail
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--delete \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html
- name: Invalidate CloudFront
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
run: |
set -euo pipefail
invalidation_id="$(aws cloudfront create-invalidation \
--distribution-id "${DISTRIBUTION_ID}" \
--paths "/*" \
--query Invalidation.Id --output text)"
echo "Invalidation ${invalidation_id} created; waiting"
aws cloudfront wait invalidation-completed \
--distribution-id "${DISTRIBUTION_ID}" \
--id "${invalidation_id}"
- name: Verify served release
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
SITE_URL: ${{ steps.deploy.outputs.site_url }}
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
API_URL: ${{ env.VITE_API_URL }}
run: bash scripts/verify-cloudfront-release.sh
- name: Live-state summary
if: always()
continue-on-error: true
env:
DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }}
SITE_URL: ${{ steps.deploy.outputs.site_url }}
run: bash scripts/summarize-cloudfront-live-state.sh

View file

@ -1,300 +0,0 @@
name: Deploy dev content
# Dev content CD through Terraform (SH-300). GitHub uploads an immutable
# releases/<sha>-<run>-<attempt>/ prefix. Terraform owns the pointer, origin
# group, and invalidation. Push-to-dev stays off until
# vars.TERRAFORM_CONTENT_CD_ENABLED is the string true.
#
# Quality gates live in Frontend checks (`ci.yaml`). This workflow does not
# re-run those gates on pull requests, pushes, or workflow_dispatch.
on:
push:
branches: [dev]
paths-ignore:
- "terraform/**"
workflow_dispatch: {}
permissions:
contents: read
jobs:
deploy-dev:
name: Deploy shoc-frontend-new-dev through Terraform
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/dev' &&
vars.TERRAFORM_CONTENT_CD_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev')
runs-on: ubuntu-latest
timeout-minutes: 180
permissions:
contents: read
id-token: write
concurrency:
group: deploy-dev
cancel-in-progress: false
env:
AWS_REGION: us-east-1
TF_CLOUD_ORGANIZATION: seahaven
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
SITE_BUCKET: seahaven-shoc-frontend-dev
DISTRIBUTION_ID: E2CWLM1AFB964P
SITE_URL: https://dev.seahaven.com
VITE_API_URL: https://api.dev.seahaven.com/api
VITE_APP_COMMIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Build SPA
run: |
set -euo pipefail
npm ci
npm run build
if grep -Rq "api.staging.seahaven.com" dist/; then
echo "::error::Built assets contain the staging API URL." >&2
exit 1
fi
if grep -Rq "localhost:5141" dist/; then
echo "::error::Built assets contain the Vite proxy target localhost:5141." >&2
exit 1
fi
grep -Rq "api.dev.seahaven.com" dist/
- name: Configure AWS credentials (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
audience: sts.amazonaws.com
- name: Assign immutable release identity
id: release
run: |
set -euo pipefail
version_label="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
prefix="releases/${version_label}"
{
echo "version_label=${version_label}"
echo "prefix=${prefix}"
} >> "${GITHUB_OUTPUT}"
# Sentry release is shoc-frontend@${GITHUB_SHA} via VITE_APP_COMMIT_SHA,
# distinct from the S3/Terraform version_label.
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Read previous release pointer
id: pointer
run: |
set -euo pipefail
body="$(aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors || true)"
printf '%s' "${body}" | python3 scripts/read-release-pointer.py
- name: Upload immutable release prefix
run: |
set -euo pipefail
prefix="${{ steps.release.outputs.prefix }}"
aws s3 sync dist/ "s3://${SITE_BUCKET}/${prefix}/" \
--exclude "index.html" \
--exclude "*.map" \
--cache-control "public,max-age=31536000,immutable"
aws s3 cp dist/index.html "s3://${SITE_BUCKET}/${prefix}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${prefix}/index.html"
index_sha="$(python3 -c 'import hashlib,pathlib; print(hashlib.sha256(pathlib.Path("dist/index.html").read_bytes()).hexdigest())')"
echo "INDEX_SHA256=${index_sha}" >> "${GITHUB_ENV}"
echo "Uploaded ${prefix}; index.html sha256=${index_sha}"
- name: Capture previous served hash
id: previous-hash
run: |
set -euo pipefail
hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' || true)"
echo "sha256=${hash}" >> "${GITHUB_OUTPUT}"
- name: Discard blocking VCS run before GitHub CD
id: discard-vcs
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
- name: Create Terraform release run
id: release-run
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
env:
TF_VAR_release_version_label: '"${{ steps.release.outputs.version_label }}"'
TF_VAR_previous_release_version_label: '"${{ steps.pointer.outputs.live_current }}"'
with:
workspace: shoc-frontend-new-dev
message: "Release ${{ steps.release.outputs.version_label }} from GitHub Actions"
- name: Read Terraform release plan counts
id: release-plan
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
plan: ${{ steps.release-run.outputs.plan_id }}
- name: Reject non-release resource counts
env:
PLAN_ADD: ${{ steps.release-plan.outputs.add }}
PLAN_CHANGE: ${{ steps.release-plan.outputs.change }}
PLAN_DESTROY: ${{ steps.release-plan.outputs.destroy }}
run: |
set -euo pipefail
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
echo "HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
exit 1
fi
- name: Guard pointer-and-origin-path Terraform plan
run: |
set -euo pipefail
# Flags must match check-terraform-release-plan.py. Pointer `before`
# and origin-ID-set stability are asserted from the plan JSON.
python3 scripts/check-terraform-release-plan.py \
--plan-id "${{ steps.release-run.outputs.plan_id }}" \
--expected-version-label "${{ steps.release.outputs.version_label }}" \
--expected-previous-version-label "${{ steps.pointer.outputs.live_current }}"
- name: Discard release run when the guard fails
if: failure() && steps.release-run.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.release-run.outputs.run_id }}
comment: Rejected by the pointer-and-origin-path plan guard from GitHub Actions
- name: Apply Terraform release run
id: release-apply
continue-on-error: true
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.release-run.outputs.run_id }}
comment: Apply pointer-and-origin-path release from GitHub Actions ${{ github.sha }}
- name: Treat already-applied release run as success
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: |
python3 scripts/hcp-run-guard.py reconcile-apply \
--run-id "${{ steps.release-run.outputs.run_id }}" \
--apply-outcome "${{ steps.release-apply.outcome }}"
- name: Verify CloudFront release
env:
EXPECTED_LABEL: ${{ steps.release.outputs.version_label }}
EXPECTED_INDEX_SHA256: ${{ env.INDEX_SHA256 }}
PREVIOUS_INDEX_SHA256: ${{ steps.previous-hash.outputs.sha256 }}
run: bash scripts/verify-cloudfront-release.sh
- name: Restore previous release on failure
if: failure()
id: rollback-prepare
run: |
set -euo pipefail
prev="${{ steps.pointer.outputs.live_current }}"
if [[ ! "${prev}" =~ ^[0-9a-f]{40}-[0-9]+-[0-9]+$ ]]; then
echo "No Terraform-managed previous label; cannot roll back through HCP." >&2
exit 0
fi
echo "rollback_label=${prev}" >> "${GITHUB_OUTPUT}"
echo "rollback_previous=${{ steps.release.outputs.version_label }}" >> "${GITHUB_OUTPUT}"
- name: Discard blocking VCS run before GitHub rollback
id: rollback-discard-vcs
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: python3 scripts/hcp-run-guard.py check-and-discard --workspace shoc-frontend-new-dev
- name: Create Terraform rollback run
id: rollback-run
if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
env:
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
TF_VAR_previous_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_previous }}"'
with:
workspace: shoc-frontend-new-dev
message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions"
- name: Read Terraform rollback plan counts
id: rollback-plan
if: failure() && steps.rollback-run.outcome == 'success'
uses: hashicorp/tfc-workflows-github/actions/plan-output@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
plan: ${{ steps.rollback-run.outputs.plan_id }}
- name: Reject non-release rollback counts
id: rollback-count-guard
if: failure() && steps.rollback-plan.outcome == 'success'
env:
PLAN_ADD: ${{ steps.rollback-plan.outputs.add }}
PLAN_CHANGE: ${{ steps.rollback-plan.outputs.change }}
PLAN_DESTROY: ${{ steps.rollback-plan.outputs.destroy }}
run: |
set -euo pipefail
if [ "$PLAN_ADD" != "0" ] || [ "$PLAN_CHANGE" != "2" ] || [ "$PLAN_DESTROY" != "0" ]; then
echo "Rollback HCP plan counts are add=${PLAN_ADD} change=${PLAN_CHANGE} destroy=${PLAN_DESTROY}; expected 0/2/0." >&2
exit 1
fi
- name: Guard pointer-and-origin-path Terraform rollback plan
id: rollback-json-guard
if: failure() && steps.rollback-count-guard.outcome == 'success'
run: |
set -euo pipefail
python3 scripts/check-terraform-release-plan.py \
--plan-id "${{ steps.rollback-run.outputs.plan_id }}" \
--expected-version-label "${{ steps.rollback-prepare.outputs.rollback_label }}" \
--expected-previous-version-label "${{ steps.rollback-prepare.outputs.rollback_previous }}"
- name: Discard rollback run when the guard fails
if: failure() && steps.rollback-run.outcome == 'success' && steps.rollback-json-guard.outcome != 'success'
uses: hashicorp/tfc-workflows-github/actions/discard-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.rollback-run.outputs.run_id }}
comment: Rejected by the pointer-and-origin-path rollback plan guard from GitHub Actions
- name: Apply Terraform rollback run
id: rollback-apply
if: failure() && steps.rollback-json-guard.outcome == 'success'
continue-on-error: true
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
with:
run: ${{ steps.rollback-run.outputs.run_id }}
comment: Apply pointer-and-origin-path rollback from GitHub Actions ${{ github.sha }}
- name: Treat already-applied rollback run as success
id: rollback-apply-result
if: failure() && steps.rollback-apply.outcome != 'skipped'
env:
TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }}
run: |
python3 scripts/hcp-run-guard.py reconcile-apply \
--run-id "${{ steps.rollback-run.outputs.run_id }}" \
--apply-outcome "${{ steps.rollback-apply.outcome }}"
- name: Verify CloudFront rollback
if: failure() && steps.rollback-apply-result.outcome == 'success'
env:
EXPECTED_LABEL: ${{ steps.rollback-prepare.outputs.rollback_label }}
run: |
set -euo pipefail
expected_sha="$(aws s3 cp "s3://${SITE_BUCKET}/releases/${EXPECTED_LABEL}/index.html" - | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
export EXPECTED_INDEX_SHA256="${expected_sha}"
bash scripts/verify-cloudfront-release.sh
- name: Live-state summary
if: always()
continue-on-error: true
run: bash scripts/summarize-cloudfront-live-state.sh

View file

@ -1,43 +0,0 @@
name: Terraform isolation
# Own workflow so labeled/unlabeled re-evaluate this gate without starting a
# new Frontend checks run. Skipping jobs inside `ci.yaml` on those events
# would report required checks as success and could merge a failing SHA.
on:
pull_request:
branches: [main, dev, staging]
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
permissions:
contents: read
jobs:
terraform-isolation:
# Fails a pull request that changes Terraform infrastructure 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. This
# job is unconditional so adding or removing that label always reads the
# current label set; a previous green check does not survive removal.
name: Terraform and application changes are isolated
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}"

3
.gitignore vendored
View file

@ -49,3 +49,6 @@ seed-data.sql
# python
__pycache__/
*.py[cod]
# local scratch
/tmp/

View file

@ -28,9 +28,11 @@ npm run verify
This chains the full set: Prettier check, ESLint (`--max-warnings=0`), TypeScript
build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance
checks (`npm run governance`). **Do not claim a task is done until `npm run
verify` is green locally.** CI runs the same `npm run verify` in a repo-owned
`governance` job, so a green local run mirrors CI.
checks (`npm run governance`), including G13 app/Terraform isolation. **Do not
claim a task is done until `npm run verify` is green locally.** CI runs the same
gates as parallel jobs in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)
(`static`, `build`, `unit`, `visual`, `governance`) with `ci-complete` failing if
any of those jobs did not succeed.
## Non-negotiable rules (enforced; do not work around)

View file

@ -8,32 +8,30 @@ npm run verify
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
`test` (`vitest run`) → `governance`. Governance also runs the repository
gates: Terraform import-plan and release-plan checkers, isolation tests,
Terraform formatting and validation, the HCP run guard, CloudFront verify, and
workflow shell checks. A task is not done until this is green.
gates: Terraform import-plan checker, Terraform formatting and validation, the
HCP run guard, CloudFront verify, workflow shell checks, and G13 (app/Terraform
isolation). A task is not done until this is green.
## Gate matrix
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| 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 release-plan contract | `npm run test:terraform-release-plan` → `scripts/test-terraform-release-plan-check.py` | `governance` + CI | Synthetic plan JSON + 15 fixtures |
| 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` |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| Terraform/app change isolation | `terraform-isolation.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
| Gate | Command / rule source | Enforced by | Scope |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------- |
| Formatting | `npm run format:check` (Prettier) | `verify` + lint-staged | Whole repo |
| Lint, zero warnings | `npm run lint` → `eslint . --max-warnings=0` | `verify` + CI | Governed TS/TSX (`eslint.config.js`) |
| Type-check + production build | `npm run build` → `tsc -b && vite build` | `verify` + CI | Whole app |
| Unit tests | `npm test` → `vitest run` | `verify` + CI | `src/test/**`, `config/**/*.test.ts` |
| Conditional rendering (no `: null`) | `no-restricted-syntax` in `eslint.config.js` | lint | Governed TSX |
| Boolean-only JSX `&&` | `seahaven/no-non-boolean-jsx-and` (type-aware) in `eslint-rules/` | lint | Governed TSX |
| Shared `Text` typography | `no-restricted-syntax` (raw `p`/`h1`–`h6`) + `seahaven/no-vp-error-outside-text` | lint | Governed TSX |
| 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 formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev`, `terraform/live/staging` |
| HCP run guard | `npm run test:hcp-run-guard` → `scripts/test-hcp-run-guard.py` | `governance` + CI | Workspace invariants + apply reconcile |
| CloudFront release verify | `npm run test:cloudfront-release-verify` → `scripts/test-verify-cloudfront-release.sh` | `governance` + CI | Stubbed aws/curl |
| GitHub workflow shell | `npm run test:github-workflows` → `scripts/check-github-workflows.sh` | `governance` + CI | `bash -n` + actionlint |
| G13 App/Terraform isolation | `python3 scripts/check_app_terraform_isolation.py` vs merge-base of `GOVERNANCE_BASE` (merge_group: each first-parent commit) | `governance` + CI | Deployable app files vs `terraform/` (live: PR, merge_group, local) |
## No-false-pass guarantees
@ -51,28 +49,28 @@ workflow shell checks. A task is not done until this is green.
-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 re-evaluates on label changes** — the
`terraform-isolation-override` label is the only way to merge a mixed
Terraform/application PR. `.github/workflows/terraform-isolation.yaml`
runs `terraform-isolation` on `labeled` and `unlabeled` as well as the
default pull-request types, so adding or removing the label re-checks
the current labels without starting a new Frontend checks run. Removing
the label fails a mixed PR that had previously passed with the override.
(`terraform/README.md`). G13 fails a diff that contains both `terraform/`
and deployable application files (`src/`, `public/`, `pages/`, `config/`,
`index.html`, Vite/tsconfig, or `.env*`). Workflow,
docs, and gate-script changes may travel with either side. Runtime isolation
stays: `deploy-web.yaml` ignores `terraform/**`, and app-only tags skip HCP
when workspace trigger patterns miss.
## Where the gates run
- **Locally:** `npm run verify`. `lint-staged` (via Husky) re-runs ESLint +
Prettier on staged files at commit; commitlint enforces Conventional Commits.
- **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` (with Terraform 1.16.0 installed) so the maintainability
ratchets and repository gates are guaranteed from this repository regardless
of the reusable workflow.
- **Terraform isolation ([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.yaml)):**
on pull requests, fails when Terraform infrastructure and application code
change together. Label add/remove re-runs only this workflow.
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this
repository owns every job. `static` (`format:check` + `lint`), `build`
(`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual`
(Playwright visual), and `governance` (`npm run governance`, with Terraform
1.16.0) run in parallel. `ci-complete` fails unless all of those jobs
succeeded and is the required merge-queue check. Live G13 runs on
`pull_request` (merge-base range), `merge_group` (each queued PR as a
first-parent commit), and locally. It skips `push`. A Terraform-only PR
stacked with an app-only PR still passes; a mixed change set still fails.
Terraform fmt/validate and the related unit tests run inside `governance` on
every event.
## Toolchain pin

118
README.md
View file

@ -1,7 +1,7 @@
# SHOC Frontend (`shoc-frontend-new`)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=dev)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
[![CI](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
[![Deploy](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml/badge.svg)](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-087EA4?logo=react&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
@ -13,29 +13,31 @@ the legacy SHOC frontend — new code follows the IrisLoan.Admin conventions
documented in [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md).
- **GitHub:** `Sea-Haven-Industries/shoc-frontend-new`
- **Hosted at:** <https://dev.seahaven.com> (dev environment; the only environment today)
- **Hosted at:** <https://dev.seahaven.com> (dev, deployed from `main`) and
<https://staging.seahaven.com> (staging, deployed from `vX.Y.Z-staging` tags)
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
## Architecture
Static SPA hosting on AWS, owned by HCP Terraform
([`terraform/README.md`](terraform/README.md)). CloudFront serves the built
`dist/` from a private S3 bucket using a current/previous origin group;
`dist/` from a private S3 bucket at the bucket root;
the SPA calls the backend directly over HTTPS at `VITE_API_URL` (no `/api`
proxy at the CDN — the backend allows CORS).
```mermaid
graph LR
U[Browser] -->|HTTPS dev.seahaven.com| CF[CloudFront]
CF -->|origin group OAC| S3[S3 seahaven-shoc-frontend-dev]
CF -->|OAC bucket root| 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] -->|OIDC upload releases/*| S3
TF[HCP Terraform shoc-frontend-new-dev] -->|pointer origin_path invalidation| CF
GH[GitHub Actions] -->|OIDC s3 sync dist/| S3
TF[HCP Terraform] -->|bucket CloudFront IAM SSM| CF
```
Dev hosting and content CD are owned by HCP Terraform (SH-300). Staging still
uses CloudFormation outputs and `scripts/deploy-web.sh` (SH-287).
Dev and staging hosting live in `terraform/live/dev` and
`terraform/live/staging`. GitHub `.github/workflows/deploy-web.yaml` syncs
content.
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
@ -47,13 +49,13 @@ architecture plan for the keep/discard migration matrix).
HCP workspace **`shoc-frontend-new-dev`** — account `396287094661`, region
`us-east-1`. Defined in [`terraform/live/dev`](terraform/live/dev).
| Resource | Name | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
| Resource | Name | Purpose |
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| S3 bucket | `seahaven-shoc-frontend-dev` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
| CloudFront distribution | (stack output `DistributionId`) | HTTPS static hosting on `dev.seahaven.com`, ACM `*.seahaven.com` |
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
| IAM role | `githubdeploy-shoc-frontend-new-dev` | GitHub Actions OIDC deploy role, trust scoped to Environment `dev` plus `deploy-web.yaml` |
| Route 53 records | A/AAAA apex alias in zone `dev.seahaven.com` (`Z07671212N75U4YLPWZR8`) | Points the custom domain at CloudFront |
No Lambdas, queues, or databases — this stack is static hosting only.
@ -61,8 +63,8 @@ No Lambdas, queues, or databases — this stack is static hosting only.
### Secrets
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
No Secrets Manager. Deploy looks up `/shoc-frontend-new/<env>/deploy/{bucket,distribution-id}`
after assuming `DEPLOY_ROLE_ARN`. AWS access is OIDC only. The one **GitHub Actions
repo secret** is:
| Secret | Purpose |
@ -121,62 +123,48 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
## Contributing
- Branch from `dev` with a kebab-case description and a prefix matching the
work: `feature/`, `bug/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
- Branch from `main` with a kebab-case description and a prefix matching the
work: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, or `refactor/`
(e.g. `feature/vendor-portal-filters`, `chore/sea-haven-branding`).
- Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) — commitlint
rejects anything else at commit time.
- Open PRs against `dev`. Both `dev` and `main` are protected: every PR needs
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.
- 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
Terraform content CD once `TERRAFORM_CONTENT_CD_ENABLED=true`)
`→ main` (production promotion — no prod environment exists yet).
- Open PRs against `main`. The PR body uses the three-section layout the
template pre-fills: Summary, Changes and value, Ticket. `main` needs the
`ci-complete` check and an approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale
approvals. PRs merge through the merge queue, so a branch does not need to
be updated with `main` before it merges. Merged branches are deleted
automatically.
- A change set cannot mix `terraform/` with deployable application files (G13),
including each queued PR on the merge-group check. Workflow, docs, and
gate-script changes may travel with either side. `deploy-web.yaml` still
ignores `terraform/**` so a Terraform-only merge does not sync the bucket.
- Promotion flow: merge to `main` deploys `dev.seahaven.com`. A person cuts
`vX.Y.Z-staging` for `staging.seahaven.com`. Core `vX.Y.Z` waits until a
prod distribution exists.
## Deployment
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`/`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),
the Terraform gates, and the content-CD guards) is guaranteed from this
repository. Conventions and gates are documented under
and PRs to `main`, runs format, lint, build, sharded unit tests, visual
regression, and `npm run governance` as parallel jobs, then `ci-complete`.
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).
- **Terraform isolation**
([`.github/workflows/terraform-isolation.yaml`](.github/workflows/terraform-isolation.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`, and push-to-`dev` when
`vars.TERRAFORM_CONTENT_CD_ENABLED` is `true` (`paths-ignore: terraform/**`).
GitHub uploads `releases/<sha>-<run>-<attempt>/` only. Terraform updates
`.release/current`, both origin paths, and the invalidation action. Verify
and rollback share `scripts/verify-cloudfront-release.sh`. Every run prints
a live-state summary.
- **Staging content**
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
— on push to `staging`, unchanged.
- **Infrastructure** — administrator-run HCP Terraform workspace
`shoc-frontend-new-dev` ([`terraform/README.md`](terraform/README.md)).
Staging hosting stays on the existing CloudFormation stack until SH-287.
Do not run `scripts/deploy-web.sh` against dev. That script remains the staging
content publisher only.
- **SPA content** ([`.github/workflows/deploy-web.yaml`](.github/workflows/deploy-web.yaml))
— push to `main` deploys `dev`; a published `vX.Y.Z-staging` release deploys
`staging`. Syncs `dist/` to the bucket root and invalidates `/*`.
- **Infrastructure** — HCP workspaces `shoc-frontend-new-dev` and
`shoc-frontend-new-staging` ([`terraform/README.md`](terraform/README.md)).
## Operations
- **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
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy Web**
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
@ -185,20 +173,18 @@ content publisher only.
- _Stale content after deploy_ — CloudFront is still `InProgress` or an edge
still serves the previous `index.html` hash. Read the live-state summary
before assuming the site is down.
- _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.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to Environment
`dev` or `staging` plus `deploy-web.yaml`. A job without `environment:`
cannot assume the role.
- _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`.
- **Push-to-`dev` is gated.** Merging to `dev` publishes only when
`TERRAFORM_CONTENT_CD_ENABLED=true`. Merging a `terraform/**` change queues
an HCP Terraform run that a human confirms or discards before the next
content release (see the operational rules in `terraform/README.md`).
- _Non-empty origin path_ — `deploy-web.yaml` refuses to sync until Terraform
has moved every origin to the bucket root.
## Documentation
- Dev Terraform runbook: [`terraform/README.md`](terraform/README.md)
- Terraform 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

@ -83,3 +83,26 @@ A review is complete when it records, briefly:
Do not write a monolithic review body or a validation transcript into the PR
surface; keep comments inline and high-signal.
Infra and application **PRs** stay separate. GitHub Actions owns SPA content
(`deploy-web.yaml`). HCP Terraform owns the bucket and CloudFront. A change set
that includes both `terraform/` and deployable application files (`src/`,
`public/`, `pages/`, `config/`, `index.html`, Vite/tsconfig, or `.env*`)
fails G13. Workflow, docs, and gate-script changes may travel with either
side.
## 8. PR description contract (minimal)
- **Summary** — what changed and why, in plain language.
- **Changes and value** — grouped by area, each with the value it delivers.
- **Ticket** — the board key(s) when one applies; "None" otherwise.
- Link any ADR relied upon.
Avoid boilerplate: no deployment notes, no validation transcripts, no
"residual-risk" theatre, no AI signatures. The validation story lives in the
check run results and the close-out, not in the PR body.
`.github/PULL_REQUEST_TEMPLATE.md` pre-fills this layout and overrides the org
template, whose Summary / Validation / Tests / Notes headings this repository
does not use. The org `callable-pr-policy` workflow hard-codes those four
headings; it is not wired into this repository, and this layout is the reason.

View file

@ -0,0 +1,265 @@
import { expect, test, type Page, type Request, type Route } from "@playwright/test";
// Technicians registered inline from work-order entry points
// become real vendor records only on save, through the additive vendor-company roster
// PATCH (never a whole-vendor POST that restates or blanks company contact fields).
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
const TEST_USER = {
token: "wo-inline-technician-e2e-token",
expiration: "2030-01-01T00:00:00.000Z",
email: "admin@seahavenind.com",
userRoles: "Admin",
phoneNumber: "5551234567",
fullname: "Work Order E2E Admin",
id: "1",
};
const VENDORS = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
},
];
const ROSTER = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
const BOARD_ROW = {
id: 1,
woNumber: "WO-501",
workOrderType: 2,
siteCode: "STL-01",
locationName: "St. Louis HQ",
locationId: 10,
lifecycleStatusLabel: "Scheduled",
scheduledDate: "2026-08-19",
dispatcherId: "1",
dispatcherName: "Pat Dispatcher",
pm: "HVAC",
vendorId: 41,
vendorName: "Vinewood LLC",
techName: "Adam Whyte",
title: "Quarterly HVAC PM",
rowVersion: "rv-1",
};
type Captured = { method: string; pathname: string; body: unknown };
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
function bodyOf(request: Request): unknown {
try {
return request.postDataJSON();
} catch {
return undefined;
}
}
function rosterAfterAdd(body: { addTechnicians?: Array<Record<string, unknown>> }) {
const added = (body.addTechnicians ?? []).map((technician, index) => ({
...technician,
id: 42 + index,
}));
return { ...ROSTER, rowVersion: "AAAAAAAAB9F=", technicians: [...ROSTER.technicians, ...added] };
}
async function mockApi(page: Page, writes: Captured[]) {
await page.clock.install({ time: new Date(FROZEN_NOW) });
await page.clock.resume();
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
await page.route("**/api/**", async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (!pathname.startsWith("/api/")) return route.fallback();
const method = request.method();
if (method !== "GET") writes.push({ method, pathname, body: bodyOf(request) });
if (pathname.includes("/workorders/board/search")) {
return fulfillJson(route, { items: [], totalCount: 0, page: 0, pageSize: 50 });
}
if (pathname === "/api/workorders/board" && method === "GET") {
return fulfillJson(route, {
weekStart: "2026-08-17",
weekEnd: "2026-08-21",
counts: { returned: 1, total: 1 },
unscheduled: [],
scheduled: [BOARD_ROW],
});
}
if (pathname === "/api/workorders/board" && method === "POST") {
return fulfillJson(route, { data: { id: 900, woNumber: "SH-900" } });
}
if (pathname === "/api/workorders/1/board" && method === "PATCH") {
return fulfillJson(route, { ...BOARD_ROW, vendorId: 42, techName: "Jordan Lee" });
}
if (pathname === "/api/vendor-company-roster" && method === "GET") {
return fulfillJson(route, ROSTER);
}
if (pathname === "/api/vendor-company-roster/7" && method === "PATCH") {
return fulfillJson(route, rosterAfterAdd(bodyOf(request) as never));
}
if (pathname.toLowerCase().includes("/vendors/dropdown")) {
return fulfillJson(route, { data: VENDORS });
}
if (pathname.includes("/locations/sites")) {
return fulfillJson(route, {
data: [{ id: 10, name: "STL-01 · St. Louis, MO", siteCode: "STL-01", code: "STL-01" }],
});
}
if (pathname === "/api/locations/10") {
return fulfillJson(route, {
data: {
id: 10,
name: "St. Louis HQ",
siteCode: "STL-01",
contact: "Pat Site",
phone: "3145550111",
},
});
}
return fulfillJson(route, { data: [] });
});
}
function vendorWrites(writes: Captured[]) {
return writes.filter(
(write) =>
write.pathname.startsWith("/api/vendors") ||
write.pathname.startsWith("/api/vendor-company-roster"),
);
}
async function openWizardVendorStep(page: Page) {
await page.goto("/workorders");
await page.getByRole("button", { name: "New WO" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select company/i }).click();
await page.getByRole("button", { name: "Vinewood LLC" }).last().click();
await page.getByRole("button", { name: "+ Add technician" }).click();
await page.getByLabel("New Technician 1 name").fill("Jordan Lee");
await page.getByLabel("New Technician 1 phone").fill("3145550199");
}
test.describe("Inline technician registration", () => {
test("wizard persists the technician under the company on create and assigns it", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
// Staging a technician is local only.
expect(vendorWrites(writes)).toEqual([]);
await page.getByRole("button", { name: "Create work order" }).click();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/board"))
.toBeTruthy();
expect(vendorWrites(writes)).toEqual([
{
method: "PATCH",
pathname: "/api/vendor-company-roster/7",
body: {
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
},
},
]);
const create = writes.find((write) => write.pathname === "/api/workorders/board");
expect(create?.body).toEqual(expect.objectContaining({ vendorId: 42 }));
});
test("cancelling the wizard after staging a technician writes no vendor (SH-366)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await openWizardVendorStep(page);
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Back" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeHidden();
// Reopening starts clean: the staged technician was discarded, not persisted.
await page.getByRole("button", { name: "New WO" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /select site/i }).click();
await page
.getByRole("button", { name: /STL-01/ })
.last()
.click();
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByRole("group", { name: "New Technician 1" })).toHaveCount(0);
expect(writes).toEqual([]);
});
test("vendor assignment modal saves an inline technician for an existing company (SH-365)", async ({
page,
}) => {
const writes: Captured[] = [];
await mockApi(page, writes);
await page.goto("/workorders");
await page
.locator("#wo-row-1")
.getByRole("button", { name: /Adam Whyte/ })
.click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /\+ add technician/i }).click();
await dialog.getByPlaceholder("Full name").fill("Jordan Lee");
await dialog.getByPlaceholder("(000) 000-0000").fill("3145550199");
await dialog.getByRole("button", { name: /^add$/i }).click();
await dialog.getByRole("button", { name: /^save$/i }).click();
await expect(dialog).toBeHidden();
await expect
.poll(() => writes.find((write) => write.pathname === "/api/workorders/1/board"))
.toBeTruthy();
expect(writes.some((write) => write.pathname.startsWith("/api/vendors"))).toBe(false);
const rosterPatch = writes.find((write) => write.pathname === "/api/vendor-company-roster/7");
expect(rosterPatch?.body).not.toHaveProperty("companyFields");
const boardPatch = writes.find((write) => write.pathname === "/api/workorders/1/board");
expect(JSON.stringify(boardPatch?.body)).toContain("42");
});
});

View file

@ -137,6 +137,12 @@ test.describe("wizard vendor company notes (SH-321)", () => {
await expect(page.getByRole("button", { name: "Continue" })).toBeEnabled();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Select company/ }).click();
await page
.getByRole("button", { name: /Gateway Plumbing/ })
.first()
.click();
await page.getByRole("button", { name: /Select technician/ }).click();
await page
.getByRole("button", { name: /Adam Whyte/ })

View file

@ -13,12 +13,11 @@
"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-release-plan": "python3 scripts/test-terraform-release-plan-check.py",
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
"test:terraform": "node scripts/terraform-validate.mjs",
"test:hcp-run-guard": "python3 scripts/test-hcp-run-guard.py",
"test:cloudfront-release-verify": "bash scripts/test-verify-cloudfront-release.sh",
"test:github-workflows": "bash scripts/check-github-workflows.sh",
"test:app-terraform-isolation": "python3 scripts/test_check_app_terraform_isolation.py && node --test scripts/test-g13-live-isolation.mjs",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",

View file

@ -10,8 +10,11 @@ from pathlib import Path
from typing import Any
from terraform_import_plan_resources import (
ALLOWED_CREATE_ADDRESSES,
CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG,
GITHUB_OIDC_PROVIDER_ARN,
GITHUB_REPO,
REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES,
)
@ -22,7 +25,12 @@ 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}
ROLE_POLICY_ADDRESS = "module.environment_owned.aws_iam_role_policy.github_deploy"
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
BUCKET_POLICY_ADDRESS,
ROLE_ADDRESS,
ROLE_POLICY_ADDRESS,
}
OWNERSHIP_TAGS = {
"Environment": None,
"ManagedBy": "terraform",
@ -330,6 +338,113 @@ def _validate_policy_update(
return violations
def _expected_github_deploy_assume_policy(environment: str) -> dict[str, Any]:
return _canonical(
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GithubDeployOidc",
"Effect": "Allow",
"Action": "sts:AssumeRoleWithWebIdentity",
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": (
"sts.amazonaws.com"
),
"token.actions.githubusercontent.com:sub": (
f"repo:{GITHUB_REPO}:environment:{environment}"
),
},
"StringLike": {
"token.actions.githubusercontent.com:job_workflow_ref": [
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/heads/main"
),
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/tags/v*"
),
],
},
},
}
],
}
)
def _validate_role_assume_policy(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
) -> list[str]:
before_policy, violations = _parse_policy(
before.get("assume_role_policy"), address, "before"
)
after_policy, after_violations = _parse_policy(
after.get("assume_role_policy"), address, "after"
)
violations.extend(after_violations)
if before_policy == after_policy:
violations.append(f"{address}: assume_role_policy semantics did not change")
expected_after = _expected_github_deploy_assume_policy(environment)
if after_policy is not None and after_policy != expected_after:
violations.append(
f"{address}: post-adoption assume_role_policy semantics are not exact"
)
return violations
def _validate_role_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
environment: str,
) -> list[str]:
changed = _changed_leaf_paths(before, after)
allowed_roots = {"tags", "tags_all", "assume_role_policy", "description"}
invalid = {path for path in changed if not path or path[0] not in allowed_roots}
violations = [
f"{address}: controlled role update changes forbidden path {'.'.join(path)}"
for path in sorted(invalid)
]
if not changed:
violations.append(f"{address}: update has no changed leaf values")
expected = {
**OWNERSHIP_TAGS,
"Environment": environment,
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"],
}
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"
)
if any(path and path[0] == "assume_role_policy" for path in changed):
violations.extend(
_validate_role_assume_policy(address, before, after, environment)
)
return violations
def _validate_iam_policy_update(
address: str,
before: dict[str, Any],
after: dict[str, Any],
) -> list[str]:
changed = _changed_leaf_paths(before, after)
if changed != {("policy",)}:
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
if before.get("policy") == after.get("policy"):
return [f"{address}: policy semantics did not change"]
return []
def _validate_controlled_update(
address: str,
change: dict[str, Any],
@ -348,6 +463,10 @@ def _validate_controlled_update(
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 == ROLE_ADDRESS:
violations.extend(_validate_role_update(address, before, after, environment))
elif address == ROLE_POLICY_ADDRESS:
violations.extend(_validate_iam_policy_update(address, before, after))
elif address == BUCKET_POLICY_ADDRESS:
violations.extend(
_validate_policy_update(
@ -416,19 +535,30 @@ def check_plan(
if change.get("replace_paths") not in (None, []):
violations.append(f"{address}: replace_paths must be empty")
import_id = REQUIRED_IMPORT_IDS[environment].get(address)
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,
if address in ALLOWED_CREATE_ADDRESSES and import_id is None:
if actions != ["create"]:
violations.append(
f"{address}: import mode requires create for deploy parameters, got {actions!r}"
)
if "importing" in change:
violations.append(
f"{address}: import metadata is forbidden for created deploy parameters"
)
else:
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(
@ -456,6 +586,8 @@ def check_plan(
distribution_id,
)
)
elif actions == ["create"] and address in ALLOWED_CREATE_ADDRESSES:
pass
elif actions != ["no-op"]:
violations.append(f"{address}: unsafe controlled actions {actions!r}")

View file

@ -1,137 +0,0 @@
// 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. The checker has no memory of
// a previous pass: the same mixed diff fails again as soon as the override
// env is unset (label removal).
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/");
}
// Markdown under terraform/ does not queue an HCP VCS run (workspace triggers
// are terraform/live/dev/** and terraform/live/modules/**), so it is not a
// Terraform change for the mixed-PR check.
export function isTerraformInfrastructurePath(file) {
return isTerraformPath(file) && !file.endsWith(".md");
}
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;
if (
/^scripts\/(hcp-run-guard|test-hcp-run-guard|verify-cloudfront-release|test-verify-cloudfront-release|summarize-cloudfront-live-state|check-github-workflows|read-release-pointer)\.[a-z]+$/.test(
file,
)
) {
return true;
}
if (file.startsWith("scripts/testdata/terraform-")) 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(isTerraformInfrastructurePath);
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

@ -1,179 +0,0 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
OVERRIDE_LABEL,
classifyChangedFiles,
isTerraformInfrastructurePath,
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",
"scripts/check-terraform-release-plan.py",
"scripts/hcp-run-guard.py",
"scripts/test-hcp-run-guard.py",
"scripts/verify-cloudfront-release.sh",
"scripts/test-verify-cloudfront-release.sh",
"scripts/summarize-cloudfront-live-state.sh",
"scripts/check-github-workflows.sh",
"scripts/read-release-pointer.py",
"scripts/testdata/terraform-release-plans/version-only.json",
]) {
assert.equal(mayAccompanyTerraform(file), true, file);
}
});
test("application, workflow, 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",
"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 documentation does not mix with application or workflow changes", () => {
assert.equal(isTerraformInfrastructurePath("terraform/README.md"), false);
assert.equal(isTerraformInfrastructurePath("terraform/live/dev/main.tf"), true);
assert.equal(
classifyChangedFiles(["terraform/README.md", ".github/workflows/ci.yaml"]).mixed,
false,
);
const docsOnly = runGate(["terraform/README.md", ".github/workflows/ci.yaml"]);
assert.equal(docsOnly.status, 0, docsOnly.stdout + docsOnly.stderr);
assert.match(docsOnly.stdout, /PASS/);
});
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("removing the override fails a mixed change that was previously green", () => {
const files = ["terraform/live/dev/main.tf", ".github/workflows/deploy.yml"];
const previouslyGreen = runGate(files, {
TERRAFORM_ISOLATION_OVERRIDE: "true",
});
assert.equal(previouslyGreen.status, 0, previouslyGreen.stdout + previouslyGreen.stderr);
assert.match(previouslyGreen.stdout, /WARNING/);
// CI sets TERRAFORM_ISOLATION_OVERRIDE from contains(...labels), which is
// the string "false" after the label is removed. A stale green check must
// not survive that.
const afterLabelRemoved = runGate(files, {
TERRAFORM_ISOLATION_OVERRIDE: "false",
});
assert.equal(afterLabelRemoved.status, 1, afterLabelRemoved.stdout + afterLabelRemoved.stderr);
assert.match(afterLabelRemoved.stdout, /FAIL/);
assert.match(afterLabelRemoved.stdout, /deploy\.yml/);
});
test("CLI refuses to run without a base ref or --stdin", () => {
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
assert.notEqual(result.status, 0);
});
test("isolation workflow re-evaluates on labeled and unlabeled without rerunning Frontend checks", () => {
const workflows = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
".github/workflows",
);
const ciYaml = readFileSync(path.join(workflows, "ci.yaml"), "utf8");
const isolationYaml = readFileSync(path.join(workflows, "terraform-isolation.yaml"), "utf8");
for (const eventType of ["opened", "synchronize", "reopened", "labeled", "unlabeled"]) {
assert.match(isolationYaml, new RegExp(`^ {6}- ${eventType}$`, "m"), eventType);
}
assert.doesNotMatch(ciYaml, /^ {6}- labeled$/m);
assert.doesNotMatch(ciYaml, /^ {6}- unlabeled$/m);
assert.doesNotMatch(ciYaml, /^ {2}terraform-isolation:\n/m);
assert.doesNotMatch(ciYaml, /github\.event\.action != 'labeled'/);
assert.match(isolationYaml, /^ {2}terraform-isolation:\n/m);
assert.match(isolationYaml, /name: Terraform and application changes are isolated/);
assert.doesNotMatch(isolationYaml, /github\.event\.action != 'labeled'/);
});

View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Fail when a change set mixes Terraform with deployable application files.
Workflow, docs, and gate-script changes may travel with either side.
"""
from __future__ import annotations
import argparse
import sys
APP_ROOTS = (
"src/",
"public/",
"pages/",
"config/",
)
APP_FILES = {
"index.html",
"vite.config.ts",
"vitest.config.ts",
}
def is_terraform_path(path: str) -> bool:
return path == "terraform" or path.startswith("terraform/")
def is_app_path(path: str) -> bool:
normalized = path.replace("\\", "/")
if normalized in APP_FILES:
return True
if normalized in {"src", "public", "pages", "config"}:
return True
if normalized.startswith(APP_ROOTS):
return True
if normalized.startswith("tsconfig"):
return True
return normalized.startswith(".env")
def isolation_violation(paths: list[str]) -> tuple[list[str], list[str]] | None:
terraform_files = sorted({path for path in paths if is_terraform_path(path)})
app_files = sorted({path for path in paths if is_app_path(path)})
if terraform_files and app_files:
return terraform_files, app_files
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"paths",
nargs="*",
help="Changed paths. Omit and pass newline-separated paths on stdin.",
)
args = parser.parse_args()
paths = list(args.paths)
if not paths and not sys.stdin.isatty():
paths = [line.strip() for line in sys.stdin if line.strip()]
violation = isolation_violation(paths)
if violation is None:
print("PASS: application and Terraform changes are isolated")
return 0
terraform_files, app_files = violation
print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr)
print("terraform:", file=sys.stderr)
for path in terraform_files:
print(f" {path}", file=sys.stderr)
print("application:", file=sys.stderr)
for path in app_files:
print(f" {path}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,79 +0,0 @@
#!/usr/bin/env bash
#
# Content publish step for the environment deploy workflows
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
#
# 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. 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}"
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
BUCKET="${SITE_BUCKET:-}"
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
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}..."
# Everything except index.html: long-lived + immutable, prune stale objects.
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)..."
aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
echo "Invalidating CloudFront ${DIST_ID}..."
INVALIDATION_ID="$(aws cloudfront create-invalidation \
--distribution-id "${DIST_ID}" \
--paths "/*" \
--query 'Invalidation.Id' \
--output text)"
if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then
echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..."
aws cloudfront wait invalidation-completed \
--distribution-id "${DIST_ID}" \
--id "${INVALIDATION_ID}"
fi
echo "Web deploy complete."

View file

@ -0,0 +1,29 @@
/**
* Live G13 scheduling and how a GitHub event is split into change sets.
*
* Isolation is a per-change rule. pull_request and local runs classify the
* merge-base...HEAD range (one PR). merge_group classifies each first-parent
* commit vs its parent (one queued PR per squash or merge-commit). The group
* union is not a change set: a Terraform-only PR stacked with an app-only PR
* must still pass. push is not classified; landing already happened.
*/
export function shouldRunLiveIsolation(eventName) {
return !eventName || eventName === "pull_request" || eventName === "merge_group";
}
export function usesPerCommitIsolation(eventName) {
return eventName === "merge_group";
}
/**
* File lists to run through check_app_terraform_isolation.py.
* `commitDiffs` is first-parent order (oldest first); ignored except on
* merge_group.
*/
export function liveIsolationFileSets(eventName, rangeFiles, commitDiffs) {
if (usesPerCommitIsolation(eventName)) {
return commitDiffs.map((commit) => commit.files);
}
return [rangeFiles];
}

View file

@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { shouldRunLiveIsolation, usesPerCommitIsolation } from "./g13-live-isolation.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(SCRIPT_DIR, "..");
const BASELINE_PATH = path.join(SCRIPT_DIR, "governance-baseline.json");
@ -21,12 +23,11 @@ const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
// script so it can also be run on its own.
const REPOSITORY_GATES = [
["Terraform import-plan contract", "test:terraform-import-plan"],
["Terraform release-plan contract", "test:terraform-release-plan"],
["Terraform isolation gate", "test:terraform-isolation"],
["Terraform formatting and validation", "test:terraform"],
["HCP run guard", "test:hcp-run-guard"],
["CloudFront release verify", "test:cloudfront-release-verify"],
["GitHub workflow shell", "test:github-workflows"],
["App/Terraform isolation tests", "test:app-terraform-isolation"],
];
function isGoverned(relativePath) {
@ -142,7 +143,7 @@ function godfileRatchet(baseRef) {
function resolveBaseRef() {
if (process.env.GOVERNANCE_BASE) return process.env.GOVERNANCE_BASE;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
for (const candidate of ["origin/dev", "origin/main"]) {
for (const candidate of ["origin/main", "origin/dev"]) {
try {
execFileSync("git", ["rev-parse", "--verify", candidate], {
cwd: ROOT,
@ -219,6 +220,79 @@ function runRepositoryGate(label, script) {
return { label, status: result.status, error: result.error };
}
function classifyIsolationPaths(files) {
return spawnSync("python3", ["scripts/check_app_terraform_isolation.py"], {
cwd: ROOT,
encoding: "utf8",
input: files.length > 0 ? `${files.join("\n")}\n` : "",
});
}
function firstParentCommitDiffs(baseRef) {
const shas = gitLines(["rev-list", "--reverse", "--first-parent", `${baseRef}..HEAD`]);
return shas.map((sha) => ({
sha,
files: gitLines(["diff", "--name-only", "--diff-filter=ACMR", `${sha}^`, sha]),
}));
}
function runIsolationGate(baseRef, eventName) {
if (usesPerCommitIsolation(eventName)) {
const commits = firstParentCommitDiffs(baseRef);
return {
mode: "per-commit",
results: commits.map((commit) => ({
...classifyIsolationPaths(commit.files),
sha: commit.sha,
})),
};
}
// Diff from the merge base, not the moving base tip. A two-dot diff against
// a branch that has advanced reports everything the base gained after the
// branch point as if this change reverted it.
const mergeBase = gitText(["merge-base", baseRef, "HEAD"]);
const files = gitLines(["diff", "--name-only", "--diff-filter=ACMR", mergeBase, "HEAD"]);
return {
mode: "range",
mergeBase,
results: [{ ...classifyIsolationPaths(files), sha: null }],
};
}
function recordIsolationFailures(isolation, failures) {
const startError = isolation.results.find((result) => result.error);
if (startError) {
failures.push(`G13: could not start: ${startError.error.message}`);
}
if (isolation.results.some((result) => !result.error && result.status !== 0)) {
failures.push("G13: do not mix deployable application files with terraform/");
}
}
function logIsolationGate(baseRef, isolation) {
if (isolation.mode === "per-commit") {
console.log(
`G13: application and Terraform isolation (merge_group, ${plural(isolation.results.length, "queued PR")} vs ${baseRef.slice(0, 7)})`,
);
for (const result of isolation.results) {
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
const prefix = result.sha ? result.sha.slice(0, 7) : "commit";
if (output) {
console.log(` ${prefix}: ${output.replaceAll("\n", "\n ")}`);
}
}
return;
}
const mergeBase = isolation.mergeBase ?? "unresolvable";
console.log(
`G13: application and Terraform isolation (${baseRef}...HEAD, merge base ${mergeBase.slice(0, 7)})`,
);
const result = isolation.results[0];
if (!result) return;
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
}
function main() {
const failures = [];
const baseRef = resolveBaseRef();
@ -317,6 +391,34 @@ function main() {
}
}
console.log("─".repeat(64));
const eventName = process.env.GITHUB_EVENT_NAME;
if (!shouldRunLiveIsolation(eventName)) {
console.log(
`G13: skipped — live isolation runs on pull_request, merge_group, and local (event: ${eventName})`,
);
} else if (!baseRef) {
console.log("G13: application and Terraform isolation (no base...HEAD)");
console.log(" FAIL (no valid base ref)");
failures.push(
"G13: base ref is required but was not found. Set GOVERNANCE_BASE to a valid commit or fetch origin/dev.",
);
} else {
let isolation;
try {
isolation = runIsolationGate(baseRef, eventName);
} catch (error) {
console.log(`G13: application and Terraform isolation (${baseRef}...HEAD)`);
console.log(" FAIL (could not resolve isolation diffs)");
const message = error instanceof Error ? error.message : String(error);
failures.push(`G13: could not resolve isolation diffs against HEAD: ${message}`);
}
if (isolation) {
logIsolationGate(baseRef, isolation);
recordIsolationFailures(isolation, failures);
}
}
console.log("─".repeat(64));
if (failures.length > 0) {
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);

View file

@ -1,29 +0,0 @@
#!/usr/bin/env python3
"""Read .release/current JSON from stdin and write GitHub Actions outputs."""
from __future__ import annotations
import json
import os
import sys
def main() -> int:
raw = sys.stdin.read().strip()
data = json.loads(raw) if raw else {}
current = data.get("current") or ""
previous = data.get("previous") or ""
output_path = os.environ["GITHUB_OUTPUT"]
with open(output_path, "a", encoding="utf-8") as handle:
handle.write(f"live_current={current}\n")
handle.write(f"live_previous={previous}\n")
print(
"Pointer live current="
+ (current or "<empty>")
+ " previous="
+ (previous or "<empty>")
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,14 +1,14 @@
#!/usr/bin/env bash
# Print pointer body, origin paths, distribution status, and served index hash.
# Used by deploy.yml's always() summary. Never fails the job on a missing pointer.
# Print origin paths, distribution status, and served index hash.
# Used by deploy-web.yaml's always() summary. Never fails the job.
set -u
DISTRIBUTION_ID="${DISTRIBUTION_ID:-E2CWLM1AFB964P}"
SITE_BUCKET="${SITE_BUCKET:-seahaven-shoc-frontend-dev}"
SITE_URL="${SITE_URL:-https://dev.seahaven.com}"
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
SITE_URL="${SITE_URL:-}"
echo "=== CloudFront live state ==="
echo "pointer:"
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || echo "(missing)"
echo
if [[ -z "${DISTRIBUTION_ID}" ]]; then
echo "DISTRIBUTION_ID unset"
exit 0
fi
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json | python3 -c '
import json, sys
payload = json.load(sys.stdin)
@ -19,5 +19,7 @@ for origin in ((config.get("Origins") or {}).get("Items") or []):
print("origin %s: origin_path=%r" % (origin.get("Id"), origin.get("OriginPath") or ""))
'
echo
echo -n "served index sha256: "
curl -fsS --max-time 30 "${SITE_URL}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"
if [[ -n "${SITE_URL}" ]]; then
echo -n "served index sha256: "
curl -fsS --max-time 30 "${SITE_URL%/}/" | python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" || echo "unreachable"
fi

View file

@ -1,18 +1,18 @@
#!/usr/bin/env node
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));
const LIVE_ROOTS = ["terraform/live/dev", "terraform/live/staging"];
function run(args, cwd = ROOT) {
function run(args, cwd = ROOT, env = process.env) {
const result = spawnSync(TERRAFORM, args, {
cwd,
encoding: "utf8",
stdio: "inherit",
env,
});
if (result.error) {
throw new Error(`could not start Terraform: ${result.error.message}`, {
@ -24,12 +24,13 @@ function run(args, cwd = ROOT) {
}
}
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);
run(["fmt", "-check", "-recursive", "terraform"]);
for (const liveRoot of LIVE_ROOTS) {
const abs = path.join(ROOT, liveRoot);
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], abs);
run(["validate", "-no-color"], abs);
}
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);
console.log(
"Terraform formatting and validation passed for terraform/live/dev and terraform/live/staging.",
);

View file

@ -1,8 +1,7 @@
"""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.
Dev is already in HCP state. Staging constants authorize the first import of
the live CDK stack onto terraform/live/staging.
"""
COMMON_RESOURCES = {
@ -31,6 +30,10 @@ COMMON_RESOURCES = {
"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",
"module.environment_owned.aws_ssm_parameter.deploy_bucket": "aws_ssm_parameter",
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": (
"aws_ssm_parameter"
),
}
REQUIRED_RESOURCES = {
@ -45,9 +48,22 @@ CONTROLLED_UPDATE_ADDRESSES = frozenset(
"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",
}
)
ALLOWED_CREATE_ADDRESSES = frozenset(
{
"module.environment_owned.aws_ssm_parameter.deploy_bucket",
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id",
}
)
GITHUB_REPO = "Sea-Haven-Industries/shoc-frontend-new"
GITHUB_OIDC_PROVIDER_ARN = (
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
)
ENVIRONMENT_CONFIG = {
"dev": {
"bucket_name": "seahaven-shoc-frontend-dev",
@ -103,6 +119,8 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"githubdeploy-shoc-frontend-new-dev:"
"GithubDeployRoleDefaultPolicyE8F540D1"
),
"module.environment_owned.aws_ssm_parameter.deploy_bucket": None,
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": None,
},
"staging": {
**_bucket_imports("seahaven-shoc-frontend-staging"),
@ -126,5 +144,7 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"githubdeploy-shoc-frontend-new-staging:"
"GithubDeployRoleDefaultPolicyE8F540D1"
),
"module.environment_owned.aws_ssm_parameter.deploy_bucket": None,
"module.environment_owned.aws_ssm_parameter.deploy_distribution_id": None,
},
}

View file

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import {
liveIsolationFileSets,
shouldRunLiveIsolation,
usesPerCommitIsolation,
} from "./g13-live-isolation.mjs";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CLASSIFIER = path.join(SCRIPT_DIR, "check_app_terraform_isolation.py");
const TERRAFORM_ONLY = ["terraform/live/dev/main.tf"];
const APP_ONLY = ["src/app/routes.tsx"];
function classify(files) {
const result = spawnSync("python3", [CLASSIFIER, ...files], { encoding: "utf8" });
return result.status;
}
function anySetFails(fileSets) {
return fileSets.some((files) => classify(files) !== 0);
}
describe("shouldRunLiveIsolation", () => {
it("runs locally and on pull_request", () => {
assert.equal(shouldRunLiveIsolation(undefined), true);
assert.equal(shouldRunLiveIsolation(""), true);
assert.equal(shouldRunLiveIsolation("pull_request"), true);
});
it("runs on merge_group so the required check classifies the candidate", () => {
assert.equal(shouldRunLiveIsolation("merge_group"), true);
assert.equal(usesPerCommitIsolation("merge_group"), true);
});
it("skips push and other CI events", () => {
assert.equal(shouldRunLiveIsolation("push"), false);
assert.equal(shouldRunLiveIsolation("workflow_dispatch"), false);
assert.equal(usesPerCommitIsolation("pull_request"), false);
});
});
describe("liveIsolationFileSets", () => {
it("classifies the PR range as one change set", () => {
const range = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("pull_request", range, [
{ files: TERRAFORM_ONLY },
{ files: APP_ONLY },
]);
assert.deepEqual(sets, [range]);
assert.equal(anySetFails(sets), true);
});
it("classifies each queued PR, not the merge-group union", () => {
const union = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("merge_group", union, [
{ files: TERRAFORM_ONLY },
{ files: APP_ONLY },
]);
assert.deepEqual(sets, [TERRAFORM_ONLY, APP_ONLY]);
assert.equal(anySetFails(sets), false);
assert.equal(classify(union), 1);
});
it("fails a single queued PR that mixes terraform and app files", () => {
const mixed = [...TERRAFORM_ONLY, ...APP_ONLY];
const sets = liveIsolationFileSets("merge_group", mixed, [{ files: mixed }]);
assert.equal(anySetFails(sets), true);
});
});

View file

@ -14,8 +14,11 @@ from pathlib import Path
from typing import Any
from terraform_import_plan_resources import (
ALLOWED_CREATE_ADDRESSES,
CONTROLLED_UPDATE_ADDRESSES,
ENVIRONMENT_CONFIG,
GITHUB_OIDC_PROVIDER_ARN,
GITHUB_REPO,
REQUIRED_IMPORT_IDS,
REQUIRED_RESOURCES,
)
@ -27,7 +30,7 @@ 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}
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
def import_id(environment: str, address: str) -> str:
@ -83,6 +86,40 @@ def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
}
def github_deploy_assume_policy(environment: str) -> dict[str, Any]:
return {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GithubDeployOidc",
"Effect": "Allow",
"Action": "sts:AssumeRoleWithWebIdentity",
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": (
f"repo:{GITHUB_REPO}:environment:{environment}"
),
},
"StringLike": {
"token.actions.githubusercontent.com:job_workflow_ref": [
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/heads/main"
),
(
f"{GITHUB_REPO}/.github/workflows/"
"deploy-web.yaml@refs/tags/v*"
),
],
},
},
}
],
}
def bucket_policy(environment: str) -> dict[str, Any]:
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
bucket_arn = f"arn:aws:s3:::{bucket}"
@ -146,6 +183,19 @@ def tag_change(environment: str, address: str) -> dict[str, Any]:
def policy_change(environment: str, address: str) -> dict[str, Any]:
if address == DEPLOY_POLICY:
return {
"actions": ["update"],
"before": {"policy": json.dumps({"Version": "2012-10-17", "Statement": []})},
"after": {
"policy": json.dumps(
{
"Version": "2012-10-17",
"Statement": [{"Sid": "ListWebBucket", "Effect": "Allow"}],
}
)
},
}
if address != BUCKET_POLICY:
raise AssertionError(f"{address} is not a reviewed policy update")
return {
@ -165,10 +215,14 @@ def make_plan(
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)},
}
import_id_value = REQUIRED_IMPORT_IDS[environment][address]
if import_id_value is None:
change = {"actions": ["create"]}
else:
change = {
"actions": ["no-op"],
"importing": {"id": import_id_value},
}
elif mode == "post-import":
change = {"actions": ["no-op"]}
elif address in updates:
@ -177,6 +231,8 @@ def make_plan(
if address in TAG_ADDRESSES
else policy_change(environment, address)
)
elif address in ALLOWED_CREATE_ADDRESSES:
change = {"actions": ["create"]}
else:
change = {"actions": ["no-op"]}
if address == DISTRIBUTION:
@ -259,8 +315,7 @@ class ImportPlanCheckerTests(unittest.TestCase):
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
source = (
REPOSITORY
/ "terraform/live/modules/environment-owned/main.tf"
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
expected = """ spa_rewrite_code = join("\\n", [
"function handler(event) {",
@ -275,55 +330,41 @@ class ImportPlanCheckerTests(unittest.TestCase):
])"""
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_live_roots_are_not_flattened(self) -> None:
live = REPOSITORY / "terraform" / "live"
self.assertTrue((live / "dev" / "versions.tf").is_file())
self.assertTrue((live / "dev" / "main.tf").is_file())
self.assertTrue((live / "staging" / "versions.tf").is_file())
self.assertTrue((live / "staging" / "main.tf").is_file())
self.assertTrue((live / "modules" / "environment-owned" / "main.tf").is_file())
self.assertFalse((REPOSITORY / "terraform" / "versions.tf").exists())
self.assertFalse((REPOSITORY / "terraform" / "main.tf").exists())
def test_dev_root_pins_adoption_complete_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+= true\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_adoption_complete_is_pinned_in_locals(self) -> None:
dev = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
staging = (REPOSITORY / "terraform/live/staging/main.tf").read_text(
encoding="utf-8"
)
self.assertRegex(dev, r"adoption_complete\s+= true")
self.assertRegex(staging, r"adoption_complete\s+= true")
self.assertNotIn('variable "adoption_complete"', dev)
self.assertNotIn('variable "environment"', dev)
self.assertNotIn('variable "release_version_label"', dev)
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
expected = {
"dev": (
"local.hosted_zone_id",
"local.certificate_arn",
"local.github_oidc_arn",
"local.cache_policy_id",
),
"hosted_zone_id": "local.hosted_zone_id",
"certificate_arn": "local.certificate_arn",
"github_oidc_provider_arn": "local.github_oidc_arn",
"cache_policy_id": "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",
)
for name, value in expected.items():
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:
@ -409,7 +450,7 @@ class ImportPlanCheckerTests(unittest.TestCase):
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"] = "{}"
resource(plan, ROLE)["change"]["after"]["max_session_duration"] = 7200
self.assert_fails(plan, "dev", ROLE)
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
@ -420,12 +461,34 @@ class ImportPlanCheckerTests(unittest.TestCase):
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_role_trust_change_is_allowed(self) -> None:
for environment in REQUIRED_RESOURCES:
plan = make_plan(
environment, mode="controlled", controlled_updates={ROLE}
)
role = resource(plan, ROLE)["change"]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = json.dumps(
github_deploy_assume_policy(environment)
)
with self.subTest(environment=environment):
self.assert_passes(plan, environment, ROLE)
def test_role_trust_rejects_mutated_document(self) -> None:
for mutation in ("principal", "missing-sub"):
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
role = resource(plan, ROLE)["change"]
policy = github_deploy_assume_policy("dev")
if mutation == "principal":
policy["Statement"][0]["Principal"] = {"AWS": "*"}
else:
del policy["Statement"][0]["Condition"]["StringEquals"][
"token.actions.githubusercontent.com:sub"
]
role["before"]["assume_role_policy"] = '{"Statement":[]}'
role["after"]["assume_role_policy"] = json.dumps(policy)
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", ROLE)
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
for mutation in ("principal", "extra"):
@ -452,40 +515,70 @@ class ImportPlanCheckerTests(unittest.TestCase):
with self.subTest(mutation=mutation):
self.assert_fails(plan, "dev", BUCKET_POLICY)
def test_github_deploy_policy_is_release_prefix_only(self) -> None:
def test_github_deploy_policy_is_bucket_root_sync(self) -> None:
source = (
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
document = source.split('data "aws_iam_policy_document" "github_deploy" {', 1)[1]
document = document.split("resource ", 1)[0]
self.assertNotIn("var.adoption_complete", document)
self.assertIn("ListReleasePrefixes", document)
self.assertIn("PublishReleasePrefix", document)
self.assertIn("ReadReleasePointer", document)
self.assertIn("ReadDistribution", document)
self.assertIn("ListWebBucket", document)
self.assertIn("SyncWebBucket", document)
self.assertIn("InvalidateDistribution", document)
self.assertIn("DeployParams", document)
self.assertIn("s3:DeleteObject", document)
self.assertIn("cloudfront:CreateInvalidation", document)
self.assertIn("cloudfront:GetInvalidation", document)
self.assertIn("cloudfront:GetDistribution", document)
self.assertIn("cloudfront:GetDistributionConfig", document)
self.assertIn("releases/*", document)
self.assertIn("ssm:GetParameter", document)
self.assertNotIn("ListReleasePrefixes", document)
self.assertNotIn("PublishReleasePrefix", document)
self.assertNotIn("ReadReleasePointer", document)
self.assertNotIn("releases/*", document)
self.assertNotIn("AssumeCdkBootstrapRoles", document)
self.assertNotIn("DescribeStack", document)
self.assertNotIn("CreateInvalidation", document)
self.assertNotIn("ReadDeploymentBucket", document)
self.assertNotIn("PublishAndRollbackSiteObjects", document)
self.assertNotIn(
self.assertIn(
"module.environment_owned.aws_iam_role_policy.github_deploy",
CONTROLLED_UPDATE_ADDRESSES,
)
def test_deploy_policy_is_not_eligible_for_controlled_update(self) -> None:
plan = make_plan("dev", mode="controlled", controlled_updates=set())
self.assert_fails(plan, "dev", DEPLOY_POLICY)
def test_github_deploy_assume_document_matches_module(self) -> None:
source = (
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
).read_text(encoding="utf-8")
document = source.split(
'data "aws_iam_policy_document" "github_deploy_assume" {', 1
)[1]
document = document.split(
'data "aws_iam_policy_document" "github_deploy" {', 1
)[0]
self.assertIn("GithubDeployOidc", document)
self.assertIn("sts:AssumeRoleWithWebIdentity", document)
self.assertIn("token.actions.githubusercontent.com:aud", document)
self.assertIn("sts.amazonaws.com", document)
self.assertIn("token.actions.githubusercontent.com:sub", document)
self.assertIn("local.github_subject", document)
self.assertIn("token.actions.githubusercontent.com:job_workflow_ref", document)
self.assertIn("deploy-web.yaml@refs/heads/main", document)
self.assertIn("deploy-web.yaml@refs/tags/v*", document)
def test_deploy_policy_controlled_update_passes(self) -> None:
self.assert_passes(
make_plan(
"dev",
mode="controlled",
controlled_updates={DEPLOY_POLICY},
),
"dev",
DEPLOY_POLICY,
)
plan = make_plan("dev", mode="controlled", controlled_updates=set())
resource(plan, DEPLOY_POLICY)["change"] = {
"actions": ["update"],
"before": {"policy": "{}"},
"after": {"policy": '{"Version":"2012-10-17"}'},
}
self.assert_fails(plan, "dev", DEPLOY_POLICY)
self.assert_fails(plan, "dev")
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
for environment in REQUIRED_RESOURCES:

View file

@ -4,12 +4,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERIFY="${ROOT}/scripts/verify-cloudfront-release.sh"
CURRENT="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1"
PREVIOUS="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1"
NEW_HASH="1111111111111111111111111111111111111111111111111111111111111111"
OLD_HASH="0000000000000000000000000000000000000000000000000000000000000000"
# Vite writes index.html with a trailing newline. Keep it in the fixture so
# the expected hash covers every served byte, exactly like dist/index.html.
INDEX_HTML=$'<!doctype html><html><head><script type="module" src="/assets/app.js"></script></head><body></body></html>\n'
INDEX_HASH="$(printf '%s' "${INDEX_HTML}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
@ -31,12 +26,7 @@ make_stubs() {
cat > "${bin}/aws" << 'AWS'
#!/usr/bin/env bash
set -euo pipefail
state_dir="${STUB_STATE}"
if [[ "${1:-}" == "s3" ]]; then
cat "${state_dir}/pointer.json"
exit 0
fi
cat "${state_dir}/distribution.json"
cat "${STUB_STATE}/distribution.json"
AWS
cat > "${bin}/curl" << 'CURL'
#!/usr/bin/env bash
@ -77,7 +67,6 @@ if [[ "${url}" == *"/assets/"* ]]; then
[[ -z "${output}" ]] && printf '%s' "${body}"
exit 0
fi
# Serve index.html byte-for-byte, trailing newline included, like real curl.
[[ -n "${dump}" ]] && printf 'HTTP/1.1 200 OK\nCache-Control: no-cache,no-store,must-revalidate\n\n' > "${dump}"
if [[ -n "${output}" ]]; then
cat "${state_dir}/index.html" > "${output}"
@ -98,149 +87,94 @@ print(json.dumps({
"Status": status,
"DistributionConfig": {
"Origins": {"Items": [
{"Id": "current", "OriginPath": path},
{"Id": "previous", "OriginPath": ""},
{"Id": "current", "OriginPath": path}
]}
}
}
}))' "${status}" "${current_path}"
}
pointer_json() {
python3 -c 'import json,sys; print(json.dumps({"current": sys.argv[1], "previous": sys.argv[2]}))' "$1" "$2"
}
run_case() {
local name="$1"
local dir
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}"
export PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com"
export SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=3
export INTERVAL=0
local log="${dir}/log.txt"
set +e
bash "${VERIFY}" > "${log}" 2>&1
local code=$?
set -e
assert_exit "${name}" "$2" "${code}" "${log}"
rm -rf "${dir}"
}
# 1. Right config, then propagates (InProgress -> Deployed, hash already matches).
# 1. Empty origin, then propagates (InProgress -> Deployed, hash already matches).
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
printf 'InProgress\n' > "${dir}/status"
cat > "${dir}/bin/aws" << AWS
#!/usr/bin/env bash
set -euo pipefail
if [[ "\${1:-}" == "s3" ]]; then
cat "${dir}/pointer.json"
exit 0
fi
status="\$(cat "${dir}/status")"
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":"/releases/${CURRENT}"},{"Id":"previous","OriginPath":""}]}}}}))' "\${status}"
python3 -c 'import json,sys; print(json.dumps({"Distribution":{"Status":sys.argv[1],"DistributionConfig":{"Origins":{"Items":[{"Id":"current","OriginPath":""}]}}}}))' "\${status}"
echo Deployed > "${dir}/status"
AWS
chmod +x "${dir}/bin/aws"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=5 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "right-config-then-propagates" 0 "${code}" "${dir}/log.txt"
assert_exit "empty-origin-then-propagates" 0 "${code}" "${dir}/log.txt"
rm -rf "${dir}"
}
# 2. Right config never propagates (Deployed, stale hash).
# 2. Empty origin never propagates (Deployed, stale hash).
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
dist_json "Deployed" "" > "${dir}/distribution.json"
printf 'stale' > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="$(printf 'stale' | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())')"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "right-config-never-propagates" 1 "${code}" "${dir}/log.txt"
assert_exit "empty-origin-never-propagates" 1 "${code}" "${dir}/log.txt"
grep -q "last observed" "${dir}/log.txt" || { echo "FAIL: timeout missing last observed state" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 3. Wrong origin path fails fast.
# 3. Non-empty origin path fails fast.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${PREVIOUS}" > "${dir}/distribution.json"
dist_json "Deployed" "/releases/deadbeef" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "wrong-origin-path" 1 "${code}" "${dir}/log.txt"
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: wrong origin path did not name origin_path" >&2; failures=$((failures + 1)); }
assert_exit "nonempty-origin-path" 1 "${code}" "${dir}/log.txt"
grep -q "origin_path" "${dir}/log.txt" || { echo "FAIL: nonempty origin path did not name origin_path" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 4. Wrong pointer fails fast.
# 4. Never Deployed.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${PREVIOUS}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
dist_json "InProgress" "" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "wrong-pointer" 1 "${code}" "${dir}/log.txt"
grep -q "pointer current" "${dir}/log.txt" || { echo "FAIL: wrong pointer did not name pointer current" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 5. Never Deployed.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "InProgress" "/releases/${CURRENT}" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${NEW_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${NEW_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
@ -251,40 +185,40 @@ AWS
rm -rf "${dir}"
}
# 6. Hash-matched Deployed release whose JS assets omit the baked API URL.
# 5. Hash-matched Deployed release whose JS assets omit the baked API URL.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
dist_json "Deployed" "" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
printf 'const x=1;' > "${dir}/asset.js"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1
code=$?
set -e
assert_exit "missing-baked-api-url" 1 "${code}" "${dir}/log.txt"
grep -q "baked dev API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked dev API URL" >&2; failures=$((failures + 1)); }
grep -q "baked API URL" "${dir}/log.txt" || { echo "FAIL: missing API URL did not name baked API URL" >&2; failures=$((failures + 1)); }
rm -rf "${dir}"
}
# 7. Hash-matched Deployed release whose JS assets contain the staging API URL.
# 6. Hash-matched Deployed release whose JS assets contain the staging API URL.
{
dir="$(mktemp -d)"
make_stubs "${dir}/bin"
pointer_json "${CURRENT}" "${PREVIOUS}" > "${dir}/pointer.json"
dist_json "Deployed" "/releases/${CURRENT}" > "${dir}/distribution.json"
dist_json "Deployed" "" > "${dir}/distribution.json"
printf '%s' "${INDEX_HTML}" > "${dir}/index.html"
printf 'const api="https://api.staging.seahaven.com/api";' > "${dir}/asset.js"
export STUB_STATE="${dir}" PATH="${dir}/bin:${PATH}"
export DISTRIBUTION_ID="E2CWLM1AFB964P" EXPECTED_LABEL="${CURRENT}"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}" PREVIOUS_INDEX_SHA256="${OLD_HASH}"
export SITE_URL="https://dev.seahaven.com" SITE_BUCKET="seahaven-shoc-frontend-dev"
export DISTRIBUTION_ID="E2CWLM1AFB964P"
export EXPECTED_INDEX_SHA256="${INDEX_HASH}"
export SITE_URL="https://dev.seahaven.com"
export API_URL="https://api.dev.seahaven.com/api"
export BUDGET=2 INTERVAL=0
set +e
bash "${VERIFY}" > "${dir}/log.txt" 2>&1

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Tests for check_app_terraform_isolation.isolation_violation."""
from __future__ import annotations
import unittest
from check_app_terraform_isolation import isolation_violation
class IsolationTests(unittest.TestCase):
def test_terraform_only(self) -> None:
self.assertIsNone(
isolation_violation(
[
"terraform/live/dev/main.tf",
"terraform/live/README.md",
]
)
)
def test_app_only(self) -> None:
self.assertIsNone(
isolation_violation(
[
"src/app/routes.tsx",
"public/favicon.ico",
"index.html",
]
)
)
def test_docs_workflows_and_gate_scripts_with_terraform(self) -> None:
self.assertIsNone(
isolation_violation(
[
"terraform/live/modules/environment-owned/main.tf",
".github/workflows/deploy-web.yaml",
"QUALITY_GATES.md",
"scripts/governance-check.mjs",
"scripts/check_app_terraform_isolation.py",
"package.json",
]
)
)
def test_mixed_src_and_terraform_fails(self) -> None:
violation = isolation_violation(
[
"terraform/live/dev/main.tf",
"src/app/routes.tsx",
]
)
self.assertIsNotNone(violation)
terraform_files, app_files = violation or ([], [])
self.assertEqual(terraform_files, ["terraform/live/dev/main.tf"])
self.assertEqual(app_files, ["src/app/routes.tsx"])
def test_mixed_vite_config_and_terraform_fails(self) -> None:
violation = isolation_violation(["terraform/live/dev/versions.tf", "vite.config.ts"])
self.assertIsNotNone(violation)
def test_mixed_env_and_terraform_fails(self) -> None:
violation = isolation_violation(["terraform/live/dev/main.tf", ".env.production"])
self.assertIsNotNone(violation)
if __name__ == "__main__":
unittest.main()

View file

@ -14,6 +14,11 @@ fi
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
RELEASE="shoc-frontend@${COMMIT_SHA}"
if ! npm exec --no -- sentry-cli --version >/dev/null 2>&1; then
echo "sentry-cli is not in this SPA tree; skipping source-map upload"
exit 0
fi
npm exec --no -- sentry-cli sourcemaps upload \
--org "${SENTRY_ORG}" \
--project "${SENTRY_PROJECT}" \

View file

@ -1,87 +1,62 @@
#!/usr/bin/env bash
# Verify a CloudFront content release or rollback.
# Verify a CloudFront SPA deploy from deploy-web.yaml.
#
# Fail fast when origin_path or .release/current is the wrong label.
# Poll while the distribution is InProgress or the served index.html hash
# still matches the previous release. On timeout, print last observed state.
# Fail fast when any origin_path is still non-empty. Poll while the
# distribution is InProgress or the served index.html hash does not match
# the build. Then smoke-check caching headers, hashed assets, the baked
# API URL, and CORS against the target API.
set -euo pipefail
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
EXPECTED_LABEL="${EXPECTED_LABEL:-}"
EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}"
SITE_URL="${SITE_URL:-}"
SITE_BUCKET="${SITE_BUCKET:-}"
PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}"
API_URL="${API_URL:-https://api.dev.seahaven.com/api}"
BUDGET="${BUDGET:-40}"
INTERVAL="${INTERVAL:-15}"
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then
echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" ]]; then
echo "Usage: DISTRIBUTION_ID EXPECTED_INDEX_SHA256 SITE_URL must be set." >&2
exit 2
fi
SITE_URL="${SITE_URL%/}"
if [[ -n "${EXPECTED_LABEL}" ]]; then
EXPECTED_PATH="/releases/${EXPECTED_LABEL}"
else
EXPECTED_PATH=""
fi
API_URL="${API_URL%/}"
sha256_of() {
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
}
read_pointer() {
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true
}
read_distribution_json() {
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json
}
parse_distribution() {
python3 -c '
import json, os, sys
import json, sys
payload = json.load(sys.stdin)
dist = payload.get("Distribution") or payload
status = dist.get("Status") or "Unknown"
config = dist.get("DistributionConfig") or {}
origins = ((config.get("Origins") or {}).get("Items")) or []
paths = [origin.get("OriginPath") or "" for origin in origins]
expected = os.environ["EXPECTED_PATH"]
nonempty = [path for path in paths if path]
print(status)
print("\x1f".join(paths))
print("yes" if expected in paths else "no")
'
}
pointer_current() {
POINTER_BODY="$1" python3 -c '
import json, os
raw = os.environ.get("POINTER_BODY", "").strip()
if not raw:
print("")
raise SystemExit
print(json.loads(raw).get("current") or "")
print("yes" if nonempty else "no")
'
}
last_status="Unknown"
last_paths="Unknown"
last_pointer="Unknown"
last_hash="Unknown"
last_path_ok="no"
last_path_nonempty="no"
observe() {
last_pointer="$(read_pointer)"
local parsed
parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)"
parsed="$(read_distribution_json | parse_distribution)"
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')"
# Hash the response stream directly. Capturing the body in "$(...)" strips
# trailing newlines, so the hash never matched dist/index.html.
last_path_nonempty="$(printf '%s\n' "${parsed}" | sed -n '3p')"
local hash
if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then
last_hash="${hash}"
@ -91,32 +66,25 @@ observe() {
}
report_state() {
echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}"
echo "last observed: status=${last_status} origins=${last_paths} served_sha256=${last_hash}"
}
fail_fast_if_misconfigured() {
local current
current="$(pointer_current "${last_pointer}")"
if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then
echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2
report_state >&2
exit 1
fi
if [[ "${last_path_ok}" != "yes" ]]; then
echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2
fail_fast_if_origin_path() {
if [[ "${last_path_nonempty}" == "yes" ]]; then
echo "FAIL: live origin_path values are '${last_paths}'; expected empty bucket-root origins." >&2
report_state >&2
exit 1
fi
}
observe
fail_fast_if_misconfigured
fail_fast_if_origin_path
attempt=0
while [[ "${attempt}" -lt "${BUDGET}" ]]; do
attempt=$((attempt + 1))
echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}"
fail_fast_if_misconfigured
fail_fast_if_origin_path
if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then
break
fi
@ -142,6 +110,10 @@ for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
' "$1"
}
api_host() {
python3 -c 'import os,urllib.parse; print(urllib.parse.urlparse(os.environ["API_URL"]).hostname or "")'
}
assert_baked_api_url() {
local tmp="$1"
if [[ ! -s "${tmp}/asset-paths.txt" ]]; then
@ -168,15 +140,27 @@ assert_baked_api_url() {
exit 1
fi
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
local forbidden
for forbidden in api.staging.seahaven.com localhost:5141; do
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
local host
host="$(api_host)"
local forbidden=""
case "${host}" in
api.dev.seahaven.com) forbidden="api.staging.seahaven.com" ;;
api.staging.seahaven.com) forbidden="api.dev.seahaven.com" ;;
*)
echo "FAIL: API_URL host '${host}' is not a known SHOC API." >&2
exit 1
fi
done
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then
echo "FAIL: served JS assets are missing the baked dev API URL." >&2
;;
esac
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
exit 1
fi
if grep -Fq "localhost:5141" "${tmp}/served.txt"; then
echo "FAIL: served assets contain forbidden URL localhost:5141." >&2
exit 1
fi
if ! grep -Fq "${host}" "${tmp}/served.txt"; then
echo "FAIL: served JS assets are missing the baked API URL ${host}." >&2
exit 1
fi
}
@ -206,5 +190,5 @@ if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then
exit 1
fi
echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean."
echo "PASS: CloudFront release is Deployed, hash-matched, and smoke-clean."
report_state

View file

@ -30,7 +30,9 @@ export const API_PATHS = {
boardCreate: "workorders/board",
boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`,
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@ -167,6 +169,8 @@ export const API_PATHS = {
},
services: {
list: "services",
byId: (id: string | number) => `services/${id}`,
deactivate: (id: string | number) => `services/${id}/deactivate`,
},
user: {
list: "User",

View file

@ -0,0 +1,379 @@
import {
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Drawer,
FormControl,
FormControlLabel,
FormHelperText,
InputLabel,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react";
import {
SERVICE_WORK_ORDER_TYPES,
type CompletionDocTemplateOption,
type Service,
type ServiceWorkOrderType,
} from "@/domain/services/types/service";
import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller";
const ICONS = [
{ key: "wrench", label: "Wrench", Icon: Wrench },
{ key: "droplets", label: "Droplets", Icon: Droplets },
{ key: "zap", label: "Zap", Icon: Zap },
{ key: "hammer", label: "Hammer", Icon: Hammer },
{ key: "settings", label: "Settings", Icon: Settings2 },
] as const;
export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
const Icon = icon.Icon;
return <Icon size={size} aria-hidden="true" />;
}
type DetailsProps = {
service: Service | null;
open: boolean;
canManage: boolean;
canDeactivate: boolean;
onClose: () => void;
onEdit: (service: Service) => void;
onDeactivate: (service: Service) => void;
};
export function ServiceDetailsDrawer({
service,
open,
canManage,
canDeactivate,
onClose,
onEdit,
onDeactivate,
}: DetailsProps) {
return (
<Drawer anchor="right" open={open} onClose={onClose}>
{service !== null && (
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
<Box className="flex items-start justify-between p-5">
<Box>
<Typography variant="h6" className="font-display font-bold">
{service.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Service details
</Typography>
</Box>
<Chip size="small" label={service.isActive ? "Active" : "Inactive"} />
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={3}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} size={24} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Trade
</Typography>
<Typography variant="body1" className="font-semibold">
{service.trade}
</Typography>
</Box>
</Stack>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Work Order Types
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{service.supportedWorkOrderTypes.map((type) => (
<Chip key={type} size="small" label={type} variant="outlined" />
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Completion Document
</Typography>
<Typography variant="body2">
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</Typography>
</Box>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
{canManage && (
<Button variant="outlined" onClick={() => onEdit(service)}>
Edit
</Button>
)}
{canDeactivate && service.isActive && (
<Button color="warning" variant="contained" onClick={() => onDeactivate(service)}>
Deactivate
</Button>
)}
</Stack>
</Box>
)}
</Drawer>
);
}
type EditorProps = {
mode: "add" | "edit" | null;
form: ServiceForm;
formError: string;
trades: string[];
templates: CompletionDocTemplateOption[];
canChangeActiveState: boolean;
isSaving: boolean;
onClose: () => void;
onSave: () => void;
onUpdate: <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => void;
onToggleType: (type: ServiceWorkOrderType) => void;
};
export function ServiceEditorDrawer({
mode,
form,
formError,
trades,
templates,
canChangeActiveState,
isSaving,
onClose,
onSave,
onUpdate,
onToggleType,
}: EditorProps) {
return (
<Drawer anchor="right" open={mode !== null} onClose={onClose}>
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
<Box className="p-5">
<Typography variant="h6" className="font-display font-bold">
{mode === "add" ? "Add Service" : "Edit Service"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
Register a service and whether it needs a completion document.
</Typography>
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={2.5}>
{Boolean(formError) && <AlertMessage message={formError} />}
<ServiceEditorFields
mode={mode}
form={form}
trades={trades}
templates={templates}
canChangeActiveState={canChangeActiveState}
onUpdate={onUpdate}
onToggleType={onToggleType}
/>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : mode === "add" ? "Add Service" : "Save Changes"}
</Button>
</Stack>
</Box>
</Drawer>
);
}
type EditorFieldsProps = Pick<
EditorProps,
"mode" | "form" | "trades" | "templates" | "canChangeActiveState" | "onUpdate" | "onToggleType"
>;
function ServiceEditorFields({
mode,
form,
trades,
templates,
canChangeActiveState,
onUpdate,
onToggleType,
}: EditorFieldsProps) {
return (
<>
<Typography variant="subtitle2" className="font-semibold">
Service Information
</Typography>
<TextField
label="Service Name"
value={form.name}
required
fullWidth
onChange={(event) => onUpdate("name", event.target.value)}
placeholder="e.g., HVAC Inspection"
/>
<FormControl fullWidth required>
<InputLabel id="service-trade-label">Trade</InputLabel>
<Select
labelId="service-trade-label"
label="Trade"
value={form.trade}
onChange={(event) => onUpdate("trade", event.target.value)}
>
{trades.map((trade) => (
<MenuItem key={trade} value={trade}>
{trade}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel id="service-icon-label">Icon</InputLabel>
<Select
labelId="service-icon-label"
label="Icon"
value={form.iconKey}
onChange={(event) => onUpdate("iconKey", event.target.value)}
renderValue={(value) => {
const icon = ICONS.find((item) => item.key === value);
return icon ? (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<icon.Icon size={18} />
<span>{icon.label}</span>
</Stack>
) : (
"Pick an icon"
);
}}
>
{ICONS.map(({ key, label, Icon }) => (
<MenuItem key={key} value={key}>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Icon size={18} />
<span>{label}</span>
</Stack>
</MenuItem>
))}
</Select>
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
</FormControl>
<Typography variant="subtitle2" className="font-semibold">
Supported Work Order Types
</Typography>
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
{SERVICE_WORK_ORDER_TYPES.map((type) => (
<FormControlLabel
key={type}
control={
<Checkbox
checked={form.supportedWorkOrderTypes.includes(type)}
onChange={() => onToggleType(type)}
/>
}
label={type}
/>
))}
</Stack>
{mode === "edit" && canChangeActiveState && (
<FormControlLabel
control={
<Switch
checked={form.isActive ?? true}
onChange={(event) => onUpdate("isActive", event.target.checked)}
/>
}
label="Active"
/>
)}
<Divider />
<Typography variant="subtitle2" className="font-semibold">
Completion Document
</Typography>
<FormControlLabel
control={
<Switch
checked={form.requiresCompletionDocument}
onChange={(event) => {
onUpdate("requiresCompletionDocument", event.target.checked);
if (!event.target.checked) onUpdate("completionDocTemplateId", null);
}}
/>
}
label="Requires completion document"
/>
{form.requiresCompletionDocument && (
<Autocomplete
options={templates}
value={templates.find((template) => template.id === form.completionDocTemplateId) ?? null}
onChange={(_, template) => onUpdate("completionDocTemplateId", template?.id ?? null)}
getOptionLabel={(template) => template.name}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
renderInput={(params) => (
<TextField {...params} label="Linked Template" placeholder="Select a template" />
)}
/>
)}
<Typography variant="caption" color="text.secondary">
Manage the document&apos;s own content on the Completion Document Templates page.
</Typography>
</>
);
}
function AlertMessage({ message }: { message: string }) {
return (
<Box className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{message}
</Box>
);
}
type DeactivateProps = {
service: Service | null;
open: boolean;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function DeactivateServiceDialog({
service,
open,
isPending,
onClose,
onConfirm,
}: DeactivateProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Deactivate this service?</DialogTitle>
<DialogContent>
<DialogContentText>
{service?.name} will no longer be selectable for new work orders. Existing work orders
will keep showing this service.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button color="warning" variant="contained" onClick={onConfirm} disabled={isPending}>
Deactivate
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,279 @@
import {
Alert,
Box,
Button,
CircularProgress,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { Plus, Search } from "lucide-react";
import { SettingsNav } from "@/components/common/settings-nav";
import {
useServicesRegistryController,
type ServiceStatus,
} from "@/domain/services/use-cases/use-services-registry-controller";
import type { Service } from "@/domain/services/types/service";
import {
DeactivateServiceDialog,
ServiceDetailsDrawer,
ServiceEditorDrawer,
ServiceIcon,
} from "@/app/(protected)/settings/_components/services-registry-components";
function statusTabLabel(label: string, count: number) {
return (
<Box className="flex items-center gap-2">
<Box component="span">{label}</Box>
<Box
component="span"
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
>
{count}
</Box>
</Box>
);
}
export function ServicesRegistryView() {
const controller = useServicesRegistryController();
return (
<Box className="p-4 md:p-8">
<SettingsNav />
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
className="mb-5"
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
>
<Box>
<Typography variant="h4" component="h1" className="font-display font-extrabold">
Services
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
{controller.activeCount} active
</Typography>
</Box>
{controller.canManage && (
<Button
variant="contained"
onClick={controller.openCreate}
startIcon={<Plus size={16} />}
>
Add Service
</Button>
)}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
<TextField
size="small"
value={controller.search}
onChange={(event) => controller.setSearch(event.target.value)}
placeholder="Search service or trade..."
className="flex-1"
slotProps={{ htmlInput: { "aria-label": "Search services" } }}
/>
</Stack>
<Tabs
value={controller.status}
onChange={(_event, value: ServiceStatus) => controller.setStatus(value)}
aria-label="Service status"
className="mb-4"
sx={{
minHeight: 36,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
"& .MuiTab-root": {
minHeight: 36,
px: 2,
py: 0,
fontSize: 13,
fontWeight: 500,
textTransform: "none",
},
"& .Mui-selected": { fontWeight: 600 },
"& .MuiTabs-indicator": { height: 2 },
}}
>
<Tab
value="active"
label={statusTabLabel("Active", controller.activeCount)}
aria-label={`Active (${controller.activeCount})`}
/>
<Tab
value="inactive"
label={statusTabLabel("Inactive", controller.inactiveCount)}
aria-label={`Inactive (${controller.inactiveCount})`}
/>
</Tabs>
{Boolean(controller.error) && (
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
)}
{controller.isLoading ? (
<Box className="flex justify-center py-16">
<CircularProgress size={28} />
</Box>
) : controller.filteredServices.length === 0 ? (
<EmptyServices
search={Boolean(controller.search)}
status={controller.status}
onAdd={controller.canManage ? controller.openCreate : undefined}
/>
) : (
<ServicesTable services={controller.filteredServices} onSelect={controller.setSelected} />
)}
{!controller.isLoading && (
<Stack
direction="row"
className="mt-2"
sx={{
justifyContent: "space-between",
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
pt: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{controller.filteredServices.length} {controller.status} service
{controller.filteredServices.length === 1 ? "" : "s"}
{controller.search.trim() ? " · filtered" : ""}
</Typography>
<Typography variant="caption" color="text.secondary">
{controller.activeCount} active · {controller.inactiveCount} inactive
</Typography>
</Stack>
)}
<ServiceDetailsDrawer
service={controller.selected}
open={controller.selected !== null && controller.editorMode === null}
canManage={controller.canManage}
canDeactivate={controller.canDeactivate}
onClose={() => controller.setSelected(null)}
onEdit={controller.openEdit}
onDeactivate={controller.setDeactivateTarget}
/>
<ServiceEditorDrawer
mode={controller.editorMode}
form={controller.form}
formError={controller.formError}
trades={controller.trades}
templates={controller.templates}
canChangeActiveState={controller.canDeactivate}
isSaving={controller.isSaving}
onClose={controller.closeEditor}
onSave={controller.save}
onUpdate={controller.updateForm}
onToggleType={controller.toggleType}
/>
<DeactivateServiceDialog
service={controller.deactivateTarget}
open={controller.deactivateTarget !== null}
isPending={controller.isDeactivating}
onClose={() => controller.setDeactivateTarget(null)}
onConfirm={controller.confirmDeactivate}
/>
</Box>
);
}
function ServicesTable({
services,
onSelect,
}: {
services: Service[];
onSelect: (service: Service) => void;
}) {
return (
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
<Table aria-label="Services">
<TableHead>
<TableRow>
<TableCell>Service</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Completion Document</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{services.map((service) => (
<TableRow
hover
key={String(service.id)}
onClick={() => onSelect(service)}
sx={{ cursor: "pointer" }}
>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} />
</Box>
<Box>
<Typography variant="body2" className="font-semibold">
{service.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{service.supportedWorkOrderTypes.join(" · ")}
</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>{service.trade}</TableCell>
<TableCell>
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</TableCell>
<TableCell>{service.isActive ? "Active" : "Inactive"}</TableCell>
<TableCell align="right">›</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}
function EmptyServices({
search,
status,
onAdd,
}: {
search: boolean;
status: ServiceStatus;
onAdd?: () => void;
}) {
return (
<Box className="flex flex-col items-center rounded-lg border border-dashed border-border bg-background px-4 py-16 text-center">
<Box sx={{ color: "text.secondary", mb: 2 }} data-testid="services-empty-icon">
<Search size={22} aria-hidden="true" />
</Box>
<Typography variant="body1" className="font-semibold">
{search ? "No services found" : `No ${status} services configured yet`}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{search
? "Try a different search, or add a new service."
: `There are no ${status} services matching this view.`}
</Typography>
{onAdd !== undefined && (
<Button variant="outlined" className="mt-4" onClick={onAdd}>
Add Service
</Button>
)}
</Box>
);
}

View file

@ -0,0 +1,5 @@
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
export default function ServicesPage() {
return <ServicesRegistryView />;
}

View file

@ -0,0 +1,27 @@
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
/** Formats an API UTC instant as local "Jan 15, 2026 · 10:00 AM"; "" when absent or invalid. */
export function formatUpliftDateTime(value: string): string {
if (!value) return "";
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
const date = new Date(hasZone ? value : `${value}Z`);
if (Number.isNaN(date.getTime())) return "";
const time = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
return `${date.toLocaleDateString("en-US", DATE_FORMAT)} · ${time}`;
}
/**
* Formats a work-order schedule as "Apr 10, 2026". The schedule is a calendar day,
* so only its date part is read and it never shifts with the viewer's time zone.
*/
export function formatUpliftCalendarDate(value: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (match == null) return "";
const [, year, month, day] = match;
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
return date.toLocaleDateString("en-US", { ...DATE_FORMAT, timeZone: "UTC" });
}

View file

@ -2,12 +2,27 @@ import { toast } from "react-toastify";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
function reportEvidenceError(error: unknown): void {
toast.error(error instanceof Error ? error.message : "Unable to download evidence right now.");
}
export function openUpliftEvidence(row: UpliftQueueItem): void {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch(reportEvidenceError);
}
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
// Open the tab inside the click handler; a tab opened after the fetch resolves
// is treated as an unsolicited popup and blocked.
const tab = window.open("about:blank", "_blank");
if (tab != null) {
tab.opener = null;
}
void upliftsApi
.openEvidence(row.id, tab, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
tab?.close();
reportEvidenceError(error);
});
}

View file

@ -1,4 +1,5 @@
import { useMemo, type ReactNode } from "react";
import CloseIcon from "@mui/icons-material/Close";
import {
Box,
Button,
@ -7,21 +8,30 @@ import {
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
Tooltip,
} from "@mui/material";
import {
formatUpliftCalendarDate,
formatUpliftDateTime,
} from "@/app/(protected)/uplifts/_components/format-uplift-dates";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { Text } from "@/components/ui/text";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUplifts } from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
const UNAVAILABLE_LABEL = "Unavailable";
const BORDERED_BLOCK_SX = {
border: 1,
borderColor: "divider",
borderRadius: 1,
px: 1.75,
py: 1.25,
} as const;
type ModalCallbacks = {
onClose: () => void;
@ -31,24 +41,122 @@ type ModalCallbacks = {
onOpenAttachment: (row: UpliftQueueItem) => void;
};
type ExposureBreakdown = { auto: number | null; admin: number | null; total: number | null };
function DetailField({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Text variant="label" tone="muted" className="block">
{label}
</Text>
{children}
<Text variant="body">{children}</Text>
</Box>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
function SubsectionTitle({ children }: { children: ReactNode }) {
return (
<Box>
<Text variant="heading" className="mb-1 block">
{title}
<Text variant="label" as="h4" className="mb-1.5 mt-4 block">
{children}
</Text>
);
}
function vendorTechnicianLabel(item: UpliftQueueItem): string {
const parts = [item.vendorCompanyName.trim(), item.technicianName.trim()].filter(Boolean);
const unique = parts.filter((part, index) => parts.indexOf(part) === index);
return unique.length > 0 ? unique.join(" · ") : "Not assigned";
}
function WorkOrderSection({ item }: { item: UpliftQueueItem }) {
return (
<Box component="section" aria-label="Work order">
<Text variant="heading" className="mb-2 block">
Work order
</Text>
<Box sx={{ display: "grid", gap: 1.5 }}>{children}</Box>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Service">{item.serviceName || "Not specified"}</DetailField>
<DetailField label="Vendor / Technician">{vendorTechnicianLabel(item)}</DetailField>
<DetailField label="Assigned Dispatcher">
{item.workOrderDispatcherName || "Unassigned"}
</DetailField>
<DetailField label="Scheduled">
{formatUpliftCalendarDate(item.workOrderScheduledDate) || "Unscheduled"}
</DetailField>
</Box>
</Box>
);
}
function JustificationBlock({ notes }: { notes: string }) {
const text = notes.trim();
return (
<Box sx={{ ...BORDERED_BLOCK_SX, minHeight: 60 }} data-testid="uplift-justification">
{text.length > 0 && <Text variant="body">{text}</Text>}
{text.length === 0 && (
<Text variant="description" tone="muted" className="italic">
No justification provided.
</Text>
)}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
const fallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
...(strong ? { borderTop: 1, borderColor: "divider", mt: 1, pt: 1 } : { py: 0.25 }),
}}
>
<Text variant={strong ? "body" : "description"} tone={strong ? "default" : "muted"}>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : fallback}
</Text>
</Box>
);
}
function ApprovedOnWoBreakdown({
breakdown,
unavailable,
}: {
breakdown: ExposureBreakdown;
unavailable: boolean;
}) {
return (
<Box sx={BORDERED_BLOCK_SX}>
<BreakdownRow
label="Auto-approved (within allowance)"
value={breakdown.auto}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow
label="Admin-approved"
value={breakdown.admin}
strong={false}
unavailable={unavailable}
/>
<BreakdownRow label="Total" value={breakdown.total} strong unavailable={unavailable} />
</Box>
);
}
@ -67,142 +175,61 @@ function UpliftAttachments({
</Text>
);
}
const canOpen = item.evidenceDocumentId != null;
const extraCount =
typeof item.attachmentCount === "number" && item.attachmentCount > 1
? item.attachmentCount - 1
: 0;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, flexWrap: "wrap" }}>
<Box
data-testid="uplift-attachments"
sx={{ display: "flex", flexWrap: "nowrap", gap: 1, overflowX: "auto", pb: 0.5 }}
>
<Chip
size="small"
variant="outlined"
label={item.evidenceFileName || "Attachment"}
onClick={item.evidenceDocumentId != null ? () => onOpenAttachment(item) : undefined}
clickable={item.evidenceDocumentId != null}
title={canOpen ? "Opens in a new tab" : undefined}
onClick={canOpen ? () => onOpenAttachment(item) : undefined}
clickable={canOpen}
sx={{ flexShrink: 0, maxWidth: 176 }}
/>
{extraCount > 0 && <Chip size="small" label={`+${extraCount}`} />}
{extraCount > 0 && (
<Chip variant="outlined" label={`+${extraCount}`} sx={{ flexShrink: 0 }} />
)}
</Box>
);
}
function WorkOrderSection({
item,
info,
unavailable,
}: {
item: UpliftQueueItem;
info?: WorkOrderTableRow;
unavailable: boolean;
}) {
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<DetailSection title="Work order">
<DetailField label="Work order #">
<Text variant="body">{item.woNumber || "—"}</Text>
</DetailField>
<DetailField label="Site">
<Text variant="body">{info?.site || item.site || "—"}</Text>
</DetailField>
<DetailField label="Status">
<Text variant="body">{info?.status || statusFallback}</Text>
</DetailField>
<DetailField label="Scheduled">
<Text variant="body">
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
</Text>
</DetailField>
<DetailField label="Vendor">
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
</DetailField>
</DetailSection>
);
}
function RequestSection({
function UpliftRequestSection({
item,
breakdown,
breakdownUnavailable,
onOpenAttachment,
}: {
item: UpliftQueueItem;
breakdown: ExposureBreakdown;
breakdownUnavailable: boolean;
onOpenAttachment: (row: UpliftQueueItem) => void;
}) {
const waitingClass = item.requestedAt
? getWaitTimeTextClass(waitTimeColor(item.requestedAt))
: undefined;
return (
<DetailSection title="Request">
<DetailField label="Amount">
<Text variant="body">
{formatUpliftMoney(item.currentNTE ?? 0)} → {formatUpliftMoney(item.requestedNTE)}
</Text>
</DetailField>
<DetailField label="Requested by">
<Text variant="body">{item.requestedByVendorName || "—"}</Text>
</DetailField>
<DetailField label="Waiting">
<Text variant="body" className={waitingClass}>
{item.requestedAt ? timeSince(item.requestedAt) : "—"}
</Text>
</DetailField>
<DetailField label="Notes">
<Text variant="description">{item.vendorReason || "No justification provided."}</Text>
</DetailField>
<DetailField label="Attachments">
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</DetailField>
</DetailSection>
);
}
function ApprovedOnWoBreakdown({
auto,
admin,
total,
unavailable,
}: {
auto: number | null;
admin: number | null;
total: number | null;
unavailable: boolean;
}) {
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
{ label: "Auto-approved", value: auto, strong: false },
{ label: "Admin-approved", value: admin, strong: false },
{ label: "Total", value: total, strong: true },
];
return (
<Box sx={{ display: "grid", gap: 0.5 }}>
{rows.map((row) => (
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
))}
</Box>
);
}
function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
return (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Text
variant={strong ? "body" : "description"}
tone={strong ? "default" : "muted"}
className={strong ? "font-medium" : undefined}
>
{label}
</Text>
<Text
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : unavailable ? UNAVAILABLE_LABEL : "—"}
<Box component="section" aria-label="Uplift request">
<Text variant="heading" className="mb-2 block">
Uplift request
</Text>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.75 }}>
<DetailField label="Requested By">
{item.requestedByVendorName || "Unknown requester"}
</DetailField>
<DetailField label="Requested At">
{formatUpliftDateTime(item.requestedAt) || "Not recorded"}
</DetailField>
</Box>
<SubsectionTitle>Justification</SubsectionTitle>
<JustificationBlock notes={item.vendorReason} />
<SubsectionTitle>Approved on WO</SubsectionTitle>
<ApprovedOnWoBreakdown breakdown={breakdown} unavailable={breakdownUnavailable} />
<SubsectionTitle>Attachments</SubsectionTitle>
<UpliftAttachments item={item} onOpenAttachment={onOpenAttachment} />
</Box>
);
}
@ -223,6 +250,19 @@ function PendingModalActions({
const tooltip = canDecide ? "" : `Requires Tier ${item.requiredTier} role`;
return (
<>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
@ -236,73 +276,61 @@ function PendingModalActions({
</Button>
</span>
</Tooltip>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="contained"
color="error"
disabled={!canDecide}
onClick={() => onReject(item)}
>
Reject
</Button>
</span>
</Tooltip>
</>
);
}
function UpliftModalActions({
function RevokeModalAction({
item,
closed,
canRevoke,
approvePending,
revokePending,
onApprove,
onReject,
onRevoke,
}: {
item: UpliftQueueItem;
closed: boolean;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
}) {
const closed = item.workOrderClosed === true;
const revokeTooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return (
<>
{item.status === "Pending" && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{item.status === "Approved" && (
<Tooltip title={revokeTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
</Button>
</span>
</Tooltip>
)}
</>
<Tooltip title={revokeTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
</Button>
</span>
</Tooltip>
);
}
export function UpliftDetailModal({
function useExposureBreakdown(item: UpliftQueueItem) {
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
// the breakdown falls back to "Unavailable" instead of toasting.
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
suppressErrorToast: true,
});
const breakdown = useMemo<ExposureBreakdown>(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [item.approvedOnWoAuto, item.approvedOnWoAdmin, item.approvedOnWoTotal, woUpliftsQuery.data]);
return { breakdown, unavailable: woUpliftsQuery.isError };
}
function UpliftDetailContent({
item,
canRevoke,
approvePending,
@ -312,98 +340,102 @@ export function UpliftDetailModal({
onReject,
onRevoke,
onOpenAttachment,
}: {
item: UpliftQueueItem;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const { breakdown, unavailable } = useExposureBreakdown(item);
const isPending = item.status === "Pending";
const isApproved = item.status === "Approved";
const subtitle = [item.site, item.status].filter(Boolean).join(" · ");
return (
<>
<DialogTitle sx={{ pr: 6 }}>
<Text variant="title" as="div">
Uplift — WO #{item.woNumber || item.dispatchNumber || "—"}
</Text>
<Text variant="description" tone="muted" when={subtitle.length > 0}>
{subtitle}
</Text>
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{ position: "absolute", top: 12, right: 12 }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
bgcolor: "action.hover",
borderRadius: 1,
px: 2,
py: 1.5,
mb: 2,
}}
>
<Text variant="label" tone="muted">
Amount
</Text>
<Text variant="heading" as="span" tone="warning">
{formatUpliftMoney(item.requestedNTE)}
</Text>
</Box>
<WorkOrderSection item={item} />
<Divider sx={{ my: 2.5 }} />
<UpliftRequestSection
item={item}
breakdown={breakdown}
breakdownUnavailable={unavailable}
onOpenAttachment={onOpenAttachment}
/>
</DialogContent>
{(isPending || isApproved) && (
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
{isPending && (
<PendingModalActions
item={item}
approvePending={approvePending}
onApprove={onApprove}
onReject={onReject}
/>
)}
{isApproved && (
<RevokeModalAction
item={item}
canRevoke={canRevoke}
revokePending={revokePending}
onRevoke={onRevoke}
/>
)}
</DialogActions>
)}
</>
);
}
export function UpliftDetailModal({
item,
...rest
}: {
item: UpliftQueueItem | null;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const open = item != null;
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
const quietMeta = { suppressErrorToast: true } as const;
const boardDetail = useWorkOrderBoardDetail(
item != null && item.workOrderId != null ? item.workOrderId : undefined,
true,
quietMeta,
);
const woUpliftsQuery = useWorkOrderUplifts(
item != null && item.workOrderId != null ? item.workOrderId : null,
quietMeta,
);
const info = boardDetail.data?.info;
const closed = useMemo(() => {
if (item?.workOrderClosed === true) return true;
return info ? isWorkOrderUpliftsReadOnly(info.status) : false;
}, [item?.workOrderClosed, info]);
const breakdown = useMemo(() => {
const woUplifts = woUpliftsQuery.data ?? [];
const autoFallback = sumUpliftAmounts(woUplifts, "auto_approved");
const adminFallback = sumUpliftAmounts(woUplifts, "approved");
const hasWoData = woUplifts.length > 0;
return {
auto: item?.approvedOnWoAuto ?? (hasWoData ? autoFallback : null),
admin: item?.approvedOnWoAdmin ?? (hasWoData ? adminFallback : null),
total: item?.approvedOnWoTotal ?? (hasWoData ? autoFallback + adminFallback : null),
};
}, [
item?.approvedOnWoAuto,
item?.approvedOnWoAdmin,
item?.approvedOnWoTotal,
woUpliftsQuery.data,
]);
if (!open) {
if (item == null) {
return null;
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
aria-label="Uplift request details"
>
<DialogTitle>
<Text variant="title" as="div">
Uplift request
</Text>
<Text variant="description" tone="muted">
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
</Text>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "grid", gap: 3 }}>
<WorkOrderSection item={item} info={info} unavailable={boardDetail.isError} />
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
<DetailSection title="Approved on work order">
<ApprovedOnWoBreakdown
auto={breakdown.auto}
admin={breakdown.admin}
total={breakdown.total}
unavailable={woUpliftsQuery.isError}
/>
</DetailSection>
</Box>
</DialogContent>
<DialogActions>
<Button size="small" variant="outlined" onClick={onClose}>
Close
</Button>
<UpliftModalActions
item={item}
closed={closed}
canRevoke={canRevoke}
approvePending={approvePending}
revokePending={revokePending}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
/>
</DialogActions>
<Dialog open onClose={rest.onClose} maxWidth="sm" fullWidth aria-label="Uplift request details">
<UpliftDetailContent item={item} {...rest} />
</Dialog>
);
}

View file

@ -6,7 +6,10 @@ import {
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import {
openUpliftEvidence,
openUpliftEvidenceInNewTab,
} from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
@ -181,7 +184,7 @@ export default function UpliftQueuePage() {
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
onOpenAttachment={openUpliftEvidence}
onOpenAttachment={openUpliftEvidenceInNewTab}
/>
)}
<UpliftDecisionDialogs controller={controller} />

View file

@ -97,12 +97,13 @@ export function CompDocDialog({
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
// Uploading a signed PDF finalizes through the same Generate flow, so the
// completedDate/address prerequisites still persist before docStatus Yes.
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
// one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
// Generate patch would carry the pre-upload version and 409 after a successful upload.
const upload = useCompDocPdfUpload({
workOrderId: row.id,
rowVersion,
onUploaded: dialog.handleGenerate,
onUploaded: dialog.markSignedPdfUploaded,
});
// A signed PDF on file means the document exists even if docState has not caught up.

View file

@ -20,7 +20,6 @@ import {
isWorkOrderFullyLocked,
} from "@/domain/work-orders/utils/work-order-lock";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state";
@ -39,22 +38,17 @@ type UseWorkOrderSlideOverArgs = {
openRequestKey?: number;
};
type CompletionUploadMutate = (
input: { file: File; workOrderVersion: string },
options: { onSuccess: () => Promise<void> | void },
) => void;
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
function uploadSlideOverCompletionPdf({
file,
row,
infoSource,
onSave,
mutate,
}: {
file: File;
row: WorkOrderTableRow | null;
infoSource: WorkOrderTableRow | null | undefined;
onSave: UseWorkOrderSlideOverArgs["onSave"];
mutate: CompletionUploadMutate;
}) {
const version = infoSource?.rowVersion;
@ -62,27 +56,11 @@ function uploadSlideOverCompletionPdf({
toast.error("Missing work order version. Refresh and try again.");
return;
}
mutate(
{ file, workOrderVersion: version },
{
onSuccess: async () => {
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
}
},
},
);
// The upload transitions the completion document to Yes and bumps the work-order row
// version in the same server transaction; the mutation's onSuccess refreshes the board.
// A client docStatus patch here would carry the pre-upload version and 409 after a
// successful upload, so it is intentionally omitted.
mutate({ file, workOrderVersion: version });
}
function resolveSlideOverSource(
@ -173,7 +151,6 @@ export function useWorkOrderSlideOver({
file,
row,
infoSource,
onSave,
mutate: uploadCompletionMutation.mutate,
});
};

View file

@ -1,7 +1,7 @@
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 { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import {
buildCompanyOptions,
buildTechnicianOptions,
@ -37,7 +37,7 @@ export function useVendorDialogForm({
vendors,
onSave,
}: UseVendorDialogFormArgs) {
const createVendor = useCreateVendor();
const addTechnicians = useAddTechniciansToCompany();
const [selectedCompany, setSelectedCompany] = useState(company);
const [selectedKey, setSelectedKey] = useState(() =>
resolveTechnicianKey(vendorId, tech, company, vendors),
@ -119,7 +119,7 @@ export function useVendorDialogForm({
anchorCompanyName,
tech: selected?.name ?? t,
phone,
createVendor,
addTechnicians,
onSave,
onOpenChange,
setSaveError,
@ -141,7 +141,7 @@ export function useVendorDialogForm({
companyVendor,
saveError,
canSubmit,
isPending: createVendor.isPending,
isPending: addTechnicians.isPending,
handleSave,
handleCompanyPick: (nextCompany: string) => {
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);

View file

@ -1,9 +1,8 @@
import { isDraftVendorId } from "@/domain/vendors/utils/draft-vendor";
import { vendorToDropdownItem } from "@/domain/vendors/utils/vendor-to-dropdown-item";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
import type { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
import type { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
type UseVendorDialogSaveArgs = {
allVendors: VendorDropdownItem[];
@ -14,13 +13,45 @@ type UseVendorDialogSaveArgs = {
anchorCompanyName: string;
tech: string;
phone: string;
createVendor: ReturnType<typeof useCreateVendor>;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
onSave: (patch: VendorPatch) => void;
onOpenChange: (open: boolean) => void;
setSaveError: (error: string | null) => void;
canSubmit: boolean;
};
/** Persists a staged draft technician under its existing company (additive roster PATCH). */
async function persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
}: {
allVendors: VendorDropdownItem[];
pendingPayload: VendorFormValues;
phone: string;
addTechnicians: ReturnType<typeof useAddTechniciansToCompany>;
}): Promise<VendorDropdownItem> {
const anchor = allVendors.find(
(vendor) => !isDraftVendorId(vendor.id) && vendor.companyName === pendingPayload.companyName,
);
if (!anchor) {
throw new Error("Select an existing vendor company before adding a technician.");
}
const [created] = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: [
{
contactName: pendingPayload.contactName,
phone: phone.trim() || (pendingPayload.phone ?? ""),
tradeSpecialties: pendingPayload.tradeSpecialties,
},
],
});
if (created == null) throw new Error("Failed to create technician");
return created;
}
export function useVendorDialogSave({
allVendors,
pendingCreates,
@ -30,7 +61,7 @@ export function useVendorDialogSave({
anchorCompanyName,
tech,
phone,
createVendor,
addTechnicians,
onSave,
onOpenChange,
setSaveError,
@ -53,11 +84,12 @@ export function useVendorDialogSave({
if (saveVendor != null && isDraftVendorId(saveVendor.id) && pendingPayload) {
try {
const created = await createVendor.mutateAsync({
...pendingPayload,
phone: phone.trim() || pendingPayload.phone,
saveVendor = await persistDraftTechnician({
allVendors,
pendingPayload,
phone,
addTechnicians,
});
saveVendor = vendorToDropdownItem(created);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create technician";
setSaveError(message);

View file

@ -14,6 +14,7 @@ import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use
import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import { useWizardVendorCompanyNotes } from "@/app/(protected)/workorders/_components/wizard/use-wizard-vendor-company-notes";
import { useServicesForWorkOrderType } from "@/domain/services/use-cases/use-services-for-work-order-type";
@ -63,7 +64,7 @@ export function useNewWoWizardController({
}
};
const createMutation = useCreateWorkOrderFromWizard((createdDraft) => {
const createWorkOrder = useCreateWorkOrderFromWizard((createdDraft) => {
// Close the dialog first: the work-order create's isPending has already dropped, so
// holding the dialog open through the notes save would re-enable Create and unlock
// pointer events, and a second click would create a second work order. The save runs
@ -72,6 +73,11 @@ export function useNewWoWizardController({
onOpenChange(false);
void saveCompanyNotes(createdDraft);
});
const createMutation = useWizardCreateWithTechnicians({
vendors,
patchDraft: draftState.patchDraft,
createMutation: createWorkOrder,
});
const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
useWizardDuplicateActions({
open,

View file

@ -0,0 +1,93 @@
import { toast } from "react-toastify";
import { useAddTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
import { isValidNorthAmericanPhone } from "@/domain/vendors/schemas/vendor-roster-schema";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type {
WizardNewTechnician,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
type UseWizardCreateWithTechniciansArgs = {
vendors: VendorDropdownItem[];
patchDraft: (patch: Partial<WorkOrderWizardDraft>) => void;
createMutation: {
mutate: (draft: WorkOrderWizardDraft) => void;
isPending: boolean;
};
};
function pendingTechnicians(draft: WorkOrderWizardDraft): WizardNewTechnician[] {
return (draft.newTechnicians ?? []).filter((technician) => technician.name.trim() !== "");
}
/** Draft changes applied once inline technicians exist as real vendor records. */
export function buildCreatedTechniciansPatch(
draft: WorkOrderWizardDraft,
company: string,
pending: WizardNewTechnician[],
created: VendorDropdownItem[],
): Partial<WorkOrderWizardDraft> {
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
const [only] = created;
// Prototype parity: a single new technician is assigned when none was picked.
if (created.length === 1 && only != null && draft.vendorId === "") {
patch.vendorId = String(only.id);
patch.vendorName = company;
patch.techPhone = pending[0]?.phone.trim() || draft.techPhone;
}
return patch;
}
/**
* Persists technicians registered inline on the Vendor & time step under the
* selected company, then creates the work order. Nothing is written before Create, so
* cancelling the wizard leaves no stray vendor records.
*/
export function useWizardCreateWithTechnicians({
vendors,
patchDraft,
createMutation,
}: UseWizardCreateWithTechniciansArgs) {
const addTechnicians = useAddTechniciansToCompany();
const mutate = (draft: WorkOrderWizardDraft) => {
const pending = pendingTechnicians(draft);
const company = (draft.vendorCompany ?? "").trim();
if (pending.length === 0 || company === "") {
createMutation.mutate(draft);
return;
}
// Mirror the vendor roster schema so an unparseable phone is caught here with a
// clear message instead of failing the roster PATCH and blocking Create with the
// raw backend sentence. A blank phone stays valid, matching the modal path.
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
toast.error("Enter a 10-digit phone number for each new technician.");
return;
}
const anchor = vendors.find((vendor) => vendor.companyName === company);
if (!anchor) {
toast.error("Select an existing vendor company before adding technicians.");
return;
}
void (async () => {
try {
const created = await addTechnicians.mutateAsync({
anchorVendorId: anchor.id,
technicians: pending.map((technician) => ({
contactName: technician.name,
phone: technician.phone,
tradeSpecialties: anchor.tradeSpecialties,
})),
});
const patch = buildCreatedTechniciansPatch(draft, company, pending, created);
// Clear the staged rows first so a failed WO create cannot re-create them on retry.
patchDraft(patch);
createMutation.mutate({ ...draft, ...patch });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to add technicians");
}
})();
};
return { mutate, isPending: createMutation.isPending || addTechnicians.isPending };
}

View file

@ -29,7 +29,16 @@ export function WizardStepLocationServiceSelect({
onPatch,
}: WizardStepLocationServiceSelectProps) {
const clearVendorIfPrimaryChanged = (nextPm: string): Partial<WorkOrderWizardDraft> =>
nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" } : {};
nextPm !== draft.pm
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}
: {};
const selectSvc = (p: string) => {
const selected = services.find((service) => service.value === p);

View file

@ -1,4 +1,5 @@
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
import { WizardVendorCompanyFields } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-company-fields";
import { Text } from "@/components/ui/text";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
@ -8,10 +9,7 @@ import {
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
import {
formatVendorDistance,
formatVendorTechnicianLabel,
} from "@/domain/work-orders/utils/vendor-dropdown-label";
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
import {
getClosabilityGaps,
wizardDraftToClosabilityInput,
@ -30,7 +28,6 @@ import {
ColorDot,
Detail,
WizLabel,
WizLabelRow,
WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
@ -201,57 +198,12 @@ export function WizardStepVendorTime({
return (
<div className="space-y-5">
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder="Select technician…"
icon={
selectedVendor ? (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(selectedVendor.contactName || selectedVendor.companyName)
.slice(0, 2)
.toUpperCase()}
</span>
) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
label: formatVendorTechnicianLabel(v),
}))}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
<WizardVendorCompanyFields
draft={draft}
vendors={vendors}
selectedVendor={selectedVendor}
onPatch={onPatch}
/>
{draft.vendorId !== "" && selectedVendor != null && (
<WizardVendorDetail

View file

@ -0,0 +1,141 @@
import { Building2, Wrench } from "lucide-react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { buildCompanyOptions } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabelRow } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { WizardVendorNewTechnicians } from "@/app/(protected)/workorders/_components/wizard/wizard-vendor-new-technicians";
type WizardVendorCompanyFieldsProps = {
draft: WorkOrderWizardDraft;
vendors: VendorDropdownItem[];
selectedVendor: VendorDropdownItem | undefined;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
const ICON_STYLE = { color: "var(--muted-foreground)" };
function TechnicianAvatar({ vendor }: { vendor: VendorDropdownItem }) {
return (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{(vendor.contactName || vendor.companyName).slice(0, 2).toUpperCase()}
</span>
);
}
/** Company-first vendor selection with inline technician registration (prototype parity). */
export function WizardVendorCompanyFields({
draft,
vendors,
selectedVendor,
onPatch,
}: WizardVendorCompanyFieldsProps) {
const company = draft.vendorCompany || selectedVendor?.companyName || "";
const companyOptions = buildCompanyOptions(vendors, company);
const technicianOptions = vendors
.filter((vendor) => vendor.companyName === company)
.map((vendor) => ({
key: String(vendor.id),
label: vendor.contactName.trim() || vendor.companyName,
}));
return (
<>
<div>
<WizLabelRow
optional
onClear={
company === ""
? undefined
: () =>
onPatch({
vendorCompany: "",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
})
}
>
Company
</WizLabelRow>
<WizardFieldSelect
value={company}
placeholder="Select company…"
icon={<Building2 size={16} className="shrink-0" style={ICON_STYLE} />}
searchPlaceholder="Search company…"
options={companyOptions.map((option) => ({ key: option.key, label: option.label }))}
onPick={(nextCompany) =>
onPatch({
vendorCompany: nextCompany,
...(nextCompany !== company
? {
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
}
: {}),
})
}
/>
</div>
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "", vendorNotes: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder={company === "" ? "Pick a company first" : "Select technician…"}
disabled={company === ""}
icon={
selectedVendor ? (
<TechnicianAvatar vendor={selectedVendor} />
) : (
<Wrench size={16} className="shrink-0" style={ICON_STYLE} />
)
}
searchPlaceholder="Search technician…"
options={technicianOptions}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorCompany: vendor?.companyName ?? company,
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
{company !== "" && (
<WizardVendorNewTechnicians
technicians={draft.newTechnicians ?? []}
onChange={(newTechnicians) => onPatch({ newTechnicians })}
/>
)}
</>
);
}

View file

@ -0,0 +1,96 @@
import { X } from "lucide-react";
import type { WizardNewTechnician } from "@/domain/work-orders/types/work-order-wizard";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardVendorNewTechniciansProps = {
technicians: WizardNewTechnician[];
onChange: (technicians: WizardNewTechnician[]) => void;
};
/**
* Inline technician registration on the wizard's Vendor & time step.
* Rows stay local to the draft; they are persisted under the selected company only
* when the work order is created, so cancelling the wizard never creates a vendor.
*/
export function WizardVendorNewTechnicians({
technicians,
onChange,
}: WizardVendorNewTechniciansProps) {
const update = (index: number, patch: Partial<WizardNewTechnician>) =>
onChange(technicians.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<>
{technicians.map((technician, index) => {
const label = `New Technician ${index + 1}`;
return (
<div
key={index}
role="group"
aria-label={label}
style={{
padding: 10,
borderRadius: "var(--radius-md)",
background: "var(--color-bg-muted)",
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
<span
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: "var(--color-text-muted)" }}
>
{label}
</span>
<button
type="button"
title="Remove technician"
aria-label={`Remove ${label}`}
onClick={() => onChange(technicians.filter((_, i) => i !== index))}
style={{ color: "var(--color-text-muted)" }}
>
<X size={13} aria-hidden />
</button>
</div>
<div className="flex flex-col gap-2">
<div>
<WizSubLabel>Technician name</WizSubLabel>
<input
aria-label={`${label} name`}
value={technician.name}
onChange={(e) => update(index, { name: e.target.value })}
placeholder="Technician name"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<WizSubLabel>Technician phone</WizSubLabel>
<input
type="tel"
aria-label={`${label} phone`}
value={technician.phone}
onChange={(e) => update(index, { phone: e.target.value })}
placeholder="(000) 000-0000"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={() => onChange([...technicians, { name: "", phone: "" }])}
className="text-left text-[12px] font-medium"
style={{ color: "var(--color-primary)" }}
>
+ Add technician
</button>
</>
);
}

View file

@ -162,11 +162,22 @@ export function createCompDocDialogActions({
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
}
// Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes
// and bumps the work-order row version in one transaction. Reflect that in the UI and refresh
// authoritative state, but do not re-run the Generate persist — that patch would carry the
// pre-upload row version and 409, surfacing an error right after a successful upload.
function markSignedPdfUploaded() {
setGeneratedAt(fmtDocNow());
setDocState("generated");
refreshAuthoritativeState();
}
return {
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
markSignedPdfUploaded,
};
}

View file

@ -2,6 +2,7 @@ import { NavLink } from "react-router";
import { Stack } from "@mui/material";
const SETTINGS_LINKS = [
{ path: "/settings/services", label: "Services" },
{ path: "/settings/dropdowns", label: "Dropdown Options" },
{ path: "/settings/task-templates", label: "Task Templates" },
] as const;

View file

@ -1,18 +1,48 @@
import { apiGet } from "@/api/api";
import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
import { mapServices } from "@/domain/services/mappers/service-mapper";
import type { Service } from "@/domain/services/types/service";
import {
mapCompletionDocTemplates,
mapService,
mapServices,
} from "@/domain/services/mappers/service-mapper";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
const SERVICE_WORK_ORDER_TYPE_API = {
PM: 2,
Emergency: 3,
Reactive: 6,
} as const;
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency,
};
function toApiInput(input: ServiceInput) {
return {
...input,
supportedWorkOrderTypes: input.supportedWorkOrderTypes.map(
(type) => SERVICE_WORK_ORDER_TYPE_API[type],
),
};
}
export const servicesApi = {
getAll: async (isActive?: boolean): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: isActive === undefined ? undefined : { isActive },
});
return mapServices(handleApiResponse(data));
},
getActive: async (): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true },
@ -27,4 +57,23 @@ export const servicesApi = {
});
return mapServices(handleApiResponse(data));
},
getCompletionTemplates: async (): Promise<CompletionDocTemplateOption[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates);
return mapCompletionDocTemplates(handleApiResponse(data));
},
create: async (input: ServiceInput): Promise<Service> => {
const data = await apiPost<unknown>(API_PATHS.services.list, toApiInput(input));
return mapService(handleApiResponse(data));
},
update: async (id: string | number, input: ServiceInput): Promise<Service> => {
const data = await apiPut<unknown>(API_PATHS.services.byId(id), toApiInput(input));
return mapService(handleApiResponse(data));
},
deactivate: async (id: string | number): Promise<void> => {
await apiPostNoContent(API_PATHS.services.deactivate(id));
},
};

View file

@ -1,62 +1,77 @@
import type { Service } from "@/domain/services/types/service";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type {
CompletionDocTemplateOption,
LinkedServiceTemplate,
Service,
ServiceWorkOrderType,
} from "@/domain/services/types/service";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readId(item: Record<string, unknown>): string | number {
const value = item.id ?? item.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readString(item: Record<string, unknown>, ...keys: string[]): string {
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = item[key];
const value = record[key];
if (typeof value === "string") return value.trim();
if (typeof value === "number") return String(value);
}
return "";
}
function readBool(item: Record<string, unknown>, ...keys: string[]): boolean {
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = item[key];
const value = record[key];
if (typeof value === "boolean") return value;
}
return false;
}
function mapWorkOrderType(value: unknown): WOType | null {
function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
if (typeof value === "number") {
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance"
? "PM"
: normalized === "emergency"
? "Emergency"
: normalized === "reactive"
? "Reactive"
: null;
}
return null;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance"
? "PM"
: normalized === "emergency"
? "Emergency"
: normalized === "reactive"
? "Reactive"
: null;
}
function mapTemplate(raw: unknown): LinkedServiceTemplate | null {
if (!raw) return null;
const record = asRecord(raw);
const id = readId(record);
return id === "" ? null : { id, name: readString(record, "name", "Name") };
}
export function mapService(raw: unknown): Service {
const item = asRecord(raw);
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
const record = asRecord(raw);
const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
return {
id: readId(item),
name: readString(item, "name", "Name"),
trade: readString(item, "trade", "Trade"),
iconKey: readString(item, "iconKey", "IconKey"),
id: readId(record),
name: readString(record, "name", "Name"),
trade: readString(record, "trade", "Trade"),
iconKey: readString(record, "iconKey", "IconKey"),
requiresCompletionDocument: readBool(
item,
record,
"requiresCompletionDocument",
"RequiresCompletionDocument",
),
isActive: readBool(item, "isActive", "IsActive"),
completionDocTemplate:
mapTemplate(record.completionDocTemplate ?? record.CompletionDocTemplate) ??
mapTemplate({
id: record.completionDocTemplateId ?? record.CompletionDocTemplateId,
}),
isActive: readBool(record, "isActive", "IsActive"),
supportedWorkOrderTypes: Array.isArray(types)
? types.flatMap((value) => {
const mapped = mapWorkOrderType(value);
@ -67,5 +82,34 @@ export function mapService(raw: unknown): Service {
}
export function mapServices(raw: unknown): Service[] {
return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : [];
const items = asRecord(raw);
const payload = Array.isArray(raw)
? raw
: (items.items ?? items.Items ?? items.data ?? items.Data);
return Array.isArray(payload)
? payload.map(mapService).filter((service) => service.id !== "")
: [];
}
export function mapCompletionDocTemplates(raw: unknown): CompletionDocTemplateOption[] {
const payload = Array.isArray(raw)
? raw
: (() => {
const record = asRecord(raw);
return record.items ?? record.Items ?? record.data ?? record.Data;
})();
return Array.isArray(payload)
? payload.flatMap((item) => {
const record = asRecord(item);
const id = readId(record);
return id === ""
? []
: [
{
id,
name: readString(record, "name", "Name"),
} satisfies CompletionDocTemplateOption,
];
})
: [];
}

View file

@ -1,4 +1,13 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export const SERVICE_WORK_ORDER_TYPES = ["PM", "Reactive", "Emergency"] as const;
export type ServiceWorkOrderType = (typeof SERVICE_WORK_ORDER_TYPES)[number];
export interface LinkedServiceTemplate {
id: string | number;
name: string;
}
export type CompletionDocTemplateOption = LinkedServiceTemplate;
export interface Service {
id: string | number;
@ -6,6 +15,17 @@ export interface Service {
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplate: LinkedServiceTemplate | null;
isActive: boolean;
supportedWorkOrderTypes: WOType[];
supportedWorkOrderTypes: ServiceWorkOrderType[];
}
export interface ServiceInput {
name: string;
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplateId: number | null;
supportedWorkOrderTypes: ServiceWorkOrderType[];
isActive?: boolean;
}

View file

@ -0,0 +1,283 @@
import { useEffect, useMemo, useState } from "react";
import { useAuthContext } from "@/providers/auth-context";
import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
import {
useCreateService,
useCompletionDocTemplates,
useDeactivateService,
useServices,
useUpdateService,
} from "@/domain/services/use-cases/use-services";
export type ServiceStatus = "active" | "inactive";
export type ServiceForm = Omit<ServiceInput, "completionDocTemplateId"> & {
completionDocTemplateId: string | number | null;
};
export const EMPTY_SERVICE_FORM: ServiceForm = {
name: "",
trade: "",
iconKey: "",
requiresCompletionDocument: false,
completionDocTemplateId: null,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
};
// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not
// the legacy DropdownOptions seed. A trade with no explicit default falls back
// to "wrench" where this map is read.
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
HVAC: "droplets",
"Plumbing & Water Systems": "droplets",
Electrical: "zap",
"General Building & Handyman": "hammer",
};
export function buildServiceInput(
form: ServiceForm,
includeActiveState = false,
): {
input: ServiceInput | null;
error: string;
} {
const name = form.name.trim();
if (!name || !form.trade || !form.iconKey) {
return { input: null, error: "Service Name, Trade, and Icon are required." };
}
if (!form.supportedWorkOrderTypes.length) {
return { input: null, error: "Select at least one Work Order Type." };
}
if (form.requiresCompletionDocument && form.completionDocTemplateId == null) {
return {
input: null,
error: "Select a completion document template when the document is required.",
};
}
const templateId = form.completionDocTemplateId;
const { isActive, ...editableFields } = form;
return {
input: {
...editableFields,
name,
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
...(includeActiveState && isActive !== undefined ? { isActive } : {}),
},
error: "",
};
}
function emptyForm(): ServiceForm {
return {
...EMPTY_SERVICE_FORM,
supportedWorkOrderTypes: [...EMPTY_SERVICE_FORM.supportedWorkOrderTypes],
};
}
function formFromService(service: Service): ServiceForm {
return {
name: service.name,
trade: service.trade,
iconKey: service.iconKey,
requiresCompletionDocument: service.requiresCompletionDocument,
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
isActive: service.isActive,
};
}
// Owns the add/edit form lifecycle so the top-level controller stays within the
// changed-file maintainability budget. Mutations are passed in so `isSaving` and
// `closeEditor` share the exact instances the controller reports on.
function useServiceEditor(
canonicalTrades: string[],
canDeactivate: boolean,
createService: ReturnType<typeof useCreateService>,
updateService: ReturnType<typeof useUpdateService>,
) {
const [selected, setSelected] = useState<Service | null>(null);
const [editorMode, setEditorMode] = useState<"add" | "edit" | null>(null);
const [form, setForm] = useState<ServiceForm>(emptyForm);
const [iconTouched, setIconTouched] = useState(false);
const [formError, setFormError] = useState("");
// Keep the trade stored on the service being edited selectable even if it was
// later deactivated in the canonical Trade catalog, so editing never silently
// blanks the field and forces an unrelated trade change.
const trades = useMemo(() => {
if (form.trade && !canonicalTrades.includes(form.trade)) {
return [form.trade, ...canonicalTrades];
}
return canonicalTrades;
}, [canonicalTrades, form.trade]);
useEffect(() => {
if (!iconTouched && form.trade) {
setForm((current) => ({
...current,
iconKey: DEFAULT_ICON_BY_TRADE[current.trade] ?? "wrench",
}));
}
}, [form.trade, iconTouched]);
const openCreate = () => {
setSelected(null);
setForm(emptyForm());
setIconTouched(false);
setFormError("");
setEditorMode("add");
};
const openEdit = (service: Service) => {
setSelected(service);
setForm(formFromService(service));
setIconTouched(true);
setFormError("");
setEditorMode("edit");
};
const closeEditor = () => {
if (!createService.isPending && !updateService.isPending) setEditorMode(null);
};
const updateForm = <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => {
if (key === "trade") setIconTouched(false);
setFormError("");
setForm((current) => ({ ...current, [key]: value }));
};
const toggleType = (type: ServiceWorkOrderType) => {
setForm((current) => {
const supported = current.supportedWorkOrderTypes.includes(type)
? current.supportedWorkOrderTypes.filter((item) => item !== type)
: [...current.supportedWorkOrderTypes, type];
return { ...current, supportedWorkOrderTypes: supported };
});
};
const save = () => {
const validation = buildServiceInput(form, canDeactivate);
if (!validation.input) {
setFormError(validation.error);
return;
}
const input = validation.input;
if (editorMode === "add") {
createService.mutate(input, {
onSuccess: (created) => {
setEditorMode(null);
setSelected(created);
},
});
} else if (editorMode === "edit" && selected) {
updateService.mutate(
{ id: selected.id, input },
{
onSuccess: (updated) => {
setEditorMode(null);
setSelected(updated);
},
},
);
}
};
return {
selected,
setSelected,
editorMode,
form,
formError,
trades,
openCreate,
openEdit,
closeEditor,
updateForm,
toggleType,
save,
};
}
export function useServicesRegistryController() {
const { user } = useAuthContext();
const canManage = hasUserRole(user?.userRoles, "scheduler") || isAdminUser(user?.userRoles);
const canDeactivate = isAdminUser(user?.userRoles);
const [status, setStatus] = useState<ServiceStatus>("active");
const [search, setSearch] = useState("");
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
const servicesQuery = useServices();
// The backend rejects any Trade outside the canonical TradeCatalog (400
// TradeInvalid), so the picker is sourced from the server-owned canonical
// Trade reference exposed on Vendor/facets — the same list the vendor roster
// form uses — never the editable DropdownOptions "Trade" category, whose
// legacy seed values ("Plumbing", "General", ...) would fail on save.
const facetsQuery = useVendorFacets();
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
const { data: templates = [] } = useCompletionDocTemplates();
const createService = useCreateService();
const updateService = useUpdateService();
const deactivateService = useDeactivateService();
const editor = useServiceEditor(canonicalTrades, canDeactivate, createService, updateService);
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]);
const activeCount = services.filter((service) => service.isActive).length;
const inactiveCount = services.length - activeCount;
const filteredServices = useMemo(() => {
const needle = search.trim().toLowerCase();
return services
.filter((service) => (status === "active" ? service.isActive : !service.isActive))
.filter(
(service) =>
!needle ||
service.name.toLowerCase().includes(needle) ||
service.trade.toLowerCase().includes(needle),
);
}, [search, services, status]);
const confirmDeactivate = () => {
if (!deactivateTarget) return;
deactivateService.mutate(deactivateTarget.id, {
onSuccess: () => {
setDeactivateTarget(null);
editor.setSelected(null);
},
});
};
return {
canManage,
canDeactivate,
status,
setStatus,
search,
setSearch,
selected: editor.selected,
setSelected: editor.setSelected,
editorMode: editor.editorMode,
form: editor.form,
formError: editor.formError,
trades: editor.trades,
templates,
filteredServices,
services,
activeCount,
inactiveCount,
isLoading: servicesQuery.isLoading,
error: servicesQuery.error,
openCreate: editor.openCreate,
openEdit: editor.openEdit,
closeEditor: editor.closeEditor,
updateForm: editor.updateForm,
toggleType: editor.toggleType,
save: editor.save,
deactivateTarget,
setDeactivateTarget,
confirmDeactivate,
isSaving: createService.isPending || updateService.isPending,
isDeactivating: deactivateService.isPending,
};
}

View file

@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { servicesApi } from "@/domain/services/api/services-api";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { queryKeys } from "@/infra/query-key/query-key";
export function useServices(isActive?: boolean): UseQueryResult<Service[], Error> {
return useQuery({
queryKey: queryKeys.services.list(isActive),
queryFn: () => servicesApi.getAll(isActive),
staleTime: 60 * 1000,
});
}
export function useCompletionDocTemplates(): UseQueryResult<CompletionDocTemplateOption[], Error> {
return useQuery({
queryKey: queryKeys.services.completionTemplates(),
queryFn: servicesApi.getCompletionTemplates,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ServiceInput) => servicesApi.create(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service added");
},
onError: (error: Error) => toast.error(error.message || "Failed to add service"),
});
}
export function useUpdateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string | number; input: ServiceInput }) =>
servicesApi.update(id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service updated");
},
onError: (error: Error) => toast.error(error.message || "Failed to update service"),
});
}
export function useDeactivateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => servicesApi.deactivate(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service deactivated");
},
onError: (error: Error) => toast.error(error.message || "Failed to deactivate service"),
});
}

View file

@ -106,28 +106,61 @@ export const upliftsApi = {
id: string | number,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
let response: Response;
try {
response = await apiRequestRaw(
"get",
evidenceUrl(id),
undefined,
"upliftsApi.downloadEvidence",
);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
const blob = await response.blob();
const fileName = readContentDispositionFilename(response, fallbackFileName);
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
},
/**
* Renders the evidence in `tab`, which the caller opens synchronously on click so
* popup blockers allow it. Only inert types render inline on the app origin; any
* other type (HTML, SVG, ...) is downloaded instead and the tab is closed.
*/
openEvidence: async (
id: string | number,
tab: Window | null,
fallbackFileName = "uplift-evidence",
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
const blob = await response.blob();
const type = blob.type.split(";")[0].trim().toLowerCase();
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
tab?.close();
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
return;
}
const url = URL.createObjectURL(new Blob([blob], { type }));
tab.location.href = url;
window.setTimeout(() => URL.revokeObjectURL(url), EVIDENCE_URL_TTL_MS);
},
};
const INLINE_EVIDENCE_TYPES = new Set([
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]);
const EVIDENCE_URL_TTL_MS = 60_000;
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
try {
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);
}
throw error;
}
}
function saveBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = window.document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}

View file

@ -116,6 +116,9 @@ export function mapUpliftQueueItem(raw: unknown): UpliftQueueItem {
"trade",
"Trade",
),
technicianName: readString(item, "technicianName", "TechnicianName"),
workOrderDispatcherName: readString(item, "workOrderDispatcherName", "WorkOrderDispatcherName"),
workOrderScheduledDate: readString(item, "workOrderScheduledDate", "WorkOrderScheduledDate"),
attachmentCount: readNumber(
item,
"attachmentCount",

View file

@ -46,6 +46,9 @@ export interface UpliftQueueItem extends UpliftRequest {
woNumber: string;
site: string;
serviceName: string;
technicianName: string;
workOrderDispatcherName: string;
workOrderScheduledDate: string;
attachmentCount: number | null;
approvedOnWoAuto: number | null;
approvedOnWoAdmin: number | null;

View file

@ -0,0 +1,96 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { vendorCompanyRosterApi } from "@/domain/vendors/api/vendor-company-roster-api";
import type { VendorRosterTechnician } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
export interface NewCompanyTechnician {
contactName: string;
phone: string;
tradeSpecialties?: string;
}
export interface AddTechniciansToCompanyInput {
/** Any persisted vendor (technician) of the target company; resolves the company roster. */
anchorVendorId: string | number;
technicians: NewCompanyTechnician[];
}
function takeAddedTechnician(
added: VendorRosterTechnician[],
contactName: string,
): VendorRosterTechnician {
const index = added.findIndex((technician) => technician.contactName.trim() === contactName);
if (index < 0) {
throw new Error("The technician was saved but could not be matched. Reload and try again.");
}
const [match] = added.splice(index, 1);
return match as VendorRosterTechnician;
}
/**
* Adds technicians to an existing vendor company through the additive roster PATCH.
*
* Work-order entry points used to POST a whole new vendor with blank company
* fields, which the create validator rejects (company phone/email) and which would
* overwrite company data. The additive path only appends technicians, keeps the
* company record untouched, and returns the persisted technician ids.
*/
export async function addTechniciansToCompany({
anchorVendorId,
technicians,
}: AddTechniciansToCompanyInput): Promise<VendorDropdownItem[]> {
const roster = await vendorCompanyRosterApi.get({ vendorId: anchorVendorId });
if (roster.companyId == null || roster.rowVersion === "") {
throw new Error("Vendor company could not be loaded. Try again.");
}
const existingIds = new Set(roster.technicians.map((technician) => String(technician.id)));
const saved = await vendorCompanyRosterApi.addTechnicians(roster.companyId, {
rowVersion: roster.rowVersion,
addTechnicians: technicians.map((technician) => {
const phone = technician.phone.trim();
return {
contactName: technician.contactName.trim(),
phone,
email: "",
tradeSpecialties: technician.tradeSpecialties ?? "",
isActive: true,
...(phone === "" ? {} : { preferredContact: "Phone" }),
};
}),
});
const added = saved.technicians.filter(
(technician) => technician.id != null && !existingIds.has(String(technician.id)),
);
return technicians.map((technician) => {
const match = takeAddedTechnician(added, technician.contactName.trim());
const email = match.email.trim();
return {
id: match.id ?? "",
companyName: saved.name || roster.name,
contactName: match.contactName,
tradeSpecialties: match.tradeSpecialties,
address: saved.address,
distanceMiles: null,
email: email || undefined,
};
});
}
export function useAddTechniciansToCompany(): UseMutationResult<
VendorDropdownItem[],
Error,
AddTechniciansToCompanyInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addTechniciansToCompany,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
},
});
}

View file

@ -1,21 +0,0 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
import type { Vendor } from "@/domain/vendors/types/vendor";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateVendor(): UseMutationResult<Vendor, Error, VendorFormValues> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: VendorFormValues) => vendorsApi.create(values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.vendors.all });
toast.success("Vendor created");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create vendor");
},
});
}

View file

@ -216,10 +216,14 @@ export const workOrderBoardDocumentsApi = {
workOrderId: string | number,
mediaId: string | number,
): Promise<Blob> => {
// No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the
// API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document
// would never open.
const response = await apiRequestRaw(
"get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
{ credentials: "include", throwHttpErrors: false },
{ throwHttpErrors: false },
"workOrderBoardDocumentsApi.getMediaContent",
);

View file

@ -0,0 +1,81 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiRequestRaw } from "@/api/api";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type {
WorkOrderBoardPatchPayload,
WorkOrderPocPatchPayload,
} from "@/domain/work-orders/types/work-order-board";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
/** Shared request/error handling for board row PATCH endpoints (board + poc). */
async function patchBoardRow(
operationName: string,
method: "patch",
url: string,
payload: WorkOrderBoardPatchPayload | WorkOrderPocPatchPayload,
): Promise<WorkOrderTableRow> {
const response = await apiRequestRaw(
method,
url,
{ json: payload, throwHttpErrors: false },
operationName,
);
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
if (response.ok) {
return mapWorkOrderTableRow(data);
}
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
const currentStateRaw = record.currentState ?? record.CurrentState;
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
currentState,
);
}
if (response.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);
}
export const workOrderBoardPatchApi = {
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> =>
patchBoardRow(
"workOrdersApi.patchBoardField",
"patch",
API_PATHS.workOrder.boardPatch(id),
payload,
),
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: async (
id: string | number,
payload: WorkOrderPocPatchPayload,
): Promise<WorkOrderTableRow> =>
patchBoardRow("workOrdersApi.updatePoc", "patch", API_PATHS.workOrder.pocPatch(id), payload),
};

View file

@ -1,12 +1,8 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
BoardPatchConflictError,
BoardPatchValidationError,
} from "@/domain/work-orders/errors/board-patch-errors";
import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
import { workOrderBoardPatchApi } from "@/domain/work-orders/api/work-order-board-patch-api";
import {
mapDispatchComment,
mapDispatchDetail,
@ -20,7 +16,6 @@ import {
mapWorkOrderDetail,
mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdateFormData,
mapWorkOrderTableRow,
mapWorkOrderTableRows,
} from "@/domain/work-orders/mappers/work-order-mapper";
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
@ -42,7 +37,6 @@ import type {
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import type {
WorkOrderBoardListResult,
WorkOrderBoardPatchPayload,
WorkOrderBoardQueryParams,
WorkOrderBoardResponseApi,
WorkOrderBoardSearchQueryParams,
@ -310,51 +304,10 @@ export const workOrdersApi = {
return mapWorkOrderDetail(handleApiResponse(data));
},
patchBoardField: async (
id: string | number,
payload: WorkOrderBoardPatchPayload,
): Promise<WorkOrderTableRow> => {
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.boardPatch(id),
{ json: payload, throwHttpErrors: false },
"workOrdersApi.patchBoardField",
);
patchBoardField: workOrderBoardPatchApi.patchBoardField,
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
if (response.ok) {
return mapWorkOrderTableRow(data);
}
const record =
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
if (response.status === 409) {
const currentStateRaw = record.currentState ?? record.CurrentState;
const currentState = currentStateRaw != null ? mapWorkOrderTableRow(currentStateRaw) : null;
throw new BoardPatchConflictError(
typeof record.message === "string"
? record.message
: "The record was modified by another user. Refresh and retry.",
currentState,
);
}
if (response.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);
},
/** Manual POC override — same row/error contract as the board patch. */
updatePoc: workOrderBoardPatchApi.updatePoc,
update: async (
id: string | number,

View file

@ -28,9 +28,6 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
"techPhone",
"location",
"locationId",
"pocName",
"pocPhone",
"pocNotes",
"title",
"priority",
"apptStart",
@ -53,16 +50,44 @@ export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
}
/** Composite manual POC override op routed to PATCH …/poc. */
export type BoardPocOp = {
kind: "poc";
pocName?: string;
pocPhone?: string;
pocNotes?: string;
};
export type BoardOp = BoardFieldOp | BoardPocOp;
function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
const hasPocFields =
patch.pocName !== undefined || patch.pocPhone !== undefined || patch.pocNotes !== undefined;
if (!hasPocFields) {
return [];
}
return [
{
kind: "poc",
pocName: patch.pocName,
pocPhone: patch.pocPhone,
pocNotes: patch.pocNotes,
},
];
}
/**
* Expands a UI table patch into ordered board field ops (one PATCH each).
* Schedule/week-only fields are emitted first in a stable order.
* Expands a UI table patch into ordered board ops (one request each).
* Schedule/week-only fields are emitted first in a stable order; the composite
* POC op follows, then scalar fields.
*/
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
if (isLocalOnlyTablePatch(patch)) {
return [];
}
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)];
return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
}
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {

View file

@ -134,6 +134,14 @@ export interface WorkOrderBoardPatchPayload {
primaryDispatchId?: number | null;
}
/** Payload for PATCH /workorders/{id}/poc (manual POC override). */
export interface WorkOrderPocPatchPayload {
pocName: string;
pocPhone: string;
pocNotes: string;
workOrderVersion: string;
}
export type WorkOrderBoardEditableField =
| "woNumber"
| "workOrderType"

View file

@ -24,6 +24,11 @@ export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
export type WizardSchedMode = "date" | "week" | "range";
export interface WizardNewTechnician {
name: string;
phone: string;
}
export interface WorkOrderWizardDraft {
locationId: string;
locationName: string;
@ -47,6 +52,10 @@ export interface WorkOrderWizardDraft {
avetaRequired: boolean;
vendorId: string;
vendorName: string;
/** Company picked on the Vendor & time step; scopes the technician list. */
vendorCompany?: string;
/** Technicians registered inline; persisted under `vendorCompany` only on create. */
newTechnicians?: WizardNewTechnician[];
techPhone: string;
vendorNotes: string;
apptStart: string;
@ -90,6 +99,8 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
avetaRequired: false,
vendorId: "",
vendorName: "",
vendorCompany: "",
newTechnicians: [],
techPhone: "",
vendorNotes: "",
apptStart: "",

View file

@ -14,6 +14,7 @@ import {
buildBoardPatchPayload,
expandTablePatchToBoardFieldOps,
isLocalOnlyTablePatch,
type BoardOp,
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
import type {
@ -53,6 +54,30 @@ export type WorkOrderTablePatchHandlers = {
type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
function isPocOp(op: BoardOp): op is Extract<BoardOp, { kind: "poc" }> {
return "kind" in op && op.kind === "poc";
}
async function applyBoardOp(
id: string | number,
op: BoardOp,
row: WorkOrderTableRow,
): Promise<WorkOrderTableRow> {
if (isPocOp(op)) {
if (!row.rowVersion) {
throw new Error("workOrderVersion is required for board patch");
}
return workOrdersApi.updatePoc(id, {
pocName: op.pocName ?? row.pocName,
pocPhone: op.pocPhone ?? row.pocPhone,
pocNotes: op.pocNotes ?? row.pocNotes,
workOrderVersion: row.rowVersion,
});
}
return workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, row));
}
async function applyBoardFieldOps(
id: string | number,
patch: WorkOrderTablePatch,
@ -72,7 +97,7 @@ async function applyBoardFieldOps(
let completedOps = 0;
for (const op of ops) {
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
latest = await applyBoardOp(id, op, latest);
completedOps += 1;
} catch (error) {
if (completedOps > 0) {

View file

@ -7,7 +7,8 @@ import type {
/**
* Fields compared for dirty state and included in Save patch.
* Only keys that map to board PATCH (or required companions like dispatcherName).
* Primary POC scalars and tech phone are not board-editable — exclude so Save cannot no-op.
* Primary POC scalars persist via the dedicated POC endpoint;
* tech phone is not board-editable — exclude so Save cannot no-op.
* additionalContacts is board-editable (SH-218 PATCH field).
*/
export const SLIDE_OVER_EDIT_KEYS = [
@ -32,6 +33,9 @@ export const SLIDE_OVER_EDIT_KEYS = [
"vendorId",
// company/tech are display companions; board persists vendor via vendorId only
"status",
"pocName",
"pocPhone",
"pocNotes",
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
type EditKey = (typeof SLIDE_OVER_EDIT_KEYS)[number];

View file

@ -75,6 +75,8 @@ export const queryKeys = {
},
services: {
all: ["services"] as const,
list: (isActive?: boolean) => [...queryKeys.services.all, "list", isActive ?? "all"] as const,
completionTemplates: () => [...queryKeys.services.all, "completionTemplates"] as const,
active: () => [...queryKeys.services.all, "active"] as const,
byWorkOrderType: (workOrderType: string) =>
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,

View file

@ -6,13 +6,18 @@ export function getPrimaryUserRole(
}
export function isAdminUser(userRoles: string | null | undefined): boolean {
return hasUserRole(userRoles, "admin");
}
export function hasUserRole(userRoles: string | null | undefined, expectedRole: string): boolean {
if (!userRoles) {
return false;
}
const normalizedExpectedRole = expectedRole.trim().toLowerCase();
return userRoles
.split(",")
.map((role) => role.trim().toLowerCase())
.includes("admin");
.includes(normalizedExpectedRole);
}
/**

View file

@ -0,0 +1,153 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
import type { Service } from "@/domain/services/types/service";
import { renderWithProviders } from "@/test/test-utils";
const useServices = vi.fn();
const mutate = vi.fn();
vi.mock("@/providers/auth-context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1", userRoles: "admin,scheduler" } }),
};
});
vi.mock("@/domain/services/use-cases/use-services", () => ({
useServices: (...args: unknown[]) => useServices(...args),
useCompletionDocTemplates: () => ({ data: [] }),
useCreateService: () => ({ mutate, isPending: false }),
useUpdateService: () => ({ mutate, isPending: false }),
useDeactivateService: () => ({ mutate, isPending: false }),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({
data: {
companies: [],
trades: ["Plumbing & Water Systems", "Electrical", "HVAC", "General Building & Handyman"],
areas: [],
locations: [],
jobBuckets: [],
},
}),
}));
const activeHvac: Service = {
id: 1,
name: "HVAC Repair",
trade: "HVAC",
iconKey: "zap",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive"],
};
const activeDrain: Service = {
...activeHvac,
id: 2,
name: "Drain Cleaning",
trade: "Plumbing",
};
const inactiveFire: Service = {
...activeHvac,
id: 3,
name: "Fire Suppression",
trade: "Fire Protection",
isActive: false,
};
function setup(services: Service[] = [activeHvac, activeDrain, inactiveFire]) {
useServices.mockReturnValue({
data: services,
isLoading: false,
error: null,
});
return renderWithProviders(<ServicesRegistryView />, { route: "/settings/services" });
}
describe("ServicesRegistryView", () => {
it("renders Active and Inactive tabs with live counts from the unfiltered list", () => {
setup();
expect(useServices).toHaveBeenCalledWith();
expect(screen.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Inactive (1)" })).toHaveAttribute(
"aria-selected",
"false",
);
expect(screen.getByText("3 services · 2 active")).toBeInTheDocument();
expect(screen.getByRole("row", { name: /HVAC Repair/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("switches to the Inactive tab and shows only inactive services", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
expect(screen.getByRole("row", { name: /Fire Suppression/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 inactive service")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("applies the search within the selected tab and reports the filtered footer count", async () => {
setup();
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "drain");
expect(screen.getByRole("row", { name: /Drain Cleaning/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 active service · filtered")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("shows an icon, message, and try-a-different-search hint when a tab and search combination is empty", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "hvac");
expect(screen.getByTestId("services-empty-icon")).toBeInTheDocument();
expect(screen.getByText("No services found")).toBeInTheDocument();
expect(screen.getByText("Try a different search, or add a new service.")).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("offers only canonical trades and sends the selected canonical trade to the create payload", async () => {
setup();
await userEvent.click(screen.getByRole("button", { name: "Add Service" }));
await userEvent.type(screen.getByRole("textbox", { name: "Service Name" }), "Panel Swap");
await userEvent.click(screen.getByRole("combobox", { name: "Trade" }));
// Canonical names are offered; legacy DropdownOptions seeds ("Plumbing") are not.
expect(screen.getByRole("option", { name: "Plumbing & Water Systems" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Plumbing" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("option", { name: "Electrical" }));
const saveButtons = screen.getAllByRole("button", { name: "Add Service" });
await userEvent.click(saveButtons[saveButtons.length - 1]);
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate.mock.calls[0][0]).toMatchObject({ trade: "Electrical" });
});
it("summarizes the filtered count and the overall split in the footer without a search", () => {
setup();
const footer = screen.getByText("2 active services").closest("div");
expect(footer).not.toBeNull();
expect(within(footer as HTMLElement).getByText("2 active · 1 inactive")).toBeInTheDocument();
expect(screen.queryByText(/filtered/)).not.toBeInTheDocument();
});
});

View file

@ -41,6 +41,9 @@ const approvedItem: UpliftQueueItem = {
woNumber: "WO-55",
site: "Site B",
serviceName: "HVAC service",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: 3,
approvedOnWoAuto: 100,
approvedOnWoAdmin: 400,

View file

@ -1,4 +1,4 @@
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { ApiError } from "@/api/api-error";
@ -80,6 +80,9 @@ const dispatcherItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "Tom Tech",
workOrderDispatcherName: "Dana Ruiz",
workOrderScheduledDate: "2026-04-10T00:00:00",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
@ -113,17 +116,31 @@ describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
woUpliftsMock.calls = 0;
});
it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => {
it("renders the work order from the queue item without a work-order fetch or error toast", async () => {
renderWithGovernedClient();
await waitFor(() => {
expect(boardDetailMock.calls).toBeGreaterThan(0);
expect(woUpliftsMock.calls).toBeGreaterThan(0);
});
expect(boardDetailMock.calls).toBe(0);
const workOrder = screen.getByRole("region", { name: "Work order" });
expect(within(workOrder).getByText("Dana Ruiz")).toBeInTheDocument();
expect(within(workOrder).getByText("Gateway Plumbing · Tom Tech")).toBeInTheDocument();
expect(within(workOrder).getByText("Apr 10, 2026")).toBeInTheDocument();
expect(within(workOrder).queryByText("Unavailable")).not.toBeInTheDocument();
expect(toastMocks.error).not.toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
});
it("marks the approved-on-WO breakdown unavailable without a toast and keeps Revoke admin-only", async () => {
renderWithGovernedClient();
const request = screen.getByRole("region", { name: "Uplift request" });
for (const label of ["Auto-approved (within allowance)", "Admin-approved", "Total"]) {
const row = within(request).getByText(label).parentElement as HTMLElement;
await waitFor(() => {
expect(within(row).getByText("Unavailable")).toBeInTheDocument();
});
}
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();

View file

@ -1,18 +1,13 @@
import { fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const boardDetail = vi.hoisted(() => ({ data: null as Record<string, unknown> | null }));
const woUplifts = vi.hoisted(() => ({ data: null as Array<Record<string, unknown>> | null }));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({ data: boardDetail.data, isLoading: false, error: null }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-uplifts", () => ({
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, error: null }),
useWorkOrderUplifts: () => ({ data: woUplifts.data, isLoading: false, isError: false }),
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
@ -28,7 +23,7 @@ const baseItem: UpliftQueueItem = {
requestedNTE: 250,
delta: 150,
vendorReason: "",
requestedAt: "2026-01-15T10:00:00Z",
requestedAt: "2026-01-15T15:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
@ -50,6 +45,9 @@ const baseItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
@ -58,76 +56,150 @@ const baseItem: UpliftQueueItem = {
};
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
const onApprove = vi.fn();
const onReject = vi.fn();
const onRevoke = vi.fn();
const onOpenAttachment = vi.fn();
const callbacks = {
onClose: vi.fn(),
onApprove: vi.fn(),
onReject: vi.fn(),
onRevoke: vi.fn(),
onOpenAttachment: vi.fn(),
};
renderWithProviders(
<UpliftDetailModal
item={{ ...baseItem, ...overrides }}
canRevoke={canRevoke}
approvePending={false}
revokePending={false}
onClose={vi.fn()}
onApprove={onApprove}
onReject={onReject}
onRevoke={onRevoke}
onOpenAttachment={onOpenAttachment}
{...callbacks}
/>,
);
return { onApprove, onReject, onRevoke, onOpenAttachment };
return callbacks;
}
function section(name: string): HTMLElement {
return screen.getByRole("region", { name });
}
function fieldValue(container: HTMLElement, label: string): string {
const labelNode = within(container).getByText(label);
return labelNode.parentElement?.lastElementChild?.textContent ?? "";
}
describe("UpliftDetailModal", () => {
beforeEach(() => {
canApproveState.data = true;
boardDetail.data = {
info: { woNumber: "WO-99", site: "Site A", status: "Open", dueDate: "" },
};
woUplifts.data = [
{ id: 1, status: "auto_approved", amount: 100 },
{ id: 2, status: "approved", amount: 250 },
];
});
it("shows work order and request placeholders for a pending uplift", () => {
it("organizes content into Work order and Uplift request divided by a single rule", () => {
renderModal();
expect(screen.getByText("Unscheduled")).toBeInTheDocument();
expect(screen.getByText("No justification provided.")).toBeInTheDocument();
expect(screen.getByText("No attachments")).toBeInTheDocument();
const dialog = screen.getByRole("dialog");
const regions = within(dialog).getAllByRole("region");
expect(regions.map((region) => region.getAttribute("aria-label"))).toEqual([
"Work order",
"Uplift request",
]);
expect(within(dialog).getAllByRole("separator")).toHaveLength(1);
expect(within(dialog).queryByText("Request")).not.toBeInTheDocument();
});
it("opens an evidence attachment through the provided callback", () => {
it("renders the work order fields from the queue item", () => {
renderModal({
technicianName: "Tom Tech",
workOrderDispatcherName: "Dana Ruiz",
workOrderScheduledDate: "2026-04-10T00:00:00",
});
const workOrder = section("Work order");
expect(fieldValue(workOrder, "Service")).toBe("Plumbing repair");
expect(fieldValue(workOrder, "Vendor / Technician")).toBe("Gateway Plumbing · Tom Tech");
expect(fieldValue(workOrder, "Assigned Dispatcher")).toBe("Dana Ruiz");
expect(fieldValue(workOrder, "Scheduled")).toBe("Apr 10, 2026");
});
it("shows explicit placeholders for an empty work order, never a blank field", () => {
renderModal({ serviceName: "", vendorCompanyName: "", technicianName: "" });
const workOrder = section("Work order");
expect(fieldValue(workOrder, "Service")).toBe("Not specified");
expect(fieldValue(workOrder, "Vendor / Technician")).toBe("Not assigned");
expect(fieldValue(workOrder, "Assigned Dispatcher")).toBe("Unassigned");
expect(fieldValue(workOrder, "Scheduled")).toBe("Unscheduled");
});
it("renders requester, request date and time, and justification in a bordered block", () => {
renderModal({ vendorReason: "Scope grew after inspection" });
const request = section("Uplift request");
expect(fieldValue(request, "Requested By")).toBe("Gateway");
// The instant renders in the viewer's zone, so the calendar day is derived, not fixed.
const localDay = new Date(baseItem.requestedAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
expect(fieldValue(request, "Requested At")).toMatch(
new RegExp(`^${localDay} · \\d{1,2}:\\d{2} [AP]M$`, "u"),
);
expect(screen.getByTestId("uplift-justification")).toHaveTextContent(
"Scope grew after inspection",
);
});
it("shows explicit request placeholders when requester, date, notes and files are missing", () => {
renderModal({ requestedByVendorName: "", requestedAt: "" });
const request = section("Uplift request");
expect(fieldValue(request, "Requested By")).toBe("Unknown requester");
expect(fieldValue(request, "Requested At")).toBe("Not recorded");
const placeholder = within(screen.getByTestId("uplift-justification")).getByText(
"No justification provided.",
);
expect(placeholder).toHaveClass("italic");
expect(within(request).getByText("No attachments")).toBeInTheDocument();
});
it("renders attachments as a horizontal scrolling chip row that opens the file", () => {
const { onOpenAttachment } = renderModal({
evidenceDocumentId: "document-1",
evidenceFileName: "quote.pdf",
attachmentCount: 1,
attachmentCount: 3,
});
fireEvent.click(screen.getByText("quote.pdf"));
const row = screen.getByTestId("uplift-attachments");
expect(row).toHaveStyle({ overflowX: "auto", flexWrap: "nowrap" });
expect(within(row).getByText("+2")).toBeInTheDocument();
fireEvent.click(within(row).getByText("quote.pdf"));
expect(onOpenAttachment).toHaveBeenCalledWith(
expect.objectContaining({ evidenceDocumentId: "document-1" }),
);
});
it("falls back to the work order uplifts for the approved-on-WO breakdown", () => {
it("shows the approved-on-WO exposure breakdown inside the uplift request", () => {
renderModal();
expect(screen.getByText("Auto-approved")).toBeInTheDocument();
expect(screen.getByText("Admin-approved")).toBeInTheDocument();
expect(screen.getByText("$100.00")).toBeInTheDocument();
expect(screen.getByText("$250.00")).toBeInTheDocument();
expect(screen.getByText("$350.00")).toBeInTheDocument();
const request = section("Uplift request");
expect(within(request).getByText("Approved on WO")).toBeInTheDocument();
expect(within(request).getByText("Auto-approved (within allowance)")).toBeInTheDocument();
expect(within(request).getByText("Admin-approved")).toBeInTheDocument();
expect(within(request).getByText("$100.00")).toBeInTheDocument();
expect(within(request).getByText("$350.00")).toBeInTheDocument();
});
it("offers status-specific actions for a pending uplift", () => {
it("offers Reject then Approve for a pending uplift", () => {
const { onApprove, onReject } = renderModal();
const footerButtons = screen
.getAllByRole("button")
.map((button) => button.textContent)
.filter((label) => label === "Reject" || label === "Approve" || label === "Revoke");
expect(footerButtons).toEqual(["Reject", "Approve"]);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(onApprove).toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(onReject).toHaveBeenCalled();
});
@ -148,12 +220,17 @@ describe("UpliftDetailModal", () => {
expect(onReject).not.toHaveBeenCalled();
});
it("offers Revoke for an approved uplift and disables it on a closed work order", () => {
it("offers Revoke for an approved uplift and disables it with a tooltip on a closed work order", async () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: true });
expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument();
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(
await screen.findByText("This work order is closed. Uplifts can no longer be revoked."),
).toBeInTheDocument();
fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled();
});
@ -169,6 +246,26 @@ describe("UpliftDetailModal", () => {
expect(onRevoke).not.toHaveBeenCalled();
});
it.each(["Rejected", "Withdrawn"])("is a read-only record with no actions when %s", (status) => {
renderModal({ status });
for (const name of ["Approve", "Reject", "Revoke"]) {
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
}
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});
it("closes from the header X without triggering any decision", () => {
const callbacks = renderModal();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onApprove).not.toHaveBeenCalled();
expect(callbacks.onReject).not.toHaveBeenCalled();
expect(callbacks.onRevoke).not.toHaveBeenCalled();
});
it("does not render a heading nested inside the dialog title heading", () => {
renderModal();

View file

@ -42,6 +42,9 @@ const pendingItem: UpliftQueueItem = {
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
technicianName: "",
workOrderDispatcherName: "",
workOrderScheduledDate: "",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,

View file

@ -0,0 +1,153 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SaveHandler = (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: () => ({ data: undefined }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }),
}));
// A completion-doc upload that settles successfully. The backend has already set
// DocStatus=Yes and bumped the work-order row version inside the same transaction,
// so any client follow-up patch would carry a stale version.
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useUploadCompletionDoc: () => ({
mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise<void> }) => {
void options?.onSuccess?.();
},
isPending: false,
}),
}));
vi.mock("react-toastify", () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
}));
vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({
showCompDocDraftSavedToast: vi.fn(),
}));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
const defaults: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
completedDate: "2026-07-01",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Seattle",
locationId: "mock-loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
vendorId: "",
company: "Vendor Co",
tech: "Tech One",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "Amazon Rep",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return { ...defaults, ...overrides };
}
describe("CompDocDialog signed PDF upload (SH-380)", () => {
beforeEach(() => {
vi.mocked(toast.error).mockReset();
});
function uploadSignedPdf(onSave: SaveHandler) {
const onDateCompletedChange = vi.fn().mockResolvedValue(undefined);
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<CompDocDialog
row={baseRow()}
open
onOpenChange={vi.fn()}
onSave={onSave}
onDateCompletedChange={onDateCompletedChange}
/>
</QueryClientProvider>,
);
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).not.toBeNull();
fireEvent.change(fileInput!, {
target: {
files: [
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", {
type: "application/pdf",
}),
],
},
});
return { onSave, onDateCompletedChange };
}
it("does not re-run the docStatus persist after a successful upload", async () => {
// The server already marked the document generated; a client docStatus patch here would
// carry the pre-upload row version and 409. It must not run.
const onSave: SaveHandler = vi.fn(() =>
Promise.reject(new Error("The record was modified by another user. Refresh and retry.")),
);
uploadSignedPdf(onSave);
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onSave).not.toHaveBeenCalled();
});
it("surfaces no error toast to the user when the signed PDF upload settles", async () => {
const onSave: SaveHandler = vi.fn(() => Promise.reject(new Error("conflict")));
const { onDateCompletedChange } = uploadSignedPdf(onSave);
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onDateCompletedChange).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,145 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
vi.mock("@/providers/auth-context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
return { ...actual, useAuthContext: () => ({ user: { id: "u1" } }) };
});
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
// A completion-doc upload that settles successfully. The mock forwards any onSuccess the
// caller passes; the fix means the slide-over passes none (no follow-up docStatus patch).
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }),
useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }),
useUploadCompletionDoc: () => ({
mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise<void> }) => {
void options?.onSuccess?.();
},
isPending: false,
}),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "",
dayGroup: "07/20",
dayLabel: "Mon",
location: "Seattle",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
company: "Vendor Co",
tech: "Tech",
techPhone: "",
vendorId: "v1",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}
describe("WorkOrderSlideOver signed PDF upload (SH-380)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not issue a docStatus patch or surface an error after a successful upload", async () => {
const row = baseRow();
// A follow-up docStatus patch would carry the pre-upload version and reject; assert none runs.
const onSave = vi.fn(
(
_id: string | number,
_patch: Partial<WorkOrderTableRow>,
options?: { onSuccess?: () => void; onError?: (error: Error) => void },
) => {
options?.onError?.(new Error("The record was modified by another user."));
},
);
render(
<WorkOrderSlideOver
row={row}
editMode={false}
users={[{ id: "u1", name: "Dispatcher" }]}
sites={[{ id: "loc-1", name: "Seattle" }]}
vendors={[]}
pmTypes={["HVAC"]}
allOrders={[row]}
onSave={onSave}
onOpenCompletionDoc={vi.fn()}
onCancelWO={vi.fn()}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("tab", { name: /completion doc/i }));
const pdfInput = document.querySelector('input[accept="application/pdf,.pdf"]');
expect(pdfInput).not.toBeNull();
fireEvent.change(pdfInput!, {
target: {
files: [
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", {
type: "application/pdf",
}),
],
},
});
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onSave).not.toHaveBeenCalled();
});
});

View file

@ -8,8 +8,8 @@ import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_component
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),

View file

@ -4,8 +4,8 @@ import { VendorDialog } from "@/app/(protected)/workorders/_components/list/tabl
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),

View file

@ -1,14 +1,13 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import type { Vendor } from "@/domain/vendors/types/vendor";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { renderWithProviders } from "@/test/test-utils";
const mutateAsync = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
useCreateVendor: () => ({
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({
mutateAsync,
isPending: false,
}),
@ -24,30 +23,19 @@ const existingVendor: VendorDropdownItem = {
email: "dispatch@gateway.test",
};
const createdVendor: Vendor = {
const createdVendor: VendorDropdownItem = {
id: 99,
companyId: 10,
companyName: "Gateway Plumbing",
contactName: "Jordan Lee",
email: "dispatch@gateway.test",
phone: "(314) 555-9999",
companyPhone: "",
googleMapsUrl: "",
notes: "",
totalJobs: 0,
address: "1 Main St",
city: "",
state: "",
zip: "",
tradeSpecialties: "Plumbing",
isActive: true,
preferredContact: "Phone",
address: "1 Main St",
distanceMiles: null,
};
describe("VendorDialog stable technician identity", () => {
beforeEach(() => {
mutateAsync.mockReset();
mutateAsync.mockResolvedValue(createdVendor);
mutateAsync.mockResolvedValue([createdVendor]);
});
it("keeps draft identity on same-name collision and posts live phone on save", async () => {
@ -90,13 +78,15 @@ describe("VendorDialog stable technician identity", () => {
expect(mutateAsync).toHaveBeenCalledTimes(1);
});
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Jordan Lee",
companyName: "Gateway Plumbing",
phone: "(314) 555-9999",
}),
);
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 10,
technicians: [
expect.objectContaining({
contactName: "Jordan Lee",
phone: "(314) 555-9999",
}),
],
});
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
@ -115,12 +105,7 @@ describe("VendorDialog stable technician identity", () => {
it("does not clear staged draft when technicianOptions rebuild after add", async () => {
const onSave = vi.fn();
mutateAsync.mockResolvedValue({
...createdVendor,
id: 88,
contactName: "Casey New",
phone: "3145550188",
});
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
renderWithProviders(
<VendorDialog
@ -161,8 +146,7 @@ describe("VendorDialog stable technician identity", () => {
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Casey New",
phone: "3145550188",
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
}),
);
@ -178,12 +162,7 @@ describe("VendorDialog stable technician identity", () => {
it("keeps a staged draft when the same company is picked again", async () => {
const onSave = vi.fn();
mutateAsync.mockResolvedValue({
...createdVendor,
id: 88,
contactName: "Casey New",
phone: "3145550188",
});
mutateAsync.mockResolvedValue([{ ...createdVendor, id: 88, contactName: "Casey New" }]);
renderWithProviders(
<VendorDialog
@ -215,8 +194,7 @@ describe("VendorDialog stable technician identity", () => {
await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
contactName: "Casey New",
phone: "3145550188",
technicians: [expect.objectContaining({ contactName: "Casey New", phone: "3145550188" })],
}),
);
});

View file

@ -44,7 +44,9 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
}),
);
});
@ -87,7 +89,78 @@ describe("WizardStepLocationServiceSelect — vendor clear on service change", (
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("clears the vendor company and staged inline technicians when the service changes", () => {
const onPatch = vi.fn();
const draft: WorkOrderWizardDraft = {
...EMPTY_WIZARD_DRAFT,
pm: "HVAC",
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
};
render(
<WizardStepLocationServiceSelect
draft={draft}
services={[
{ id: "1", value: "HVAC" },
{ id: "2", value: "Plumbing" },
]}
svcOpen={false}
onSvcOpenChange={vi.fn()}
svcSearch=""
onSvcSearchChange={vi.fn()}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
fireEvent.click(screen.getByRole("button", { name: "Plumbing" }));
expect(onPatch).toHaveBeenCalledWith({
pm: "Plumbing",
serviceId: "2",
extraServices: [],
vendorId: "",
vendorName: "",
techPhone: "",
vendorCompany: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("keeps vendor company and staged technicians when the service is unchanged", () => {
const onPatch = vi.fn();
const draft: WorkOrderWizardDraft = {
...EMPTY_WIZARD_DRAFT,
pm: "HVAC",
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
};
render(
<WizardStepLocationServiceSelect
draft={draft}
services={[{ id: "1", value: "HVAC" }]}
svcOpen={false}
onSvcOpenChange={vi.fn()}
svcSearch=""
onSvcSearchChange={vi.fn()}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
fireEvent.click(screen.getByRole("button", { name: "HVAC" }));
expect(onPatch).toHaveBeenCalledWith({ pm: "HVAC", serviceId: "1", extraServices: [] });
});
});

View file

@ -0,0 +1,260 @@
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
import { useWizardCreateWithTechnicians } from "@/app/(protected)/workorders/_components/wizard/use-wizard-create-with-technicians";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
EMPTY_WIZARD_DRAFT,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
const mutateAsync = vi.fn();
vi.mock("@/domain/vendors/use-cases/use-add-technicians-to-company", () => ({
useAddTechniciansToCompany: () => ({ mutateAsync, isPending: false }),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
const vendors: VendorDropdownItem[] = [
{
id: 41,
companyName: "Vinewood LLC",
contactName: "Adam Whyte",
tradeSpecialties: "HVAC",
address: "1 Market St",
distanceMiles: null,
},
{
id: 52,
companyName: "Gateway Plumbing",
contactName: "Casey Pipe",
tradeSpecialties: "Plumbing",
address: "",
distanceMiles: null,
},
];
function draftWith(patch: Partial<WorkOrderWizardDraft>): WorkOrderWizardDraft {
return { ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete", ...patch };
}
describe("WizardStepVendorTime inline technician (SH-364)", () => {
it("hides the add action until a company is picked", () => {
render(
<WizardStepVendorTime
draft={draftWith({})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "+ Add technician" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /pick a company first/i })).toBeDisabled();
});
it("stages a new technician row for the picked company", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({ vendorCompany: "Vinewood LLC" })}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "+ Add technician" }));
expect(onPatch).toHaveBeenCalledWith({ newTechnicians: [{ name: "", phone: "" }] });
});
it("scopes technicians to the picked company", () => {
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.getByRole("group", { name: "New Technician 1" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /select technician/i }));
expect(screen.getByRole("button", { name: "Adam Whyte" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Casey Pipe" })).not.toBeInTheDocument();
});
it("clears the picked vendor and staged technicians when the company changes", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0100",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" }));
fireEvent.click(screen.getByRole("button", { name: "Gateway Plumbing" }));
expect(onPatch).toHaveBeenCalledWith({
vendorCompany: "Gateway Plumbing",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
newTechnicians: [],
});
});
it("preserves the picked vendor and staged technicians when the same company is re-picked", () => {
const onPatch = vi.fn();
render(
<WizardStepVendorTime
draft={draftWith({
vendorCompany: "Vinewood LLC",
vendorId: "41",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0100",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-0199" }],
})}
vendors={vendors}
onUpdate={vi.fn()}
onPatch={onPatch}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Vinewood LLC" }));
const companyOptions = screen.getAllByRole("button", { name: "Vinewood LLC" });
fireEvent.click(companyOptions[companyOptions.length - 1]);
expect(onPatch).toHaveBeenCalledWith({ vendorCompany: "Vinewood LLC" });
});
});
describe("useWizardCreateWithTechnicians (SH-364)", () => {
beforeEach(() => {
mutateAsync.mockReset();
});
it("creates the work order directly when no technician was staged", () => {
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
const draft = draftWith({ vendorCompany: "Vinewood LLC", newTechnicians: [] });
result.current.mutate(draft);
expect(mutateAsync).not.toHaveBeenCalled();
expect(createMutation.mutate).toHaveBeenCalledWith(draft);
});
it("persists staged technicians first and assigns a single new technician", async () => {
mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]);
const createMutation = { mutate: vi.fn(), isPending: false };
const patchDraft = vi.fn();
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft, createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [
{ name: "Jordan Lee", phone: "(314) 555-0199" },
{ name: " ", phone: "" },
],
}),
);
});
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 41,
technicians: [
{ contactName: "Jordan Lee", phone: "(314) 555-0199", tradeSpecialties: "HVAC" },
],
});
const expectedPatch = {
newTechnicians: [],
vendorId: "99",
vendorName: "Vinewood LLC",
techPhone: "(314) 555-0199",
};
expect(patchDraft).toHaveBeenCalledWith(expectedPatch);
expect(createMutation.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedPatch));
});
it("blocks Create and skips the roster save when a staged phone is not a valid number", async () => {
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "(314) 555-01" }],
}),
);
});
expect(mutateAsync).not.toHaveBeenCalled();
expect(createMutation.mutate).not.toHaveBeenCalled();
});
it("allows a staged technician with a blank phone, matching the modal path", async () => {
mutateAsync.mockResolvedValue([{ ...vendors[0], id: 99, contactName: "Jordan Lee" }]);
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
);
});
expect(mutateAsync).toHaveBeenCalledWith({
anchorVendorId: 41,
technicians: [{ contactName: "Jordan Lee", phone: "", tradeSpecialties: "HVAC" }],
});
expect(createMutation.mutate).toHaveBeenCalled();
});
it("does not create the work order when the technician save fails", async () => {
mutateAsync.mockRejectedValue(new Error("boom"));
const createMutation = { mutate: vi.fn(), isPending: false };
const { result } = renderHook(() =>
useWizardCreateWithTechnicians({ vendors, patchDraft: vi.fn(), createMutation }),
);
await act(async () => {
result.current.mutate(
draftWith({
vendorCompany: "Vinewood LLC",
newTechnicians: [{ name: "Jordan Lee", phone: "" }],
}),
);
});
expect(createMutation.mutate).not.toHaveBeenCalled();
});
});

View file

@ -150,7 +150,7 @@ function DayAssignHarness({
dispatcherId: id,
dispatcherName: name,
});
const patched = await patchBoardField(item.id, ops[0]!);
const patched = await patchBoardField(item.id, ops[0] as { field: string });
setRow({ ...patched, dispatcherId: id, dispatcherName: name });
}}
/>

View file

@ -1,10 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
import type { ServiceInput } from "@/domain/services/types/service";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPostNoContent = vi.fn();
const apiPut = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiPostNoContent: (...args: unknown[]) => apiPostNoContent(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
}));
import { servicesApi } from "@/domain/services/api/services-api";
@ -12,6 +19,38 @@ import { servicesApi } from "@/domain/services/api/services-api";
describe("servicesApi", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPostNoContent.mockReset();
apiPut.mockReset();
});
it("loads active services and maps the linked completion template", async () => {
apiGet.mockResolvedValue({
items: [
{
Id: 7,
Name: "HVAC inspection",
Trade: "HVAC",
IconKey: "droplets",
RequiresCompletionDocument: true,
CompletionDocTemplate: { Id: 4, Name: "HVAC checklist" },
IsActive: true,
SupportedWorkOrderTypes: [2, 6],
},
],
});
const services = await servicesApi.getAll(true);
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
searchParams: { isActive: true },
});
expect(services[0]).toMatchObject({
id: 7,
name: "HVAC inspection",
completionDocTemplate: { id: 4, name: "HVAC checklist" },
supportedWorkOrderTypes: ["PM", "Reactive"],
});
});
it("loads active services filtered by the selected work-order type", async () => {
@ -45,4 +84,49 @@ describe("servicesApi", () => {
expect(apiGet).not.toHaveBeenCalled();
expect(services).toEqual([]);
});
it("creates, updates, and deactivates through the registry routes", async () => {
const input: ServiceInput = {
name: "Boiler inspection",
trade: "HVAC",
iconKey: "droplets",
requiresCompletionDocument: false,
completionDocTemplateId: null,
supportedWorkOrderTypes: ["PM"],
};
apiPost.mockResolvedValue({ id: 8, ...input, isActive: true, supportedWorkOrderTypes: [2] });
apiPut.mockResolvedValue({
id: 8,
...input,
completionDocTemplateId: 12,
isActive: true,
supportedWorkOrderTypes: [2],
});
apiPostNoContent.mockResolvedValue(undefined);
await servicesApi.create(input);
const updated = await servicesApi.update(8, input);
await servicesApi.deactivate(8);
expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, {
...input,
supportedWorkOrderTypes: [2],
});
expect(apiPostNoContent).toHaveBeenCalledWith(API_PATHS.services.deactivate(8));
expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), {
...input,
supportedWorkOrderTypes: [2],
});
expect(updated.completionDocTemplate).toEqual({ id: 12, name: "" });
});
it("loads active Completion Document Template options from the completion route", async () => {
apiGet.mockResolvedValue([{ Id: 4, Name: "HVAC checklist", IsActive: true }]);
const templates = await servicesApi.getCompletionTemplates();
expect(apiGet).toHaveBeenCalledWith(API_PATHS.workOrder.completionTemplates);
expect(templates).toEqual([{ id: 4, name: "HVAC checklist" }]);
});
});

View file

@ -42,4 +42,53 @@ describe("upliftsApi", () => {
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
});
describe("openEvidence", () => {
function evidenceResponse(body: string, contentType: string): Response {
return new Response(body, {
headers: {
"Content-Type": contentType,
"Content-Disposition": 'attachment; filename="quote.pdf"',
},
});
}
function fakeTab() {
return { location: { href: "about:blank" }, close: vi.fn() };
}
beforeEach(() => {
URL.createObjectURL = vi.fn(() => "blob:evidence");
URL.revokeObjectURL = vi.fn();
});
it("renders an inert evidence type in the tab opened by the click", async () => {
apiRequestRaw.mockResolvedValueOnce(evidenceResponse("%PDF", "application/pdf"));
const tab = fakeTab();
await upliftsApi.openEvidence(7, tab as unknown as Window);
expect(apiRequestRaw).toHaveBeenCalledWith(
"get",
"uplifts/7/evidence",
undefined,
"upliftsApi.openEvidence",
);
expect(tab.location.href).toBe("blob:evidence");
expect(tab.close).not.toHaveBeenCalled();
});
it("downloads a type the browser could execute instead of rendering it on the app origin", async () => {
apiRequestRaw.mockResolvedValueOnce(evidenceResponse("<svg/>", "image/svg+xml"));
const tab = fakeTab();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
await upliftsApi.openEvidence(8, tab as unknown as Window);
expect(tab.location.href).toBe("about:blank");
expect(tab.close).toHaveBeenCalled();
expect(click).toHaveBeenCalled();
click.mockRestore();
});
});
});

View file

@ -108,9 +108,25 @@ describe("mapUpliftQueueItem", () => {
expect(result.workOrderClosed).toBe(false);
});
it("maps the detail modal work-order context from the queue contract", () => {
const result = mapUpliftQueueItem({
id: 4,
TechnicianName: "Tom Tech",
WorkOrderDispatcherName: "Dana Ruiz",
WorkOrderScheduledDate: "2026-04-10T09:30:00",
});
expect(result.technicianName).toBe("Tom Tech");
expect(result.workOrderDispatcherName).toBe("Dana Ruiz");
expect(result.workOrderScheduledDate).toBe("2026-04-10T09:30:00");
});
it("tolerates absent optional queue fields", () => {
const result = mapUpliftQueueItem({ id: 3 });
expect(result.technicianName).toBe("");
expect(result.workOrderDispatcherName).toBe("");
expect(result.workOrderScheduledDate).toBe("");
expect(result.woNumber).toBe("");
expect(result.site).toBe("");
expect(result.serviceName).toBe("");

View file

@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPut = vi.fn();
const apiPatch = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
apiPatch: (...args: unknown[]) => apiPatch(...args),
}));
import { addTechniciansToCompany } from "@/domain/vendors/use-cases/use-add-technicians-to-company";
const roster = {
companyId: 7,
rowVersion: "AAAAAAAAB9E=",
name: "Vinewood LLC",
companyPhone: "(314) 555-0100",
email: "office@vinewood.test",
address: "1 Market St",
city: "St. Louis",
state: "MO",
zip: "63101",
notes: "",
technicians: [{ id: 41, contactName: "Adam Whyte", phone: "", email: "", isActive: true }],
};
describe("addTechniciansToCompany (SH-365)", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPut.mockReset();
apiPatch.mockReset();
apiGet.mockResolvedValue(roster);
apiPatch.mockResolvedValue({
...roster,
rowVersion: "AAAAAAAAB9F=",
technicians: [
...roster.technicians,
{ id: 42, contactName: "Jordan Lee", phone: "(314) 555-0199", email: "", isActive: true },
],
});
});
it("appends the technician through the additive roster PATCH without company fields", async () => {
const created = await addTechniciansToCompany({
anchorVendorId: 41,
technicians: [{ contactName: " Jordan Lee ", phone: "3145550199", tradeSpecialties: "HVAC" }],
});
expect(apiGet).toHaveBeenCalledWith("vendor-company-roster", {
searchParams: { vendorId: "41" },
});
expect(apiPost).not.toHaveBeenCalled();
expect(apiPut).not.toHaveBeenCalled();
expect(apiPatch).toHaveBeenCalledTimes(1);
const [path, body] = apiPatch.mock.calls[0] ?? [];
expect(path).toBe("vendor-company-roster/7");
// No companyFields: an existing company's phone/email/address are never sent or blanked.
expect(body).toEqual({
rowVersion: "AAAAAAAAB9E=",
addTechnicians: [
{
contactName: "Jordan Lee",
phone: "(314) 555-0199",
email: "",
tradeSpecialties: "HVAC",
isActive: true,
preferredContact: "Phone",
},
],
});
expect(created).toEqual([
expect.objectContaining({ id: 42, contactName: "Jordan Lee", companyName: "Vinewood LLC" }),
]);
});
it("fails instead of returning a technician the server did not persist", async () => {
apiPatch.mockResolvedValue(roster);
await expect(
addTechniciansToCompany({
anchorVendorId: 41,
technicians: [{ contactName: "Jordan Lee", phone: "" }],
}),
).rejects.toThrow(/could not be matched/i);
});
});

View file

@ -658,6 +658,100 @@ describe("workOrdersApi.patchBoardField", () => {
});
});
describe("workOrdersApi.updatePoc", () => {
beforeEach(() => {
apiPatchFn.mockReset();
});
it("PATCHes poc payload to workorders/{id}/poc and maps row", async () => {
apiPatchFn.mockResolvedValue({
ok: true,
json: async () => ({
id: 10,
siteCode: "BK5",
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "Call first",
lifecycleStatusLabel: "Scheduled",
rowVersion: "AAAAAAAB",
dispatchRowVersion: null,
primaryDispatchId: null,
vendorId: null,
}),
});
const row = await workOrdersApi.updatePoc(10, {
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "Call first",
workOrderVersion: "AAAAAAA=",
});
expect(apiPatchFn).toHaveBeenCalledWith(API_PATHS.workOrder.pocPatch(10), {
json: {
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "Call first",
workOrderVersion: "AAAAAAA=",
},
throwHttpErrors: false,
});
expect(row.pocName).toBe("Manual POC");
expect(row.rowVersion).toBe("AAAAAAAB");
});
it("throws BoardPatchConflictError with currentState on 409", async () => {
apiPatchFn.mockResolvedValue({
ok: false,
status: 409,
json: async () => ({
message: "The record was modified by another user. Refresh and retry.",
currentState: {
id: 10,
siteCode: "BK5",
rowVersion: "NEWVER==",
lifecycleStatusLabel: "Scheduled",
},
}),
});
const { BoardPatchConflictError } =
await import("@/domain/work-orders/errors/board-patch-errors");
await expect(
workOrdersApi.updatePoc(10, {
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "",
workOrderVersion: "STALE==",
}),
).rejects.toBeInstanceOf(BoardPatchConflictError);
});
it("throws BoardPatchValidationError on 422 ReadOnly", async () => {
apiPatchFn.mockResolvedValue({
ok: false,
status: 422,
json: async () => ({
code: "ReadOnly",
message: "Work order is read-only in its current status.",
}),
});
await expect(
workOrdersApi.updatePoc(10, {
pocName: "Manual POC",
pocPhone: "312-555-0001",
pocNotes: "",
workOrderVersion: "AAAAAAA=",
}),
).rejects.toMatchObject({
name: "BoardPatchValidationError",
code: "ReadOnly",
});
});
});
describe("workOrdersApi.createComment", () => {
beforeEach(() => {
apiPost.mockReset();
@ -1022,8 +1116,14 @@ describe("workOrdersApi.getMediaContent", () => {
expect(result).toBe(blob);
expect(apiGetFn).toHaveBeenCalledWith(
API_PATHS.workOrder.mediaContent(10, 12),
expect.objectContaining({ credentials: "include", throwHttpErrors: false }),
expect.objectContaining({ throwHttpErrors: false }),
);
// Regression guard (SH-382): a credentialed cross-origin fetch is rejected by the browser
// against the API's `Access-Control-Allow-Origin: *`, so the content request must not opt
// into credentials mode — otherwise the document never opens. JSDOM cannot enforce CORS, so
// this asserts the request shape rather than reproducing the block.
const [, options] = apiGetFn.mock.calls[0] as [string, Record<string, unknown>];
expect(options).not.toHaveProperty("credentials");
});
it("throws ApiError on 404 without inventing a filename", async () => {

View file

@ -34,6 +34,12 @@ describe("isLocalOnlyTablePatch", () => {
it("treats scheduleEnd and schedMode as local-only overlays", () => {
expect(isLocalOnlyTablePatch({ scheduleEnd: "2026-06-12", schedMode: "range" })).toBe(true);
});
it("does not treat POC edits as local-only", () => {
expect(isLocalOnlyTablePatch({ pocName: "Pat", pocPhone: "111", pocNotes: "call" })).toBe(
false,
);
});
});
describe("expandTablePatchToBoardFieldOps", () => {
@ -185,7 +191,33 @@ describe("expandTablePatchToBoardFieldOps", () => {
pocPhone: "111",
pocNotes: "call",
}),
).toEqual([{ field: "siteCode", value: "BK5" }]);
).toEqual([
{
kind: "poc",
pocName: "Pat",
pocPhone: "111",
pocNotes: "call",
},
{ field: "siteCode", value: "BK5" },
]);
});
it("emits one composite POC op before scalar ops", () => {
expect(
expandTablePatchToBoardFieldOps({
pocName: "Pat",
pocNotes: "call first",
docStatus: "Yes",
}),
).toEqual([
{
kind: "poc",
pocName: "Pat",
pocPhone: undefined,
pocNotes: "call first",
},
{ field: "docStatus", value: "Yes" },
]);
});
it("keeps dueDate and scheduledDate patches independent", () => {

View file

@ -9,12 +9,14 @@ import type {
const getById = vi.fn();
const update = vi.fn();
const updatePoc = vi.fn();
const patchBoardField = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
getById: (...args: unknown[]) => getById(...args),
update: (...args: unknown[]) => update(...args),
updatePoc: (...args: unknown[]) => updatePoc(...args),
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
},
}));
@ -121,6 +123,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
beforeEach(() => {
getById.mockReset();
update.mockReset();
updatePoc.mockReset();
patchBoardField.mockReset();
serverState = { ...BASE_SERVER };
rowState = { ...BASE_ROW };
@ -829,6 +832,40 @@ describe("useWorkOrderTableMutations type and severity", () => {
});
});
describe("useWorkOrderTableMutations POC persistence", () => {
it("fills omitted POC fields from the current row before the full replacement", async () => {
const currentRow = {
...BASE_ROW,
pocName: "Alex",
pocPhone: "555-0000",
pocNotes: "Gate",
};
updatePoc.mockResolvedValue({ ...currentRow, pocName: "Jordan", rowVersion: "wo-v2" });
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch: vi.fn(),
clearPatch: () => undefined,
getRow: () => currentRow,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(42, { pocName: "Jordan" });
});
await waitFor(() => expect(updatePoc).toHaveBeenCalled());
expect(updatePoc).toHaveBeenCalledWith(42, {
pocName: "Jordan",
pocPhone: "555-0000",
pocNotes: "Gate",
workOrderVersion: "AAAAAAA=",
});
});
});
describe("useWorkOrderTableMutations audit cache refresh", () => {
it("invalidates work-order queries after a successful status transition", async () => {
const client = new QueryClient({

View file

@ -67,7 +67,9 @@ describe("slide-over-draft", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts");
expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
@ -85,11 +87,11 @@ describe("slide-over-draft", () => {
expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
});
it("ignores POC-only edits for dirty state", () => {
it("tracks POC edits for dirty state and patch", () => {
const row = baseRow();
const draft = baseRow({ pocName: "Jordan", pocPhone: "555-0100" });
expect(isSlideOverDraftDirty(draft, row)).toBe(false);
expect(buildSlideOverPatch(draft, row)).toEqual({});
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
expect(buildSlideOverPatch(draft, row)).toEqual({ pocName: "Jordan", pocPhone: "555-0100" });
});
it("detects dirty draft when a tracked field changes", () => {

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils";
import { canViewAllDispatchersOnDashboard, hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
describe("isAdminUser", () => {
it("returns true when Admin appears in roles", () => {
@ -14,6 +14,13 @@ describe("isAdminUser", () => {
});
});
describe("hasUserRole", () => {
it("finds a role regardless of its position", () => {
expect(hasUserRole("Dispatcher,Scheduler", "Scheduler")).toBe(true);
expect(hasUserRole("Dispatcher", "Scheduler")).toBe(false);
});
});
describe("canViewAllDispatchersOnDashboard", () => {
it("grants the dashboard picker to Admin and Scheduler", () => {
expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true);

View file

@ -1,352 +1,75 @@
# Frontend Terraform adoption runbook (dev)
# Frontend Terraform (SPA CD)
This tree adopts the existing Sea Haven SHOC frontend dev hosting resources
into HCP Terraform without recreating them. It mirrors the backend adoption
(`shoc-backend` #94, #98, #99, #102) and lands in three PRs:
`terraform/live/dev` and `terraform/live/staging` in AWS account `396287094661`.
HCP Terraform owns the bucket, CloudFront, DNS, and the GitHub deploy role.
GitHub Actions owns content: `.github/workflows/deploy-web.yaml` syncs `dist/`
to the bucket root and invalidates `/*`.
| PR | Branch | Change |
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| A | `feature/frontend-terraform-adoption` | Merged (#159). Dev root with `adoption_complete = false`, import guard, CDK retain mode. |
| B | `feature/terraform-dev-adoption` | Merged (#178). `adoption_complete = true`: ownership tags, bucket policy drops the auto-delete grant. |
| C | `feature/terraform-dev-content-cd` | This PR. Content CD through Terraform: release prefixes, pointer, origin group, invalidation, rollback. |
Creating, formatting, initializing with `-backend=false`, and validating these
files does not authorize an AWS, HCP Terraform, GitHub, or deployment
mutation.
Creating these files, formatting them, initializing with `-backend=false`, and
validating them does not authorize an AWS, HCP Terraform, GitHub,
CloudFormation, DNS, or deployment mutation. Every live step below is gated on
an explicit go from the owner, with the production impact stated first.
Staging stays on the CDK and `deploy-staging.yml` path. Its cutover is tracked
separately (SH-287) and adds its own root under `live/staging` when it starts.
The `staging` constants in `scripts/terraform_import_plan_resources.py` exist
only so the checker can prove a dev plan carrying a staging identifier fails.
Do not collapse these roots into one `terraform/` tree. Flattening retargets
two live HCP working directories and is its own change.
## Fixed targets
- AWS account: `396287094661`
- AWS region: `us-east-1`
- HCP organization: `seahaven`
- HCP project: `seahaven-external-dev`
- HCP workspace: `shoc-frontend-new-dev`, VCS branch `dev`, working
directory `terraform/live/dev`
- Site: `dev.seahaven.com`
- API build value: `https://api.dev.seahaven.com/api`
| | dev | staging |
| ------------- | ------------------------------------ | ---------------------------------------- |
| Site | `dev.seahaven.com` | `staging.seahaven.com` |
| Bucket | `seahaven-shoc-frontend-dev` | `seahaven-shoc-frontend-staging` |
| Distribution | `E2CWLM1AFB964P` | `E2JDVEZ6EGD49J` |
| Deploy role | `githubdeploy-shoc-frontend-new-dev` | `githubdeploy-shoc-frontend-new-staging` |
| HCP workspace | `shoc-frontend-new-dev` | `shoc-frontend-new-staging` |
| Working dir | `terraform/live/dev` | `terraform/live/staging` |
## Workspace invariants
There is no prod CloudFront in this round. Do not create
`shoc-frontend-new-prod`.
Set before any Terraform lands on `dev`, read back after setting, and re-read
before the first release after any Terraform merge:
## Ownership
- Auto-apply **off**. GitHub or a human applies every run.
- Automatic speculative plans **on** (PR plans are read-only evidence).
- Automatic run triggering: **patterns**
`terraform/live/dev/**` and `terraform/live/modules/**`. No trigger
prefixes, no tags regex. Do not switch to tag-based triggering.
- Execution mode remote, Terraform `1.16.x` (`versions.tf` requires
`>= 1.14.0, < 2.0.0`; CI validates with `1.16.0`).
- Dynamic AWS credentials only: environment variables
`TFC_AWS_PROVIDER_AUTH=true`, `TFC_AWS_PLAN_ROLE_ARN`, and
`TFC_AWS_APPLY_ROLE_ARN` pointing at `hcptf-shoc-frontend-new-dev-plan`
and `hcptf-shoc-frontend-new-dev`. No access keys.
- **No** `adoption_complete` workspace variable. The dev root pins it in code
(`local.adoption_complete`) so the value under review is the value that
applies. `scripts/test-terraform-import-plan-check.py` fails if a `variable`
block reappears in the root.
`module.environment_owned` keeps the same addresses as the adopted HCP
`shoc-frontend-new-dev` state. The SPA origin path is empty. The release
pointer is forgotten (`removed { destroy = false }`), not destroyed.
## Ownership boundary
Deploy parameters live under `/shoc-frontend-new/<env>/deploy/{bucket,distribution-id}`.
`githubdeploy` may List/Get/Put/Delete the bucket root, CreateInvalidation, and
GetParameter on those two names. OIDC trust is `environment:<env>` plus
`job_workflow_ref` for `.github/workflows/deploy-web.yaml` at `refs/heads/main`
and `refs/tags/v*`.
`live/modules/environment-owned` owns these 14 addresses (13 imported hosting
resources plus the release pointer created in Phase 3):
`adoption_complete` is pinned in each live root. It is not a workspace
variable.
1. `module.environment_owned.aws_s3_bucket.site`
2. `module.environment_owned.aws_s3_bucket_public_access_block.site`
3. `module.environment_owned.aws_s3_bucket_ownership_controls.site`
4. `module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site`
5. `module.environment_owned.aws_s3_bucket_versioning.site`
6. `module.environment_owned.aws_s3_bucket_policy.site`
7. `module.environment_owned.aws_cloudfront_distribution.site`
8. `module.environment_owned.aws_cloudfront_origin_access_control.site`
9. `module.environment_owned.aws_cloudfront_function.spa_rewrite`
10. `module.environment_owned.aws_route53_record.site_a`
11. `module.environment_owned.aws_route53_record.site_aaaa`
12. `module.environment_owned.aws_iam_role.github_deploy`
13. `module.environment_owned.aws_iam_role_policy.github_deploy`
14. `module.environment_owned.aws_s3_object.release_pointer`
The CloudFront invalidation is a Terraform action
(`action.aws_cloudfront_create_invalidation.release`), not a managed resource.
Every managed resource has `prevent_destroy = true`.
`live/modules/environment-inventory` is data-only. It resolves and checks the
caller account, provider region, public hosted zone, ACM certificate, account
GitHub OIDC provider, and the AWS managed `Managed-CachingOptimized` cache
policy against pinned values, and fails the plan on any mismatch.
The following remain outside state:
- the `dev.seahaven.com` hosted zone and the `*.seahaven.com` certificate
- the account-global GitHub OIDC provider
- the AWS managed CloudFront cache policy
- `CDKToolkit` resources and CDK metadata
- the S3 auto-delete custom resource, its provider Lambda and role
- the HCP plan/apply roles and the deploy-role permissions boundary
## Exact live inventory (dev)
- Bucket and all bucket subresources: `seahaven-shoc-frontend-dev`
- Distribution: `E2CWLM1AFB964P`
- OAC: `E30VSIK87N8H64`, name
`shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620`,
description modeled as `""`
- Distribution origin ID: `shocfrontenddevDistributionOrigin10CCD0EE1`
- Function: `us-east-1shocfrontenddevSpaRewrite58674DB8`
- A import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_A`
- AAAA import ID: `Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA`
- Deploy role: `githubdeploy-shoc-frontend-new-dev`
- Inline policy import ID:
`githubdeploy-shoc-frontend-new-dev:GithubDeployRoleDefaultPolicyE8F540D1`
- Hosted zone: `Z07671212N75U4YLPWZR8`
- Certificate:
`arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`
- Legacy stack: `shoc-frontend-dev`
- Auto-delete helper role:
`arn:aws:iam::396287094661:role/shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV`
- Permissions boundary:
`arn:aws:iam::396287094661:policy/shoc-frontend-new-dev-deploy-boundary`
With `adoption_complete = false` the root declares the configuration observed
after the CDK retain deploy (Phase 1, step 2), not the configuration live
today:
- `Environment=dev`, `ManagedBy=cdk`, `Project=shoc-frontend` tags, plus the
S3-only `aws-cdk:auto-delete-objects=true` tag
- the deploy-role-only `HcpTerraformWorkspace=shoc-frontend-new-dev` tag
- the permissions boundary attached to the deploy role
- `StringEquals` on the OIDC subject
`repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev`
- the legacy bucket policy including the auto-delete helper grant
- the legacy deploy inline policy (`AssumeCdkBootstrapRoles`, `DescribeStack`,
bucket read/write, `InvalidateDistribution`)
The retain deploy adds the boundary, the tag, and the `StringEquals` narrowing.
If read-back after that deploy differs from the root in any other way, update
the root to the observed value and prove a zero-change import plan. Do not
approve drift through the controlled-update checker.
## Phase 1: import-first adoption (merged)
Each step is gated. State the impact, get the go, act, read back, record.
1. **Workspace invariants.** Set the invariants above on
`shoc-frontend-new-dev`. Read back the workspace and record the JSON in the
PR.
2. **CDK retain deploy.** Completed from the reviewed PR A head. The CDK app
is no longer in this repository.
Expected: an update-only change set (no create, no delete, no replace)
that adds `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the
13 transferred resources and the `Custom::S3AutoDeleteObjects` resource,
attaches the boundary, adds the `HcpTerraformWorkspace` tag, and narrows
the trust operator. Read back the role, bucket policy, and stack resources
as JSON and attach it to the PR.
3. **Merge PR A.** The merge triggers a VCS run on the workspace (auto-apply
off). Download the plan JSON and run the guard:
```bash
python3 scripts/check-terraform-import-plan.py plan.json --environment dev
```
Confirm the apply only when the plan is exactly 13 imports, 0 create,
0 update, 0 delete, 0 replace and the guard exits 0. Otherwise discard the
run and fix the root in a new PR.
4. **Post-import no-op.** Queue a plan and require it to be no-op:
```bash
python3 scripts/check-terraform-import-plan.py post-import.json \
--environment dev --post-import-no-op
```
Post the run URLs and the guard output on SH-300.
After Phase 1 CloudFormation still owns every resource. Terraform holds state
for them and nothing else.
## Phase 2: controlled ownership transfer (merged #178)
PR B pins `adoption_complete = true`. The controlled apply may update only:
- `module.environment_owned.aws_s3_bucket.site` (tags)
- `module.environment_owned.aws_s3_bucket_policy.site` (drops only the
auto-delete helper grant)
- `module.environment_owned.aws_cloudfront_distribution.site` (tags)
- `module.environment_owned.aws_cloudfront_function.spa_rewrite` (tags)
- `module.environment_owned.aws_iam_role.github_deploy` (tags)
The OAC, both Route 53 records, and the deploy inline policy must be no-op.
PR B keeps the GitHub deploy inline policy byte-identical to live so
`aws_iam_role_policy.github_deploy` does not appear in the plan. Run the
checker with one `--allow-update-address` per updating address; it rejects
unused allowlist entries, unknown values, and replacements:
## Local checks (no apply)
```bash
python3 scripts/check-terraform-import-plan.py plan.json --environment dev \
--allow-update-address module.environment_owned.aws_s3_bucket.site \
--allow-update-address module.environment_owned.aws_s3_bucket_policy.site \
--allow-update-address module.environment_owned.aws_cloudfront_distribution.site \
--allow-update-address module.environment_owned.aws_cloudfront_function.spa_rewrite \
--allow-update-address module.environment_owned.aws_iam_role.github_deploy
terraform fmt -check -recursive terraform
terraform -chdir=terraform/live/dev init -backend=false -lockfile=readonly
terraform -chdir=terraform/live/dev validate
terraform -chdir=terraform/live/staging init -backend=false -lockfile=readonly
terraform -chdir=terraform/live/staging validate
python3 scripts/test-terraform-import-plan-check.py
python3 scripts/test_check_app_terraform_isolation.py
bash scripts/test-verify-cloudfront-release.sh
```
After the apply and a no-op plan, the CDK stack was relinquished with
`ManageSiteInfrastructure=false`. Never deploy that stack with
`ManageSiteInfrastructure=true` again. The CDK app was removed in PR C.
PRs cannot mix `terraform/` with deployable application files. Workflow, docs,
and gate-script changes may travel with either side. G13 is
`python3 scripts/check_app_terraform_isolation.py` against the merge base of
the PR, and against each queued PR (first-parent commit) on `merge_group`.
Confirm `dev.seahaven.com` still serves. Phase 2 proved a manual
`workflow_dispatch` of `deploy.yml` could still upload with the then-unchanged
GitHub content policy. PR C replaces that policy with the release-prefix
document during bootstrap.
`npm run test:terraform` and `npm run verify` wrap the same gates. They never
create an HCP run or touch AWS.
## Phase 3: content CD through Terraform (this PR)
## Workspaces
GitHub builds the SPA and uploads only `releases/<sha>-<run>-<attempt>/`.
The GitHub role may `GetObject` on `.release/current` and read the exact
distribution (`GetDistribution` / `GetDistributionConfig`) so verify and
live-state summary can observe origin paths. It cannot invalidate or write
the pointer. Terraform owns `.release/current`, both origin paths of the
CloudFront origin group, and the `aws_cloudfront_create_invalidation` action. Rollback is one
guarded Terraform run that swaps the labels. Push-to-`dev` stays off until
`vars.TERRAFORM_CONTENT_CD_ENABLED` is the string `true`. Dev no longer calls
`scripts/deploy-web.sh`; that script remains the staging publisher (SH-287).
Dev (`shoc-frontend-new-dev`) watches `main` with working directory
`terraform/live/dev` and auto-apply on. Staging (`shoc-frontend-new-staging`)
watches tag regex `^v[0-9]+\.[0-9]+\.[0-9]+-staging$` with working directory
`terraform/live/staging` and auto-apply on. Merges to `main` do not apply
staging.
Release vars `release_version_label` and `previous_release_version_label` are
nullable, default null, and must not be set on the workspace or in tfvars.
Null VCS plans read the pointer back from S3. Empty string is the legacy root
layout.
Per GitHub content release after bootstrap: exactly two managed updates plus
one action invocation (`0/2/0`). `scripts/check-terraform-release-plan.py`
accepts a plan that updates only the pointer `content` and
`origin[*].origin_path`, with `after` equal to the expected labels, `before`
equal to the pointer's prior values, and exactly one invalidation
`action_invocations` entry.
The first VCS apply after merge is **bootstrap**, not `0/2/0`. It creates
`.release/current` (legacy empty labels), adds the previous origin and origin
group, switches the default behavior to the group, replaces the GitHub inline
policy with the release-prefix document, and invokes invalidation. A human
confirms that apply. GitHub CD starts only after bootstrap is applied.
Activation (each step gated; do not run without an explicit go):
1. Merge this PR with `TERRAFORM_CONTENT_CD_ENABLED` unset. Confirm or discard
the HCP VCS run. Apply bootstrap as a human-confirmed controlled update.
2. Re-read workspace invariants (auto-apply off, speculative on, trigger
patterns only, no prefixes, no tags-regex).
3. `workflow_dispatch` on `dev`. Confirm pointer, origin paths, invalidation,
smoke, and rollback readiness from the live-state summary.
4. Set `TERRAFORM_CONTENT_CD_ENABLED=true` only after that proof and owner
approval.
5. Confirm the first push-to-`dev` run. Close SH-300 on that proof.
A red job does not mean the site is down. Read the live-state summary first.
## Operational rules
- **Terraform-only PRs.** A PR that changes `terraform/**` may not change
deployable application code. The `terraform-isolation` job in
`.github/workflows/terraform-isolation.yaml` enforces this; documentation and the
`scripts/*terraform*` tooling are allowed alongside. A reviewer may add the
`terraform-isolation-override` label for the rare change that must introduce
Terraform variables together with the workflow that consumes them (PR C).
Adding or removing that label re-runs only that workflow against the labels
currently on the PR; Frontend checks does not start a new run. Removing the
label fails a mixed PR that had previously passed with the override, so a
stale green check cannot merge. Markdown under `terraform/` does not count as a Terraform
change for this gate; it does not match the workspace trigger patterns.
The label is the approval record. The override is temporary:
a follow-up PR after PR C removes the label path from the checker and
workflow so the gate has no exception.
- **Every Terraform merge produces a VCS run.** A human confirms or discards
it before the next content release. Do not leave a pending run on the
workspace.
- **Re-read the workspace invariants** before the first release after any
Terraform merge or workspace settings change.
- **A red job does not mean the site is down.** Read the live-state summary
first (served `index.html` hash, distribution status, pointer body, both
origin paths), then triage.
- **Exact-head evidence.** Every live step records the run URL, the SHA, and a
machine-readable read-back on the PR or SH-300.
## Local validation
From the repository root (also run by `npm run verify` through
`scripts/governance-check.mjs`):
```bash
npm run test:terraform # fmt -check, init -backend=false, validate
npm run test:terraform-import-plan # checker unit tests against synthetic plans
npm run test:terraform-release-plan # content-release plan guard
npm run test:terraform-isolation # isolation gate unit tests
npm run test:hcp-run-guard # workspace invariant and apply reconcile
npm run test:cloudfront-release-verify
npm run test:github-workflows # bash -n and actionlint
```
`terraform init -backend=false -lockfile=readonly` may download the provider
but never contacts HCP state or plans against AWS. Only HCP runs plan against
the account.
The lock file must carry `h1:` hashes for every platform that runs the gate
(CI and HCP are `linux_amd64`, laptops are `darwin_*`). After changing the
provider version, refresh them with:
```bash
terraform -chdir=terraform/live/dev providers lock \
-platform=linux_amd64 -platform=linux_arm64 \
-platform=darwin_amd64 -platform=darwin_arm64
```
## Import plan safety
Import mode requires exactly the canonical 13 addresses and AWS types, valid
import metadata for every resource, the exact dev import IDs (a staging ID in a
dev plan fails), and zero create, update, delete, or replace actions.
Post-import mode requires all 13 resources to be no-op and rejects any
remaining import metadata.
Controlled mode permits only in-place updates to the addresses explicitly
listed with `--allow-update-address`, verifies `before` against the exact
pre-adoption policies and tags and `after` against the exact adopted values,
and rejects create, delete, replace, import metadata, unknown values,
unapproved addresses, and unused allowlist entries.
## Rollback
- Before import apply: discard the run and correct the root.
- After import, before the controlled update (end of Phase 1): remove only the
13 imported addresses from state under a separately reviewed state
operation. CloudFormation remains authoritative; a
`ManageSiteInfrastructure=true` stack is unchanged by this.
- After the controlled update, before detachment: either complete the reviewed
detachment or restore the exact pre-adoption policy and tags under a
separate approval. Do not remove state or redeploy CloudFormation blindly.
- After detachment: Terraform is authoritative. Restore content from the
versioned bucket. Re-establishing CloudFormation ownership requires a
reviewed `IMPORT` change set, never an ordinary update.
Any replacement, destroy, cross-environment ID, missing import, broad policy
change, or failed smoke check is a hard stop.
## Evidence per phase
- HCP run URL and the workspace settings read-back
- plan JSON and checker output
- `terraform state list` showing exactly the 13 addresses
- read-only inventory before and after each mutation
- synthesized CloudFormation template, change set, and stack events
- deploy, invalidation, and smoke output
- the post-action no-op plan
- phase close-out on SH-300: completed work, validation, risks, deviations,
remaining work
GitHub Environments `dev` and `staging` set `DEPLOY_ROLE_ARN` and allow `main`
plus tag `v*`. Promote staging with `gh release create vX.Y.Z-staging --target
main`.

8
terraform/live/README.md Normal file
View file

@ -0,0 +1,8 @@
# Live Terraform roots
`live/dev/` is the adopted HCP workspace `shoc-frontend-new-dev`.
`live/staging/` is `shoc-frontend-new-staging` (`adoption_complete = true`).
Do not collapse these into one `terraform/` root in the same PR as application
CD. Flattening retargets two live HCP working directories and belongs in its
own change.

View file

@ -2,20 +2,25 @@ locals {
# Controlled ownership transfer. Pinned in code, never a workspace variable.
adoption_complete = true
environment = "dev"
workspace_name = "shoc-frontend-new-dev"
aws_account_id = "396287094661"
aws_region = "us-east-1"
bucket_name = "seahaven-shoc-frontend-dev"
distribution_id = "E2CWLM1AFB964P"
oac_id = "E30VSIK87N8H64"
oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620"
origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1"
function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8"
domain_name = "dev.seahaven.com"
hosted_zone_id = "Z07671212N75U4YLPWZR8"
certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
github_oidc_arn = "arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
environment = "dev"
workspace_name = "shoc-frontend-new-dev"
github_repo = "Sea-Haven-Industries/shoc-frontend-new"
aws_account_id = "396287094661"
aws_region = "us-east-1"
bucket_name = "seahaven-shoc-frontend-dev"
distribution_id = "E2CWLM1AFB964P"
oac_id = "E30VSIK87N8H64"
oac_name = "shocfrontenddevDistributionOrigin1S3OriginAccessControlDFC82620"
origin_id = "shocfrontenddevDistributionOrigin10CCD0EE1"
function_name = "us-east-1shocfrontenddevSpaRewrite58674DB8"
domain_name = "dev.seahaven.com"
hosted_zone_id = "Z07671212N75U4YLPWZR8"
certificate_arn = (
"arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00"
)
github_oidc_arn = (
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
)
deploy_role_name = "githubdeploy-shoc-frontend-new-dev"
inline_policy = "GithubDeployRoleDefaultPolicyE8F540D1"
stack_name = "shoc-frontend-dev"
@ -61,35 +66,30 @@ module "inventory" {
module "environment_owned" {
source = "../modules/environment-owned"
environment = local.environment
adoption_complete = local.adoption_complete
aws_account_id = local.aws_account_id
aws_region = local.aws_region
bucket_name = local.bucket_name
distribution_id = local.distribution_id
origin_access_control_name = local.oac_name
origin_access_control_description = ""
origin_id = local.origin_id
function_name = local.function_name
domain_name = local.domain_name
hosted_zone_id = local.hosted_zone_id
certificate_arn = local.certificate_arn
cache_policy_id = local.cache_policy_id
github_oidc_provider_arn = local.github_oidc_arn
github_subject = "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev"
pre_adoption_github_subject_operator = "StringEquals"
post_adoption_github_subject_operator = "StringEquals"
deploy_branch = "dev"
deploy_role_name = local.deploy_role_name
deploy_inline_policy_name = local.inline_policy
deploy_permissions_boundary_arn = local.permissions_boundary_arn
cloudformation_stack_name = local.stack_name
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
pre_adoption_tags = local.legacy_tags
pre_adoption_bucket_tags = local.legacy_bucket_tags
ownership_tags = local.terraform_tags
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
release_version_label = var.release_version_label
previous_release_version_label = var.previous_release_version_label
environment = local.environment
adoption_complete = local.adoption_complete
aws_account_id = local.aws_account_id
aws_region = local.aws_region
github_repo = local.github_repo
bucket_name = local.bucket_name
distribution_id = local.distribution_id
origin_access_control_name = local.oac_name
origin_access_control_description = ""
origin_id = local.origin_id
function_name = local.function_name
domain_name = local.domain_name
hosted_zone_id = local.hosted_zone_id
certificate_arn = local.certificate_arn
cache_policy_id = local.cache_policy_id
github_oidc_provider_arn = local.github_oidc_arn
deploy_role_name = local.deploy_role_name
deploy_inline_policy_name = local.inline_policy
deploy_permissions_boundary_arn = local.permissions_boundary_arn
cloudformation_stack_name = local.stack_name
bucket_auto_delete_helper_role_arn = local.bucket_auto_delete_helper_role_arn
pre_adoption_tags = local.legacy_tags
pre_adoption_bucket_tags = local.legacy_bucket_tags
ownership_tags = local.terraform_tags
pre_adoption_deploy_role_tags = merge(local.legacy_tags, local.manager_tag)
post_adoption_deploy_role_tags = merge(local.terraform_tags, local.manager_tag)
}

View file

@ -1,19 +1,19 @@
output "bucket_name" {
value = module.environment_owned.bucket_name
value = module.environment_owned.bucket_name
description = "SPA origin bucket name."
}
output "distribution_id" {
value = module.environment_owned.distribution_id
value = module.environment_owned.distribution_id
description = "CloudFront distribution ID."
}
output "deploy_role_arn" {
value = module.environment_owned.deploy_role_arn
value = module.environment_owned.deploy_role_arn
description = "GitHub Actions deploy role ARN."
}
output "current_origin_id" {
value = module.environment_owned.current_origin_id
}
output "previous_origin_id" {
value = module.environment_owned.previous_origin_id
output "origin_id" {
value = module.environment_owned.origin_id
description = "CloudFront origin ID for the bucket-root SPA."
}

View file

@ -1,33 +0,0 @@
variable "release_version_label" {
type = string
default = null
nullable = true
description = "Immutable content release label. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.release_version_label == null ||
var.release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.release_version_label))
)
error_message = "release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}
variable "previous_release_version_label" {
type = string
default = null
nullable = true
description = "Previous content release label used as the origin-group failover. Null VCS plans read the live pointer from S3."
validation {
condition = (
var.previous_release_version_label == null ||
var.previous_release_version_label == "" ||
can(regex("^[0-9a-f]{40}-[0-9]+-[0-9]+$", var.previous_release_version_label))
)
error_message = "previous_release_version_label must be empty or <full-sha>-<run-id>-<attempt>."
}
}

View file

@ -1,28 +1,11 @@
locals {
bucket_arn = "arn:aws:s3:::${var.bucket_name}"
distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}"
resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
github_subject_operator = var.pre_adoption_github_subject_operator
previous_origin_id = "${var.origin_id}-previous"
origin_group_id = "${var.origin_id}-group"
pointer_key = ".release/current"
pointer_body = try(jsondecode(data.aws_s3_object.release_pointer[0].body), {})
# coalesce() skips empty strings, so a null var plus a missing pointer
# would error. Empty string is the legacy root layout and must be valid.
current_label = (
var.release_version_label != null
? var.release_version_label
: try(local.pointer_body.current, "")
)
previous_label = (
var.previous_release_version_label != null
? var.previous_release_version_label
: try(local.pointer_body.previous, "")
)
current_origin_path = local.current_label == "" ? "" : "/releases/${local.current_label}"
previous_origin_path = local.previous_label == "" ? "" : "/releases/${local.previous_label}"
bucket_arn = "arn:aws:s3:::${var.bucket_name}"
distribution_arn = "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.distribution_id}"
resource_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_tags
bucket_tags = var.adoption_complete ? var.ownership_tags : var.pre_adoption_bucket_tags
deploy_role_tags = var.adoption_complete ? var.post_adoption_deploy_role_tags : var.pre_adoption_deploy_role_tags
ssm_prefix = "/shoc-frontend-new/${var.environment}"
github_subject = "repo:${var.github_repo}:environment:${var.environment}"
spa_rewrite_code = join("\n", [
"function handler(event) {",
@ -37,17 +20,6 @@ locals {
])
}
data "aws_s3_objects" "release_prefix" {
bucket = aws_s3_bucket.site.bucket
prefix = ".release/"
}
data "aws_s3_object" "release_pointer" {
count = contains(coalesce(data.aws_s3_objects.release_prefix.keys, []), local.pointer_key) ? 1 : 0
bucket = aws_s3_bucket.site.bucket
key = local.pointer_key
}
data "aws_iam_policy_document" "site_bucket" {
dynamic "statement" {
for_each = var.adoption_complete ? [] : [1]
@ -115,6 +87,7 @@ data "aws_iam_policy_document" "site_bucket" {
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
sid = "GithubDeployOidc"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
@ -130,59 +103,70 @@ data "aws_iam_policy_document" "github_deploy_assume" {
}
condition {
test = local.github_subject_operator
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = [var.github_subject]
values = [local.github_subject]
}
# StringLike: a release-triggered job loads the workflow file from the tag,
# so job_workflow_ref ends in @refs/tags/vX.Y.Z-staging there and
# @refs/heads/main on push and workflow_dispatch. The environment claim in
# sub is the gate.
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [
"${var.github_repo}/.github/workflows/deploy-web.yaml@refs/heads/main",
"${var.github_repo}/.github/workflows/deploy-web.yaml@refs/tags/v*",
]
}
}
}
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "ListReleasePrefixes"
sid = "ListWebBucket"
effect = "Allow"
actions = [
"s3:GetBucketLocation",
"s3:ListBucket",
]
resources = [local.bucket_arn]
condition {
test = "StringLike"
variable = "s3:prefix"
values = [
"releases/",
"releases/*",
]
}
}
statement {
sid = "PublishReleasePrefix"
sid = "SyncWebBucket"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
]
resources = ["${local.bucket_arn}/releases/*"]
resources = ["${local.bucket_arn}/*"]
}
statement {
sid = "ReadReleasePointer"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${local.bucket_arn}/${local.pointer_key}"]
}
statement {
sid = "ReadDistribution"
sid = "InvalidateDistribution"
effect = "Allow"
actions = [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
]
resources = [local.distribution_arn]
}
statement {
sid = "DeployParams"
effect = "Allow"
actions = [
"ssm:GetParameter",
]
resources = [
aws_ssm_parameter.deploy_bucket.arn,
aws_ssm_parameter.deploy_distribution_id.arn,
]
}
}
resource "aws_s3_bucket" "site" {
@ -257,17 +241,13 @@ resource "aws_s3_bucket_policy" "site" {
}
}
resource "aws_s3_object" "release_pointer" {
bucket = aws_s3_bucket.site.bucket
key = local.pointer_key
content_type = "application/json"
content = jsonencode({
current = local.current_label
previous = local.previous_label
})
# Pointer leftover from Terraform-promoted content CD. Forgotten, not destroyed.
# deploy-web.yaml syncs dist/ to the bucket root with --delete.
removed {
from = aws_s3_object.release_pointer
lifecycle {
prevent_destroy = true
destroy = false
}
}
@ -313,32 +293,7 @@ resource "aws_cloudfront_distribution" "site" {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
origin_id = var.origin_id
origin_path = local.current_origin_path
}
origin {
connection_attempts = 3
connection_timeout = 10
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
origin_id = local.previous_origin_id
origin_path = local.previous_origin_path
}
origin_group {
origin_id = local.origin_group_id
failover_criteria {
status_codes = [403, 404]
}
member {
origin_id = var.origin_id
}
member {
origin_id = local.previous_origin_id
}
origin_path = ""
}
default_cache_behavior {
@ -346,7 +301,7 @@ resource "aws_cloudfront_distribution" "site" {
cache_policy_id = var.cache_policy_id
cached_methods = ["GET", "HEAD"]
compress = true
target_origin_id = local.origin_group_id
target_origin_id = var.origin_id
viewer_protocol_policy = "redirect-to-https"
function_association {
@ -369,18 +324,6 @@ resource "aws_cloudfront_distribution" "site" {
lifecycle {
prevent_destroy = true
action_trigger {
events = [after_update]
actions = [action.aws_cloudfront_create_invalidation.release]
}
}
}
action "aws_cloudfront_create_invalidation" "release" {
config {
distribution_id = aws_cloudfront_distribution.site.id
paths = ["/*"]
}
}
@ -419,7 +362,7 @@ resource "aws_route53_record" "site_aaaa" {
resource "aws_iam_role" "github_deploy" {
name = var.deploy_role_name
path = "/"
description = "GitHub Actions deploy role for Sea-Haven-Industries/shoc-frontend-new@${var.deploy_branch}"
description = "GitHub Actions SPA deploy role for ${var.github_repo} Environment ${var.environment}"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
max_session_duration = 3600
permissions_boundary = var.deploy_permissions_boundary_arn
@ -427,6 +370,9 @@ resource "aws_iam_role" "github_deploy" {
lifecycle {
prevent_destroy = true
# SCP ProtectDeploymentPrincipalLifecycle denies UpdateRoleDescription on
# githubdeploy-* for HCP apply roles.
ignore_changes = [description]
}
}
@ -439,3 +385,23 @@ resource "aws_iam_role_policy" "github_deploy" {
prevent_destroy = true
}
}
resource "aws_ssm_parameter" "deploy_bucket" {
name = "${local.ssm_prefix}/deploy/bucket"
type = "String"
data_type = "text"
tier = "Standard"
value = aws_s3_bucket.site.id
description = "SPA origin bucket; deploy-web syncs dist/ to the bucket root"
tags = local.resource_tags
}
resource "aws_ssm_parameter" "deploy_distribution_id" {
name = "${local.ssm_prefix}/deploy/distribution-id"
type = "String"
data_type = "text"
tier = "Standard"
value = aws_cloudfront_distribution.site.id
description = "CloudFront distribution ID; deploy-web invalidates /* after sync"
tags = local.resource_tags
}

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