mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
80 commits
279b4d272e
...
38bbb4a575
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38bbb4a575 | ||
|
|
e51551c160 | ||
|
|
cc7ecb9b5a | ||
|
|
ec9812ae8d | ||
| af0ae60b80 | |||
| 8ad7438f26 | |||
|
|
3e5fba4d2b | ||
|
|
cbfed3dde8 | ||
|
|
f25a3dd628 | ||
|
|
41b26692c3 | ||
| 1698d53d38 | |||
|
|
73e3362fcf | ||
|
|
e611f193bd | ||
| bebd517290 | |||
|
|
7c470d389c | ||
|
|
de7066d31b | ||
|
|
2c47b4a9ca | ||
|
|
7fdf85fc47 | ||
|
|
7eb7e4a730 | ||
|
|
9bba6402e0 | ||
|
|
7c1243eec3 | ||
|
|
744d7af7bb | ||
|
|
e3afa182d6 | ||
|
|
d7ee290b1d | ||
|
|
4d3dd82afb | ||
|
|
29caecd449 | ||
|
|
e1b03dc151 | ||
|
|
5766616cc0 | ||
|
|
148f5f1823 | ||
| 0c867e2cb4 | |||
|
|
2600234b89 | ||
|
|
1199252673 | ||
|
|
daa590622b | ||
| ae852f15ea | |||
|
|
fa9e60ce0b | ||
|
|
6f4b3d0e43 | ||
| 7b37529006 | |||
| c99273d3fc | |||
|
|
8d50c6f75e | ||
|
|
95f0342271 | ||
|
|
1a7a4ba58f | ||
| 0bf834bb8e | |||
| 7ed0743b90 | |||
|
|
bd79617aa2 | ||
| ceecab5438 | |||
|
|
c96a259365 | ||
|
|
ff79c9d56e | ||
|
|
84568dd737 | ||
|
|
3b1da21abb | ||
|
|
5b0c758a9f | ||
|
|
33d0ab4fd6 | ||
|
|
3c84d851c0 | ||
|
|
c9562650ca | ||
|
|
e780bafdd8 | ||
|
|
28ff14d457 | ||
|
|
8a0a134238 | ||
|
|
abe28a3a7f | ||
|
|
9ed8099e71 | ||
|
|
361f6b65a2 | ||
|
|
534d7ce4d6 | ||
|
|
bbdf418ee3 | ||
|
|
65d55d7c9f | ||
|
|
ff4ecb37e4 | ||
|
|
0eeae02785 | ||
|
|
0302b2828f | ||
|
|
3dbf3df5d5 | ||
|
|
3011cb1eda | ||
|
|
90a3c24846 | ||
|
|
c40f0c4a37 | ||
|
|
7dd7ea497e | ||
|
|
f39328576e | ||
|
|
2fb5d4a965 | ||
|
|
9c360fc95b | ||
|
|
0b229fd054 | ||
|
|
1c66f8d60e | ||
|
|
b449df97e6 | ||
|
|
0b63d882ab | ||
|
|
c9ab1f3598 | ||
|
|
712d8e0dc6 | ||
|
|
ee95847354 |
112 changed files with 5744 additions and 2720 deletions
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
21
.github/renovate.json
vendored
|
|
@ -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/**"],
|
||||
|
|
|
|||
145
.github/workflows/ci.yaml
vendored
145
.github/workflows/ci.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
148
.github/workflows/deploy-staging.yml
vendored
148
.github/workflows/deploy-staging.yml
vendored
|
|
@ -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
316
.github/workflows/deploy-web.yaml
vendored
Normal 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
|
||||
300
.github/workflows/deploy.yml
vendored
300
.github/workflows/deploy.yml
vendored
|
|
@ -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
|
||||
43
.github/workflows/terraform-isolation.yaml
vendored
43
.github/workflows/terraform-isolation.yaml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -49,3 +49,6 @@ seed-data.sql
|
|||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# local scratch
|
||||
/tmp/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
118
README.md
|
|
@ -1,7 +1,7 @@
|
|||
# SHOC Frontend (`shoc-frontend-new`)
|
||||
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy.yml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/ci.yaml)
|
||||
[](https://github.com/Sea-Haven-Industries/shoc-frontend-new/actions/workflows/deploy-web.yaml)
|
||||

|
||||

|
||||

|
||||
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
265
e2e/work-orders/inline-technician.spec.ts
Normal file
265
e2e/work-orders/inline-technician.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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/ })
|
||||
|
|
|
|||
|
|
@ -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 .",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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'/);
|
||||
});
|
||||
78
scripts/check_app_terraform_isolation.py
Normal file
78
scripts/check_app_terraform_isolation.py
Normal 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())
|
||||
|
|
@ -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."
|
||||
29
scripts/g13-live-isolation.mjs
Normal file
29
scripts/g13-live-isolation.mjs
Normal 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];
|
||||
}
|
||||
|
|
@ -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")})`);
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
73
scripts/test-g13-live-isolation.mjs
Normal file
73
scripts/test-g13-live-isolation.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
scripts/test_check_app_terraform_isolation.py
Normal file
69
scripts/test_check_app_terraform_isolation.py
Normal 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()
|
||||
|
|
@ -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}" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
5
src/app/(protected)/settings/services.tsx
Normal file
5
src/app/(protected)/settings/services.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
|
||||
|
||||
export default function ServicesPage() {
|
||||
return <ServicesRegistryView />;
|
||||
}
|
||||
|
|
@ -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" });
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
62
src/domain/services/use-cases/use-services.ts
Normal file
62
src/domain/services/use-cases/use-services.ts
Normal 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"),
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
96
src/domain/vendors/use-cases/use-add-technicians-to-company.ts
vendored
Normal file
96
src/domain/vendors/use-cases/use-add-technicians-to-company.ts
vendored
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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",
|
||||
);
|
||||
|
||||
|
|
|
|||
81
src/domain/work-orders/api/work-order-board-patch-api.ts
Normal file
81
src/domain/work-orders/api/work-order-board-patch-api.ts
Normal 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),
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
91
src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts
vendored
Normal file
91
src/test/domain/vendors/use-cases/add-technicians-to-company.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
8
terraform/live/README.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>."
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue