Compare commits

..

4 commits

Author SHA1 Message Date
111eb55659
fix(terraform): validate exact pre-adoption policies 2026-08-31 17:11:10 -04:00
a9cdd947bf
fix(deploy): normalize CORS smoke-test origin
Enable grouped non-major Terraform updates in Renovate.
2026-08-31 17:11:10 -04:00
74ef5b9ecd
ci(deploy): clarify content-only workflows 2026-08-31 17:11:10 -04:00
8380548917
feat(terraform): adopt live deployment roles safely 2026-08-31 17:11:10 -04:00
183 changed files with 2792 additions and 8859 deletions

View file

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

View file

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

View file

@ -18,7 +18,7 @@
],
"packageRules": [
{
"description": ["Group non-major Terraform provider updates"],
"description": ["Group non-major Terraform updates"],
"matchManagers": ["terraform"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "terraform minor and patch"

View file

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

View file

@ -1,17 +1,6 @@
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.
name: Deploy staging content
on:
push:
branches: [staging]
workflow_dispatch: {}
permissions:
@ -24,7 +13,7 @@ concurrency:
jobs:
deploy:
name: Deploy to staging
name: Publish content 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'
@ -32,27 +21,23 @@ jobs:
environment: staging
env:
VITE_API_URL: https://api.staging.seahaven.com/api
VITE_SENTRY_ENVIRONMENT: staging
VITE_APP_COMMIT_SHA: ${{ github.sha }}
EXPECTED_API_URL: https://api.staging.seahaven.com/api
FORBIDDEN_API_URLS: https://api.dev.seahaven.com/api,https://api.tf-poc.seahaven.com/api
SITE_URL: https://staging.seahaven.com
API_SMOKE_URL: https://api.staging.seahaven.com/swagger/v1/swagger.json
SITE_BUCKET: seahaven-shoc-frontend-staging
EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-staging
CLOUDFRONT_DISTRIBUTION_ID: E2JDVEZ6EGD49J
DEPLOY_RELEASE_ID: ${{ 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}"
- name: Set up Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.9.8"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
@ -62,7 +47,7 @@ jobs:
- name: Quality gates (full verify before any deploy)
run: npm ci && npm run verify
env:
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
GOVERNANCE_BASE: origin/dev
- name: Assume staging deploy role (OIDC)
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
@ -70,79 +55,5 @@ jobs:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
aws-region: us-east-1
# Builds the SPA with the staging VITE_API_URL 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
- name: Build, publish, and verify 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."

53
.github/workflows/deploy-tf-poc.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Deploy tf-poc content
on:
workflow_dispatch: {}
permissions:
id-token: write
contents: read
concurrency:
group: deploy-tf-poc
cancel-in-progress: false
jobs:
deploy:
name: Publish content to tf-poc
if: github.ref == 'refs/heads/feature/terraform-cd-poc'
runs-on: ubuntu-latest
environment: tf-poc
env:
AWS_REGION: us-east-1
VITE_API_URL: https://api.tf-poc.seahaven.com/api
EXPECTED_API_URL: https://api.tf-poc.seahaven.com/api
FORBIDDEN_API_URLS: https://api.dev.seahaven.com/api,https://api.staging.seahaven.com/api
SITE_URL: https://frontend-tf-poc.seahaven.com
SITE_BUCKET: seahaven-shoc-frontend-tf-poc
EXPECTED_SITE_BUCKET: seahaven-shoc-frontend-tf-poc
CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }}
API_SMOKE_URL: https://api.tf-poc.seahaven.com/swagger/v1/swagger.json
DEPLOY_RELEASE_ID: ${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: "1.9.8"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
- name: Quality gates
run: npm ci && npm run verify
env:
GOVERNANCE_BASE: origin/dev
- name: Assume tf-poc 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-tf-poc
aws-region: us-east-1
- name: Build, publish, and verify SPA
run: bash scripts/deploy-web.sh

View file

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

3
.gitignore vendored
View file

@ -48,13 +48,14 @@ infra/cdk/bin/*.js
infra/cdk/lib/*.d.ts
infra/cdk/lib/*.js
# terraform (the provider lock file is committed)
# terraform
**/.terraform/*
*.tfstate
*.tfstate.*
*.tfplan
*.tfvars
*.tfvars.json
!*.tfvars.example
# python
__pycache__/

View file

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

163
README.md
View file

@ -13,15 +13,17 @@ 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)
- **Backend API:** `https://api.dev.seahaven.com/api` (called directly, cross-origin) — source: `Sea-Haven-Industries/shoc-backend`
- **Hosted at:** <https://dev.seahaven.com> and <https://staging.seahaven.com>
- **Backend APIs:** matching `api.<environment>.seahaven.com/api` endpoints,
called directly from the browser
## Architecture
Static SPA hosting on AWS, provisioned by a CDK app local to this repo
([`infra/cdk/`](infra/cdk/README.md)). CloudFront serves the built `dist/`
from a private S3 bucket; the SPA calls the backend directly over HTTPS at
`VITE_API_URL` (no `/api` proxy at the CDN — the backend allows CORS).
Static SPA hosting on AWS. CloudFront serves the built `dist/` from a private,
versioned S3 bucket; the SPA calls the backend directly over HTTPS at
`VITE_API_URL`. The live stacks remain CDK/CloudFormation-owned while the
import-first Terraform transfer is rehearsed and reviewed. See
[`terraform/README.md`](terraform/README.md).
```mermaid
graph LR
@ -29,15 +31,10 @@ graph LR
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
ROLE -->|s3 sync + invalidation| S3
TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3
GH[Manual GitHub deployment] -->|OIDC| ROLE[Environment deploy role]
ROLE -->|content publish + invalidation| S3
```
Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see
[`terraform/README.md`](terraform/README.md) for the phase runbook and the
current ownership state.
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
@ -45,16 +42,17 @@ architecture plan for the keep/discard migration matrix).
## AWS Resources
Stack **`shoc-frontend-dev`** — CDK, account `396287094661`, region
`us-east-1`. Defined in [`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
Stacks **`shoc-frontend-dev`** and **`shoc-frontend-staging`** are currently
CDK-owned in account `396287094661`, region `us-east-1`. They are defined in
[`infra/cdk/lib/frontend-stack.ts`](infra/cdk/lib/frontend-stack.ts).
| 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,staging}` | Private origin (BLOCK_ALL, SSE, versioned; OAC-only reads) |
| CloudFront distribution | `E2CWLM1AFB964P` / `E2JDVEZ6EGD49J` | HTTPS static hosting on the matching environment domain |
| CloudFront Function | `SpaRewrite` | Viewer-request rewrite of extensionless paths to `/index.html` (deep links) |
| IAM role | `githubdeploy-shoc-frontend-new-{dev,staging}` | Environment-scoped GitHub OIDC content deploy role |
| Route 53 records | A/AAAA aliases in the dev and staging hosted zones | Point each custom domain at its CloudFront distribution |
No Lambdas, queues, or databases — this stack is static hosting only.
@ -62,13 +60,9 @@ 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
repo secret** is:
| Secret | Purpose |
| ------------------- | ------------------------------------------------------------------ |
| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy |
No Secrets Manager, SSM parameters, AWS access keys, or deploy-role repo secret
are used. Content workflows assume their pinned environment role through
GitHub OIDC.
### Environment variables (build-time, `VITE_*`)
@ -83,9 +77,9 @@ repo secret** is:
build otherwise. See [`.env.example`](.env.example),
[`.env.development`](.env.development), and [`.env.production`](.env.production).
CDK context (domain, certificate ARN, hosted zone) lives in
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs
`cdk deploy` with no flags.
CDK context for normal dev synthesis lives in
[`infra/cdk/cdk.json`](infra/cdk/cdk.json). CDK deployment is no longer part of
recurring content releases during the ownership transfer.
## Local Development
@ -102,22 +96,17 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
the deployed dev API).
| Command | Description |
| ------------------------------------------ | ------------------------------------------------------------ |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) |
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) |
| `npm run verify` | **All gates**: format + lint + build + test + governance |
`npm run governance` needs `terraform` and `python3` on `PATH` for the
Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`,
`npm run test:terraform-isolation`) and installs `infra/cdk` for
`npm run test:infra`.
| Command | Description |
| ------------------------------------------ | -------------------------------------------------------- |
| `npm run dev` | Start Vite dev server on port 3000 |
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
| `npm run preview` | Preview the production build locally |
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
| `npm run format` / `npm run format:check` | Prettier (write / check) |
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
| `npm run verify` | **All gates**: format + lint + build + test + governance |
Husky + lint-staged run ESLint and Prettier on staged files at commit;
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
@ -126,7 +115,7 @@ 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/`
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
@ -135,81 +124,65 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
a green CI run and an approving review from a code owner
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
Merged branches are deleted automatically.
- A PR that changes `terraform/**` may not also change application code (the
`terraform-isolation` CI job); ship Terraform in its own PR.
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through the
**Deploy dev content** workflow while the Terraform adoption is in progress)
`→ main` (production promotion — no prod environment exists yet).
- Promotion flow during migration: `feature/* → dev`, then an explicitly
approved manual dev deployment and verification on `dev.seahaven.com`.
Staging promotion and deployment are separate approvals. No production
environment exists yet.
## Deployment
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
CI/CD uses OIDC and stores no AWS access keys:
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
and PRs to `main`/`dev`/`staging`, calls
and PRs to `main`/`dev`, 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 CDK template tests) is guaranteed from this
repository. Conventions and gates are documented under
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
guaranteed from this repository. Conventions and gates are documented under
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
- **Terraform isolation** (the `terraform-isolation` job in
[`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — fails a PR that
mixes `terraform/**` with application code, so a Terraform merge never races
a content release for the HCP workspace.
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
— `workflow_dispatch` on `dev` only while the Terraform adoption is in
progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`,
and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
CloudFront invalidation, then uploads source maps and checks the served
`index.html` matches the build. Push-to-`dev` releases return with the
Terraform content-CD change.
- **Staging content**
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
— on push to `staging`, unchanged.
- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence
and the HCP Terraform workspace `shoc-frontend-new-dev`
([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy`
([`infra/cdk/README.md`](infra/cdk/README.md)).
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml),
[`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml),
and [`.github/workflows/deploy-tf-poc.yml`](.github/workflows/deploy-tf-poc.yml))
is manual-only during migration. [`scripts/deploy-web.sh`](scripts/deploy-web.sh)
publishes to pinned targets, verifies cache/API/routing behavior, retains two
release manifests, and restores the prior versioned index on verification
failure.
Manual content deploy (emergency/reference only — needs credentials for the
external-dev AWS account):
The isolated rehearsal, retention mechanism, and deployment prerequisites are
documented in [`infra/cdk/README.md`](infra/cdk/README.md). Terraform ownership,
HCP configuration, import gates, evidence, and rollback are documented in
[`terraform/README.md`](terraform/README.md).
```bash
SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \
AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
Infrastructure changes and ownership transfer remain separate reviewed
administrator actions. Content workflows never run `cdk deploy` or Terraform
apply.
## 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
route) loads directly and API calls succeed.
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy** run in
the Actions tab; confirm a deep link (e.g. a work-orders route) loads
directly and API calls succeed.
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
are no CloudWatch application logs — the stack is static hosting; runtime
errors surface in the browser and on the backend API's side.
- **Common failure modes:**
- _Stale content after deploy_ — the CloudFront invalidation step failed or
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to 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 pushes to `dev`
on this repo; deploys from other branches/repos are rejected by design.
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
suffix or carrying the wrong environment's host (it is baked in at build time).
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
not proxy `/api`.
- **Dev has no push-triggered deploy during the adoption.** Merging to `dev`
runs CI only; publish through the **Deploy dev content** workflow. Merging a
`terraform/**` change also queues an HCP Terraform run that a human confirms
or discards (see the operational rules in `terraform/README.md`).
- **Deploy workflow is unavailable on an arbitrary ref** — each manual workflow
checks its exact branch or protected GitHub environment before assuming AWS
credentials.
## Documentation
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md)
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
design system and UI docs under [`docs/`](docs/)

View file

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

View file

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

View file

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

View file

@ -1,270 +1,136 @@
# Infrastructure & CI/CD — Sea Haven SHOC frontend
# Frontend infrastructure and migration rehearsal
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo.
Infrastructure deploys are administrator-run; GitHub Actions publishes content
only.
This CDK app describes the existing Sea Haven SHOC SPA hosting and an isolated,
production-shaped Terraform adoption rehearsal. It performs no content upload.
Content-only deployment is handled by `scripts/deploy-web.sh`.
> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack
> `shoc-frontend-dev` is in the retain/transfer sequence described under
> [Terraform adoption mode](#terraform-adoption-mode) and in
> [`terraform/README.md`](../terraform/README.md). Do not run a plain
> `cdk deploy` against dev while that sequence is in progress. Staging is
> unaffected and stays on this CDK path (SH-287 tracks its cutover).
## Existing environments
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
- **API:** the SPA calls the backend **directly** over HTTPS at
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
allows CORS). CloudFront serves static content only — no `/api` proxy.
- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks
them up with no flags. `VITE_API_URL` is baked into the build, so it's
per-environment (see the note under "Adding staging / prod").
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
- **Content workflows:** `.github/workflows/deploy.yml` (dev,
`workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to
`staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy
role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was
retired with the adoption PR.
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
Normal synthesis remains unchanged when the adoption flag is off:
```
infra/cdk/
bin/app.ts entry point (reads -c context)
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition)
test/frontend-stack.test.mjs template assertions for both modes
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
.github/workflows/
ci.yaml quality gates (lint / build / test / governance / terraform isolation)
deploy.yml dev content publish (workflow_dispatch on dev)
deploy-staging.yml standalone staging deploy (push to staging)
```
- private, versioned S3 bucket with CDK auto-delete cleanup
- CloudFront distribution and origin access control
- viewer-request function that rewrites extensionless SPA routes
- optional Route 53 A and AAAA aliases
- GitHub Actions OIDC deploy role
## What the stack creates
Dev remains the default context in `cdk.json`. Staging uses explicit context
arguments. During migration, both content workflows are manual-only and use
fixed environment configuration rather than discovering deployment targets
from CloudFormation.
| Resource | Purpose |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| S3 bucket `seahaven-shoc-frontend-dev` | private origin (BLOCK_ALL, SSE, OAC-only reads) |
| CloudFront distribution | HTTPS, gzip/br; serves the static SPA from S3 (the app calls the API directly, cross-origin) |
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
## Terraform POC
The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants:
`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`,
read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It
is left byte-identical on purpose so the Terraform import is a no-op; the
Terraform content-CD change narrows it. The OIDC **provider** is a singleton
account resource — the stack only _imports_ it (created in step 2), so
`cdk destroy` can't delete a resource shared by other roles.
The POC is isolated in account `396287094661`, region `us-east-1`, and is
created only with `-c tfPoc=true` plus an explicit `tfPocPhase`. It uses three
ownership scopes:
## Terraform adoption mode
1. `shoc-frontend-tf-poc-shared`: a dedicated public hosted zone for
`frontend-tf-poc.seahaven.com`.
2. `shoc-frontend-tf-poc-certificate`: the DNS-validated ACM certificate.
3. `shoc-frontend-tf-poc`: the private versioned bucket, CloudFront OAC,
distribution, SPA function, A/AAAA aliases, and GitHub OIDC content deploy
role.
`-c retainForTerraformAdoption=true` switches the stack into the safety mode
used only while HCP Terraform adopts the dev resources. It is off by default
and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts
both). In adoption mode the stack:
Fixed application values:
- pins the origin ID CloudFormation generated for the live distribution
(`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is
metadata-only; environments without a verified value fail synthesis
- attaches the `seahaven-org-baseline` permissions boundary
`shoc-frontend-new-dev-deploy-boundary` and the
`HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role
- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the
same exact value
- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13
transferred resources (bucket, bucket policy, distribution, OAC, SPA
function, A and AAAA records, deploy role, inline policy) and to
`SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider
Lambda and role stay unretained
- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no
default) and conditions those same resources and every output on it
- emits `TerraformImport*` outputs carrying the exact import IDs
- bucket: `seahaven-shoc-frontend-tf-poc`
- role: `githubdeploy-shoc-frontend-new-tf-poc`
- GitHub environment: `tf-poc`
- site: `https://frontend-tf-poc.seahaven.com`
- API: `https://api.tf-poc.seahaven.com/api`
`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must
state the ownership phase:
```bash
cd infra/cdk && npm ci
# Phase 1, before the Terraform import: keep the resources in the stack and
# install Retain on them. Update-only change set.
npx cdk deploy shoc-frontend-dev \
-c retainForTerraformAdoption=true \
--parameters ManageSiteInfrastructure=true
# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish
# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource.
npx cdk deploy shoc-frontend-dev \
-c retainForTerraformAdoption=true \
--parameters ManageSiteInfrastructure=false
```
Both deploys must use the same reviewed SHA. Review the change set before
confirming: Phase 1 must show no create, delete, or replace. After the
`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used
again. If the `true` deploy rolls back, inspect the stack resources and the
live bucket before retrying; retained resources can outlive a failed update and
must not be cleaned up automatically. Never delete the auto-delete custom
resource while its handler can still empty the versioned bucket.
Local checks (`npm run test:infra` from the repo root) build the app, run the
template assertions, and synthesize both modes.
---
## One-time setup (run by a human with admin AWS creds)
### 1. Authenticate to the AWS account
```bash
aws configure # or: aws sso login --profile <admin>
aws sts get-caller-identity # confirm the right account + region (us-east-1)
```
### 2. Ensure the GitHub OIDC provider exists (once per account)
```bash
aws iam list-open-id-connect-providers
# If none ends in token.actions.githubusercontent.com, create it (thumbprint is
# no longer required — AWS validates GitHub against its own trust store):
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
```
### 3. CDK bootstrap (once per account/region)
The shared stack is intentionally staged. The zone must exist and be delegated
before ACM can validate a certificate inside it:
```bash
cd infra/cdk
npm ci
npx cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
npm test
npm run synth:tf-poc-zone
npm run synth:tf-poc-environment
```
### 4. Domain, cert, and API URL (already wired for dev)
After approval, deploy only `shoc-frontend-tf-poc-shared` with
`tfPocPhase=zone`. Its outputs provide the child name servers. Create the
parent NS record as a separate approved change and verify public delegation.
Only then use `tfPocPhase=environment` to deploy the separate certificate and
site stacks. The zone stack never contains the certificate, so re-running the
zone phase cannot remove a certificate created by the environment phase.
Omitting `tfPocPhase` fails closed.
Domain/cert/zone are set in `cdk.json` context (account `396287094661`):
The stacks output the hosted zone ID, certificate ARN, delegation evidence,
workspace tag, boundary ARN, and import IDs for the bucket, bucket policy,
distribution, OAC, SPA function, A/AAAA records, deploy role, and inline role
policy. They also emit the generated OAC name/description, deterministic origin
ID, and inline policy name required by the tf-poc Terraform configuration.
| Context key | Value |
| --------------------------------- | ------------------------------------------------------------ |
| `domainNames` | `dev.seahaven.com` |
| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) |
| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` |
## Adoption retention
The stack creates the apex A/AAAA alias in the hosted zone (in this account,
delegated from the parent `seahaven.com` zone). The **API URL is not infra** —
it's `VITE_API_URL` in `.env.production` (`https://api.dev.seahaven.com/api`),
baked into the build. Per-environment; override for staging/prod.
`-c retainForTerraformAdoption=true` is deliberately opt-in. Keep it enabled
from the reviewed retention deployment through CloudFormation ownership
detachment.
### 5. First deploy (locally, with admin creds)
The emitted template applies both `DeletionPolicy: Retain` and
`UpdateReplacePolicy: Retain` to:
The deploy role doesn't exist until the first `cdk deploy`, so bootstrap it
locally. This provisions infra + the role:
- site bucket and bucket policy
- distribution, OAC, and SPA rewrite function
- A and AAAA records
- GitHub deploy role and its inline policy
- `SiteBucket/AutoDeleteObjectsCustomResource`
```bash
cd infra/cdk
npx cdk deploy
```
The bucket remains configured with `autoDeleteObjects: true`. The emitted
bucket and its matching custom resource are both retained, so deleting the
stack cannot invoke that custom resource to empty the versioned bucket.
Generated provider Lambda resources, provider IAM resources, provider logs,
and CDK metadata are intentionally excluded. Template tests enforce this exact
boundary.
Note the `DeployRoleArn` output. Then publish the first content manually:
In adoption mode, the deploy role also receives:
```bash
# from repo root, optional manual first content publish:
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
```
- tag `HcpTerraformWorkspace=shoc-frontend-new-{env}`
- permissions boundary
`arn:aws:iam::<account>:policy/shoc-frontend-new-{env}-deploy-boundary`
- exact `StringEquals` OIDC subject matching; for dev this narrows the current
no-wildcard `StringLike` subject before Terraform import
### 6. Content deploys
These changes are absent when the flag is off.
The deploy role ARN is deterministic and pinned in
`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the
Terraform adoption, dev content deploys run only through **Actions → Deploy dev
content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes
`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the
pinned bucket and distribution, uploads source maps, and verifies the served
`index.html` matches the build. Automatic push-to-`dev` releases return with the
Terraform content-CD change.
## Content deployment safeguards
---
`scripts/deploy-web.sh` requires explicit target and expectation variables:
## Staging environment (same account, exact OIDC subject)
- `SITE_BUCKET` and matching `EXPECTED_SITE_BUCKET`
- `CLOUDFRONT_DISTRIBUTION_ID`
- `SITE_URL`
- `VITE_API_URL` and matching `EXPECTED_API_URL`
- `DEPLOY_RELEASE_ID` or `GITHUB_SHA`
- optional comma-separated `FORBIDDEN_API_URLS`
- optional `API_SMOKE_URL` and `API_CORS_ORIGIN`
Staging lives in the same AWS account (396287094661) and deploys through its
own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to
`staging`:
The script verifies bucket versioning, builds the app, publishes immutable
assets and a no-cache index, records a release manifest, invalidates and waits,
then checks `/`, `/login`, an extensionless route, asset references, API URLs,
and cache headers. Optional API preflight checks verify CORS.
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
environment subject
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
(`StringEquals` on both `aud` and `sub`). The workflow declares
`environment: staging`, so only runs in that environment can assume the role.
Without `githubEnvironment`, the dev stack keeps its branch-ref trust
unchanged.
- **No secret:** the role ARN is static (the role name is deterministic), so
the workflow pins
`arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging`
directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set.
- **Gates first:** the workflow runs the full `npm run verify` before assuming
the staging role, then runs `scripts/deploy-web.sh` with
`STACK_NAME=shoc-frontend-staging`,
`VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the
CloudFront invalidation to complete.
- **Application-only role:** the recurring staging workflow can describe only
its exact stack, publish only to its exact bucket, and invalidate only its
exact distribution. It cannot assume the shared CDK bootstrap roles or
modify infrastructure. Staging infrastructure changes use the Administrator
command below.
- **Post-deploy checks:** bucket + distribution existence, HTTPS on
`https://staging.seahaven.com`, and the actual post-invalidation remote assets
contain the staging API URL and no dev API URL. (Not browser QA.)
If verification fails after publishing the index, the previous index version
is restored and invalidated. Pruning starts only after successful remote
verification. Current versions needed by the latest two release manifests are
kept; unreferenced object versions are deleted.
### One-time setup (run by a human with admin AWS creds + GitHub Admin)
## Manual workflows
1. **GitHub Admin — create the `staging` environment** (Settings →
Environments → New environment → `staging`). Add protection rules as
appropriate (e.g. required reviewers, restrict to the `staging` branch). If
the environment does not exist, GitHub creates it unprotected on first use.
2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the
OIDC provider and bootstrap already exist in this account):
- `.github/workflows/deploy.yml`: dev content deployment
- `.github/workflows/deploy-staging.yml`: staging content deployment
- `.github/workflows/deploy-tf-poc.yml`: isolated POC content deployment
```bash
cd infra/cdk
npx cdk deploy shoc-frontend-staging \
-c envName=staging \
-c deployBranch=staging \
-c githubEnvironment=staging \
-c domainNames=staging.seahaven.com \
-c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \
-c hostedZoneId=Z02602739VQWBWCAGXP4 \
-c hostedZoneName=staging.seahaven.com
```
All are `workflow_dispatch` only. Staging and tf-poc retain their GitHub
environment protection and exact environment-scoped OIDC trust. Each dev and
staging distribution ID is pinned in its workflow. The tf-poc environment
must define its generated `CLOUDFRONT_DISTRIBUTION_ID` as a protected variable.
Each workflow pins its environment's Swagger URL for API CORS/preflight checks.
The `DeployRoleArn` output must match the ARN pinned in
`deploy-staging.yml` (it will — the role name is deterministic).
3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must
allow the `https://staging.seahaven.com` origin.
4. Push to `staging` — `ci.yaml` runs the quality gates and
`deploy-staging.yml` deploys.
### Adding prod later
Same pattern: a prod account/stack with its own contexts and, ideally, its own
`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked
into each environment's build, and the bucket's `RemovalPolicy.DESTROY` +
`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited
for prod.
## Notes
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
Never run it against dev during or after the Terraform adoption: the
adoption-mode stack retains the transferred resources, and after Phase 2
Terraform owns them.
- **CI and staging CD both fire on push to `staging`** in parallel; the
staging CD workflow runs `npm run verify` itself before deploying. Dev has
no push-triggered deploy during the adoption.
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
Infrastructure creation, parent-zone delegation, Terraform imports, and
ownership detachment remain separate administrator actions. None of these
workflows performs them.

View file

@ -1,58 +1,102 @@
#!/usr/bin/env node
import { App, Tags } from "aws-cdk-lib";
import { App, Stack, Tags } from "aws-cdk-lib";
import { FrontendStack } from "../lib/frontend-stack";
import { TfPocCertificateStack, TfPocZoneStack } from "../lib/tf-poc-shared-stack";
const app = new App();
const tfPoc = String(app.node.tryGetContext("tfPoc") ?? "false").toLowerCase() === "true";
// Defaults match the dev setup; override via `-c key=value` on the CLI.
const envName = app.node.tryGetContext("envName") ?? "dev";
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
// branch-ref trust.
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
if (tfPoc) {
const pocEnv = { account: "396287094661", region: "us-east-1" };
const tfPocPhase = String(app.node.tryGetContext("tfPocPhase") ?? "").toLowerCase();
if (!["zone", "environment"].includes(tfPocPhase)) {
throw new Error("tfPocPhase must be set explicitly to 'zone' or 'environment'.");
}
const createEnvironment = tfPocPhase === "environment";
const zoneStack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
env: pocEnv,
terminationProtection: true,
});
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
.split(",")
.map((d: string) => d.trim())
.filter((d: string) => d.length > 0);
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
const stacks: Stack[] = [zoneStack];
if (createEnvironment) {
const certificateStack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", {
env: pocEnv,
terminationProtection: true,
hostedZone: zoneStack.hostedZone,
});
const environmentStack = new FrontendStack(app, "shoc-frontend-tf-poc", {
envName: "tf-poc",
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
deployBranch: "tf-poc",
githubEnvironment: "tf-poc",
terminationProtection: true,
domainNames: [zoneStack.hostedZoneName],
certificateArn: certificateStack.certificateArn,
hostedZoneId: zoneStack.hostedZoneId,
hostedZoneName: zoneStack.hostedZoneName,
retainForTerraformAdoption: true,
env: pocEnv,
});
certificateStack.addDependency(zoneStack);
environmentStack.addDependency(certificateStack);
stacks.push(certificateStack, environmentStack);
}
// Route 53 hosted zone (this account) for the custom-domain alias record.
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
for (const stack of stacks) {
Tags.of(stack).add("Project", "shoc-frontend");
Tags.of(stack).add("Environment", "tf-poc");
Tags.of(stack).add("ManagedBy", "cdk");
}
} else {
// Defaults match the dev setup; override via `-c key=value` on the CLI.
const envName = app.node.tryGetContext("envName") ?? "dev";
const githubRepo =
app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
// When set (e.g. "staging"), the deploy role trusts the exact GitHub
// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style
// branch-ref trust.
const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? "";
// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required
// ManageSiteInfrastructure parameter and Retain policies on the transferred
// resources. Off by default so ordinary synthesis is unchanged.
const retainForTerraformAdoption =
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true";
// Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
.split(",")
.map((d: string) => d.trim())
.filter((d: string) => d.length > 0);
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
// Staging and beyond protect their stacks from accidental deletion; dev
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
// at deploy time — it is not part of the synthesized template.
const terminationProtection = envName !== "dev";
// Route 53 hosted zone (this account) for the custom-domain alias record.
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
const retainForTerraformAdoption =
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() ===
"true";
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
envName,
githubRepo,
deployBranch,
githubEnvironment,
terminationProtection,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
retainForTerraformAdoption,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
},
});
// Staging and beyond protect their stacks from accidental deletion; dev
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
// at deploy time — it is not part of the synthesized template.
const terminationProtection = envName !== "dev";
Tags.of(stack).add("Project", "shoc-frontend");
Tags.of(stack).add("Environment", envName);
Tags.of(stack).add("ManagedBy", "cdk");
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
envName,
githubRepo,
deployBranch,
githubEnvironment,
terminationProtection,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
retainForTerraformAdoption,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
},
});
Tags.of(stack).add("Project", "shoc-frontend");
Tags.of(stack).add("Environment", envName);
Tags.of(stack).add("ManagedBy", "cdk");
}

View file

@ -7,10 +7,11 @@
"context": {
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:defaultCrossStackReferences": "strong",
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
"//": "Dev synthesis defaults for account 396287094661. Infrastructure deployment is administrator-run.",
"domainNames": "dev.seahaven.com",
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
"hostedZoneId": "Z07671212N75U4YLPWZR8",

View file

@ -1,11 +1,8 @@
import {
Aspects,
CfnCondition,
CfnOutput,
CfnParameter,
CfnResource,
Duration,
Fn,
RemovalPolicy,
Stack,
StackProps,
@ -89,20 +86,6 @@ export class FrontendStack extends Stack {
retainForTerraformAdoption = false,
} = props;
const manageSiteInfrastructureCondition = retainForTerraformAdoption
? new CfnCondition(this, "ManageSiteInfrastructureCondition", {
expression: Fn.conditionEquals(
new CfnParameter(this, "ManageSiteInfrastructure", {
type: "String",
allowedValues: ["true", "false"],
description:
"Set true only before Terraform adoption. After ownership transfer, always reuse false.",
}).valueAsString,
"true",
),
})
: undefined;
const hasCustomDomain = domainNames.length > 0;
if (hasCustomDomain && !certificateArn) {
throw new Error(
@ -146,11 +129,10 @@ export class FrontendStack extends Stack {
// --- CloudFront: serves the static SPA from S3 -------------------------
// The SPA calls the backend directly at its absolute HTTPS URL
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
// Adoption mode pins the origin ID CloudFormation generated for the live
// distribution so the retention deploy is a metadata-only update. Only
// environments with a read-back-verified value may enter adoption mode.
const adoptionOriginIds: Record<string, string> = {
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
staging: "shocfrontendstagingDistributionOrigin16E4628FC",
"tf-poc": "shoc-frontend-tf-poc-origin",
};
const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined;
if (retainForTerraformAdoption && !originId) {
@ -305,45 +287,28 @@ export class FrontendStack extends Stack {
});
}
const gateOutput = (output: CfnOutput): CfnOutput => {
if (manageSiteInfrastructureCondition) {
output.condition = manageSiteInfrastructureCondition;
}
return output;
};
// --- Outputs -----------------------------------------------------------
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
gateOutput(
new CfnOutput(this, "SiteUrl", {
value: hasCustomDomain
? `https://${domainNames[0]}`
: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
}),
);
gateOutput(
new CfnOutput(this, "DistributionDomainName", {
value: distribution.distributionDomainName,
description: "CloudFront domain — point the custom-domain DNS record here",
}),
);
gateOutput(
new CfnOutput(this, "BucketName", {
value: bucket.bucketName,
}),
);
gateOutput(
new CfnOutput(this, "DistributionId", {
value: distribution.distributionId,
}),
);
gateOutput(
new CfnOutput(this, "DeployRoleArn", {
value: deployRole.roleArn,
description: "Pinned GitHub OIDC content-deployment role",
}),
);
new CfnOutput(this, "SiteUrl", {
value: hasCustomDomain
? `https://${domainNames[0]}`
: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
});
new CfnOutput(this, "DistributionDomainName", {
value: distribution.distributionDomainName,
description: "CloudFront domain — point the custom-domain DNS record here",
});
new CfnOutput(this, "BucketName", {
value: bucket.bucketName,
});
new CfnOutput(this, "DistributionId", {
value: distribution.distributionId,
});
new CfnOutput(this, "DeployRoleArn", {
value: deployRole.roleArn,
description: "Pinned GitHub OIDC content-deployment role",
});
if (retainForTerraformAdoption) {
const originAccessControl = distribution.node
@ -374,92 +339,60 @@ export class FrontendStack extends Stack {
}
const recordName = domainNames[0];
gateOutput(
new CfnOutput(this, "TerraformWorkspaceTag", {
value: `shoc-frontend-new-${envName}`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformDeployBoundaryArn", {
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
}),
);
gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName }));
gateOutput(
new CfnOutput(this, "TerraformImportBucketPolicy", {
value: bucket.bucketName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDistribution", {
value: distribution.distributionId,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportOriginAccessControl", {
value: originAccessControl.attrId,
}),
);
gateOutput(
new CfnOutput(this, "TerraformOriginAccessControlName", {
value: originAccessControlConfig.name,
}),
);
gateOutput(
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
value: "EMPTY_STRING",
description: "Use an empty Terraform string because the generated OAC has no description",
}),
);
gateOutput(
new CfnOutput(this, "TerraformDistributionOriginId", {
value: originId!,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
value: spaRewrite.functionName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportAliasA", {
value: `${hostedZoneId}_${recordName}_A`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportAliasAAAA", {
value: `${hostedZoneId}_${recordName}_AAAA`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDeployRole", {
value: deployRole.roleName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
}),
);
gateOutput(
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
value: rolePolicy.policyName,
}),
);
gateOutput(
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
value: autoDeleteProviderRole.getAtt("Arn").toString(),
}),
);
gateOutput(
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
value: "SiteBucket/AutoDeleteObjectsCustomResource",
description:
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
}),
);
new CfnOutput(this, "TerraformWorkspaceTag", {
value: `shoc-frontend-new-${envName}`,
});
new CfnOutput(this, "TerraformDeployBoundaryArn", {
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
});
new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName });
new CfnOutput(this, "TerraformImportBucketPolicy", {
value: bucket.bucketName,
});
new CfnOutput(this, "TerraformImportDistribution", {
value: distribution.distributionId,
});
new CfnOutput(this, "TerraformImportOriginAccessControl", {
value: originAccessControl.attrId,
});
new CfnOutput(this, "TerraformOriginAccessControlName", {
value: originAccessControlConfig.name,
});
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
value: "EMPTY_STRING",
description: "Use an empty Terraform string because the generated OAC has no description",
});
new CfnOutput(this, "TerraformDistributionOriginId", {
value: originId!,
});
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
value: spaRewrite.functionName,
});
new CfnOutput(this, "TerraformImportAliasA", {
value: `${hostedZoneId}_${recordName}_A`,
});
new CfnOutput(this, "TerraformImportAliasAAAA", {
value: `${hostedZoneId}_${recordName}_AAAA`,
});
new CfnOutput(this, "TerraformImportDeployRole", {
value: deployRole.roleName,
});
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
});
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
value: rolePolicy.policyName,
});
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
value: autoDeleteProviderRole.getAtt("Arn").toString(),
});
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
value: "SiteBucket/AutoDeleteObjectsCustomResource",
description:
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
});
Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition));
Aspects.of(this).add(new RetainForTerraformAdoption());
}
}
}

View file

@ -1,4 +1,4 @@
import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
import { CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
import { IConstruct } from "constructs";
const TRANSFERRED_RESOURCE_TYPES = new Set([
@ -34,12 +34,8 @@ function isTransferredResource(resource: CfnResource): boolean {
*
* The bucket auto-delete custom resource is intentionally retained while the
* generated provider Lambda, role, log group, and CDK metadata remain excluded.
* When a management condition is supplied, those same resources share it so
* CloudFormation can later relinquish them without deleting them.
*/
export class RetainForTerraformAdoption implements IAspect {
constructor(private readonly manageCondition?: CfnCondition) {}
public visit(node: IConstruct): void {
if (!(node instanceof CfnResource) || !isTransferredResource(node)) {
return;
@ -51,13 +47,10 @@ export class RetainForTerraformAdoption implements IAspect {
if (node.cfnResourceType === "AWS::S3::Bucket") {
node.addOverride("DeletionPolicy", "Retain");
node.addOverride("UpdateReplacePolicy", "Retain");
} else {
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
return;
}
if (this.manageCondition) {
node.cfnOptions.condition = this.manageCondition;
}
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
}
}

View file

@ -0,0 +1,75 @@
import { CfnOutput, Fn, Stack, StackProps } from "aws-cdk-lib";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import { Construct } from "constructs";
const TF_POC_DOMAIN = "frontend-tf-poc.seahaven.com";
export interface TfPocCertificateStackProps extends StackProps {
readonly hostedZone: route53.IHostedZone;
}
/**
* Temporary, isolated DNS zone for the production-shaped Terraform POC.
* Parent-zone delegation is deliberately excluded from this stack.
*/
export class TfPocZoneStack extends Stack {
public readonly hostedZone: route53.IHostedZone;
public readonly hostedZoneId: string;
public readonly hostedZoneName = TF_POC_DOMAIN;
public constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
this.hostedZone = new route53.PublicHostedZone(this, "HostedZone", {
zoneName: TF_POC_DOMAIN,
comment: "Temporary isolated hosted zone for frontend Terraform adoption rehearsal",
});
this.hostedZoneId = this.hostedZone.hostedZoneId;
const nameServers = this.hostedZone.hostedZoneNameServers;
if (!nameServers) {
throw new Error("Public hosted zone must expose delegation name servers.");
}
new CfnOutput(this, "HostedZoneId", {
value: this.hostedZone.hostedZoneId,
description: "Terraform aws_route53_zone import ID",
});
new CfnOutput(this, "HostedZoneName", { value: TF_POC_DOMAIN });
new CfnOutput(this, "DelegationNameServers", {
value: Fn.join(",", nameServers),
description:
"Evidence only. Add these NS values to the seahaven.com parent zone in a separately approved change.",
});
new CfnOutput(this, "DelegationRecordName", {
value: TF_POC_DOMAIN,
});
new CfnOutput(this, "DelegationRequiredAction", {
value:
"SEPARATE APPROVAL REQUIRED: create an NS record for frontend-tf-poc.seahaven.com in the parent seahaven.com zone",
});
}
}
/**
* Certificate is isolated so re-running the zone phase cannot remove it.
*/
export class TfPocCertificateStack extends Stack {
public readonly certificateArn: string;
public constructor(scope: Construct, id: string, props: TfPocCertificateStackProps) {
super(scope, id, props);
const certificate = new acm.Certificate(this, "Certificate", {
domainName: TF_POC_DOMAIN,
validation: acm.CertificateValidation.fromDns(props.hostedZone),
});
this.certificateArn = certificate.certificateArn;
new CfnOutput(this, "CertificateArn", {
value: certificate.certificateArn,
description: "Inventory-only certificate ARN for the frontend tf-poc root",
});
}
}

View file

@ -13,7 +13,8 @@
"build": "tsc",
"test": "npm run build && node --test test/*.test.mjs",
"synth": "cdk synth",
"synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true",
"synth:tf-poc-zone": "cdk synth -c tfPoc=true -c tfPocPhase=zone",
"synth:tf-poc-environment": "cdk synth -c tfPoc=true -c tfPocPhase=environment",
"diff": "cdk diff",
"deploy": "cdk deploy"
},

View file

@ -1,41 +1,29 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { test } from "node:test";
import test from "node:test";
const require = createRequire(import.meta.url);
const { App } = require("aws-cdk-lib");
const { Template } = require("aws-cdk-lib/assertions");
const { FrontendStack } = require("../lib/frontend-stack.js");
const { TfPocCertificateStack, TfPocZoneStack } = require("../lib/tf-poc-shared-stack.js");
const account = "396287094661";
const region = "us-east-1";
const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev";
const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1";
const CONDITION = "ManageSiteInfrastructureCondition";
const RETAINED_TYPES = new Set([
"AWS::S3::Bucket",
"AWS::S3::BucketPolicy",
"AWS::CloudFront::Distribution",
"AWS::CloudFront::Function",
"AWS::CloudFront::OriginAccessControl",
"AWS::Route53::RecordSet",
"Custom::S3AutoDeleteObjects",
]);
function devTemplate(retainForTerraformAdoption, overrides = {}) {
function frontendTemplate(retainForTerraformAdoption) {
const app = new App();
const stack = new FrontendStack(app, "shoc-frontend-dev", {
envName: "dev",
const stack = new FrontendStack(app, "shoc-frontend-tf-poc", {
envName: "tf-poc",
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
deployBranch: "dev",
domainNames: ["dev.seahaven.com"],
certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`,
hostedZoneId: "Z07671212N75U4YLPWZR8",
hostedZoneName: "dev.seahaven.com",
deployBranch: "tf-poc",
githubEnvironment: "tf-poc",
domainNames: ["frontend-tf-poc.seahaven.com"],
certificateArn: `arn:aws:acm:${region}:${account}:certificate/test`,
hostedZoneId: "ZTESTPOC",
hostedZoneName: "frontend-tf-poc.seahaven.com",
retainForTerraformAdoption,
env: { account, region },
...overrides,
});
return Template.fromStack(stack).toJSON();
}
@ -44,35 +32,10 @@ function entriesByType(template, type) {
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
}
function isTransferred(logicalId, resource) {
const isDeployRoleResource =
(resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) ||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource;
}
test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => {
const template = devTemplate(true);
assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1);
assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
const transferred = Object.entries(template.Resources).filter(([id, resource]) =>
isTransferred(id, resource),
);
// Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline
// policy = 9 CloudFormation resources (Terraform splits the bucket into 6
// addresses) plus the retained custom resource.
assert.equal(transferred.length, 10);
});
test("adoption mode preserves the live dev identifiers", () => {
const template = devTemplate(true);
test("tf-poc has fixed production-shaped resources and adoption metadata", () => {
const template = frontendTemplate(true);
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev");
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-tf-poc");
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
assert.ok(
bucket.Properties.Tags.some(
@ -80,43 +43,30 @@ test("adoption mode preserves the live dev identifiers", () => {
),
);
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID);
assert.equal(
distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId,
DEV_ORIGIN_ID,
);
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
const [deployRole] = entriesByType(template, "AWS::IAM::Role").filter(
([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc",
);
assert.ok(deployRole);
assert.equal(
deployRole.Properties.PermissionsBoundary,
`arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`,
deployRole[1].Properties.PermissionsBoundary,
`arn:aws:iam::${account}:policy/shoc-frontend-new-tf-poc-deploy-boundary`,
);
assert.ok(
deployRole.Properties.Tags.some(
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev",
deployRole[1].Properties.Tags.some(
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-tf-poc",
),
);
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
assert.equal(
condition.StringEquals["token.actions.githubusercontent.com:sub"],
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
);
assert.equal(condition.StringLike, undefined);
// Legacy inline policy stays byte-compatible with the live document.
const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) =>
id.startsWith("GithubDeployRole"),
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
assert.equal(
entriesByType(template, "AWS::CloudFront::Distribution")[0][1].Properties.DistributionConfig
.Origins[0].Id,
"shoc-frontend-tf-poc-origin",
);
const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid);
assert.deepEqual(sids, [
"AssumeCdkBootstrapRoles",
"DescribeStack",
undefined,
"InvalidateDistribution",
]);
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
for (const output of [
"TerraformWorkspaceTag",
@ -139,17 +89,27 @@ test("adoption mode preserves the live dev identifiers", () => {
]) {
assert.ok(template.Outputs[output], `missing output ${output}`);
}
assert.equal(
template.Outputs.TerraformImportAliasA.Value,
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A",
);
assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID);
});
test("adoption mode retains exactly the transferred resources", () => {
const template = devTemplate(true);
const template = frontendTemplate(true);
const retainedTypes = new Set([
"AWS::S3::Bucket",
"AWS::S3::BucketPolicy",
"AWS::CloudFront::Distribution",
"AWS::CloudFront::Function",
"AWS::CloudFront::OriginAccessControl",
"AWS::Route53::RecordSet",
"Custom::S3AutoDeleteObjects",
]);
for (const [logicalId, resource] of Object.entries(template.Resources)) {
if (isTransferred(logicalId, resource)) {
const isDeployRoleResource =
(resource.Type === "AWS::IAM::Role" &&
resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc") ||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
const shouldRetain = retainedTypes.has(resource.Type) || isDeployRoleResource;
if (shouldRetain) {
assert.equal(resource.DeletionPolicy, "Retain", logicalId);
assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId);
} else {
@ -157,69 +117,88 @@ test("adoption mode retains exactly the transferred resources", () => {
assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId);
}
}
// The auto-delete provider Lambda, role, and log group stay unretained.
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
assert.equal(customResource.DeletionPolicy, "Retain");
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
for (const [, resource] of entriesByType(template, type)) {
assert.notEqual(resource.DeletionPolicy, "Retain");
}
}
const providerRoles = entriesByType(template, "AWS::IAM::Role").filter(
([, resource]) => resource.Properties.RoleName !== DEV_ROLE,
);
assert.equal(providerRoles.length, 1);
assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain");
});
test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => {
const template = devTemplate(true);
const parameter = template.Parameters.ManageSiteInfrastructure;
assert.ok(parameter);
assert.equal(parameter.Type, "String");
assert.deepEqual(parameter.AllowedValues, ["true", "false"]);
assert.equal(parameter.Default, undefined);
assert.ok(template.Conditions[CONDITION]);
for (const [logicalId, resource] of Object.entries(template.Resources)) {
if (isTransferred(logicalId, resource)) {
assert.equal(resource.Condition, CONDITION, logicalId);
} else {
assert.notEqual(resource.Condition, CONDITION, logicalId);
}
}
for (const [outputName, output] of Object.entries(template.Outputs)) {
assert.equal(output.Condition, CONDITION, outputName);
}
});
test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => {
const template = devTemplate(false);
test("normal mode preserves destructive cleanup and has no adoption boundary or tag", () => {
const template = frontendTemplate(false);
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
assert.equal(bucket.DeletionPolicy, "Delete");
assert.equal(bucket.UpdateReplacePolicy, "Delete");
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
assert.notEqual(customResource.DeletionPolicy, "Retain");
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
);
const deployRole = entriesByType(template, "AWS::IAM::Role").find(
([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-tf-poc",
)[1];
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
assert.equal(
condition.StringLike["token.actions.githubusercontent.com:sub"],
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
);
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined);
assert.equal(template.Conditions?.[CONDITION], undefined);
for (const resource of Object.values(template.Resources)) {
assert.equal(resource.Condition, undefined);
}
});
test("adoption mode refuses an environment without a verified origin ID", () => {
assert.throws(
() => devTemplate(true, { envName: "staging" }),
/No verified Terraform adoption origin ID exists for staging/,
test("dev adoption prerequisite preserves origin ID and narrows exact trust", () => {
const app = new App();
const stack = new FrontendStack(app, "shoc-frontend-dev", {
envName: "dev",
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
deployBranch: "dev",
domainNames: ["dev.seahaven.com"],
certificateArn: `arn:aws:acm:${region}:${account}:certificate/test`,
hostedZoneId: "Z07671212N75U4YLPWZR8",
hostedZoneName: "dev.seahaven.com",
retainForTerraformAdoption: true,
env: { account, region },
});
const template = Template.fromStack(stack).toJSON();
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
assert.equal(
distribution.Properties.DistributionConfig.Origins[0].Id,
"shocfrontenddevDistributionOrigin10CCD0EE1",
);
const deployRole = entriesByType(template, "AWS::IAM::Role").find(
([, resource]) => resource.Properties.RoleName === "githubdeploy-shoc-frontend-new-dev",
)[1];
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
assert.equal(
condition.StringEquals["token.actions.githubusercontent.com:sub"],
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
);
assert.equal(condition.StringLike, undefined);
});
test("zone stack never owns a certificate or parent delegation", () => {
const app = new App();
const stack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
env: { account, region },
});
const template = Template.fromStack(stack).toJSON();
assert.equal(entriesByType(template, "AWS::Route53::HostedZone").length, 1);
assert.equal(entriesByType(template, "AWS::CertificateManager::Certificate").length, 0);
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 0);
assert.ok(template.Outputs.DelegationNameServers);
assert.equal(template.Outputs.CertificateArn, undefined);
assert.match(template.Outputs.DelegationRequiredAction.Value, /SEPARATE APPROVAL REQUIRED/);
});
test("environment phase creates its certificate in a separate stack", () => {
const app = new App();
const zoneStack = new TfPocZoneStack(app, "shoc-frontend-tf-poc-shared", {
env: { account, region },
});
const stack = new TfPocCertificateStack(app, "shoc-frontend-tf-poc-certificate", {
env: { account, region },
hostedZone: zoneStack.hostedZone,
});
const template = Template.fromStack(stack).toJSON();
assert.equal(entriesByType(template, "AWS::Route53::HostedZone").length, 0);
assert.equal(entriesByType(template, "AWS::CertificateManager::Certificate").length, 1);
assert.ok(template.Outputs.CertificateArn);
});

308
package-lock.json generated
View file

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

View file

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

View file

@ -1,120 +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.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const OVERRIDE_LABEL = "terraform-isolation-override";
export function isTerraformPath(file) {
return file.startsWith("terraform/");
}
export function mayAccompanyTerraform(file) {
if (isTerraformPath(file)) return true;
if (file.endsWith(".md")) return true;
if (file.startsWith("docs/")) return true;
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
return false;
}
/**
* @param {string[]} files changed paths relative to the repository root
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
*/
export function classifyChangedFiles(files) {
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
const terraform = unique.filter(isTerraformPath);
const application = unique.filter((file) => !mayAccompanyTerraform(file));
return {
terraform,
application,
mixed: terraform.length > 0 && application.length > 0,
};
}
function changedFilesFromGit(base, head) {
const mergeBase = execFileSync("git", ["merge-base", base, head], {
cwd: ROOT,
encoding: "utf8",
}).trim();
return execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
{ cwd: ROOT, encoding: "utf8" },
)
.split("\n")
.filter(Boolean);
}
function parseArgs(argv) {
const options = { base: null, head: "HEAD", stdin: false };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--base") options.base = argv[++index];
else if (argument === "--head") options.head = argv[++index];
else if (argument === "--stdin") options.stdin = true;
else throw new Error(`unknown argument: ${argument}`);
}
if (!options.stdin && !options.base) {
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
}
return options;
}
function main(argv) {
const options = parseArgs(argv);
const files = options.stdin
? readFileSync(0, "utf8").split("\n")
: changedFilesFromGit(options.base, options.head);
const result = classifyChangedFiles(files);
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
console.log("─".repeat(64));
console.log(
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
);
if (!result.mixed) {
console.log(" PASS: Terraform and application changes are not mixed");
return 0;
}
console.log(" Terraform files:");
for (const file of result.terraform) console.log(` ${file}`);
console.log(" Application files that cannot ship in the same PR:");
for (const file of result.application) console.log(` ${file}`);
if (override) {
console.log(
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
);
return 0;
}
console.log(
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
);
return 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exit(main(process.argv.slice(2)));
}

View file

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

View file

@ -1,79 +1,438 @@
#!/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
set -Eeuo pipefail
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
REGION="${AWS_REGION:-us-east-1}"
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
required_vars=(
SITE_BUCKET
EXPECTED_SITE_BUCKET
CLOUDFRONT_DISTRIBUTION_ID
SITE_URL
EXPECTED_API_URL
)
for name in "${required_vars[@]}"; do
if [[ -z "${!name:-}" ]]; then
echo "::error::${name} must be set explicitly." >&2
exit 1
fi
done
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
DEPLOY_RELEASE_ID="${DEPLOY_RELEASE_ID:-${GITHUB_SHA:-}}"
EXTENSIONLESS_SMOKE_PATH="${EXTENSIONLESS_SMOKE_PATH:-/deployment-smoke}"
FORBIDDEN_API_URLS="${FORBIDDEN_API_URLS:-}"
API_SMOKE_URL="${API_SMOKE_URL:-}"
API_CORS_ORIGIN="${API_CORS_ORIGIN:-}"
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
if [[ "${SITE_BUCKET}" != "${EXPECTED_SITE_BUCKET}" ]]; then
echo "::error::SITE_BUCKET does not match EXPECTED_SITE_BUCKET." >&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" \
fi
if [[ "${VITE_API_URL:-}" != "${EXPECTED_API_URL}" ]]; then
echo "::error::VITE_API_URL must exactly match EXPECTED_API_URL." >&2
exit 1
fi
if [[ ! "${DEPLOY_RELEASE_ID}" =~ ^[A-Za-z0-9._-]{7,128}$ ]]; then
echo "::error::DEPLOY_RELEASE_ID is missing or unsafe." >&2
exit 1
fi
if [[ ! "${SITE_URL}" =~ ^https://[^/]+/?$ || ! "${EXPECTED_API_URL}" =~ ^https:// ]]; then
echo "::error::SITE_URL and EXPECTED_API_URL must be HTTPS URLs." >&2
exit 1
fi
if [[ "${EXTENSIONLESS_SMOKE_PATH}" != /* || "${EXTENSIONLESS_SMOKE_PATH}" == *.* ]]; then
echo "::error::EXTENSIONLESS_SMOKE_PATH must be an extensionless absolute path." >&2
exit 1
fi
SITE_URL="${SITE_URL%/}"
API_CORS_ORIGIN="${API_CORS_ORIGIN:-${SITE_URL}}"
API_CORS_ORIGIN="${API_CORS_ORIGIN%/}"
work_dir="$(mktemp -d)"
published_index_version=""
prior_index_version=""
deployment_verified="false"
invalidate_and_wait() {
local invalidation_id
invalidation_id="$(
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*" \
--query "Invalidation.Id" \
--output text
}
)"
test -n "${invalidation_id}"
aws cloudfront wait invalidation-completed \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--id "${invalidation_id}"
}
BUCKET="$(stack_output BucketName)"
DIST_ID="$(stack_output DistributionId)"
rollback_index() {
[[ -n "${published_index_version}" ]] || return 0
echo "::warning::Verification failed. Restoring the prior index version." >&2
if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then
aws s3api copy-object \
--bucket "${SITE_BUCKET}" \
--key index.html \
--copy-source "${SITE_BUCKET}/index.html?versionId=${prior_index_version}" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html" \
--metadata-directive REPLACE >/dev/null
else
aws s3api delete-object \
--bucket "${SITE_BUCKET}" \
--key index.html \
--version-id "${published_index_version}" >/dev/null
fi
invalidate_and_wait || true
}
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
cleanup() {
local status=$?
if [[ "${status}" -ne 0 && "${deployment_verified}" != "true" ]]; then
rollback_index
fi
rm -rf "${work_dir}"
exit "${status}"
}
trap cleanup EXIT
versioning_status="$(
aws s3api get-bucket-versioning \
--bucket "${SITE_BUCKET}" \
--query Status \
--output text
)"
if [[ "${versioning_status}" != "Enabled" ]]; then
echo "::error::The target bucket must have versioning enabled." >&2
exit 1
fi
if ! prior_index_version="$(
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key index.html \
--query VersionId \
--output text 2>"${work_dir}/prior-index.error"
)"; then
if grep -Eqi "(404|Not Found|NoSuchKey)" "${work_dir}/prior-index.error"; then
prior_index_version=""
else
cat "${work_dir}/prior-index.error" >&2
echo "::error::Could not inspect the current index version." >&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}"
: >"${work_dir}/prior-asset-versions.tsv"
if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then
prior_manifest_key="$(
aws s3api list-objects-v2 \
--bucket "${SITE_BUCKET}" \
--prefix ".deploy/releases/" \
--query "reverse(sort_by(Contents,&LastModified))[0].Key" \
--output text
)"
if [[ -n "${prior_manifest_key}" && "${prior_manifest_key}" != "None" ]]; then
aws s3 cp "s3://${SITE_BUCKET}/${prior_manifest_key}" \
"${work_dir}/prior-manifest.json" --quiet
node - "${work_dir}/prior-manifest.json" \
>"${work_dir}/prior-asset-versions.tsv" <<'NODE'
const manifest = require(process.argv[2]);
for (const asset of manifest.assets ?? []) {
if (typeof asset === "object" && asset.key && asset.versionId) {
console.log(`${asset.key}\t${asset.versionId}`);
}
}
NODE
else
aws s3api list-objects-v2 \
--bucket "${SITE_BUCKET}" \
--query "Contents[].Key" \
--output text | tr "\t" "\n" \
| awk '$0 != "index.html" && $0 !~ /^\.deploy\// && $0 != "None"' \
| sort -u >"${work_dir}/prior-asset-keys.txt"
while IFS= read -r prior_asset_key; do
[[ -n "${prior_asset_key}" ]] || continue
prior_asset_version="$(
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${prior_asset_key}" \
--query VersionId \
--output text
)"
if [[ -z "${prior_asset_version}" || "${prior_asset_version}" == "None" ]]; then
echo "::error::Prior asset ${prior_asset_key} did not resolve to a version ID." >&2
exit 1
fi
printf "%s\t%s\n" "${prior_asset_key}" "${prior_asset_version}" \
>>"${work_dir}/prior-asset-versions.tsv"
done <"${work_dir}/prior-asset-keys.txt"
fi
fi
echo "Web deploy complete."
echo "Building SPA for ${EXPECTED_API_URL}..."
npm ci
npm run build
test -s dist/index.html
if ! grep -RqsF -- "${EXPECTED_API_URL}" dist; then
echo "::error::Built output does not contain EXPECTED_API_URL." >&2
exit 1
fi
for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do
if [[ -n "${forbidden_url}" ]] && grep -RqsF -- "${forbidden_url}" dist; then
echo "::error::Built output contains forbidden API URL ${forbidden_url}." >&2
exit 1
fi
done
echo "Publishing immutable release assets..."
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--exclude "index.html" \
--cache-control "public,max-age=31536000,immutable"
published_index_version="$(
aws s3api put-object \
--bucket "${SITE_BUCKET}" \
--key index.html \
--body dist/index.html \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html" \
--query VersionId \
--output text
)"
if [[ -z "${published_index_version}" || "${published_index_version}" == "None" ]]; then
echo "::error::Index upload did not return a version ID." >&2
exit 1
fi
node - >"${work_dir}/asset-keys.txt" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
function files(directory, prefix = "") {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const relative = path.posix.join(prefix, entry.name);
return entry.isDirectory()
? files(path.join(directory, entry.name), relative)
: [relative];
});
}
for (const file of files("dist").filter((entry) => entry !== "index.html").sort()) {
console.log(file);
}
NODE
: >"${work_dir}/asset-versions.tsv"
while IFS= read -r asset_key; do
asset_version="$(
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${asset_key}" \
--query VersionId \
--output text
)"
if [[ -z "${asset_version}" || "${asset_version}" == "None" ]]; then
echo "::error::Asset ${asset_key} did not resolve to a version ID." >&2
exit 1
fi
printf "%s\t%s\n" "${asset_key}" "${asset_version}" >>"${work_dir}/asset-versions.tsv"
done <"${work_dir}/asset-keys.txt"
node - "${DEPLOY_RELEASE_ID}" "${published_index_version}" "${prior_index_version}" \
"${work_dir}/asset-versions.tsv" "${work_dir}/prior-asset-versions.tsv" \
>"${work_dir}/manifest.json" <<'NODE'
const fs = require("node:fs");
const [release, indexVersion, priorIndexVersion, versionsPath, priorVersionsPath] =
process.argv.slice(2);
function readVersions(path) {
return fs
.readFileSync(path, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [key, versionId] = line.split("\t");
return { key, versionId };
});
}
process.stdout.write(
`${JSON.stringify(
{
release,
indexVersion,
priorIndexVersion,
assets: readVersions(versionsPath),
priorAssets: readVersions(priorVersionsPath),
},
null,
2,
)}\n`,
);
NODE
manifest_key=".deploy/releases/${DEPLOY_RELEASE_ID}.json"
echo "Invalidating CloudFront and waiting for propagation..."
invalidate_and_wait
fetch_route() {
local route="$1"
local slug="$2"
curl -fsS --max-time 30 \
-D "${work_dir}/${slug}.headers" \
-o "${work_dir}/${slug}.body" \
"${SITE_URL}${route}"
grep -qi "^content-type:.*text/html" "${work_dir}/${slug}.headers"
grep -qi "^cache-control:.*no-cache" "${work_dir}/${slug}.headers"
grep -qi "^cache-control:.*no-store" "${work_dir}/${slug}.headers"
grep -qi "^cache-control:.*must-revalidate" "${work_dir}/${slug}.headers"
cmp -s "${work_dir}/${slug}.body" "${work_dir}/root.body"
}
curl -fsS --max-time 30 \
-D "${work_dir}/root.headers" \
-o "${work_dir}/root.body" \
"${SITE_URL}/"
grep -qi "^content-type:.*text/html" "${work_dir}/root.headers"
grep -qi "^cache-control:.*no-cache" "${work_dir}/root.headers"
grep -qi "^cache-control:.*no-store" "${work_dir}/root.headers"
grep -qi "^cache-control:.*must-revalidate" "${work_dir}/root.headers"
fetch_route "/login" "login"
fetch_route "${EXTENSIONLESS_SMOKE_PATH}" "extensionless"
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${work_dir}/root.body" \
| awk -F'"' '{ print $2 }' \
| sort -u >"${work_dir}/asset-paths.txt"
test -s "${work_dir}/asset-paths.txt"
: >"${work_dir}/asset-content.txt"
while IFS= read -r asset_path; do
asset_slug="$(printf "%s" "${asset_path}" | tr "/." "__")"
curl -fsS --max-time 30 \
-D "${work_dir}/${asset_slug}.headers" \
-o "${work_dir}/${asset_slug}.body" \
"${SITE_URL}${asset_path}"
grep -qi "^cache-control:.*max-age=31536000" "${work_dir}/${asset_slug}.headers"
grep -qi "^cache-control:.*immutable" "${work_dir}/${asset_slug}.headers"
cat "${work_dir}/${asset_slug}.body" >>"${work_dir}/asset-content.txt"
done <"${work_dir}/asset-paths.txt"
grep -qsF -- "${EXPECTED_API_URL}" "${work_dir}/asset-content.txt"
for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do
if [[ -n "${forbidden_url}" ]] &&
grep -qsF -- "${forbidden_url}" "${work_dir}/asset-content.txt"; then
echo "::error::Deployed assets contain forbidden API URL ${forbidden_url}." >&2
exit 1
fi
done
if [[ -n "${API_SMOKE_URL}" ]]; then
api_status="$(
curl -sS --max-time 30 \
-H "Origin: ${API_CORS_ORIGIN}" \
-D "${work_dir}/api.headers" \
-o "${work_dir}/api.body" \
-w "%{http_code}" \
"${API_SMOKE_URL}"
)"
if [[ "${api_status}" == "000" || "${api_status}" -ge 500 ]]; then
echo "::error::API smoke request failed with HTTP ${api_status}." >&2
exit 1
fi
grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/api.headers"
curl -fsS --max-time 30 \
-X OPTIONS \
-H "Origin: ${API_CORS_ORIGIN}" \
-H "Access-Control-Request-Method: GET" \
-D "${work_dir}/cors.headers" \
-o /dev/null \
"${API_SMOKE_URL}"
grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/cors.headers"
grep -qi "^access-control-allow-methods:.*GET" "${work_dir}/cors.headers"
fi
aws s3 cp "${work_dir}/manifest.json" "s3://${SITE_BUCKET}/${manifest_key}" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "application/json"
# Once the manifest is durable, this release and its rollback target are both
# protected from pruning. A later cleanup failure must not roll back a release
# whose prior assets may already have been pruned.
deployment_verified="true"
# Keep exactly the current and immediately prior release manifests and every
# object version they reference. Prune only unreferenced versions, after all
# remote checks pass.
aws s3api list-objects-v2 \
--bucket "${SITE_BUCKET}" \
--prefix ".deploy/releases/" \
--query "reverse(sort_by(Contents,&LastModified))[].Key" \
--output text | tr "\t" "\n" >"${work_dir}/manifest-keys.txt"
printf "%s\n" "${manifest_key}" >"${work_dir}/kept-manifests.txt"
awk -v current="${manifest_key}" '$0 != current { print; exit }' \
"${work_dir}/manifest-keys.txt" >>"${work_dir}/kept-manifests.txt"
: >"${work_dir}/retained-versions.tsv"
while IFS= read -r kept_manifest; do
[[ -n "${kept_manifest}" ]] || continue
aws s3 cp "s3://${SITE_BUCKET}/${kept_manifest}" "${work_dir}/kept.json" --quiet
kept_manifest_version="$(
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${kept_manifest}" \
--query VersionId \
--output text
)"
printf "%s\t%s\n" "${kept_manifest}" "${kept_manifest_version}" \
>>"${work_dir}/retained-versions.tsv"
is_current_manifest="false"
if [[ "${kept_manifest}" == "${manifest_key}" ]]; then
is_current_manifest="true"
fi
node - "${work_dir}/kept.json" "${is_current_manifest}" \
>>"${work_dir}/retained-versions.tsv" <<'NODE'
const manifest = require(process.argv[2]);
const isCurrentManifest = process.argv[3] === "true";
if (manifest.indexVersion && manifest.indexVersion !== "None") {
console.log(`index.html\t${manifest.indexVersion}`);
}
if (manifest.priorIndexVersion && manifest.priorIndexVersion !== "None") {
console.log(`index.html\t${manifest.priorIndexVersion}`);
}
for (const asset of manifest.assets) {
if (typeof asset === "object" && asset.key && asset.versionId) {
console.log(`${asset.key}\t${asset.versionId}`);
}
}
if (isCurrentManifest) {
for (const asset of manifest.priorAssets ?? []) {
if (typeof asset === "object" && asset.key && asset.versionId) {
console.log(`${asset.key}\t${asset.versionId}`);
}
}
}
NODE
done <"${work_dir}/kept-manifests.txt"
sort -u -o "${work_dir}/retained-versions.tsv" "${work_dir}/retained-versions.tsv"
aws s3api list-object-versions \
--bucket "${SITE_BUCKET}" \
--output json >"${work_dir}/object-versions.json"
node - "${work_dir}/object-versions.json" >"${work_dir}/prune-candidates.tsv" <<'NODE'
const listing = require(process.argv[2]);
for (const version of listing.Versions ?? []) {
console.log(`version\t${version.Key}\t${version.VersionId}`);
}
for (const marker of listing.DeleteMarkers ?? []) {
console.log(`marker\t${marker.Key}\t${marker.VersionId}`);
}
NODE
while IFS=$'\t' read -r kind object_key version_id; do
[[ -n "${object_key}" && -n "${version_id}" ]] || continue
if [[ "${kind}" == "version" ]] &&
grep -qxF -- "${object_key}"$'\t'"${version_id}" "${work_dir}/retained-versions.tsv"; then
continue
fi
aws s3api delete-object \
--bucket "${SITE_BUCKET}" \
--key "${object_key}" \
--version-id "${version_id}" >/dev/null
done <"${work_dir}/prune-candidates.tsv"
echo "Web release ${DEPLOY_RELEASE_ID} deployed and verified."

105
scripts/deploy-web.test.mjs Normal file
View file

@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";
const scriptPath = new URL("./deploy-web.sh", import.meta.url);
const script = readFileSync(scriptPath, "utf8");
const windowsGitBash = `${process.env.ProgramFiles ?? "C:\\Program Files"}\\Git\\bin\\bash.exe`;
const bash = process.platform === "win32" ? windowsGitBash : "bash";
const hasBash = process.platform !== "win32" || existsSync(windowsGitBash);
const nativeScriptPath = fileURLToPath(scriptPath);
const bashScriptPath =
process.platform === "win32"
? nativeScriptPath
.replace(/^([A-Za-z]):\\/, (_, drive) => `/${drive.toLowerCase()}/`)
.replaceAll("\\", "/")
: nativeScriptPath;
test("deploy script has valid bash syntax", { skip: !hasBash }, () => {
const result = spawnSync(bash, ["-n", bashScriptPath], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
});
test("deploy script fails closed before running tools", { skip: !hasBash }, () => {
const result = spawnSync(bash, [bashScriptPath], {
encoding: "utf8",
env: { PATH: process.env.PATH },
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /SITE_BUCKET must be set explicitly/);
});
test("target bucket mismatch fails before publishing", { skip: !hasBash }, () => {
const result = spawnSync(bash, [bashScriptPath], {
encoding: "utf8",
env: {
...process.env,
SITE_BUCKET: "wrong-bucket",
EXPECTED_SITE_BUCKET: "expected-bucket",
CLOUDFRONT_DISTRIBUTION_ID: "DIST123",
SITE_URL: "https://example.test",
EXPECTED_API_URL: "https://api.example.test/api",
VITE_API_URL: "https://api.example.test/api",
DEPLOY_RELEASE_ID: "1234567",
},
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /SITE_BUCKET does not match EXPECTED_SITE_BUCKET/);
});
test("content safety contract is present and ordered", () => {
for (const required of [
"get-bucket-versioning",
"head-object",
"list-object-versions",
"asset-versions.tsv",
"prior-asset-versions.tsv",
"prior-manifest.json",
"priorAssets",
"isCurrentManifest",
"--version-id",
"public,max-age=31536000,immutable",
"no-cache,no-store,must-revalidate",
"cloudfront wait invalidation-completed",
'fetch_route "/login"',
'fetch_route "${EXTENSIONLESS_SMOKE_PATH}"',
"Access-Control-Request-Method: GET",
".deploy/releases/${DEPLOY_RELEASE_ID}.json",
]) {
assert.ok(script.includes(required), `missing contract: ${required}`);
}
assert.ok(
script.indexOf('deployment_verified="true"') < script.indexOf("aws s3api list-object-versions"),
"pruning must occur only after remote verification",
);
assert.ok(
script.indexOf('grep -qi "^access-control-allow-methods:.*GET"') <
script.indexOf('aws s3 cp "${work_dir}/manifest.json"'),
"failed remote verification must not publish a retention manifest",
);
assert.ok(
script.indexOf('aws s3 cp "${work_dir}/manifest.json"') <
script.indexOf('deployment_verified="true"'),
"manifest publication failures must roll back the new index",
);
assert.ok(
script.indexOf('>"${work_dir}/prior-asset-keys.txt"') <
script.indexOf('aws s3 sync dist/ "s3://${SITE_BUCKET}/"'),
"the pre-manifest release must be inventoried before new assets publish",
);
assert.match(
script,
/if \(isCurrentManifest\) \{[\s\S]*manifest\.priorAssets/,
"only the current manifest may retain its pre-script rollback assets",
);
assert.ok(
script.indexOf('SITE_URL="${SITE_URL%/}"') <
script.indexOf('API_CORS_ORIGIN="${API_CORS_ORIGIN:-${SITE_URL}}"'),
"the default CORS origin must use the normalized site URL",
);
assert.match(script, /Could not inspect the current index version/);
assert.doesNotMatch(script, /s3 sync[\s\S]{0,250}--delete/);
});

View file

@ -17,13 +17,11 @@ const MAINTAINABILITY_RULES = [
const GOVERNED_ROOTS = ["src/", "config/"];
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
// Repository-level gates that run after the source gates. Each is an npm
// script so it can also be run on its own.
const REPOSITORY_GATES = [
["Terraform import-plan contract", "test:terraform-import-plan"],
["Terraform isolation gate", "test:terraform-isolation"],
["Terraform formatting and validation", "test:terraform"],
["CDK build, tests, and synth", "test:infra"],
["Web deployment contract", "test:deploy-web"],
["CDK build and synth", "test:infra"],
];
function isGoverned(relativePath) {
@ -203,8 +201,6 @@ function plural(count, word) {
}
function runRepositoryGate(label, script) {
// Reuse the npm that launched us when available (matches its version and
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
const npmCli = process.env.npm_execpath;
const executable = npmCli ? process.execPath : "npm";
const args = npmCli ? [npmCli, "run", script] : ["run", script];
@ -213,7 +209,11 @@ function runRepositoryGate(label, script) {
encoding: "utf8",
stdio: "inherit",
});
return { label, status: result.status, error: result.error };
return {
label,
status: result.status,
error: result.error,
};
}
function main() {

View file

@ -4,9 +4,9 @@ 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 ROOTS = ["tf-poc", "dev", "staging"].map((environment) =>
path.join(ROOT, "terraform", "live", environment),
);
function run(args, cwd = ROOT) {
const result = spawnSync(TERRAFORM, args, {
@ -26,10 +26,8 @@ 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(["init", "-backend=false", "-input=false", "-no-color"], root);
run(["validate", "-no-color"], root);
}
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);
console.log("Terraform formatting and validation passed for tf-poc, dev, and staging.");

View file

@ -1,9 +1,4 @@
"""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.
"""
"""Canonical frontend Terraform ownership and import-ID maps."""
COMMON_RESOURCES = {
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
@ -35,7 +30,7 @@ COMMON_RESOURCES = {
REQUIRED_RESOURCES = {
environment: dict(COMMON_RESOURCES)
for environment in ("dev", "staging")
for environment in ("dev", "staging", "tf-poc")
}
CONTROLLED_UPDATE_ADDRESSES = frozenset(
@ -70,6 +65,16 @@ ENVIRONMENT_CONFIG = {
"distribution_id": "E2JDVEZ6EGD49J",
"workspace_name": "shoc-frontend-new-staging",
},
"tf-poc": {
"bucket_name": "seahaven-shoc-frontend-tf-poc",
"bucket_auto_delete_helper_role_arn": (
"arn:aws:iam::396287094661:role/"
"shoc-frontend-tf-poc-CustomS3AutoDeleteObjectsCusto-GBCGk2k2ZORz"
),
"cloudformation_stack_name": "shoc-frontend-tf-poc",
"distribution_id": None,
"workspace_name": "shoc-frontend-new-tf-poc",
},
}
@ -128,4 +133,16 @@ REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
"GithubDeployRoleDefaultPolicyE8F540D1"
),
},
"tf-poc": {
**_bucket_imports("seahaven-shoc-frontend-tf-poc"),
"module.environment_owned.aws_cloudfront_distribution.site": None,
"module.environment_owned.aws_cloudfront_origin_access_control.site": None,
"module.environment_owned.aws_cloudfront_function.spa_rewrite": None,
"module.environment_owned.aws_route53_record.site_a": None,
"module.environment_owned.aws_route53_record.site_aaaa": None,
"module.environment_owned.aws_iam_role.github_deploy": (
"githubdeploy-shoc-frontend-new-tf-poc"
),
"module.environment_owned.aws_iam_role_policy.github_deploy": None,
},
}

View file

@ -32,14 +32,15 @@ TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
def import_id(environment: str, address: str) -> str:
expected = REQUIRED_IMPORT_IDS[environment][address]
assert expected is not None, f"{environment} must pin an import ID for {address}"
return expected
if expected is not None:
return expected
suffix = address.rsplit(".", 1)[-1].replace("_", "-")
return f"tf-poc-generated-{suffix}"
def distribution_id(environment: str) -> str:
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
return configured
return configured if isinstance(configured, str) else "ETFPOCGENERATED123"
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
@ -387,26 +388,6 @@ 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_dev_root_pins_import_phase_in_code(self) -> None:
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
self.assertRegex(source, r"\n\s+adoption_complete\s+= false\n")
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
self.assertNotIn('variable "adoption_complete"', source)
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
self.assertNotIn(
"variable ",
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
root_file,
)
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
expected = {
"dev": (
@ -415,6 +396,18 @@ class ImportPlanCheckerTests(unittest.TestCase):
"local.github_oidc_arn",
"local.cache_policy_id",
),
"staging": (
"local.hosted_zone_id",
"local.certificate_arn",
"local.github_oidc_arn",
"local.cache_policy_id",
),
"tf-poc": (
"var.hosted_zone_id",
"var.certificate_arn",
"local.github_oidc_arn",
"local.cache_policy_id",
),
}
for environment, values in expected.items():
source = (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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