mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
71 commits
111eb55659
...
8ec91f0dac
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ec91f0dac | |||
| 7ab6fa30e7 | |||
| 0bc7e22884 | |||
| b71c0ad87b | |||
| 08da408a13 | |||
| 87e79072ad | |||
| 82361e14b5 | |||
| 78398482cf | |||
|
|
73346371e5 | ||
|
|
fdd97c24f9 | ||
|
|
b7d531f554 | ||
|
|
1fdecdd45d | ||
|
|
8e0f7617aa | ||
|
|
117f7949f5 | ||
|
|
15de3fca0a | ||
|
|
11743caa61 | ||
|
|
a7d359ffc4 | ||
|
|
eab83f6562 | ||
|
|
54671baf5c | ||
|
|
ef2ba9da1f | ||
|
|
a54fcaae9f | ||
|
|
252c0d0856 | ||
|
|
ba69706fcf | ||
|
|
3bb7acde2d | ||
|
|
c71d9482ec | ||
|
|
dee1578efe | ||
|
|
e761e0dae5 | ||
|
|
1607bac8ad | ||
|
|
47f78fec89 | ||
|
|
edf4a51d4c | ||
|
|
599e38aae3 | ||
|
|
4006145ef8 | ||
|
|
807d8ac1a8 | ||
|
|
6c765e8b42 | ||
|
|
747515870e | ||
|
|
74e5c58e5d | ||
|
|
793230f0c2 | ||
|
|
dc9cc07a73 | ||
|
|
ccdad96170 | ||
|
|
a766dc9c50 | ||
|
|
f51f6bd493 | ||
|
|
418eb3749d | ||
|
|
7cc7cc19d0 | ||
|
|
b0c58958ae | ||
|
|
3ed8600beb | ||
|
|
d6163e7eb6 | ||
|
|
fc07371f0d | ||
|
|
b8349783a7 | ||
|
|
e3ffa5a94b | ||
|
|
9f9aa5c824 | ||
|
|
fa866c5ea7 | ||
|
|
207199559f | ||
|
|
27ddeb2903 | ||
|
|
dc313dc50a | ||
|
|
a24002bc24 | ||
|
|
370b2a68ad | ||
|
|
3a8f41a444 | ||
|
|
c1863c4274 | ||
|
|
9fc67516ca | ||
|
|
ddd83ffec1 | ||
|
|
c2d1add79c | ||
|
|
9cc9bc4dfb | ||
|
|
dc178340de | ||
|
|
bc1e7a1fa2 | ||
|
|
08e00c213d | ||
|
|
c70587941c | ||
|
|
426889427c | ||
|
|
7e1dcb7a36 | ||
|
|
0ee2879234 | ||
|
|
85ea33b0ad | ||
|
|
72417bbb77 |
176 changed files with 11257 additions and 977 deletions
|
|
@ -8,5 +8,13 @@
|
|||
# Production: absolute URLs MUST include the /api path segment
|
||||
VITE_API_URL=https://api.seahavenindustries.com/api
|
||||
|
||||
# Sentry browser DSN. This value is public configuration and is baked into the bundle.
|
||||
# Leave blank to disable telemetry for that build.
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# Sentry environment label (development, staging, production). Blank falls back
|
||||
# to the Vite build mode.
|
||||
#VITE_SENTRY_ENVIRONMENT=
|
||||
|
||||
# Development proxy target (used by vite.config.ts)
|
||||
#VITE_API_TARGET=http://localhost:5141
|
||||
|
|
|
|||
|
|
@ -2,3 +2,9 @@
|
|||
# NOTE: baked into the build at `vite build`, so this is the DEV value. Staging
|
||||
# and prod builds must override VITE_API_URL per environment (api.staging..., etc.).
|
||||
VITE_API_URL=https://api.dev.seahaven.com/api
|
||||
|
||||
# Public browser configuration; the DSN is public by design and baked into the
|
||||
# bundle. Blank disables telemetry. These are the DEV deploy values: staging
|
||||
# and future prod jobs override them per environment.
|
||||
VITE_SENTRY_DSN=https://902f431c6e762aa849c9a07b77f0c3ac@o4511989453029376.ingest.de.sentry.io/4511990364700752
|
||||
VITE_SENTRY_ENVIRONMENT=development
|
||||
|
|
|
|||
8
.github/renovate.json
vendored
8
.github/renovate.json
vendored
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"enabledManagers": ["npm", "custom.regex"],
|
||||
"enabledManagers": ["npm", "custom.regex", "terraform"],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"internalChecksFilter": "strict",
|
||||
"customManagers": [
|
||||
|
|
@ -17,6 +17,12 @@
|
|||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": ["Group non-major Terraform provider updates"],
|
||||
"matchManagers": ["terraform"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "terraform minor and patch"
|
||||
},
|
||||
{
|
||||
"description": ["Do not open major or replacement PRs until approved on the dashboard"],
|
||||
"matchUpdateTypes": ["major", "replacement"],
|
||||
|
|
|
|||
39
.github/workflows/ci.yaml
vendored
39
.github/workflows/ci.yaml
vendored
|
|
@ -23,9 +23,11 @@ jobs:
|
|||
# repository, independent of (and in addition to) the reusable workflow.
|
||||
# `npm run verify` is the single command that chains: format check, lint
|
||||
# (--max-warnings=0), type-check + build, unit tests, then the governance
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet + changed-file
|
||||
# maintainability gate). If the reusable workflow is later confirmed to run
|
||||
# every gate, this job can be slimmed to `npm run governance`.
|
||||
# checks in scripts/governance-check.mjs (godfile ratchet, changed-file
|
||||
# maintainability gate, Terraform fmt/validate, Terraform import-plan guard
|
||||
# tests, Terraform isolation gate tests, CDK build/test/synth). If the
|
||||
# reusable workflow is later confirmed to run every gate, this job can be
|
||||
# slimmed to `npm run governance`.
|
||||
#
|
||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||
# PR -> the PR target branch (origin/<base_ref>)
|
||||
|
|
@ -53,6 +55,13 @@ jobs:
|
|||
base="origin/dev"
|
||||
fi
|
||||
printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Set up Terraform
|
||||
# Same minor as the HCP workspace (1.16.x) so fmt/validate see what
|
||||
# the remote run will see.
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
|
@ -62,6 +71,30 @@ jobs:
|
|||
env:
|
||||
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||
|
||||
terraform-isolation:
|
||||
# Fails a pull request that changes `terraform/**` together with deployable
|
||||
# application code (scripts/check-terraform-isolation.mjs). A merge that
|
||||
# does both queues an HCP VCS run and a content release at the same time,
|
||||
# and the two race for the workspace lock. The
|
||||
# `terraform-isolation-override` label is the reviewed exception; it is
|
||||
# read when the job runs, so re-run this workflow after labeling.
|
||||
name: Terraform and application changes are isolated
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Check changed files
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
TERRAFORM_ISOLATION_OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'terraform-isolation-override') }}
|
||||
run: node scripts/check-terraform-isolation.mjs --base "${BASE_SHA}" --head "${HEAD_SHA}"
|
||||
|
||||
visual-regression:
|
||||
name: Visual regression
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
14
.github/workflows/deploy-staging.yml
vendored
14
.github/workflows/deploy-staging.yml
vendored
|
|
@ -32,6 +32,8 @@ jobs:
|
|||
environment: staging
|
||||
env:
|
||||
VITE_API_URL: https://api.staging.seahaven.com/api
|
||||
VITE_SENTRY_ENVIRONMENT: staging
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
AWS_REGION: us-east-1
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
|
@ -68,15 +70,21 @@ jobs:
|
|||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds the SPA with the staging VITE_API_URL (process env overrides the
|
||||
# dev value committed in .env.production), syncs to the staging bucket,
|
||||
# and invalidates CloudFront.
|
||||
# Builds the SPA with the staging VITE_API_URL and Sentry environment
|
||||
# label (process env overrides the dev values committed in
|
||||
# .env.production), syncs to the staging bucket, and invalidates
|
||||
# CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
STACK_NAME: shoc-frontend-staging
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
112
.github/workflows/deploy.yml
vendored
112
.github/workflows/deploy.yml
vendored
|
|
@ -1,22 +1,22 @@
|
|||
name: Deploy
|
||||
name: Deploy dev content
|
||||
|
||||
# Continuous deployment to AWS (S3 + CloudFront) on push to `dev`.
|
||||
# Manual dev content deployment during the Terraform adoption (SH-300).
|
||||
#
|
||||
# This is a thin caller of the org's reusable CD workflow. `cd-cdk.yaml` runs
|
||||
# `cdk deploy` (provisioning the infra in infra/cdk) and then the
|
||||
# post-deploy-script, which builds the SPA and syncs it to S3 + invalidates
|
||||
# CloudFront. Both run as the OIDC deploy role created by the stack.
|
||||
# The push-to-`dev` trigger and the org reusable `cd-cdk.yaml` caller are
|
||||
# retired: `cdk deploy` no longer runs from CI. Infrastructure changes are
|
||||
# administrator-run (`infra/cdk/README.md`) while CloudFormation still owns the
|
||||
# resources, and move to HCP Terraform (`terraform/README.md`) as adoption
|
||||
# completes. Automatic push-to-`dev` releases return with the Terraform
|
||||
# content-CD change, gated on a repository variable.
|
||||
#
|
||||
# When staging/prod accounts exist, add jobs keyed to their branches and their
|
||||
# own AWS_DEPLOY_ROLE_ARN, reusing this same reusable workflow.
|
||||
# This workflow publishes only content: verify, build, `aws s3 sync`, and a
|
||||
# CloudFront invalidation through `scripts/deploy-web.sh`, as the pinned OIDC
|
||||
# deploy role. The bucket and distribution are pinned here so a content deploy
|
||||
# keeps working after CloudFormation relinquishes the stack outputs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch: {}
|
||||
|
||||
# OIDC needs id-token: write — it is never in the default token set and cannot
|
||||
# be granted to the reusable workflow unless the caller has it.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
|
@ -27,12 +27,82 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||
with:
|
||||
node-version: "24"
|
||||
region: us-east-1
|
||||
cdk-dir: infra/cdk
|
||||
stack-name: shoc-frontend-dev
|
||||
post-deploy-script: scripts/deploy-web.sh
|
||||
secrets:
|
||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||
name: Publish content to dev
|
||||
# Deploy only the exact dev branch ref: workflow_dispatch can be invoked
|
||||
# from arbitrary refs, and the deploy role trusts only refs/heads/dev.
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
VITE_APP_COMMIT_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- name: Set up Terraform
|
||||
# Required by `npm run verify` (governance runs terraform fmt/validate).
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
- name: Quality gates (full verify before any deploy)
|
||||
run: npm ci && npm run verify
|
||||
env:
|
||||
GOVERNANCE_BASE: origin/dev
|
||||
|
||||
- name: Assume dev deploy role (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev
|
||||
aws-region: us-east-1
|
||||
|
||||
# Builds with the dev values committed in .env.production (VITE_API_URL,
|
||||
# Sentry DSN), syncs to the pinned bucket, and invalidates CloudFront.
|
||||
- name: Build and publish SPA
|
||||
run: bash scripts/deploy-web.sh
|
||||
env:
|
||||
SITE_BUCKET: seahaven-shoc-frontend-dev
|
||||
CLOUDFRONT_DISTRIBUTION_ID: E2CWLM1AFB964P
|
||||
WAIT_FOR_INVALIDATION: "true"
|
||||
|
||||
- name: Upload private source maps
|
||||
run: bash scripts/upload-sourcemaps.sh
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SITE_URL="https://dev.seahaven.com"
|
||||
if grep -Rq "api.staging.seahaven.com" dist/; then
|
||||
echo "::error::Built assets contain the staging API URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Rq "api.dev.seahaven.com" dist/
|
||||
echo "Built assets reference the dev API URL."
|
||||
|
||||
# The invalidation has completed, but give edges a short window to
|
||||
# converge before calling the served index.html wrong.
|
||||
remote_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${remote_dir}"' EXIT
|
||||
matched=false
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html" \
|
||||
&& cmp -s dist/index.html "${remote_dir}/index.html"; then
|
||||
matched=true
|
||||
break
|
||||
fi
|
||||
echo "Served index.html does not yet match the published build (attempt ${i}); retrying in 20s..."
|
||||
sleep 20
|
||||
done
|
||||
if [[ "${matched}" != "true" ]]; then
|
||||
echo "::error::Served index.html does not match the build just published." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Served index.html matches the published build."
|
||||
curl -fsS --max-time 30 -o /dev/null "${SITE_URL}/login"
|
||||
echo "Extensionless SPA route serves."
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -47,3 +47,15 @@ infra/cdk/bin/*.d.ts
|
|||
infra/cdk/bin/*.js
|
||||
infra/cdk/lib/*.d.ts
|
||||
infra/cdk/lib/*.js
|
||||
|
||||
# terraform (the provider lock file is committed)
|
||||
**/.terraform/*
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ npm run verify
|
|||
```
|
||||
|
||||
`verify` chains: `format:check` → `lint` → `build` (`tsc -b && vite build`) →
|
||||
`test` (`vitest run`) → `governance`. A task is not done until this is green.
|
||||
`test` (`vitest run`) → `governance`. Governance also runs the repository
|
||||
gates: the Terraform import-plan checker tests, the Terraform isolation gate
|
||||
tests, Terraform formatting and validation, and the CDK build, template tests,
|
||||
and synthesis. A task is not done until this is green.
|
||||
|
||||
## Gate matrix
|
||||
|
||||
|
|
@ -23,6 +26,11 @@ npm run verify
|
|||
| Hooks correctness | `eslint-plugin-react-hooks` recommended (incl. `exhaustive-deps`) under zero-warnings | lint | Governed TS/TSX |
|
||||
| Godfile ratchet (file length) | `scripts/governance-check.mjs` + `scripts/governance-baseline.json` | `governance` | `src/**`, `config/**` (non-test) |
|
||||
| Changed-file maintainability | `scripts/governance-check.mjs` → ESLint (`complexity`, `max-lines-per-function`, `max-params`, `max-depth`) | `governance` | Changed TS/TSX vs base ref |
|
||||
| Terraform import-plan contract | `npm run test:terraform-import-plan` → `scripts/test-terraform-import-plan-check.py` | `governance` + CI | Synthetic plan JSON + canonical maps |
|
||||
| Terraform isolation gate contract | `npm run test:terraform-isolation` → `scripts/check-terraform-isolation.test.mjs` | `governance` + CI | Changed-file classifier |
|
||||
| Terraform formatting/validation | `npm run test:terraform` → `scripts/terraform-validate.mjs` | `governance` + CI | `terraform/live/dev` |
|
||||
| CDK build, tests, synthesis | `npm run test:infra` | `governance` + CI | `infra/cdk/**`, both synth modes |
|
||||
| Terraform/app change isolation | `ci.yaml` job `terraform-isolation` → `scripts/check-terraform-isolation.mjs` | CI (PR) | Changed files of the PR |
|
||||
|
||||
## No-false-pass guarantees
|
||||
|
||||
|
|
@ -36,6 +44,15 @@ npm run verify
|
|||
- **Changed-file maintainability fails closed without a valid base** — in CI the
|
||||
base ref is derived from `GITHUB_BASE_REF` (PR) or `github.event.before`
|
||||
(push). An absent or unresolvable base is a failure, not a pass.
|
||||
- **Terraform gates never touch live state** — `terraform init -backend=false
|
||||
-lockfile=readonly` and `validate` run offline; the plan checker is tested
|
||||
against synthetic plan JSON. Real import and controlled-update plans from HCP
|
||||
are migration evidence reviewed by a human before an approved apply
|
||||
(`terraform/README.md`).
|
||||
- **The isolation gate reads the override when it runs** — the
|
||||
`terraform-isolation-override` label is the only way to merge a mixed
|
||||
Terraform/application PR, the gate logs the override on the run, and the
|
||||
workflow must be re-run after the label is added or removed.
|
||||
|
||||
## Where the gates run
|
||||
|
||||
|
|
@ -44,11 +61,16 @@ npm run verify
|
|||
- **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** the org
|
||||
reusable workflow (`ci-typescript-frontend.yaml`, Node 24) runs
|
||||
format/lint/build/tests, **and** a repo-owned `governance` job runs
|
||||
`npm run verify` so the maintainability ratchets are guaranteed from this
|
||||
repository regardless of the reusable workflow.
|
||||
`npm run verify` (with Terraform 1.16.0 installed) so the maintainability
|
||||
ratchets and repository gates are guaranteed from this repository regardless
|
||||
of the reusable workflow. On pull requests the same workflow's
|
||||
`terraform-isolation` job fails when `terraform/**` and application code
|
||||
change together.
|
||||
|
||||
## Toolchain pin
|
||||
|
||||
Node ≥ 22.22.1 (CI uses Node 24); npm 11.16.0 via `packageManager` (use
|
||||
`corepack npm …` if your default `npm` is older). The lockfile is
|
||||
`package-lock.json` v3; install with `npm ci`.
|
||||
`package-lock.json` v3; install with `npm ci`. Governance also needs
|
||||
`terraform` (CI: 1.16.0; `versions.tf` accepts `>= 1.9.0, < 2.0.0`) and
|
||||
`python3` (3.10+) on `PATH`.
|
||||
|
|
|
|||
121
README.md
121
README.md
|
|
@ -29,10 +29,15 @@ graph LR
|
|||
CF -->|OAC| S3[S3 seahaven-shoc-frontend-dev]
|
||||
CF -.->|viewer-request fn| FN[SPA rewrite → /index.html]
|
||||
U -->|HTTPS api.dev.seahaven.com/api CORS| API[SHOC backend API]
|
||||
GH[GitHub Actions push to dev] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||
ROLE -->|cdk deploy + s3 sync + invalidation| S3
|
||||
GH[GitHub Actions: Deploy dev content] -->|OIDC| ROLE[githubdeploy-shoc-frontend-new-dev]
|
||||
ROLE -->|s3 sync + invalidation| S3
|
||||
TF[HCP Terraform shoc-frontend-new-dev] -.->|adopting: bucket, CloudFront, DNS, role| S3
|
||||
```
|
||||
|
||||
Dev hosting is being adopted from CDK into HCP Terraform (SH-300); see
|
||||
[`terraform/README.md`](terraform/README.md) for the phase runbook and the
|
||||
current ownership state.
|
||||
|
||||
Frontend stack: React 19, TypeScript, Vite, Tailwind CSS 4 + MUI, TanStack
|
||||
Query, React Router (via `@generouted/react-router`), React Hook Form + Zod,
|
||||
Ky HTTP client. Source layout: `src/api/`, `src/domain/`, `src/app/` (see the
|
||||
|
|
@ -57,12 +62,13 @@ No Lambdas, queues, or databases — this stack is static hosting only.
|
|||
|
||||
### Secrets
|
||||
|
||||
No Secrets Manager or SSM parameters. The one secret is a **GitHub Actions
|
||||
repo secret**:
|
||||
No Secrets Manager or SSM parameters. AWS access is OIDC only; the deploy role
|
||||
ARNs are deterministic and pinned in the workflows. The one **GitHub Actions
|
||||
repo secret** is:
|
||||
|
||||
| Secret | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `AWS_DEPLOY_ROLE_ARN` | ARN of `githubdeploy-shoc-frontend-new-dev`, passed to the org reusable CD workflow |
|
||||
| Secret | Purpose |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `SENTRY_AUTH_TOKEN` | Source-map upload by `scripts/upload-sourcemaps.sh` after a deploy |
|
||||
|
||||
### Environment variables (build-time, `VITE_*`)
|
||||
|
||||
|
|
@ -78,7 +84,8 @@ build otherwise. See [`.env.example`](.env.example),
|
|||
[`.env.development`](.env.development), and [`.env.production`](.env.production).
|
||||
|
||||
CDK context (domain, certificate ARN, hosted zone) lives in
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so CI runs `cdk deploy` with no flags.
|
||||
[`infra/cdk/cdk.json`](infra/cdk/cdk.json) so an administrator runs
|
||||
`cdk deploy` with no flags.
|
||||
|
||||
## Local Development
|
||||
|
||||
|
|
@ -95,17 +102,22 @@ The dev proxy expects the `shoc-backend` API at `http://localhost:5141`;
|
|||
override with `VITE_API_TARGET` (e.g. `https://api.dev.seahaven.com` to use
|
||||
the deployed dev API).
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | -------------------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Frontend governance checks (godfile + maintainability) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Vite dev server on port 3000 |
|
||||
| `npm run build` | Type-check (`tsc -b`) and production build to `dist/` |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` / `npm run test:watch` | Vitest unit tests (once / watch) |
|
||||
| `npm run test:e2e` / `npm run test:e2e:ui` | Playwright e2e tests (headless / UI mode) |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint (check / auto-fix) |
|
||||
| `npm run format` / `npm run format:check` | Prettier (write / check) |
|
||||
| `npm run governance` | Governance checks (godfile, maintainability, Terraform, CDK) |
|
||||
| `npm run verify` | **All gates**: format + lint + build + test + governance |
|
||||
|
||||
`npm run governance` needs `terraform` and `python3` on `PATH` for the
|
||||
Terraform gates (`npm run test:terraform`, `npm run test:terraform-import-plan`,
|
||||
`npm run test:terraform-isolation`) and installs `infra/cdk` for
|
||||
`npm run test:infra`.
|
||||
|
||||
Husky + lint-staged run ESLint and Prettier on staged files at commit;
|
||||
commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
||||
|
|
@ -123,66 +135,81 @@ commitlint enforces conventional commit messages. Run `npx tsc --noEmit` (or
|
|||
a green CI run and an approving review from a code owner
|
||||
(`@Sea-Haven-Industries/internal-dev`); new pushes dismiss stale approvals.
|
||||
Merged branches are deleted automatically.
|
||||
- Promotion flow: `feature/* → dev` (auto-deployed and verified on
|
||||
`dev.seahaven.com`) `→ main` (production promotion — no prod environment
|
||||
exists yet).
|
||||
- A PR that changes `terraform/**` may not also change application code (the
|
||||
`terraform-isolation` CI job); ship Terraform in its own PR.
|
||||
- Promotion flow: `feature/* → dev` (deployed to `dev.seahaven.com` through the
|
||||
**Deploy dev content** workflow while the Terraform adoption is in progress)
|
||||
`→ main` (production promotion — no prod environment exists yet).
|
||||
|
||||
## Deployment
|
||||
|
||||
CI/CD uses the org's reusable workflows (no stored AWS keys — OIDC only):
|
||||
No stored AWS keys — OIDC only. Infrastructure and content deploy separately:
|
||||
|
||||
- **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push
|
||||
and PRs to `main`/`dev`, calls
|
||||
and PRs to `main`/`dev`/`staging`, calls
|
||||
`Sea-Haven-Industries/.github` → `ci-typescript-frontend.yaml` (Node 24):
|
||||
format check, lint, build, tests; **and** runs a repo-owned `governance` job
|
||||
that calls `npm run verify` so every gate (including the maintainability
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs)) is
|
||||
guaranteed from this repository. Conventions and gates are documented under
|
||||
ratchets in [`scripts/governance-check.mjs`](scripts/governance-check.mjs),
|
||||
the Terraform gates, and the CDK template tests) is guaranteed from this
|
||||
repository. Conventions and gates are documented under
|
||||
[`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md),
|
||||
[`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and
|
||||
[`REVIEW_AND_PR_FRAMEWORK.md`](REVIEW_AND_PR_FRAMEWORK.md).
|
||||
- **CD** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) — on
|
||||
push to `dev`, calls `Sea-Haven-Industries/.github` → `cd-cdk.yaml`, which
|
||||
runs `cdk deploy` on `infra/cdk` (stack `shoc-frontend-dev`, `us-east-1`)
|
||||
and then [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||
- **Terraform isolation** (the `terraform-isolation` job in
|
||||
[`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — fails a PR that
|
||||
mixes `terraform/**` with application code, so a Terraform merge never races
|
||||
a content release for the HCP workspace.
|
||||
- **Dev content** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml))
|
||||
— `workflow_dispatch` on `dev` only while the Terraform adoption is in
|
||||
progress. Runs `npm run verify`, assumes `githubdeploy-shoc-frontend-new-dev`,
|
||||
and runs [`scripts/deploy-web.sh`](scripts/deploy-web.sh): `npm run build`,
|
||||
`aws s3 sync dist/` (hashed assets immutable, `index.html` never cached),
|
||||
CloudFront invalidation. Both run as the OIDC deploy role.
|
||||
CloudFront invalidation, then uploads source maps and checks the served
|
||||
`index.html` matches the build. Push-to-`dev` releases return with the
|
||||
Terraform content-CD change.
|
||||
- **Staging content**
|
||||
([`.github/workflows/deploy-staging.yml`](.github/workflows/deploy-staging.yml))
|
||||
— on push to `staging`, unchanged.
|
||||
- **Infrastructure** — administrator-run. Dev: the CDK retain/transfer sequence
|
||||
and the HCP Terraform workspace `shoc-frontend-new-dev`
|
||||
([`terraform/README.md`](terraform/README.md)). Staging: `cdk deploy`
|
||||
([`infra/cdk/README.md`](infra/cdk/README.md)).
|
||||
|
||||
One-time provisioning (OIDC provider, CDK bootstrap, first local deploy,
|
||||
setting `AWS_DEPLOY_ROLE_ARN`) is documented in
|
||||
[`infra/cdk/README.md`](infra/cdk/README.md).
|
||||
|
||||
Manual deploy (emergency/reference only — needs credentials for the
|
||||
external-dev AWS account; the normal path is push to `dev`):
|
||||
Manual content deploy (emergency/reference only — needs credentials for the
|
||||
external-dev AWS account):
|
||||
|
||||
```bash
|
||||
(cd infra/cdk && npx cdk deploy)
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
SITE_BUCKET=seahaven-shoc-frontend-dev CLOUDFRONT_DISTRIBUTION_ID=E2CWLM1AFB964P \
|
||||
AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy** run in
|
||||
the Actions tab; confirm a deep link (e.g. a work-orders route) loads
|
||||
directly and API calls succeed.
|
||||
- **Verify:** open <https://dev.seahaven.com> after a green **Deploy dev
|
||||
content** run in the Actions tab; confirm a deep link (e.g. a work-orders
|
||||
route) loads directly and API calls succeed.
|
||||
- **Logs:** deploy logs live in GitHub Actions (CI + Deploy workflows). There
|
||||
are no CloudWatch application logs — the stack is static hosting; runtime
|
||||
errors surface in the browser and on the backend API's side.
|
||||
- **Common failure modes:**
|
||||
- _Stale content after deploy_ — the CloudFront invalidation step failed or
|
||||
is still propagating; re-run the Deploy workflow or invalidate `/*` manually.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to pushes to `dev`
|
||||
on this repo; deploys from other branches/repos are rejected by design.
|
||||
- _OIDC `AssumeRole` errors_ — the trust policy is scoped to the `dev` ref
|
||||
on this repo; dispatching the workflow from another branch is rejected by
|
||||
design.
|
||||
- _Broken API requests after a build_ — `VITE_API_URL` missing the `/api`
|
||||
suffix or carrying the wrong environment's host (it is baked in at build time).
|
||||
- _CORS errors_ — the backend must allow the frontend origin; CloudFront does
|
||||
not proxy `/api`.
|
||||
- **CI and CD both fire on push to `dev` in parallel** — a red-CI commit still
|
||||
deploys (matches the org's push-time-CD model; gating deploy on CI is known
|
||||
follow-up work).
|
||||
- **Dev has no push-triggered deploy during the adoption.** Merging to `dev`
|
||||
runs CI only; publish through the **Deploy dev content** workflow. Merging a
|
||||
`terraform/**` change also queues an HCP Terraform run that a human confirms
|
||||
or discards (see the operational rules in `terraform/README.md`).
|
||||
|
||||
## Documentation
|
||||
|
||||
- Infra one-time setup and stack details: [`infra/cdk/README.md`](infra/cdk/README.md)
|
||||
- Dev Terraform adoption runbook: [`terraform/README.md`](terraform/README.md)
|
||||
- Rebuild strategy and conventions: [`docs/ARCHITECTURE_PLAN.md`](docs/ARCHITECTURE_PLAN.md);
|
||||
design system and UI docs under [`docs/`](docs/)
|
||||
|
|
|
|||
26
config/sentry-release.test.ts
Normal file
26
config/sentry-release.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LOCAL_DEVELOPMENT_RELEASE, resolveSentryRelease } from "./sentry-release";
|
||||
|
||||
describe("resolveSentryRelease", () => {
|
||||
it("maps the exact commit to the frontend release", () => {
|
||||
const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
|
||||
expect(resolveSentryRelease(sha, true)).toEqual({
|
||||
commitSha: sha.toLowerCase(),
|
||||
release: `shoc-frontend@${sha.toLowerCase()}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails CI when the commit identity is missing or malformed", () => {
|
||||
expect(() => resolveSentryRelease(undefined, true)).toThrow(/40-character commit SHA/);
|
||||
expect(() => resolveSentryRelease("not-a-sha", true)).toThrow(/40-character commit SHA/);
|
||||
});
|
||||
|
||||
it("labels non-CI builds without a commit as local development", () => {
|
||||
expect(resolveSentryRelease(undefined, false)).toEqual({
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
});
|
||||
});
|
||||
});
|
||||
32
config/sentry-release.ts
Normal file
32
config/sentry-release.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export const SENTRY_SERVICE_NAME = "shoc-frontend";
|
||||
export const LOCAL_DEVELOPMENT_RELEASE = "local-development";
|
||||
|
||||
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
|
||||
|
||||
export function resolveSentryRelease(
|
||||
candidate: string | undefined,
|
||||
ci: boolean,
|
||||
): {
|
||||
commitSha: string;
|
||||
release: string;
|
||||
} {
|
||||
const commitSha = candidate?.trim();
|
||||
|
||||
if (commitSha && COMMIT_SHA.test(commitSha)) {
|
||||
return {
|
||||
commitSha: commitSha.toLowerCase(),
|
||||
release: `${SENTRY_SERVICE_NAME}@${commitSha.toLowerCase()}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ci) {
|
||||
throw new Error(
|
||||
"CI builds require VITE_APP_COMMIT_SHA or GITHUB_SHA as a 40-character commit SHA",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
commitSha: LOCAL_DEVELOPMENT_RELEASE,
|
||||
release: LOCAL_DEVELOPMENT_RELEASE,
|
||||
};
|
||||
}
|
||||
94
docs/adr/0002-sentry-observability.md
Normal file
94
docs/adr/0002-sentry-observability.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# 0002. Browser error and transaction telemetry with Sentry
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The SeaHaven admin SPA has no centralized browser error reporting or distributed
|
||||
transaction tracing. Operational failures therefore have to be reconstructed
|
||||
from user reports and isolated backend logs. We need browser navigation and API
|
||||
request transactions to join the backend trace while preserving the existing UI,
|
||||
routing, authentication, and server-state behavior. Every emitted transaction
|
||||
must also identify its exact build, trace, transaction, operation, function, and
|
||||
opaque actor without sending credentials, request bodies, query values, or
|
||||
personal data.
|
||||
|
||||
## Decision
|
||||
|
||||
Use the pinned `@sentry/react` 10.73.0 SDK. Initialize it before the application
|
||||
router is created, and wrap `createBrowserRouter` with Sentry's current React
|
||||
Router compatibility API. Configure the browser tracing integration with a 100%
|
||||
transaction sample rate, and propagate trace headers only to the SPA origin and
|
||||
the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the
|
||||
`onCaughtError`, `onUncaughtError`, and `onRecoverableError` callbacks of
|
||||
`ReactDOM.createRoot`, so React 19's root error hooks capture caught, uncaught,
|
||||
and recoverable component errors as Sentry events.
|
||||
|
||||
All shared API helpers, direct Ky calls, vendor-link `fetch` calls, and vendor
|
||||
document `XMLHttpRequest` calls run inside explicit `http.client` transactions.
|
||||
They use normalized route templates and attach `code.function`, `operation.type`,
|
||||
HTTP method, `trace_id`, and `transaction_id`. Browser navigation transactions
|
||||
receive the same searchable trace identifiers in `beforeSendTransaction`.
|
||||
Authenticated sessions set only the application's opaque numeric/string user ID;
|
||||
logout and session expiry clear it.
|
||||
|
||||
The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public
|
||||
browser configuration because every `VITE_` value is embedded in the built
|
||||
JavaScript. `.env.production` commits the public DSN of the real
|
||||
`shoc-frontend` Sentry project, so dev and staging deploys are live; a missing
|
||||
or blank DSN in any other build leaves telemetry inactive without preventing
|
||||
the application from starting. Default personally identifiable information
|
||||
collection remains disabled.
|
||||
|
||||
The Sentry environment label comes from `VITE_SENTRY_ENVIRONMENT` when set and
|
||||
non-blank, falling back to the Vite build mode otherwise. The committed
|
||||
`.env.production` labels dev deploys `development`, the staging deploy job
|
||||
overrides the label to `staging`, and a future production pipeline can set
|
||||
`production` without code changes.
|
||||
|
||||
Every deployment embeds `shoc-frontend@<40-character commit SHA>` as the Sentry
|
||||
release and exposes the same SHA as the `app.commit` tag. CI fails if it cannot
|
||||
resolve that identity. Vite creates hidden source maps, and the dev/staging
|
||||
deployment workflows upload them to the private `shoc-frontend` Sentry project
|
||||
using the repository's `SENTRY_AUTH_TOKEN` secret. The public S3 sync explicitly
|
||||
excludes `*.map`; the upload credential is never available to the browser build.
|
||||
|
||||
Before-send processors reduce request data to normalized URL plus HTTP method,
|
||||
reduce users to opaque ID only, attach native trace/transaction identifiers as
|
||||
searchable tags, and reduce breadcrumbs to safe network method/status/normalized
|
||||
URL metadata. Root and child span attributes use the same allowlist, retaining
|
||||
function, operation, actor type, normalized route, method/status, service host,
|
||||
timing, and native IDs while removing raw URLs and query values. Bodies, headers,
|
||||
cookies, query strings/values, free-form breadcrumb messages, usernames, email
|
||||
addresses, IP addresses, tokens, and filenames are not intentionally sent.
|
||||
`sendDefaultPii` remains disabled.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Manual error and timing calls** — rejected because they would miss route
|
||||
transitions and distributed request context, and would require every feature
|
||||
to maintain its own instrumentation.
|
||||
- **Propagate tracing headers to every request** — rejected because third-party
|
||||
requests must not receive SeaHaven trace metadata and broad propagation can
|
||||
introduce cross-origin request failures.
|
||||
- **Publish source maps with the SPA** — rejected because source code belongs in
|
||||
Sentry's private artifact store, not the public application bucket.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Browser navigations and API requests are recorded as connected transactions
|
||||
when the deployment supplies a valid DSN.
|
||||
- Events are labeled by `VITE_SENTRY_ENVIRONMENT` (`development` on dev
|
||||
deploys, `staging` on the staging job), falling back to the Vite build mode
|
||||
when unset.
|
||||
- Every transaction is sampled. This meets the current requirement but increases
|
||||
event volume and should be reviewed against Sentry quota and retention after
|
||||
real traffic is measured.
|
||||
- Cross-origin API tracing depends on the backend accepting `sentry-trace` and
|
||||
`baggage` request headers; the current API CORS policy allows request headers.
|
||||
- Deployed stack traces resolve against the exact commit's private source maps
|
||||
once the scoped upload secret is present and the deployment workflow runs.
|
||||
- Rollback is isolated: remove `@sentry/react`, the initialization module and
|
||||
import, restore the unwrapped router factory, and remove the Sentry env entries.
|
||||
|
|
@ -1,34 +1,44 @@
|
|||
# Infrastructure & CI/CD — Sea Haven SHOC frontend
|
||||
|
||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
|
||||
deployed through the org's **reusable** GitHub Actions workflow.
|
||||
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo.
|
||||
Infrastructure deploys are administrator-run; GitHub Actions publishes content
|
||||
only.
|
||||
|
||||
> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack
|
||||
> `shoc-frontend-dev` is in the retain/transfer sequence described under
|
||||
> [Terraform adoption mode](#terraform-adoption-mode) and in
|
||||
> [`terraform/README.md`](../terraform/README.md). Do not run a plain
|
||||
> `cdk deploy` against dev while that sequence is in progress. Staging is
|
||||
> unaffected and stays on this CDK path (SH-287 tracks its cutover).
|
||||
|
||||
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
|
||||
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
|
||||
- **API:** the SPA calls the backend **directly** over HTTPS at
|
||||
`https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend
|
||||
allows CORS). CloudFront serves static content only — no `/api` proxy.
|
||||
- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy`
|
||||
picks them up with no flags. `VITE_API_URL` is baked into the build, so it's
|
||||
- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks
|
||||
them up with no flags. `VITE_API_URL` is baked into the build, so it's
|
||||
per-environment (see the note under "Adding staging / prod").
|
||||
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
|
||||
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
|
||||
`Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy`
|
||||
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
|
||||
- **Content workflows:** `.github/workflows/deploy.yml` (dev,
|
||||
`workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to
|
||||
`staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy
|
||||
role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was
|
||||
retired with the adoption PR.
|
||||
- **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is
|
||||
created by this stack, not added to the central `oidc-deploy-roles.yaml`.
|
||||
- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and
|
||||
`staging` (push to `staging`, via the standalone `deploy-staging.yml`).
|
||||
|
||||
```
|
||||
infra/cdk/
|
||||
bin/app.ts entry point (reads -c context)
|
||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||
bin/app.ts entry point (reads -c context)
|
||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||
lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition)
|
||||
test/frontend-stack.test.mjs template assertions for both modes
|
||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||
.github/workflows/
|
||||
ci.yaml quality gates (lint / build / test / e2e)
|
||||
deploy.yml caller of the org reusable cd-cdk.yaml (push to dev)
|
||||
deploy-staging.yml standalone staging deploy (push to staging)
|
||||
ci.yaml quality gates (lint / build / test / governance / terraform isolation)
|
||||
deploy.yml dev content publish (workflow_dispatch on dev)
|
||||
deploy-staging.yml standalone staging deploy (push to staging)
|
||||
```
|
||||
|
||||
## What the stack creates
|
||||
|
|
@ -40,12 +50,67 @@ scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
|||
| CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) |
|
||||
| IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` |
|
||||
|
||||
The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on
|
||||
`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's
|
||||
pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`),
|
||||
and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a
|
||||
singleton account resource — the stack only _imports_ it (created in step 2),
|
||||
so `cdk destroy` can't delete a resource shared by other roles.
|
||||
The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants:
|
||||
`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`,
|
||||
read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It
|
||||
is left byte-identical on purpose so the Terraform import is a no-op; the
|
||||
Terraform content-CD change narrows it. The OIDC **provider** is a singleton
|
||||
account resource — the stack only _imports_ it (created in step 2), so
|
||||
`cdk destroy` can't delete a resource shared by other roles.
|
||||
|
||||
## Terraform adoption mode
|
||||
|
||||
`-c retainForTerraformAdoption=true` switches the stack into the safety mode
|
||||
used only while HCP Terraform adopts the dev resources. It is off by default
|
||||
and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts
|
||||
both). In adoption mode the stack:
|
||||
|
||||
- pins the origin ID CloudFormation generated for the live distribution
|
||||
(`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is
|
||||
metadata-only; environments without a verified value fail synthesis
|
||||
- attaches the `seahaven-org-baseline` permissions boundary
|
||||
`shoc-frontend-new-dev-deploy-boundary` and the
|
||||
`HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role
|
||||
- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the
|
||||
same exact value
|
||||
- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13
|
||||
transferred resources (bucket, bucket policy, distribution, OAC, SPA
|
||||
function, A and AAAA records, deploy role, inline policy) and to
|
||||
`SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider
|
||||
Lambda and role stay unretained
|
||||
- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no
|
||||
default) and conditions those same resources and every output on it
|
||||
- emits `TerraformImport*` outputs carrying the exact import IDs
|
||||
|
||||
`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must
|
||||
state the ownership phase:
|
||||
|
||||
```bash
|
||||
cd infra/cdk && npm ci
|
||||
|
||||
# Phase 1, before the Terraform import: keep the resources in the stack and
|
||||
# install Retain on them. Update-only change set.
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=true
|
||||
|
||||
# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish
|
||||
# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource.
|
||||
npx cdk deploy shoc-frontend-dev \
|
||||
-c retainForTerraformAdoption=true \
|
||||
--parameters ManageSiteInfrastructure=false
|
||||
```
|
||||
|
||||
Both deploys must use the same reviewed SHA. Review the change set before
|
||||
confirming: Phase 1 must show no create, delete, or replace. After the
|
||||
`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used
|
||||
again. If the `true` deploy rolls back, inspect the stack resources and the
|
||||
live bucket before retrying; retained resources can outlive a failed update and
|
||||
must not be cleaned up automatically. Never delete the auto-delete custom
|
||||
resource while its handler can still empty the versioned bucket.
|
||||
|
||||
Local checks (`npm run test:infra` from the repo root) build the app, run the
|
||||
template assertions, and synthesize both modes.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -102,48 +167,31 @@ cd infra/cdk
|
|||
npx cdk deploy
|
||||
```
|
||||
|
||||
Note the `DeployRoleArn` output. Then push the first content (or just push to
|
||||
`dev` and let CI do everything from here on):
|
||||
Note the `DeployRoleArn` output. Then publish the first content manually:
|
||||
|
||||
```bash
|
||||
# from repo root, optional manual first content publish:
|
||||
STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh
|
||||
```
|
||||
|
||||
### 6. Set the one GitHub secret
|
||||
### 6. Content deploys
|
||||
|
||||
`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable):
|
||||
|
||||
```bash
|
||||
REPO=Sea-Haven-Industries/shoc-frontend-new
|
||||
gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \
|
||||
--body "arn:aws:iam::<acct>:role/githubdeploy-shoc-frontend-new-dev"
|
||||
```
|
||||
|
||||
(Or **Settings → Secrets and variables → Actions → Secrets**.)
|
||||
|
||||
### 7. From now on: push to `dev`
|
||||
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs
|
||||
`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open
|
||||
the `SiteUrl` output.
|
||||
|
||||
> First-run verification: this first push is what actually exercises the role's
|
||||
> permissions and the OIDC trust through the reusable workflow (the local
|
||||
> bootstrap used admin creds and tested none of that). Watch for
|
||||
> credential/OIDC errors and a green post-deploy step.
|
||||
The deploy role ARN is deterministic and pinned in
|
||||
`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the
|
||||
Terraform adoption, dev content deploys run only through **Actions → Deploy dev
|
||||
content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes
|
||||
`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the
|
||||
pinned bucket and distribution, uploads source maps, and verifies the served
|
||||
`index.html` matches the build. Automatic push-to-`dev` releases return with the
|
||||
Terraform content-CD change.
|
||||
|
||||
---
|
||||
|
||||
## Staging environment (same account, exact OIDC subject)
|
||||
|
||||
Staging lives in the same AWS account (396287094661) but deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
|
||||
reusable `cd-cdk.yaml`:
|
||||
Staging lives in the same AWS account (396287094661) and deploys through its
|
||||
own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to
|
||||
`staging`:
|
||||
|
||||
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
||||
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
||||
|
|
@ -212,10 +260,11 @@ for prod.
|
|||
|
||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
||||
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
|
||||
differs only in that its CD workflow also runs `npm run verify` itself
|
||||
before deploying); a red-CI commit still deploys on `dev` (matches the
|
||||
org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part
|
||||
of enabling CICD.
|
||||
Never run it against dev during or after the Terraform adoption: the
|
||||
adoption-mode stack retains the transferred resources, and after Phase 2
|
||||
Terraform owns them.
|
||||
- **CI and staging CD both fire on push to `staging`** in parallel; the
|
||||
staging CD workflow runs `npm run verify` itself before deploying. Dev has
|
||||
no push-triggered deploy during the adoption.
|
||||
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
|
|||
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
||||
|
||||
// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required
|
||||
// ManageSiteInfrastructure parameter and Retain policies on the transferred
|
||||
// resources. Off by default so ordinary synthesis is unchanged.
|
||||
const retainForTerraformAdoption =
|
||||
String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true";
|
||||
|
||||
// Staging and beyond protect their stacks from accidental deletion; dev
|
||||
// stays teardown-friendly (its artifacts are reproducible). CDK applies this
|
||||
// at deploy time — it is not part of the synthesized template.
|
||||
|
|
@ -40,6 +46,7 @@ const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
|||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption,
|
||||
env: {
|
||||
account: process.env.CDK_DEFAULT_ACCOUNT,
|
||||
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
|
||||
import {
|
||||
Aspects,
|
||||
CfnCondition,
|
||||
CfnOutput,
|
||||
CfnParameter,
|
||||
CfnResource,
|
||||
Duration,
|
||||
Fn,
|
||||
RemovalPolicy,
|
||||
Stack,
|
||||
StackProps,
|
||||
Tags,
|
||||
} from "aws-cdk-lib";
|
||||
import { Construct } from "constructs";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
||||
|
|
@ -7,6 +19,7 @@ import * as iam from "aws-cdk-lib/aws-iam";
|
|||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
||||
import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption";
|
||||
|
||||
export interface FrontendStackProps extends StackProps {
|
||||
/** Environment label, e.g. "dev". Used in names/tags. */
|
||||
|
|
@ -42,6 +55,11 @@ export interface FrontendStackProps extends StackProps {
|
|||
readonly hostedZoneId: string;
|
||||
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
||||
readonly hostedZoneName: string;
|
||||
/**
|
||||
* Opt-in safety mode used only during the reviewed Terraform adoption.
|
||||
* Normal dev/staging synthesis remains unchanged when false.
|
||||
*/
|
||||
readonly retainForTerraformAdoption?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,11 +68,10 @@ export interface FrontendStackProps extends StackProps {
|
|||
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
||||
* - a GitHub Actions OIDC deploy role
|
||||
*
|
||||
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
|
||||
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
|
||||
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
|
||||
* stack only owns the infrastructure, and the deploy role carries the
|
||||
* permissions those post-deploy steps need.
|
||||
* Content (the built `dist/`) is NOT uploaded here. Manual environment
|
||||
* workflows run `scripts/deploy-web.sh` independently of infrastructure
|
||||
* changes, so this stack only owns infrastructure and the deploy role carries
|
||||
* content-publication permissions.
|
||||
*/
|
||||
export class FrontendStack extends Stack {
|
||||
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
||||
|
|
@ -69,8 +86,23 @@ export class FrontendStack extends Stack {
|
|||
certificateArn,
|
||||
hostedZoneId,
|
||||
hostedZoneName,
|
||||
retainForTerraformAdoption = false,
|
||||
} = props;
|
||||
|
||||
const manageSiteInfrastructureCondition = retainForTerraformAdoption
|
||||
? new CfnCondition(this, "ManageSiteInfrastructureCondition", {
|
||||
expression: Fn.conditionEquals(
|
||||
new CfnParameter(this, "ManageSiteInfrastructure", {
|
||||
type: "String",
|
||||
allowedValues: ["true", "false"],
|
||||
description:
|
||||
"Set true only before Terraform adoption. After ownership transfer, always reuse false.",
|
||||
}).valueAsString,
|
||||
"true",
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const hasCustomDomain = domainNames.length > 0;
|
||||
if (hasCustomDomain && !certificateArn) {
|
||||
throw new Error(
|
||||
|
|
@ -114,6 +146,16 @@ export class FrontendStack extends Stack {
|
|||
// --- CloudFront: serves the static SPA from S3 -------------------------
|
||||
// The SPA calls the backend directly at its absolute HTTPS URL
|
||||
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
||||
// Adoption mode pins the origin ID CloudFormation generated for the live
|
||||
// distribution so the retention deploy is a metadata-only update. Only
|
||||
// environments with a read-back-verified value may enter adoption mode.
|
||||
const adoptionOriginIds: Record<string, string> = {
|
||||
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
|
||||
};
|
||||
const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined;
|
||||
if (retainForTerraformAdoption && !originId) {
|
||||
throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`);
|
||||
}
|
||||
const distribution = new cloudfront.Distribution(this, "Distribution", {
|
||||
comment: `SeaHaven SHOC frontend (${envName})`,
|
||||
defaultRootObject: "index.html",
|
||||
|
|
@ -130,7 +172,9 @@ export class FrontendStack extends Stack {
|
|||
: undefined,
|
||||
defaultBehavior: {
|
||||
// withOriginAccessControl wires up OAC + the bucket policy automatically.
|
||||
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
||||
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, {
|
||||
originId,
|
||||
}),
|
||||
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
||||
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
||||
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
||||
|
|
@ -158,9 +202,9 @@ export class FrontendStack extends Stack {
|
|||
// Trust conditions for the OIDC principal. With a GitHub environment
|
||||
// (staging): exact StringEquals match on both aud and the environment
|
||||
// subject — the staging workflow declares `environment: staging`, so only
|
||||
// runs in that environment can assume the role. Without one (dev): keep
|
||||
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
|
||||
// deploy branch (reusable-workflow runs still carry the caller-based sub).
|
||||
// runs in that environment can assume the role. Normal dev synthesis keeps
|
||||
// the current branch-ref StringLike trust. The adoption prerequisite
|
||||
// narrows that already-exact value to StringEquals before Terraform import.
|
||||
const oidcConditions = githubEnvironment
|
||||
? {
|
||||
StringEquals: {
|
||||
|
|
@ -168,30 +212,48 @@ export class FrontendStack extends Stack {
|
|||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
: retainForTerraformAdoption
|
||||
? {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
}
|
||||
: {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
},
|
||||
StringLike: {
|
||||
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
||||
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
||||
// matches even though the deploy job lives in the `.github` repo.
|
||||
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
||||
},
|
||||
};
|
||||
|
||||
const deployPermissionsBoundary = retainForTerraformAdoption
|
||||
? iam.ManagedPolicy.fromManagedPolicyArn(
|
||||
this,
|
||||
"GithubDeployPermissionsBoundary",
|
||||
`arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
||||
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
||||
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
||||
maxSessionDuration: Duration.hours(1),
|
||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
||||
permissionsBoundary: deployPermissionsBoundary,
|
||||
});
|
||||
if (retainForTerraformAdoption) {
|
||||
Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`);
|
||||
}
|
||||
|
||||
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
|
||||
// intentionally narrower: its recurring promotion workflow only publishes
|
||||
// application assets to this stack's bucket/distribution. Infrastructure
|
||||
// changes remain an administrator-run CDK operation, so the staging OIDC
|
||||
// role cannot inherit the bootstrap roles' account-wide deployment power.
|
||||
// Preserve dev's legacy CDK capability until the reviewed adoption update
|
||||
// replaces this inline policy. Staging is intentionally narrower: its
|
||||
// content role only publishes application assets to this stack's
|
||||
// bucket/distribution. Infrastructure changes remain administrator-run.
|
||||
if (!githubEnvironment) {
|
||||
deployRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
|
|
@ -224,6 +286,8 @@ export class FrontendStack extends Stack {
|
|||
// --- DNS: point the custom domain at CloudFront ------------------------
|
||||
// Only when a hosted zone is supplied (it must be in THIS account). Creates
|
||||
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
|
||||
let aliasA: route53.ARecord | undefined;
|
||||
let aliasAaaa: route53.AaaaRecord | undefined;
|
||||
if (hostedZoneId && hasCustomDomain) {
|
||||
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
|
||||
hostedZoneId,
|
||||
|
|
@ -233,31 +297,169 @@ export class FrontendStack extends Stack {
|
|||
// apex record when the domain equals the zone name.
|
||||
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
|
||||
|
||||
new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
|
||||
aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
||||
aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", {
|
||||
zone,
|
||||
recordName,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
const gateOutput = (output: CfnOutput): CfnOutput => {
|
||||
if (manageSiteInfrastructureCondition) {
|
||||
output.condition = manageSiteInfrastructureCondition;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// --- Outputs -----------------------------------------------------------
|
||||
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
|
||||
new CfnOutput(this, "SiteUrl", {
|
||||
value: hasCustomDomain
|
||||
? `https://${domainNames[0]}`
|
||||
: `https://${distribution.distributionDomainName}`,
|
||||
description: "Public URL of the deployed SPA",
|
||||
});
|
||||
new CfnOutput(this, "DistributionDomainName", {
|
||||
value: distribution.distributionDomainName,
|
||||
description: "CloudFront domain — point the custom-domain DNS record here",
|
||||
});
|
||||
new CfnOutput(this, "BucketName", {
|
||||
value: bucket.bucketName,
|
||||
});
|
||||
new CfnOutput(this, "DistributionId", {
|
||||
value: distribution.distributionId,
|
||||
});
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
value: deployRole.roleArn,
|
||||
description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN",
|
||||
});
|
||||
gateOutput(
|
||||
new CfnOutput(this, "SiteUrl", {
|
||||
value: hasCustomDomain
|
||||
? `https://${domainNames[0]}`
|
||||
: `https://${distribution.distributionDomainName}`,
|
||||
description: "Public URL of the deployed SPA",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DistributionDomainName", {
|
||||
value: distribution.distributionDomainName,
|
||||
description: "CloudFront domain — point the custom-domain DNS record here",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "BucketName", {
|
||||
value: bucket.bucketName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DistributionId", {
|
||||
value: distribution.distributionId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "DeployRoleArn", {
|
||||
value: deployRole.roleArn,
|
||||
description: "Pinned GitHub OIDC content-deployment role",
|
||||
}),
|
||||
);
|
||||
|
||||
if (retainForTerraformAdoption) {
|
||||
const originAccessControl = distribution.node
|
||||
.findAll()
|
||||
.find(
|
||||
(node): node is cloudfront.CfnOriginAccessControl =>
|
||||
node instanceof cloudfront.CfnOriginAccessControl,
|
||||
);
|
||||
if (!originAccessControl || !aliasA || !aliasAaaa) {
|
||||
throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records.");
|
||||
}
|
||||
const originAccessControlConfig =
|
||||
originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty;
|
||||
|
||||
const rolePolicy = deployRole.node
|
||||
.findAll()
|
||||
.find((node): node is iam.Policy => node instanceof iam.Policy);
|
||||
const autoDeleteProviderRole = this.node
|
||||
.findAll()
|
||||
.find(
|
||||
(node): node is CfnResource =>
|
||||
node instanceof CfnResource &&
|
||||
node.cfnResourceType === "AWS::IAM::Role" &&
|
||||
node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"),
|
||||
);
|
||||
if (!rolePolicy || !autoDeleteProviderRole) {
|
||||
throw new Error("Terraform adoption outputs require deploy and auto-delete roles.");
|
||||
}
|
||||
|
||||
const recordName = domainNames[0];
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformWorkspaceTag", {
|
||||
value: `shoc-frontend-new-${envName}`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDeployBoundaryArn", {
|
||||
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
||||
}),
|
||||
);
|
||||
gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName }));
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportBucketPolicy", {
|
||||
value: bucket.bucketName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDistribution", {
|
||||
value: distribution.distributionId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportOriginAccessControl", {
|
||||
value: originAccessControl.attrId,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformOriginAccessControlName", {
|
||||
value: originAccessControlConfig.name,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
|
||||
value: "EMPTY_STRING",
|
||||
description: "Use an empty Terraform string because the generated OAC has no description",
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDistributionOriginId", {
|
||||
value: originId!,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
|
||||
value: spaRewrite.functionName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportAliasA", {
|
||||
value: `${hostedZoneId}_${recordName}_A`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportAliasAAAA", {
|
||||
value: `${hostedZoneId}_${recordName}_AAAA`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDeployRole", {
|
||||
value: deployRole.roleName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
|
||||
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
|
||||
value: rolePolicy.policyName,
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
|
||||
value: autoDeleteProviderRole.getAtt("Arn").toString(),
|
||||
}),
|
||||
);
|
||||
gateOutput(
|
||||
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
|
||||
value: "SiteBucket/AutoDeleteObjectsCustomResource",
|
||||
description:
|
||||
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
|
||||
}),
|
||||
);
|
||||
|
||||
Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
infra/cdk/lib/retain-for-terraform-adoption.ts
Normal file
63
infra/cdk/lib/retain-for-terraform-adoption.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib";
|
||||
import { IConstruct } from "constructs";
|
||||
|
||||
const TRANSFERRED_RESOURCE_TYPES = new Set([
|
||||
"AWS::S3::Bucket",
|
||||
"AWS::S3::BucketPolicy",
|
||||
"AWS::CloudFront::Distribution",
|
||||
"AWS::CloudFront::Function",
|
||||
"AWS::CloudFront::OriginAccessControl",
|
||||
"AWS::Route53::RecordSet",
|
||||
]);
|
||||
|
||||
function isTransferredResource(resource: CfnResource): boolean {
|
||||
if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
resource.cfnResourceType === "Custom::S3AutoDeleteObjects" &&
|
||||
resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(resource.cfnResourceType === "AWS::IAM::Role" ||
|
||||
resource.cfnResourceType === "AWS::IAM::Policy") &&
|
||||
resource.node.path.includes("/GithubDeployRole")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retains only the resources in the approved Terraform transfer set.
|
||||
*
|
||||
* The bucket auto-delete custom resource is intentionally retained while the
|
||||
* generated provider Lambda, role, log group, and CDK metadata remain excluded.
|
||||
* When a management condition is supplied, those same resources share it so
|
||||
* CloudFormation can later relinquish them without deleting them.
|
||||
*/
|
||||
export class RetainForTerraformAdoption implements IAspect {
|
||||
constructor(private readonly manageCondition?: CfnCondition) {}
|
||||
|
||||
public visit(node: IConstruct): void {
|
||||
if (!(node instanceof CfnResource) || !isTransferredResource(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the L2 bucket's configured DESTROY policy visible to its
|
||||
// AutoDeleteObjects validator while overriding the emitted CloudFormation
|
||||
// resource. This preserves the custom resource and retains both together.
|
||||
if (node.cfnResourceType === "AWS::S3::Bucket") {
|
||||
node.addOverride("DeletionPolicy", "Retain");
|
||||
node.addOverride("UpdateReplacePolicy", "Retain");
|
||||
} else {
|
||||
node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN;
|
||||
node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN;
|
||||
}
|
||||
|
||||
if (this.manageCondition) {
|
||||
node.cfnOptions.condition = this.manageCondition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,9 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "npm run build && node --test test/*.test.mjs",
|
||||
"synth": "cdk synth",
|
||||
"synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true",
|
||||
"diff": "cdk diff",
|
||||
"deploy": "cdk deploy"
|
||||
},
|
||||
|
|
|
|||
225
infra/cdk/test/frontend-stack.test.mjs
Normal file
225
infra/cdk/test/frontend-stack.test.mjs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { test } from "node:test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { App } = require("aws-cdk-lib");
|
||||
const { Template } = require("aws-cdk-lib/assertions");
|
||||
const { FrontendStack } = require("../lib/frontend-stack.js");
|
||||
|
||||
const account = "396287094661";
|
||||
const region = "us-east-1";
|
||||
const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev";
|
||||
const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1";
|
||||
const CONDITION = "ManageSiteInfrastructureCondition";
|
||||
|
||||
const RETAINED_TYPES = new Set([
|
||||
"AWS::S3::Bucket",
|
||||
"AWS::S3::BucketPolicy",
|
||||
"AWS::CloudFront::Distribution",
|
||||
"AWS::CloudFront::Function",
|
||||
"AWS::CloudFront::OriginAccessControl",
|
||||
"AWS::Route53::RecordSet",
|
||||
"Custom::S3AutoDeleteObjects",
|
||||
]);
|
||||
|
||||
function devTemplate(retainForTerraformAdoption, overrides = {}) {
|
||||
const app = new App();
|
||||
const stack = new FrontendStack(app, "shoc-frontend-dev", {
|
||||
envName: "dev",
|
||||
githubRepo: "Sea-Haven-Industries/shoc-frontend-new",
|
||||
deployBranch: "dev",
|
||||
domainNames: ["dev.seahaven.com"],
|
||||
certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`,
|
||||
hostedZoneId: "Z07671212N75U4YLPWZR8",
|
||||
hostedZoneName: "dev.seahaven.com",
|
||||
retainForTerraformAdoption,
|
||||
env: { account, region },
|
||||
...overrides,
|
||||
});
|
||||
return Template.fromStack(stack).toJSON();
|
||||
}
|
||||
|
||||
function entriesByType(template, type) {
|
||||
return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type);
|
||||
}
|
||||
|
||||
function isTransferred(logicalId, resource) {
|
||||
const isDeployRoleResource =
|
||||
(resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) ||
|
||||
(resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole"));
|
||||
return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource;
|
||||
}
|
||||
|
||||
test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => {
|
||||
const template = devTemplate(true);
|
||||
assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1);
|
||||
assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2);
|
||||
assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1);
|
||||
const transferred = Object.entries(template.Resources).filter(([id, resource]) =>
|
||||
isTransferred(id, resource),
|
||||
);
|
||||
// Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline
|
||||
// policy = 9 CloudFormation resources (Terraform splits the bucket into 6
|
||||
// addresses) plus the retained custom resource.
|
||||
assert.equal(transferred.length, 10);
|
||||
});
|
||||
|
||||
test("adoption mode preserves the live dev identifiers", () => {
|
||||
const template = devTemplate(true);
|
||||
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||
assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev");
|
||||
assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled");
|
||||
assert.ok(
|
||||
bucket.Properties.Tags.some(
|
||||
(tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true",
|
||||
),
|
||||
);
|
||||
|
||||
const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1];
|
||||
assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID);
|
||||
assert.equal(
|
||||
distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId,
|
||||
DEV_ORIGIN_ID,
|
||||
);
|
||||
|
||||
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
||||
);
|
||||
assert.equal(
|
||||
deployRole.Properties.PermissionsBoundary,
|
||||
`arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`,
|
||||
);
|
||||
assert.ok(
|
||||
deployRole.Properties.Tags.some(
|
||||
(tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev",
|
||||
),
|
||||
);
|
||||
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
||||
assert.equal(
|
||||
condition.StringEquals["token.actions.githubusercontent.com:sub"],
|
||||
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
||||
);
|
||||
assert.equal(condition.StringLike, undefined);
|
||||
|
||||
// Legacy inline policy stays byte-compatible with the live document.
|
||||
const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) =>
|
||||
id.startsWith("GithubDeployRole"),
|
||||
);
|
||||
const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid);
|
||||
assert.deepEqual(sids, [
|
||||
"AssumeCdkBootstrapRoles",
|
||||
"DescribeStack",
|
||||
undefined,
|
||||
"InvalidateDistribution",
|
||||
]);
|
||||
|
||||
for (const output of [
|
||||
"TerraformWorkspaceTag",
|
||||
"TerraformDeployBoundaryArn",
|
||||
"TerraformImportBucket",
|
||||
"TerraformImportBucketPolicy",
|
||||
"TerraformImportDistribution",
|
||||
"TerraformImportOriginAccessControl",
|
||||
"TerraformOriginAccessControlName",
|
||||
"TerraformOriginAccessControlDescription",
|
||||
"TerraformDistributionOriginId",
|
||||
"TerraformImportSpaRewriteFunction",
|
||||
"TerraformImportAliasA",
|
||||
"TerraformImportAliasAAAA",
|
||||
"TerraformImportDeployRole",
|
||||
"TerraformImportDeployRolePolicy",
|
||||
"TerraformDeployInlinePolicyName",
|
||||
"TerraformBucketAutoDeleteHelperRoleArn",
|
||||
"TerraformRetainedAutoDeleteCustomResource",
|
||||
]) {
|
||||
assert.ok(template.Outputs[output], `missing output ${output}`);
|
||||
}
|
||||
assert.equal(
|
||||
template.Outputs.TerraformImportAliasA.Value,
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A",
|
||||
);
|
||||
assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID);
|
||||
});
|
||||
|
||||
test("adoption mode retains exactly the transferred resources", () => {
|
||||
const template = devTemplate(true);
|
||||
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||
if (isTransferred(logicalId, resource)) {
|
||||
assert.equal(resource.DeletionPolicy, "Retain", logicalId);
|
||||
assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId);
|
||||
} else {
|
||||
assert.notEqual(resource.DeletionPolicy, "Retain", logicalId);
|
||||
assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId);
|
||||
}
|
||||
}
|
||||
// The auto-delete provider Lambda, role, and log group stay unretained.
|
||||
for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) {
|
||||
for (const [, resource] of entriesByType(template, type)) {
|
||||
assert.notEqual(resource.DeletionPolicy, "Retain");
|
||||
}
|
||||
}
|
||||
const providerRoles = entriesByType(template, "AWS::IAM::Role").filter(
|
||||
([, resource]) => resource.Properties.RoleName !== DEV_ROLE,
|
||||
);
|
||||
assert.equal(providerRoles.length, 1);
|
||||
assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain");
|
||||
});
|
||||
|
||||
test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => {
|
||||
const template = devTemplate(true);
|
||||
const parameter = template.Parameters.ManageSiteInfrastructure;
|
||||
assert.ok(parameter);
|
||||
assert.equal(parameter.Type, "String");
|
||||
assert.deepEqual(parameter.AllowedValues, ["true", "false"]);
|
||||
assert.equal(parameter.Default, undefined);
|
||||
assert.ok(template.Conditions[CONDITION]);
|
||||
|
||||
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
||||
if (isTransferred(logicalId, resource)) {
|
||||
assert.equal(resource.Condition, CONDITION, logicalId);
|
||||
} else {
|
||||
assert.notEqual(resource.Condition, CONDITION, logicalId);
|
||||
}
|
||||
}
|
||||
for (const [outputName, output] of Object.entries(template.Outputs)) {
|
||||
assert.equal(output.Condition, CONDITION, outputName);
|
||||
}
|
||||
});
|
||||
|
||||
test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => {
|
||||
const template = devTemplate(false);
|
||||
const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1];
|
||||
assert.equal(bucket.DeletionPolicy, "Delete");
|
||||
assert.equal(bucket.UpdateReplacePolicy, "Delete");
|
||||
const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1];
|
||||
assert.notEqual(customResource.DeletionPolicy, "Retain");
|
||||
|
||||
const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find(
|
||||
([, resource]) => resource.Properties.RoleName === DEV_ROLE,
|
||||
);
|
||||
assert.equal(deployRole.Properties.PermissionsBoundary, undefined);
|
||||
assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace"));
|
||||
const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition;
|
||||
assert.equal(
|
||||
condition.StringLike["token.actions.githubusercontent.com:sub"],
|
||||
"repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev",
|
||||
);
|
||||
assert.equal(template.Outputs.TerraformWorkspaceTag, undefined);
|
||||
assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined);
|
||||
assert.equal(template.Conditions?.[CONDITION], undefined);
|
||||
for (const resource of Object.values(template.Resources)) {
|
||||
assert.equal(resource.Condition, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("adoption mode refuses an environment without a verified origin ID", () => {
|
||||
assert.throws(
|
||||
() => devTemplate(true, { envName: "staging" }),
|
||||
/No verified Terraform adoption origin ID exists for staging/,
|
||||
);
|
||||
});
|
||||
308
package-lock.json
generated
308
package-lock.json
generated
|
|
@ -21,6 +21,7 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
@ -1974,6 +1976,295 @@
|
|||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz",
|
||||
"integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.73.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/feedback": "10.73.0",
|
||||
"@sentry/replay": "10.73.0",
|
||||
"@sentry/replay-canvas": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser-utils": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz",
|
||||
"integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz",
|
||||
"integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"progress": "^2.0.3",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"undici": "^6.22.0",
|
||||
"which": "^2.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"sentry-cli": "bin/sentry-cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@sentry/cli-darwin": "3.7.0",
|
||||
"@sentry/cli-linux-arm": "3.7.0",
|
||||
"@sentry/cli-linux-arm64": "3.7.0",
|
||||
"@sentry/cli-linux-i686": "3.7.0",
|
||||
"@sentry/cli-linux-x64": "3.7.0",
|
||||
"@sentry/cli-win32-arm64": "3.7.0",
|
||||
"@sentry/cli-win32-i686": "3.7.0",
|
||||
"@sentry/cli-win32-x64": "3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-darwin": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz",
|
||||
"integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==",
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz",
|
||||
"integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-arm64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz",
|
||||
"integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-i686": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz",
|
||||
"integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-linux-x64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz",
|
||||
"integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux",
|
||||
"freebsd",
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-arm64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz",
|
||||
"integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-i686": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz",
|
||||
"integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==",
|
||||
"cpu": [
|
||||
"x86",
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli-win32-x64": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz",
|
||||
"integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "FSL-1.1-MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli/node_modules/undici": {
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/conventions": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
|
||||
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz",
|
||||
"integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/feedback": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz",
|
||||
"integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/react": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz",
|
||||
"integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "10.73.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz",
|
||||
"integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser-utils": "10.73.0",
|
||||
"@sentry/core": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay-canvas": {
|
||||
"version": "10.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz",
|
||||
"integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "10.73.0",
|
||||
"@sentry/replay": "10.73.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@simple-libs/child-process-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
|
||||
|
|
@ -5736,6 +6027,16 @@
|
|||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -5753,6 +6054,13 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
"test:e2e": "playwright test",
|
||||
"test:e2e:visual": "playwright test --config playwright.visual.config.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:terraform-import-plan": "python3 scripts/test-terraform-import-plan-check.py",
|
||||
"test:terraform-isolation": "node --test scripts/check-terraform-isolation.test.mjs",
|
||||
"test:terraform": "node scripts/terraform-validate.mjs",
|
||||
"test:infra": "npm --prefix infra/cdk ci && npm --prefix infra/cdk test && npm --prefix infra/cdk run synth && npm --prefix infra/cdk run synth:adoption",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
|
|
@ -55,6 +59,7 @@
|
|||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.2.0",
|
||||
"@sentry/react": "10.73.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query-devtools": "5.101.2",
|
||||
|
|
@ -79,6 +84,7 @@
|
|||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@sentry/cli": "3.7.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
|
|
|
|||
628
scripts/check-terraform-import-plan.py
Normal file
628
scripts/check-terraform-import-plan.py
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reject plans that violate the frontend Terraform adoption boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
BUCKET_POLICY_ADDRESS = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET_ADDRESS = "module.environment_owned.aws_s3_bucket.site"
|
||||
DEPLOY_POLICY_ADDRESS = (
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
)
|
||||
DISTRIBUTION_ADDRESS = (
|
||||
"module.environment_owned.aws_cloudfront_distribution.site"
|
||||
)
|
||||
ROLE_ADDRESS = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
TAG_UPDATE_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {
|
||||
BUCKET_POLICY_ADDRESS,
|
||||
DEPLOY_POLICY_ADDRESS,
|
||||
}
|
||||
OWNERSHIP_TAGS = {
|
||||
"Environment": None,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("plan_json", type=Path)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
required=True,
|
||||
choices=sorted(REQUIRED_RESOURCES),
|
||||
help="Exact environment ownership boundary expected in the plan.",
|
||||
)
|
||||
modes = parser.add_mutually_exclusive_group()
|
||||
modes.add_argument(
|
||||
"--post-import-no-op",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Require all managed resources to be no-op after import and forbid "
|
||||
"import metadata."
|
||||
),
|
||||
)
|
||||
modes.add_argument(
|
||||
"--allow-update-address",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="ADDRESS",
|
||||
help=(
|
||||
"Enter controlled-update mode and allow one exact reviewed address. "
|
||||
"Repeat for every expected update."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_plan(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("plan JSON root must be an object")
|
||||
if not isinstance(value.get("resource_changes"), list):
|
||||
raise ValueError("plan JSON must contain a resource_changes array")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_import_metadata(
|
||||
*,
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
importing = change.get("importing")
|
||||
if not isinstance(importing, dict) or set(importing) != {"id"}:
|
||||
return [f"{address}: import metadata must be exactly {{'id': <string>}}"]
|
||||
|
||||
import_id = importing.get("id")
|
||||
if not isinstance(import_id, str) or not import_id.strip():
|
||||
return [f"{address}: import ID must be a non-empty string"]
|
||||
if import_id.startswith("REPLACE_WITH_"):
|
||||
return [f"{address}: import ID is still a placeholder"]
|
||||
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
if expected is not None and import_id != expected:
|
||||
return [f"{address}: expected import ID {expected!r}, got {import_id!r}"]
|
||||
|
||||
other_environment_ids = {
|
||||
imports[address]
|
||||
for name, imports in REQUIRED_IMPORT_IDS.items()
|
||||
if name != environment and imports[address] is not None
|
||||
}
|
||||
if import_id in other_environment_ids:
|
||||
return [f"{address}: import ID belongs to another environment"]
|
||||
return []
|
||||
|
||||
|
||||
def _contains_unknown(value: Any) -> bool:
|
||||
if value is True:
|
||||
return True
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_unknown(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_unknown(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _changed_leaf_paths(
|
||||
before: Any,
|
||||
after: Any,
|
||||
path: tuple[str, ...] = (),
|
||||
) -> set[tuple[str, ...]]:
|
||||
if isinstance(before, dict) and isinstance(after, dict):
|
||||
result: set[tuple[str, ...]] = set()
|
||||
for key in set(before) | set(after):
|
||||
result.update(
|
||||
_changed_leaf_paths(
|
||||
before.get(key),
|
||||
after.get(key),
|
||||
(*path, str(key)),
|
||||
)
|
||||
)
|
||||
return result
|
||||
if before != after:
|
||||
return {path}
|
||||
return set()
|
||||
|
||||
|
||||
def _canonical(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _canonical(value[key]) for key in sorted(value)}
|
||||
if isinstance(value, list):
|
||||
items = [_canonical(item) for item in value]
|
||||
return sorted(items, key=lambda item: json.dumps(item, sort_keys=True))
|
||||
return value
|
||||
|
||||
|
||||
def _parse_policy(value: Any, address: str, side: str) -> tuple[Any, list[str]]:
|
||||
if not isinstance(value, str):
|
||||
return None, [f"{address}: {side} policy must be a JSON string"]
|
||||
try:
|
||||
document = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return None, [f"{address}: {side} policy is not valid JSON"]
|
||||
if not isinstance(document, dict):
|
||||
return None, [f"{address}: {side} policy must be a JSON object"]
|
||||
return _canonical(document), []
|
||||
|
||||
|
||||
def _distribution_id(
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
) -> str | None:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
if isinstance(configured, str):
|
||||
return configured
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict) or resource.get("address") != DISTRIBUTION_ADDRESS:
|
||||
continue
|
||||
after = resource.get("change", {}).get("after")
|
||||
if isinstance(after, dict):
|
||||
identifier = after.get("id")
|
||||
if isinstance(identifier, str) and identifier.strip():
|
||||
return identifier
|
||||
return None
|
||||
|
||||
|
||||
def _expected_pre_adoption_bucket_policy(
|
||||
environment: str,
|
||||
distribution_id: str,
|
||||
) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _expected_bucket_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {
|
||||
"StringEquals": {"AWS:SourceArn": distribution_arn}
|
||||
},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _expected_pre_adoption_deploy_policy(
|
||||
environment: str,
|
||||
distribution_id: str,
|
||||
) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
statements: list[dict[str, Any]] = []
|
||||
if environment == "dev":
|
||||
statements.append(
|
||||
{
|
||||
"Sid": "AssumeCdkBootstrapRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
|
||||
}
|
||||
)
|
||||
statements.extend(
|
||||
[
|
||||
{
|
||||
"Sid": "DescribeStack",
|
||||
"Effect": "Allow",
|
||||
"Action": "cloudformation:DescribeStacks",
|
||||
"Resource": (
|
||||
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
|
||||
f"{config['cloudformation_stack_name']}/*"
|
||||
),
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:Abort*",
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:GetObject*",
|
||||
"s3:List*",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectLegalHold",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutObjectVersionTagging",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
]
|
||||
)
|
||||
return _canonical({"Version": "2012-10-17", "Statement": statements})
|
||||
|
||||
|
||||
def _expected_deploy_policy(environment: str, distribution_id: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
f"arn:aws:cloudfront::396287094661:distribution/{distribution_id}"
|
||||
)
|
||||
return _canonical(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadDeploymentBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions",
|
||||
],
|
||||
"Resource": bucket_arn,
|
||||
},
|
||||
{
|
||||
"Sid": "PublishAndRollbackSiteObjects",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteObjectVersion",
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
],
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_tag_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
invalid = {
|
||||
path
|
||||
for path in changed
|
||||
if len(path) != 2 or path[0] not in {"tags", "tags_all"}
|
||||
}
|
||||
violations = [
|
||||
f"{address}: controlled tag update changes forbidden path {'.'.join(path)}"
|
||||
for path in sorted(invalid)
|
||||
]
|
||||
expected = {**OWNERSHIP_TAGS, "Environment": environment}
|
||||
if address == ROLE_ADDRESS:
|
||||
expected["HcpTerraformWorkspace"] = ENVIRONMENT_CONFIG[environment][
|
||||
"workspace_name"
|
||||
]
|
||||
if address == BUCKET_ADDRESS:
|
||||
expected["aws-cdk:auto-delete-objects"] = None
|
||||
expected_after = {
|
||||
key: value for key, value in expected.items() if value is not None
|
||||
}
|
||||
for tag_attribute in ("tags", "tags_all"):
|
||||
if after.get(tag_attribute) != expected_after:
|
||||
violations.append(
|
||||
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
||||
)
|
||||
for path in sorted(changed - invalid):
|
||||
key = path[1]
|
||||
if key not in expected:
|
||||
violations.append(f"{address}: tag {key!r} is not an ownership tag")
|
||||
elif key == "aws-cdk:auto-delete-objects" and key in after.get(path[0], {}):
|
||||
violations.append(
|
||||
f"{address}: legacy auto-delete ownership tag was not removed"
|
||||
)
|
||||
elif after.get(path[0], {}).get(key) != expected[key]:
|
||||
violations.append(
|
||||
f"{address}: tag {key!r} does not have its expected adopted value"
|
||||
)
|
||||
if not changed:
|
||||
violations.append(f"{address}: update has no changed leaf values")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_policy_update(
|
||||
address: str,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
changed = _changed_leaf_paths(before, after)
|
||||
if changed != {("policy",)}:
|
||||
return [f"{address}: policy update changes forbidden attributes {sorted(changed)!r}"]
|
||||
before_policy, violations = _parse_policy(before.get("policy"), address, "before")
|
||||
after_policy, after_violations = _parse_policy(
|
||||
after.get("policy"), address, "after"
|
||||
)
|
||||
violations.extend(after_violations)
|
||||
if before_policy == after_policy:
|
||||
violations.append(f"{address}: policy semantics did not change")
|
||||
if distribution_id is None:
|
||||
violations.append(
|
||||
f"{address}: cannot verify policy without the pinned distribution ID"
|
||||
)
|
||||
return violations
|
||||
expected_before = (
|
||||
_expected_pre_adoption_bucket_policy(environment, distribution_id)
|
||||
if address == BUCKET_POLICY_ADDRESS
|
||||
else _expected_pre_adoption_deploy_policy(environment, distribution_id)
|
||||
)
|
||||
expected_after = (
|
||||
_expected_bucket_policy(environment, distribution_id)
|
||||
if address == BUCKET_POLICY_ADDRESS
|
||||
else _expected_deploy_policy(environment, distribution_id)
|
||||
)
|
||||
if before_policy is not None and before_policy != expected_before:
|
||||
violations.append(f"{address}: pre-adoption policy semantics are not exact")
|
||||
if after_policy is not None and after_policy != expected_after:
|
||||
violations.append(f"{address}: post-adoption policy semantics are not exact")
|
||||
return violations
|
||||
|
||||
|
||||
def _validate_controlled_update(
|
||||
address: str,
|
||||
change: dict[str, Any],
|
||||
environment: str,
|
||||
distribution_id: str | None,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
replace_paths = change.get("replace_paths", [])
|
||||
if replace_paths not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
if _contains_unknown(change.get("after_unknown", {})):
|
||||
violations.append(f"{address}: controlled update contains unknown values")
|
||||
before = change.get("before")
|
||||
after = change.get("after")
|
||||
if not isinstance(before, dict) or not isinstance(after, dict):
|
||||
return [*violations, f"{address}: controlled update requires before/after objects"]
|
||||
if address in TAG_UPDATE_ADDRESSES:
|
||||
violations.extend(_validate_tag_update(address, before, after, environment))
|
||||
elif address in {BUCKET_POLICY_ADDRESS, DEPLOY_POLICY_ADDRESS}:
|
||||
violations.extend(
|
||||
_validate_policy_update(
|
||||
address,
|
||||
before,
|
||||
after,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def check_plan(
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
environment: str,
|
||||
mode: str,
|
||||
allowed_updates: set[str],
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
invalid_allowed = allowed_updates - CONTROLLED_UPDATE_ADDRESSES
|
||||
for address in sorted(invalid_allowed):
|
||||
violations.append(
|
||||
f"{address}: address is not eligible for the controlled adoption update"
|
||||
)
|
||||
|
||||
distribution_id = _distribution_id(plan, environment)
|
||||
seen_addresses: set[str] = set()
|
||||
seen_updates: set[str] = set()
|
||||
required_resources = REQUIRED_RESOURCES[environment]
|
||||
for resource in plan["resource_changes"]:
|
||||
if not isinstance(resource, dict):
|
||||
violations.append("<unknown>: resource change must be an object")
|
||||
continue
|
||||
if resource.get("mode", "managed") != "managed":
|
||||
continue
|
||||
address = resource.get("address")
|
||||
if not isinstance(address, str):
|
||||
violations.append("<unknown>: managed resource has no valid address")
|
||||
continue
|
||||
if address in seen_addresses:
|
||||
violations.append(f"{address}: duplicate managed resource change")
|
||||
seen_addresses.add(address)
|
||||
|
||||
expected_type = required_resources.get(address)
|
||||
if expected_type is None:
|
||||
violations.append(f"{address}: managed address is outside the ownership boundary")
|
||||
elif resource.get("type") != expected_type:
|
||||
violations.append(
|
||||
f"{address}: expected managed type {expected_type!r}, "
|
||||
f"got {resource.get('type')!r}"
|
||||
)
|
||||
|
||||
change = resource.get("change")
|
||||
if not isinstance(change, dict):
|
||||
violations.append(f"{address}: missing change object")
|
||||
continue
|
||||
actions = change.get("actions")
|
||||
if not isinstance(actions, list) or not all(
|
||||
isinstance(action, str) for action in actions
|
||||
):
|
||||
violations.append(f"{address}: actions must be a string array")
|
||||
continue
|
||||
|
||||
if change.get("replace_paths") not in (None, []):
|
||||
violations.append(f"{address}: replace_paths must be empty")
|
||||
|
||||
if mode == "import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if expected_type is not None:
|
||||
violations.extend(
|
||||
_validate_import_metadata(
|
||||
address=address,
|
||||
change=change,
|
||||
environment=environment,
|
||||
)
|
||||
)
|
||||
elif mode == "post-import":
|
||||
if actions != ["no-op"]:
|
||||
violations.append(
|
||||
f"{address}: post-import mode requires no-op, got {actions!r}"
|
||||
)
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden in post-import mode"
|
||||
)
|
||||
else:
|
||||
if "importing" in change:
|
||||
violations.append(
|
||||
f"{address}: import metadata is forbidden in controlled-update mode"
|
||||
)
|
||||
if actions == ["update"]:
|
||||
seen_updates.add(address)
|
||||
if address not in allowed_updates:
|
||||
violations.append(f"{address}: update is not explicitly allowlisted")
|
||||
else:
|
||||
violations.extend(
|
||||
_validate_controlled_update(
|
||||
address,
|
||||
change,
|
||||
environment,
|
||||
distribution_id,
|
||||
)
|
||||
)
|
||||
elif actions != ["no-op"]:
|
||||
violations.append(f"{address}: unsafe controlled actions {actions!r}")
|
||||
|
||||
for missing in sorted(set(required_resources) - seen_addresses):
|
||||
violations.append(f"{missing}: required managed resource is absent")
|
||||
for unused in sorted(allowed_updates - seen_updates):
|
||||
violations.append(f"{unused}: allowlisted update address is not updating")
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
plan = _load_plan(args.plan_json)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"FAIL: unable to read Terraform plan JSON: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
allowed_updates = set(args.allow_update_address or [])
|
||||
if args.post_import_no_op:
|
||||
mode = "post-import"
|
||||
elif allowed_updates:
|
||||
mode = "controlled"
|
||||
else:
|
||||
mode = "import"
|
||||
violations = check_plan(
|
||||
plan,
|
||||
environment=args.environment,
|
||||
mode=mode,
|
||||
allowed_updates=allowed_updates,
|
||||
)
|
||||
if violations:
|
||||
print("FAIL: Terraform plan is not adoption-safe", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
label = {
|
||||
"import": "zero-change import",
|
||||
"post-import": "post-import no-op",
|
||||
"controlled": "controlled update",
|
||||
}[mode]
|
||||
print(
|
||||
f"PASS: {label} plan has {len(REQUIRED_RESOURCES[args.environment])} "
|
||||
f"managed resources and {len(allowed_updates)} exact updates"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
120
scripts/check-terraform-isolation.mjs
Normal file
120
scripts/check-terraform-isolation.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// Terraform/application change isolation gate.
|
||||
//
|
||||
// A merge to `dev` that touches `terraform/**` queues an HCP Terraform VCS run
|
||||
// on the workspace. If the same merge also changes deployable application
|
||||
// code, the content release and the VCS run race for the workspace lock
|
||||
// (backend incident, 2026-09-04). This gate fails a pull request that mixes the
|
||||
// two, so Terraform changes ship in their own PR and their VCS run is confirmed
|
||||
// or discarded by a human before the next content release.
|
||||
//
|
||||
// Files that may accompany a Terraform change without triggering a release:
|
||||
// the Terraform tree itself, its plan-guard tooling, and documentation.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-terraform-isolation.mjs --base <ref> --head <ref>
|
||||
// git diff --name-only A B | node scripts/check-terraform-isolation.mjs --stdin
|
||||
//
|
||||
// TERRAFORM_ISOLATION_OVERRIDE=true downgrades a failure to a warning. CI sets
|
||||
// it only when the PR carries the `terraform-isolation-override` label, which
|
||||
// reviewers grant to the rare change that must introduce Terraform variables
|
||||
// together with the workflow that consumes them.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export const OVERRIDE_LABEL = "terraform-isolation-override";
|
||||
|
||||
export function isTerraformPath(file) {
|
||||
return file.startsWith("terraform/");
|
||||
}
|
||||
|
||||
export function mayAccompanyTerraform(file) {
|
||||
if (isTerraformPath(file)) return true;
|
||||
if (file.endsWith(".md")) return true;
|
||||
if (file.startsWith("docs/")) return true;
|
||||
if (/^scripts\/[^/]*terraform[^/]*$/.test(file)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} files changed paths relative to the repository root
|
||||
* @returns {{ terraform: string[], application: string[], mixed: boolean }}
|
||||
*/
|
||||
export function classifyChangedFiles(files) {
|
||||
const unique = [...new Set(files.map((file) => file.trim()).filter(Boolean))].sort();
|
||||
const terraform = unique.filter(isTerraformPath);
|
||||
const application = unique.filter((file) => !mayAccompanyTerraform(file));
|
||||
return {
|
||||
terraform,
|
||||
application,
|
||||
mixed: terraform.length > 0 && application.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function changedFilesFromGit(base, head) {
|
||||
const mergeBase = execFileSync("git", ["merge-base", base, head], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
return execFileSync(
|
||||
"git",
|
||||
["diff", "--name-only", "--diff-filter=ACDMR", "--no-renames", mergeBase, head],
|
||||
{ cwd: ROOT, encoding: "utf8" },
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { base: null, head: "HEAD", stdin: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--base") options.base = argv[++index];
|
||||
else if (argument === "--head") options.head = argv[++index];
|
||||
else if (argument === "--stdin") options.stdin = true;
|
||||
else throw new Error(`unknown argument: ${argument}`);
|
||||
}
|
||||
if (!options.stdin && !options.base) {
|
||||
throw new Error("provide --base <ref> (and optionally --head <ref>) or --stdin");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const options = parseArgs(argv);
|
||||
const files = options.stdin
|
||||
? readFileSync(0, "utf8").split("\n")
|
||||
: changedFilesFromGit(options.base, options.head);
|
||||
const result = classifyChangedFiles(files);
|
||||
const override = process.env.TERRAFORM_ISOLATION_OVERRIDE === "true";
|
||||
|
||||
console.log("─".repeat(64));
|
||||
console.log(
|
||||
`terraform isolation gate: ${result.terraform.length} terraform file(s), ${result.application.length} application file(s)`,
|
||||
);
|
||||
if (!result.mixed) {
|
||||
console.log(" PASS: Terraform and application changes are not mixed");
|
||||
return 0;
|
||||
}
|
||||
console.log(" Terraform files:");
|
||||
for (const file of result.terraform) console.log(` ${file}`);
|
||||
console.log(" Application files that cannot ship in the same PR:");
|
||||
for (const file of result.application) console.log(` ${file}`);
|
||||
if (override) {
|
||||
console.log(
|
||||
` WARNING: mixed change accepted through the '${OVERRIDE_LABEL}' label. Confirm or discard the HCP VCS run before the next content release.`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
console.log(
|
||||
` FAIL: split the Terraform change into its own PR, or have a reviewer add the '${OVERRIDE_LABEL}' label.`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
115
scripts/check-terraform-isolation.test.mjs
Normal file
115
scripts/check-terraform-isolation.test.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
OVERRIDE_LABEL,
|
||||
classifyChangedFiles,
|
||||
mayAccompanyTerraform,
|
||||
} from "./check-terraform-isolation.mjs";
|
||||
|
||||
const SCRIPT = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"check-terraform-isolation.mjs",
|
||||
);
|
||||
|
||||
function runGate(files, env = {}) {
|
||||
return spawnSync(process.execPath, [SCRIPT, "--stdin"], {
|
||||
input: `${files.join("\n")}\n`,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TERRAFORM_ISOLATION_OVERRIDE: "", ...env },
|
||||
});
|
||||
}
|
||||
|
||||
test("terraform tree, docs, and terraform tooling may accompany a Terraform change", () => {
|
||||
for (const file of [
|
||||
"terraform/live/dev/main.tf",
|
||||
"terraform/live/modules/environment-owned/main.tf",
|
||||
"terraform/README.md",
|
||||
"README.md",
|
||||
"docs/adr/0003-terraform.md",
|
||||
"scripts/check-terraform-import-plan.py",
|
||||
"scripts/terraform_import_plan_resources.py",
|
||||
"scripts/test-terraform-import-plan-check.py",
|
||||
"scripts/terraform-validate.mjs",
|
||||
"scripts/check-terraform-isolation.mjs",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("application, workflow, CDK, and dependency files count as application changes", () => {
|
||||
for (const file of [
|
||||
"src/App.tsx",
|
||||
"public/favicon.ico",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
".env.production",
|
||||
"vite.config.ts",
|
||||
".github/workflows/deploy.yml",
|
||||
"infra/cdk/lib/frontend-stack.ts",
|
||||
"scripts/deploy-web.sh",
|
||||
"scripts/governance-check.mjs",
|
||||
"e2e/login.spec.ts",
|
||||
]) {
|
||||
assert.equal(mayAccompanyTerraform(file), false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("terraform-only and application-only changes are not mixed", () => {
|
||||
assert.equal(
|
||||
classifyChangedFiles(["terraform/live/dev/main.tf", "terraform/README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
classifyChangedFiles(["src/App.tsx", ".github/workflows/deploy.yml", "README.md"]).mixed,
|
||||
false,
|
||||
);
|
||||
assert.equal(classifyChangedFiles([]).mixed, false);
|
||||
});
|
||||
|
||||
test("terraform plus application is mixed and lists the offending files", () => {
|
||||
const result = classifyChangedFiles([
|
||||
"terraform/live/dev/main.tf",
|
||||
"src/App.tsx",
|
||||
"README.md",
|
||||
" ",
|
||||
"src/App.tsx",
|
||||
]);
|
||||
assert.equal(result.mixed, true);
|
||||
assert.deepEqual(result.terraform, ["terraform/live/dev/main.tf"]);
|
||||
assert.deepEqual(result.application, ["src/App.tsx"]);
|
||||
});
|
||||
|
||||
test("CLI exits 1 on a mixed change and 0 when isolated", () => {
|
||||
const mixed = runGate(["terraform/live/dev/main.tf", "src/App.tsx"]);
|
||||
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
|
||||
assert.match(mixed.stdout, /FAIL/);
|
||||
assert.match(mixed.stdout, /src\/App\.tsx/);
|
||||
|
||||
const isolated = runGate(["terraform/live/dev/main.tf", "terraform/README.md"]);
|
||||
assert.equal(isolated.status, 0, isolated.stdout + isolated.stderr);
|
||||
assert.match(isolated.stdout, /PASS/);
|
||||
});
|
||||
|
||||
test("CLI override downgrades a mixed change to a warning that names the label", () => {
|
||||
const result = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "true",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /WARNING/);
|
||||
assert.match(result.stdout, new RegExp(OVERRIDE_LABEL));
|
||||
|
||||
const notTrue = runGate(["terraform/live/dev/main.tf", "src/App.tsx"], {
|
||||
TERRAFORM_ISOLATION_OVERRIDE: "yes",
|
||||
});
|
||||
assert.equal(notTrue.status, 1);
|
||||
});
|
||||
|
||||
test("CLI refuses to run without a base ref or --stdin", () => {
|
||||
const result = spawnSync(process.execPath, [SCRIPT], { encoding: "utf8" });
|
||||
assert.notEqual(result.status, 0);
|
||||
});
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Post-deploy step for the org reusable workflow `cd-cdk.yaml`
|
||||
# (wired in via `.github/workflows/deploy.yml` -> `post-deploy-script`).
|
||||
# Content publish step for the environment deploy workflows
|
||||
# (`.github/workflows/deploy.yml`, `.github/workflows/deploy-staging.yml`).
|
||||
#
|
||||
# Runs AFTER `cdk deploy` has provisioned/updated the infra, as the GitHub
|
||||
# OIDC deploy role. Builds the SPA, uploads it to the stack's S3 bucket with
|
||||
# the right cache headers, and invalidates CloudFront.
|
||||
# Runs as the GitHub OIDC deploy role. Builds the SPA, uploads it to the
|
||||
# environment's S3 bucket with the right cache headers, and invalidates
|
||||
# CloudFront. It never touches infrastructure.
|
||||
#
|
||||
# Runs from the repo root. Reads the bucket + distribution from stack outputs,
|
||||
# so it has no hardcoded resource IDs.
|
||||
# Runs from the repo root. The target is resolved from, in order:
|
||||
# 1. SITE_BUCKET + CLOUDFRONT_DISTRIBUTION_ID (pinned by the workflow; used by
|
||||
# dev, whose CloudFormation outputs disappear during Terraform adoption)
|
||||
# 2. the BucketName/DistributionId outputs of STACK_NAME (staging)
|
||||
set -euo pipefail
|
||||
|
||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||
|
|
@ -16,24 +18,35 @@ REGION="${AWS_REGION:-us-east-1}"
|
|||
WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}"
|
||||
|
||||
echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..."
|
||||
export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
BUCKET="${SITE_BUCKET:-}"
|
||||
DIST_ID="${CLOUDFRONT_DISTRIBUTION_ID:-}"
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
if [[ -n "${BUCKET}" && -n "${DIST_ID}" ]]; then
|
||||
echo "Using pinned target: bucket ${BUCKET}, distribution ${DIST_ID}."
|
||||
elif [[ -n "${BUCKET}" || -n "${DIST_ID}" ]]; then
|
||||
echo "::error::Set both SITE_BUCKET and CLOUDFRONT_DISTRIBUTION_ID, or neither." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Reading stack outputs from ${STACK_NAME}..."
|
||||
stack_output() {
|
||||
aws cloudformation describe-stacks \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--region "${REGION}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \
|
||||
--output text
|
||||
}
|
||||
|
||||
BUCKET="$(stack_output BucketName)"
|
||||
DIST_ID="$(stack_output DistributionId)"
|
||||
|
||||
if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" ]]; then
|
||||
echo "::error::Could not resolve BucketName/DistributionId from stack ${STACK_NAME}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
||||
|
|
@ -41,6 +54,7 @@ echo "Uploading hashed assets (immutable) to s3://${BUCKET}..."
|
|||
aws s3 sync dist/ "s3://${BUCKET}/" \
|
||||
--delete \
|
||||
--exclude "index.html" \
|
||||
--exclude "*.map" \
|
||||
--cache-control "public,max-age=31536000,immutable"
|
||||
|
||||
echo "Uploading index.html (never cached)..."
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ const MAINTAINABILITY_RULES = [
|
|||
const GOVERNED_ROOTS = ["src/", "config/"];
|
||||
const EXCLUDE_DIR = /(^|\/)(mocks|test|__mocks__|node_modules|dist|coverage|e2e)\//;
|
||||
const EXCLUDE_NAME = /\.(mock|test|spec)\.(ts|tsx)$|\.d\.ts$/;
|
||||
// Repository-level gates that run after the source gates. Each is an npm
|
||||
// script so it can also be run on its own.
|
||||
const REPOSITORY_GATES = [
|
||||
["Terraform import-plan contract", "test:terraform-import-plan"],
|
||||
["Terraform isolation gate", "test:terraform-isolation"],
|
||||
["Terraform formatting and validation", "test:terraform"],
|
||||
["CDK build, tests, and synth", "test:infra"],
|
||||
];
|
||||
|
||||
function isGoverned(relativePath) {
|
||||
return (
|
||||
|
|
@ -194,6 +202,20 @@ function plural(count, word) {
|
|||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function runRepositoryGate(label, script) {
|
||||
// Reuse the npm that launched us when available (matches its version and
|
||||
// config); fall back to PATH for direct `node scripts/governance-check.mjs`.
|
||||
const npmCli = process.env.npm_execpath;
|
||||
const executable = npmCli ? process.execPath : "npm";
|
||||
const args = npmCli ? [npmCli, "run", script] : ["run", script];
|
||||
const result = spawnSync(executable, args, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
return { label, status: result.status, error: result.error };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const failures = [];
|
||||
const baseRef = resolveBaseRef();
|
||||
|
|
@ -281,6 +303,17 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
for (const [label, script] of REPOSITORY_GATES) {
|
||||
console.log("─".repeat(64));
|
||||
console.log(`${label}: npm run ${script}`);
|
||||
const gate = runRepositoryGate(label, script);
|
||||
if (gate.error) {
|
||||
failures.push(`${label}: could not start: ${gate.error.message}`);
|
||||
} else if (gate.status !== 0) {
|
||||
failures.push(`${label}: failed with exit code ${gate.status ?? "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("─".repeat(64));
|
||||
if (failures.length > 0) {
|
||||
console.log(`RESULT: FAIL (${plural(failures.length, "gate")})`);
|
||||
|
|
|
|||
35
scripts/terraform-validate.mjs
Normal file
35
scripts/terraform-validate.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const TERRAFORM = process.env.TERRAFORM_BIN || "terraform";
|
||||
// Only dev has a live root. Staging adoption (SH-287) adds its own root here.
|
||||
const ENVIRONMENTS = ["dev"];
|
||||
const ROOTS = ENVIRONMENTS.map((environment) => path.join(ROOT, "terraform", "live", environment));
|
||||
|
||||
function run(args, cwd = ROOT) {
|
||||
const result = spawnSync(TERRAFORM, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`could not start Terraform: ${result.error.message}`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`terraform ${args.join(" ")} failed with exit code ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
run(["fmt", "-check", "-recursive", path.join(ROOT, "terraform")]);
|
||||
for (const root of ROOTS) {
|
||||
// -backend=false never touches HCP state; -lockfile=readonly refuses to
|
||||
// silently rewrite the committed provider lock.
|
||||
run(["init", "-backend=false", "-input=false", "-lockfile=readonly", "-no-color"], root);
|
||||
run(["validate", "-no-color"], root);
|
||||
}
|
||||
|
||||
console.log(`Terraform formatting and validation passed for ${ENVIRONMENTS.join(", ")}.`);
|
||||
131
scripts/terraform_import_plan_resources.py
Normal file
131
scripts/terraform_import_plan_resources.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Canonical frontend Terraform ownership and import-ID maps.
|
||||
|
||||
Only ``dev`` has a Terraform root in this repository. The ``staging`` constants
|
||||
are kept so the checker can prove that a dev plan carrying a staging identifier
|
||||
is rejected; they do not authorize a staging import.
|
||||
"""
|
||||
|
||||
COMMON_RESOURCES = {
|
||||
"module.environment_owned.aws_s3_bucket.site": "aws_s3_bucket",
|
||||
"module.environment_owned.aws_s3_bucket_public_access_block.site": (
|
||||
"aws_s3_bucket_public_access_block"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_ownership_controls.site": (
|
||||
"aws_s3_bucket_ownership_controls"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_server_side_encryption_configuration.site": (
|
||||
"aws_s3_bucket_server_side_encryption_configuration"
|
||||
),
|
||||
"module.environment_owned.aws_s3_bucket_versioning.site": "aws_s3_bucket_versioning",
|
||||
"module.environment_owned.aws_s3_bucket_policy.site": "aws_s3_bucket_policy",
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": (
|
||||
"aws_cloudfront_distribution"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"aws_cloudfront_origin_access_control"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"aws_cloudfront_function"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": "aws_route53_record",
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": "aws_route53_record",
|
||||
"module.environment_owned.aws_iam_role.github_deploy": "aws_iam_role",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": "aws_iam_role_policy",
|
||||
}
|
||||
|
||||
REQUIRED_RESOURCES = {
|
||||
environment: dict(COMMON_RESOURCES)
|
||||
for environment in ("dev", "staging")
|
||||
}
|
||||
|
||||
CONTROLLED_UPDATE_ADDRESSES = frozenset(
|
||||
{
|
||||
"module.environment_owned.aws_s3_bucket.site",
|
||||
"module.environment_owned.aws_s3_bucket_policy.site",
|
||||
"module.environment_owned.aws_cloudfront_distribution.site",
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite",
|
||||
"module.environment_owned.aws_iam_role.github_deploy",
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy",
|
||||
}
|
||||
)
|
||||
|
||||
ENVIRONMENT_CONFIG = {
|
||||
"dev": {
|
||||
"bucket_name": "seahaven-shoc-frontend-dev",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-dev-CustomS3AutoDeleteObjectsCustomRe-dmSDIY8EH7KV"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-dev",
|
||||
"distribution_id": "E2CWLM1AFB964P",
|
||||
"workspace_name": "shoc-frontend-new-dev",
|
||||
},
|
||||
"staging": {
|
||||
"bucket_name": "seahaven-shoc-frontend-staging",
|
||||
"bucket_auto_delete_helper_role_arn": (
|
||||
"arn:aws:iam::396287094661:role/"
|
||||
"shoc-frontend-staging-CustomS3AutoDeleteObjectsCust-QbMDqZbl7YQ3"
|
||||
),
|
||||
"cloudformation_stack_name": "shoc-frontend-staging",
|
||||
"distribution_id": "E2JDVEZ6EGD49J",
|
||||
"workspace_name": "shoc-frontend-new-staging",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bucket_imports(bucket_name: str) -> dict[str, str]:
|
||||
return {
|
||||
address: bucket_name
|
||||
for address in COMMON_RESOURCES
|
||||
if address.startswith("module.environment_owned.aws_s3_bucket")
|
||||
}
|
||||
|
||||
|
||||
REQUIRED_IMPORT_IDS: dict[str, dict[str, str | None]] = {
|
||||
"dev": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-dev"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": "E2CWLM1AFB964P",
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"E30VSIK87N8H64"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"us-east-1shocfrontenddevSpaRewrite58674DB8"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": (
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_A"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||
"Z07671212N75U4YLPWZR8_dev.seahaven.com_AAAA"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-dev"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-dev:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
"staging": {
|
||||
**_bucket_imports("seahaven-shoc-frontend-staging"),
|
||||
"module.environment_owned.aws_cloudfront_distribution.site": "E2JDVEZ6EGD49J",
|
||||
"module.environment_owned.aws_cloudfront_origin_access_control.site": (
|
||||
"E1PF5R6QQNBZAI"
|
||||
),
|
||||
"module.environment_owned.aws_cloudfront_function.spa_rewrite": (
|
||||
"us-east-1shocfrontendstagingSpaRewriteE9C0CBDA"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_a": (
|
||||
"Z02602739VQWBWCAGXP4_staging.seahaven.com_A"
|
||||
),
|
||||
"module.environment_owned.aws_route53_record.site_aaaa": (
|
||||
"Z02602739VQWBWCAGXP4_staging.seahaven.com_AAAA"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-staging"
|
||||
),
|
||||
"module.environment_owned.aws_iam_role_policy.github_deploy": (
|
||||
"githubdeploy-shoc-frontend-new-staging:"
|
||||
"GithubDeployRoleDefaultPolicyE8F540D1"
|
||||
),
|
||||
},
|
||||
}
|
||||
638
scripts/test-terraform-import-plan-check.py
Normal file
638
scripts/test-terraform-import-plan-check.py
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Deterministic unit tests for the frontend Terraform plan checker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from terraform_import_plan_resources import (
|
||||
CONTROLLED_UPDATE_ADDRESSES,
|
||||
ENVIRONMENT_CONFIG,
|
||||
REQUIRED_IMPORT_IDS,
|
||||
REQUIRED_RESOURCES,
|
||||
)
|
||||
|
||||
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
|
||||
REPOSITORY = SCRIPT.parent.parent
|
||||
BUCKET_POLICY = "module.environment_owned.aws_s3_bucket_policy.site"
|
||||
BUCKET = "module.environment_owned.aws_s3_bucket.site"
|
||||
DEPLOY_POLICY = "module.environment_owned.aws_iam_role_policy.github_deploy"
|
||||
ROLE = "module.environment_owned.aws_iam_role.github_deploy"
|
||||
DISTRIBUTION = "module.environment_owned.aws_cloudfront_distribution.site"
|
||||
TAG_ADDRESSES = CONTROLLED_UPDATE_ADDRESSES - {BUCKET_POLICY, DEPLOY_POLICY}
|
||||
|
||||
|
||||
def import_id(environment: str, address: str) -> str:
|
||||
expected = REQUIRED_IMPORT_IDS[environment][address]
|
||||
assert expected is not None, f"{environment} must pin an import ID for {address}"
|
||||
return expected
|
||||
|
||||
|
||||
def distribution_id(environment: str) -> str:
|
||||
configured = ENVIRONMENT_CONFIG[environment]["distribution_id"]
|
||||
assert isinstance(configured, str), f"{environment} must pin a distribution ID"
|
||||
return configured
|
||||
|
||||
|
||||
def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
source = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": config["bucket_auto_delete_helper_role_arn"]
|
||||
},
|
||||
"Action": [
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:List*",
|
||||
"s3:PutBucketPolicy",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def bucket_policy(environment: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
source = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
"Condition": {"StringEquals": {"AWS:SourceArn": source}},
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def pre_adoption_deploy_policy(environment: str) -> dict[str, Any]:
|
||||
config = ENVIRONMENT_CONFIG[environment]
|
||||
bucket_arn = f"arn:aws:s3:::{config['bucket_name']}"
|
||||
distribution_arn = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
statements: list[dict[str, Any]] = []
|
||||
if environment == "dev":
|
||||
statements.append(
|
||||
{
|
||||
"Sid": "AssumeCdkBootstrapRoles",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "arn:aws:iam::396287094661:role/cdk-hnb659fds-*",
|
||||
}
|
||||
)
|
||||
statements.extend(
|
||||
[
|
||||
{
|
||||
"Sid": "DescribeStack",
|
||||
"Effect": "Allow",
|
||||
"Action": "cloudformation:DescribeStacks",
|
||||
"Resource": (
|
||||
"arn:aws:cloudformation:us-east-1:396287094661:stack/"
|
||||
f"{config['cloudformation_stack_name']}/*"
|
||||
),
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:Abort*",
|
||||
"s3:DeleteObject*",
|
||||
"s3:GetBucket*",
|
||||
"s3:GetObject*",
|
||||
"s3:List*",
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectLegalHold",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutObjectVersionTagging",
|
||||
],
|
||||
"Resource": [bucket_arn, f"{bucket_arn}/*"],
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
]
|
||||
)
|
||||
return {"Version": "2012-10-17", "Statement": statements}
|
||||
|
||||
|
||||
def deploy_policy(environment: str) -> dict[str, Any]:
|
||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||
distribution_arn = (
|
||||
"arn:aws:cloudfront::396287094661:distribution/"
|
||||
f"{distribution_id(environment)}"
|
||||
)
|
||||
return {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ReadDeploymentBucket",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions",
|
||||
],
|
||||
"Resource": bucket_arn,
|
||||
},
|
||||
{
|
||||
"Sid": "PublishAndRollbackSiteObjects",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:DeleteObject",
|
||||
"s3:DeleteObjectVersion",
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
],
|
||||
"Resource": f"{bucket_arn}/*",
|
||||
},
|
||||
{
|
||||
"Sid": "InvalidateDistribution",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"cloudfront:CreateInvalidation",
|
||||
"cloudfront:GetInvalidation",
|
||||
],
|
||||
"Resource": distribution_arn,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tag_change(environment: str, address: str) -> dict[str, Any]:
|
||||
manager = {
|
||||
"HcpTerraformWorkspace": ENVIRONMENT_CONFIG[environment]["workspace_name"]
|
||||
}
|
||||
before_tags = {
|
||||
"Environment": environment,
|
||||
"ManagedBy": "cdk",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
after_tags = {
|
||||
"Environment": environment,
|
||||
"ManagedBy": "terraform",
|
||||
"Ownership": "terraform",
|
||||
"Project": "shoc-frontend",
|
||||
}
|
||||
if address == ROLE:
|
||||
before_tags.update(manager)
|
||||
after_tags.update(manager)
|
||||
if address == BUCKET:
|
||||
before_tags["aws-cdk:auto-delete-objects"] = "true"
|
||||
before: dict[str, Any] = {
|
||||
"tags": before_tags,
|
||||
"tags_all": before_tags,
|
||||
}
|
||||
after: dict[str, Any] = {
|
||||
"tags": after_tags,
|
||||
"tags_all": after_tags,
|
||||
}
|
||||
if address == DISTRIBUTION:
|
||||
before["id"] = distribution_id(environment)
|
||||
after["id"] = distribution_id(environment)
|
||||
return {"actions": ["update"], "before": before, "after": after}
|
||||
|
||||
|
||||
def policy_change(environment: str, address: str) -> dict[str, Any]:
|
||||
before_policy = (
|
||||
pre_adoption_bucket_policy(environment)
|
||||
if address == BUCKET_POLICY
|
||||
else pre_adoption_deploy_policy(environment)
|
||||
)
|
||||
after_policy = (
|
||||
bucket_policy(environment)
|
||||
if address == BUCKET_POLICY
|
||||
else deploy_policy(environment)
|
||||
)
|
||||
return {
|
||||
"actions": ["update"],
|
||||
"before": {"policy": json.dumps(before_policy)},
|
||||
"after": {"policy": json.dumps(after_policy)},
|
||||
}
|
||||
|
||||
|
||||
def make_plan(
|
||||
environment: str,
|
||||
*,
|
||||
mode: str = "import",
|
||||
controlled_updates: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resources: list[dict[str, Any]] = []
|
||||
updates = controlled_updates or set()
|
||||
for address, resource_type in REQUIRED_RESOURCES[environment].items():
|
||||
if mode == "import":
|
||||
change: dict[str, Any] = {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": import_id(environment, address)},
|
||||
}
|
||||
elif mode == "post-import":
|
||||
change = {"actions": ["no-op"]}
|
||||
elif address in updates:
|
||||
change = (
|
||||
tag_change(environment, address)
|
||||
if address in TAG_ADDRESSES
|
||||
else policy_change(environment, address)
|
||||
)
|
||||
else:
|
||||
change = {"actions": ["no-op"]}
|
||||
if address == DISTRIBUTION:
|
||||
change["after"] = {"id": distribution_id(environment)}
|
||||
resources.append(
|
||||
{
|
||||
"address": address,
|
||||
"mode": "managed",
|
||||
"type": resource_type,
|
||||
"change": change,
|
||||
}
|
||||
)
|
||||
return {"resource_changes": resources}
|
||||
|
||||
|
||||
def resource(plan: dict[str, Any], address: str) -> dict[str, Any]:
|
||||
return next(
|
||||
item for item in plan["resource_changes"] if item["address"] == address
|
||||
)
|
||||
|
||||
|
||||
def run_checker(
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "plan.json"
|
||||
path.write_text(json.dumps(plan), encoding="utf-8")
|
||||
command = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
str(path),
|
||||
"--environment",
|
||||
environment,
|
||||
]
|
||||
if post_import:
|
||||
command.append("--post-import-no-op")
|
||||
for address in allowed_updates:
|
||||
command.extend(["--allow-update-address", address])
|
||||
return subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class ImportPlanCheckerTests(unittest.TestCase):
|
||||
def assert_passes(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> None:
|
||||
result = run_checker(
|
||||
plan,
|
||||
environment,
|
||||
*allowed_updates,
|
||||
post_import=post_import,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def assert_fails(
|
||||
self,
|
||||
plan: dict[str, Any],
|
||||
environment: str,
|
||||
*allowed_updates: str,
|
||||
post_import: bool = False,
|
||||
) -> None:
|
||||
result = run_checker(
|
||||
plan,
|
||||
environment,
|
||||
*allowed_updates,
|
||||
post_import=post_import,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_cloudfront_function_source_matches_exact_nine_line_join(self) -> None:
|
||||
source = (
|
||||
REPOSITORY
|
||||
/ "terraform/live/modules/environment-owned/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
expected = """ spa_rewrite_code = join("\\n", [
|
||||
"function handler(event) {",
|
||||
" var request = event.request;",
|
||||
" var uri = request.uri;",
|
||||
" // No file extension after the last slash -> a client-side route.",
|
||||
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
||||
" request.uri = '/index.html';",
|
||||
" }",
|
||||
" return request;",
|
||||
"}",
|
||||
])"""
|
||||
self.assertIn(expected, source)
|
||||
|
||||
def test_only_dev_has_a_live_root(self) -> None:
|
||||
live_roots = sorted(
|
||||
path.name
|
||||
for path in (REPOSITORY / "terraform/live").iterdir()
|
||||
if path.is_dir() and path.name != "modules"
|
||||
)
|
||||
self.assertEqual(["dev"], live_roots)
|
||||
|
||||
def test_dev_root_pins_import_phase_in_code(self) -> None:
|
||||
source = (REPOSITORY / "terraform/live/dev/main.tf").read_text(encoding="utf-8")
|
||||
self.assertRegex(source, r"\n\s+adoption_complete\s+= false\n")
|
||||
self.assertRegex(source, r"adoption_complete\s+= local\.adoption_complete")
|
||||
self.assertNotIn('variable "adoption_complete"', source)
|
||||
for root_file in ("main.tf", "imports.tf", "outputs.tf", "providers.tf", "versions.tf"):
|
||||
self.assertNotIn(
|
||||
"variable ",
|
||||
(REPOSITORY / f"terraform/live/dev/{root_file}").read_text(encoding="utf-8"),
|
||||
root_file,
|
||||
)
|
||||
|
||||
def test_managed_modules_use_direct_pinned_inputs(self) -> None:
|
||||
expected = {
|
||||
"dev": (
|
||||
"local.hosted_zone_id",
|
||||
"local.certificate_arn",
|
||||
"local.github_oidc_arn",
|
||||
"local.cache_policy_id",
|
||||
),
|
||||
}
|
||||
for environment, values in expected.items():
|
||||
source = (
|
||||
REPOSITORY / f"terraform/live/{environment}/main.tf"
|
||||
).read_text(encoding="utf-8")
|
||||
for name, value in zip(
|
||||
(
|
||||
"hosted_zone_id",
|
||||
"certificate_arn",
|
||||
"github_oidc_provider_arn",
|
||||
"cache_policy_id",
|
||||
),
|
||||
values,
|
||||
strict=True,
|
||||
):
|
||||
self.assertIn(f"{name}", source)
|
||||
self.assertRegex(source, rf"{name}\s+= {re.escape(value)}")
|
||||
self.assertNotRegex(
|
||||
source,
|
||||
r"(hosted_zone_id|certificate_arn|github_oidc_provider_arn|cache_policy_id)\s+= module\.inventory",
|
||||
)
|
||||
|
||||
def test_exact_import_plan_passes_for_every_environment(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
with self.subTest(environment=environment):
|
||||
self.assert_passes(make_plan(environment), environment)
|
||||
|
||||
def test_import_missing_extra_wrong_type_and_cross_environment_fail(self) -> None:
|
||||
for mutation in ("missing", "extra", "wrong-type", "cross-environment"):
|
||||
plan = make_plan("dev")
|
||||
if mutation == "missing":
|
||||
plan["resource_changes"].pop()
|
||||
elif mutation == "extra":
|
||||
plan["resource_changes"].append(
|
||||
{
|
||||
"address": "module.inventory.aws_route53_zone.site",
|
||||
"mode": "managed",
|
||||
"type": "aws_route53_zone",
|
||||
"change": {
|
||||
"actions": ["no-op"],
|
||||
"importing": {"id": "Z00000000000000000000"},
|
||||
},
|
||||
}
|
||||
)
|
||||
elif mutation == "wrong-type":
|
||||
plan["resource_changes"][0]["type"] = "aws_s3_object"
|
||||
else:
|
||||
resource(plan, DISTRIBUTION)["change"]["importing"]["id"] = (
|
||||
REQUIRED_IMPORT_IDS["staging"][DISTRIBUTION]
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_import_rejects_mutation_and_invalid_metadata(self) -> None:
|
||||
for actions in (["create"], ["update"], ["delete"], ["delete", "create"]):
|
||||
plan = make_plan("dev")
|
||||
plan["resource_changes"][0]["change"]["actions"] = actions
|
||||
with self.subTest(actions=actions):
|
||||
self.assert_fails(plan, "dev")
|
||||
plan = make_plan("dev")
|
||||
plan["resource_changes"][0]["change"]["importing"] = {"id": ""}
|
||||
self.assert_fails(plan, "dev")
|
||||
|
||||
def test_post_import_no_op_passes(self) -> None:
|
||||
self.assert_passes(
|
||||
make_plan("staging", mode="post-import"),
|
||||
"staging",
|
||||
post_import=True,
|
||||
)
|
||||
|
||||
def test_post_import_rejects_import_metadata_and_update(self) -> None:
|
||||
plan = make_plan("dev", mode="post-import")
|
||||
plan["resource_changes"][0]["change"]["importing"] = {"id": "unexpected"}
|
||||
self.assert_fails(plan, "dev", post_import=True)
|
||||
plan = make_plan("dev", mode="post-import")
|
||||
plan["resource_changes"][0]["change"]["actions"] = ["update"]
|
||||
self.assert_fails(plan, "dev", post_import=True)
|
||||
|
||||
def test_every_allowed_controlled_diff_passes(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
for address in CONTROLLED_UPDATE_ADDRESSES:
|
||||
with self.subTest(environment=environment, address=address):
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
environment,
|
||||
mode="controlled",
|
||||
controlled_updates={address},
|
||||
),
|
||||
environment,
|
||||
address,
|
||||
)
|
||||
|
||||
def test_full_exact_controlled_allowlist_passes(self) -> None:
|
||||
addresses = tuple(sorted(CONTROLLED_UPDATE_ADDRESSES))
|
||||
self.assert_passes(
|
||||
make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates=set(addresses),
|
||||
),
|
||||
"dev",
|
||||
*addresses,
|
||||
)
|
||||
|
||||
def test_tag_update_rejects_extra_attribute_and_wrong_value(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["assume_role_policy"] = "{}"
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
resource(plan, ROLE)["change"]["after"]["tags"]["ManagedBy"] = "attacker"
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_tag_update_requires_complete_adopted_tag_sets(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={BUCKET})
|
||||
del resource(plan, BUCKET)["change"]["after"]["tags"]["Ownership"]
|
||||
self.assert_fails(plan, "dev", BUCKET)
|
||||
|
||||
def test_role_trust_change_is_rejected(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
role = resource(plan, ROLE)["change"]
|
||||
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
|
||||
for mutation in ("principal", "extra"):
|
||||
plan = make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={BUCKET_POLICY},
|
||||
)
|
||||
policy = copy.deepcopy(bucket_policy("dev"))
|
||||
if mutation == "principal":
|
||||
policy["Statement"][0]["Principal"] = {"AWS": "*"}
|
||||
else:
|
||||
policy["Statement"].append(
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "s3:*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
resource(plan, BUCKET_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||
policy
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
|
||||
def test_deploy_policy_rejects_resource_action_and_extra_statement(self) -> None:
|
||||
for mutation in ("resource", "action", "extra"):
|
||||
plan = make_plan(
|
||||
"staging",
|
||||
mode="controlled",
|
||||
controlled_updates={DEPLOY_POLICY},
|
||||
)
|
||||
policy = copy.deepcopy(deploy_policy("staging"))
|
||||
if mutation == "resource":
|
||||
policy["Statement"][0]["Resource"] = "*"
|
||||
elif mutation == "action":
|
||||
policy["Statement"][0]["Action"].append("iam:PassRole")
|
||||
else:
|
||||
policy["Statement"].append(
|
||||
{
|
||||
"Sid": "Extra",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
resource(plan, DEPLOY_POLICY)["change"]["after"]["policy"] = json.dumps(
|
||||
policy
|
||||
)
|
||||
with self.subTest(mutation=mutation):
|
||||
self.assert_fails(plan, "staging", DEPLOY_POLICY)
|
||||
|
||||
def test_policy_updates_require_exact_pre_adoption_state(self) -> None:
|
||||
for environment in REQUIRED_RESOURCES:
|
||||
for address in (BUCKET_POLICY, DEPLOY_POLICY):
|
||||
plan = make_plan(
|
||||
environment,
|
||||
mode="controlled",
|
||||
controlled_updates={address},
|
||||
)
|
||||
change = resource(plan, address)["change"]
|
||||
before = json.loads(change["before"]["policy"])
|
||||
before["Statement"].append(
|
||||
{
|
||||
"Sid": "UnexpectedDrift",
|
||||
"Effect": "Deny",
|
||||
"Action": "*",
|
||||
"Resource": "*",
|
||||
}
|
||||
)
|
||||
change["before"]["policy"] = json.dumps(before)
|
||||
with self.subTest(environment=environment, address=address):
|
||||
self.assert_fails(plan, environment, address)
|
||||
|
||||
def test_controlled_update_rejects_unknown_and_replace_paths(self) -> None:
|
||||
for field, value in (
|
||||
("after_unknown", {"tags": {"ManagedBy": True}}),
|
||||
("replace_paths", [["tags"]]),
|
||||
):
|
||||
plan = make_plan(
|
||||
"dev",
|
||||
mode="controlled",
|
||||
controlled_updates={ROLE},
|
||||
)
|
||||
resource(plan, ROLE)["change"][field] = value
|
||||
with self.subTest(field=field):
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
def test_nonallowlisted_update_and_unused_allowlist_fail(self) -> None:
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||
self.assert_fails(plan, "dev", BUCKET_POLICY)
|
||||
plan = make_plan("dev", mode="controlled", controlled_updates=set())
|
||||
self.assert_fails(plan, "dev", ROLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
24
scripts/upload-sourcemaps.sh
Executable file
24
scripts/upload-sourcemaps.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${SENTRY_AUTH_TOKEN:?SENTRY_AUTH_TOKEN is required for source-map upload}"
|
||||
|
||||
SENTRY_ORG="${SENTRY_ORG:-seahaven}"
|
||||
SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}"
|
||||
COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}"
|
||||
|
||||
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')"
|
||||
RELEASE="shoc-frontend@${COMMIT_SHA}"
|
||||
|
||||
npm exec --no -- sentry-cli sourcemaps upload \
|
||||
--org "${SENTRY_ORG}" \
|
||||
--project "${SENTRY_PROJECT}" \
|
||||
--release "${RELEASE}" \
|
||||
--url-prefix "~/" \
|
||||
--validate \
|
||||
dist
|
||||
|
|
@ -37,6 +37,8 @@ export const API_PATHS = {
|
|||
media: (id: string | number) => `workorders/${id}/media`,
|
||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}`,
|
||||
mediaContent: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}/content`,
|
||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||
|
|
|
|||
141
src/api/api.ts
141
src/api/api.ts
|
|
@ -3,18 +3,9 @@ import { env } from "@/lib/env";
|
|||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||
|
||||
function clearSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
stopQueryBroadcast(queryClient);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
|
||||
function isAuthLoginRequest(request: Request): boolean {
|
||||
return request.url.includes(API_PATHS.authentication.login);
|
||||
|
|
@ -51,35 +42,99 @@ export const api: KyInstance = ky.create({
|
|||
return response;
|
||||
}
|
||||
|
||||
clearSessionAndRedirectToLogin();
|
||||
expireSessionAndRedirectToLogin();
|
||||
return response;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete";
|
||||
|
||||
export async function apiRequestRaw(
|
||||
method: ApiRawMethod,
|
||||
url: string,
|
||||
options?: Options,
|
||||
entryPoint?: string,
|
||||
): Promise<Response> {
|
||||
return traceHttpOperation(
|
||||
{
|
||||
method: method.toUpperCase(),
|
||||
url,
|
||||
entryPoint: entryPoint ?? `api.${method}`,
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api[method](url, options);
|
||||
reportStatus(response.status);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.get(url, options).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "GET", url, entryPoint: "api.apiGet" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.get(url, options);
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPost" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
|
||||
return api.post(url, { ...options, body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPostForm" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.put(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "PUT", url, entryPoint: "api.apiPut" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.put(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||
return api.patch(url, { ...options, json: body }).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "PATCH", url, entryPoint: "api.apiPatch" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.patch(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
||||
return api.delete(url, options).json<T>();
|
||||
return traceHttpOperation(
|
||||
{ method: "DELETE", url, entryPoint: "api.apiDelete" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.delete(url, options);
|
||||
reportStatus(response.status);
|
||||
return response.json<T>();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPostNoContent(
|
||||
|
|
@ -87,31 +142,43 @@ export async function apiPostNoContent(
|
|||
body?: unknown,
|
||||
options?: Options,
|
||||
): Promise<void> {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return traceHttpOperation(
|
||||
{ method: "POST", url, entryPoint: "api.apiPostNoContent" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.post(url, { ...options, json: body });
|
||||
reportStatus(response.status);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSON.parse(text);
|
||||
JSON.parse(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
|
||||
const response = await api.delete(url, options);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
return traceHttpOperation(
|
||||
{ method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" },
|
||||
async ({ reportStatus }) => {
|
||||
const response = await api.delete(url, options);
|
||||
reportStatus(response.status);
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSON.parse(text);
|
||||
JSON.parse(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export { mapHttpStatusToMessage };
|
||||
|
|
|
|||
103
src/api/upload-form-with-progress.ts
Normal file
103
src/api/upload-form-with-progress.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
|
||||
export interface UploadFormResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface UploadFormOptions {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
function parseJsonBody(text: string): unknown {
|
||||
if (!text.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createAbortError(): DOMException {
|
||||
return new DOMException("The upload was aborted.", "AbortError");
|
||||
}
|
||||
|
||||
/**
|
||||
* hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept
|
||||
* streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies
|
||||
* as a network error before reaching the API. XHR reports progress on every transport.
|
||||
*/
|
||||
export function uploadFormWithProgress(
|
||||
path: string,
|
||||
body: FormData,
|
||||
options?: UploadFormOptions,
|
||||
): Promise<UploadFormResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options?.signal?.aborted) {
|
||||
reject(createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
request.setRequestHeader("Authorization", token);
|
||||
}
|
||||
|
||||
const abortRequest = () => {
|
||||
request.abort();
|
||||
};
|
||||
options?.signal?.addEventListener("abort", abortRequest);
|
||||
|
||||
const detachSignal = () => {
|
||||
options?.signal?.removeEventListener("abort", abortRequest);
|
||||
};
|
||||
|
||||
const { onProgress } = options ?? {};
|
||||
if (onProgress) {
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
detachSignal();
|
||||
const { status } = request;
|
||||
|
||||
if (status === 401) {
|
||||
expireSessionAndRedirectToLogin();
|
||||
}
|
||||
|
||||
resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
data: parseJsonBody(request.responseText),
|
||||
});
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => {
|
||||
detachSignal();
|
||||
reject(new Error("Upload failed. Check your connection and try again."));
|
||||
});
|
||||
|
||||
request.addEventListener("abort", () => {
|
||||
detachSignal();
|
||||
reject(createAbortError());
|
||||
});
|
||||
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
|
|
@ -20,9 +21,9 @@ import {
|
|||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
workOrderMediaAcceptAttribute,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = {
|
|||
isReadOnly: boolean;
|
||||
pending?: boolean;
|
||||
avetaRequired?: boolean;
|
||||
workOrderId?: string | number;
|
||||
onUpload: (files: File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (!tab) {
|
||||
toast.error("Unable to open this document. Please allow pop-ups and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
tab.opener = null;
|
||||
tab.location.href = objectUrl;
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
} catch {
|
||||
tab.close();
|
||||
toast.error("Unable to open this document.");
|
||||
}
|
||||
}
|
||||
|
||||
function ExtraDocRow({
|
||||
file,
|
||||
isReadOnly,
|
||||
pending,
|
||||
workOrderId,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
}: {
|
||||
file: MediaFile;
|
||||
isReadOnly: boolean;
|
||||
pending: boolean;
|
||||
workOrderId?: string | number;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
}) {
|
||||
|
|
@ -75,15 +98,20 @@ function ExtraDocRow({
|
|||
className="shrink-0"
|
||||
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 flex-1 truncate hover:underline"
|
||||
<button
|
||||
type="button"
|
||||
disabled={workOrderId == null}
|
||||
onClick={() => {
|
||||
if (workOrderId == null) {
|
||||
return;
|
||||
}
|
||||
void openExtraDocContent(workOrderId, file.id);
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
</button>
|
||||
{showCategory ? (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
|
|
@ -134,6 +162,7 @@ export function SlideOverExtrasTab({
|
|||
isReadOnly,
|
||||
pending = false,
|
||||
avetaRequired = false,
|
||||
workOrderId,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
|
|
@ -145,8 +174,8 @@ export function SlideOverExtrasTab({
|
|||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||
if (!isWorkOrderExtraDocTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
|
|
@ -193,7 +222,7 @@ export function SlideOverExtrasTab({
|
|||
event.preventDefault();
|
||||
addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 100,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -208,13 +237,14 @@ export function SlideOverExtrasTab({
|
|||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
|
||||
per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
accept={workOrderExtraDocsAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
@ -238,6 +268,7 @@ export function SlideOverExtrasTab({
|
|||
file={file}
|
||||
isReadOnly={isReadOnly}
|
||||
pending={pending}
|
||||
workOrderId={workOrderId}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import { DocBadge } from "./slide-over-doc-badge";
|
||||
|
||||
|
|
@ -31,6 +32,75 @@ type SlideOverInfoTabEditViewProps = {
|
|||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
function SlideOverScheduleOnField({
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: {
|
||||
draft: WorkOrderTableRow;
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
}) {
|
||||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
|
||||
return (
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onSave={(scheduledOn) =>
|
||||
onDraftChange({
|
||||
scheduledOn,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: scheduledOn ? "" : draft.targetWeek,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(scheduledOn),
|
||||
})
|
||||
}
|
||||
onSaveWeek={(weekStart) =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
targetWeek: weekStart,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(""),
|
||||
})
|
||||
}
|
||||
onSaveRange={(start, end) =>
|
||||
onDraftChange({
|
||||
scheduledOn: start,
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(start),
|
||||
})
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isPastDue: false,
|
||||
...deriveDayFields(""),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlideOverInfoTabEditView({
|
||||
draft,
|
||||
users,
|
||||
|
|
@ -50,6 +120,9 @@ export function SlideOverInfoTabEditView({
|
|||
rescheduleCount={draft.rescheduleCount}
|
||||
carriedOver={draft.carriedOver}
|
||||
originalDate={draft.originalDate}
|
||||
originalWeek={draft.originalWeek}
|
||||
scheduledOn={draft.scheduledOn}
|
||||
status={draft.status}
|
||||
currentId={draft.id}
|
||||
allOrders={allOrders}
|
||||
onSave={(woNumber) => onDraftChange({ woNumber })}
|
||||
|
|
@ -96,23 +169,7 @@ export function SlideOverInfoTabEditView({
|
|||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Schedule On">
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||
onSave={(scheduledOn) =>
|
||||
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
|
||||
}
|
||||
onSaveWeek={(weekStart) =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
targetWeek: weekStart,
|
||||
scheduleWeekOnly: true,
|
||||
isPastDue: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SlideOverScheduleOnField draft={draft} onDraftChange={onDraftChange} />
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Due Date">
|
||||
<DueDateCell
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
|
|
@ -9,9 +9,13 @@ import type {
|
|||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
UploadWorkOrderMediaInput,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
|
||||
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||
import {
|
||||
isCompletionSurfaceMedia,
|
||||
isExtrasSurfaceMedia,
|
||||
type MediaSurfaceOwnership,
|
||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
|
||||
type UseSlideOverMediaStateArgs = {
|
||||
detailForRow?: WorkOrderBoardDetail;
|
||||
|
|
@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = {
|
|||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||
};
|
||||
|
||||
function addOwnedId(previous: Set<string>, id: string | undefined): Set<string> {
|
||||
if (!id) return previous;
|
||||
const next = new Set(previous);
|
||||
next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
function dropOwnedId(previous: Set<string>, id: string): Set<string> {
|
||||
if (!previous.has(id)) return previous;
|
||||
const next = new Set(previous);
|
||||
next.delete(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:";
|
||||
|
||||
function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership {
|
||||
if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`);
|
||||
if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown };
|
||||
return {
|
||||
completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []),
|
||||
extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []),
|
||||
};
|
||||
} catch {
|
||||
return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
}
|
||||
}
|
||||
|
||||
function saveOwnership(
|
||||
workOrderId: string | number | undefined,
|
||||
ownership: MediaSurfaceOwnership,
|
||||
): void {
|
||||
if (workOrderId == null) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`,
|
||||
JSON.stringify({
|
||||
completion: [...ownership.completionOwnedIds],
|
||||
extra: [...ownership.extraOwnedIds],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSlideOverMediaState({
|
||||
detailForRow,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id;
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||
loadOwnership(workOrderId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOwnershipState(loadOwnership(workOrderId));
|
||||
}, [workOrderId]);
|
||||
|
||||
const updateOwnership = useCallback(
|
||||
(update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => {
|
||||
setOwnershipState((current) => {
|
||||
const next = update(current);
|
||||
saveOwnership(workOrderId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[workOrderId],
|
||||
);
|
||||
|
||||
const persistCompletionUpload = useCallback(
|
||||
async (input: UploadWorkOrderMediaInput) => {
|
||||
const item = await uploadMediaMutation.mutateAsync(input);
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
return item;
|
||||
},
|
||||
[updateOwnership, uploadMediaMutation],
|
||||
);
|
||||
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
|
||||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||
|
||||
const completionMedia = useMemo(
|
||||
() =>
|
||||
uploads.filter(
|
||||
(item) =>
|
||||
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
|
||||
item.uploadStatus === "uploading" ||
|
||||
item.uploadStatus === "failed",
|
||||
),
|
||||
[uploads],
|
||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
|
||||
[uploads, ownership],
|
||||
);
|
||||
|
||||
const extrasUploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detailForRow?.media ?? []).filter(
|
||||
(item) => item.category === "Extra" || item.category === "Aveta",
|
||||
),
|
||||
boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) =>
|
||||
isExtrasSurfaceMedia(item, ownership),
|
||||
),
|
||||
[detailForRow?.media],
|
||||
[detailForRow?.media, ownership],
|
||||
);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
|
|
@ -68,7 +145,11 @@ export function useSlideOverMediaState({
|
|||
try {
|
||||
for (const file of files) {
|
||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setMediaBatchPending(false);
|
||||
|
|
@ -81,7 +162,20 @@ export function useSlideOverMediaState({
|
|||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
|
||||
categorizeMediaMutation.mutate(
|
||||
{ mediaId, category, workOrderVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds:
|
||||
category === "Extra"
|
||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeMedia = (id: string) => {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
workOrderId={infoSource?.id}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
infoSource?.avetaRequired,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
|
|||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
|
||||
export function buildInlineDraftCreateInput(
|
||||
draft: WorkOrderTableDraft,
|
||||
|
|
@ -36,13 +37,19 @@ export function buildInlineDraftCreateInput(
|
|||
vendorNotes: "",
|
||||
apptStart: draft.apptStart,
|
||||
apptEnd: draft.apptEnd,
|
||||
status: draft.status || "Incomplete",
|
||||
status: resolveCreationStatus({
|
||||
status: draft.status || "Incomplete",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly,
|
||||
}),
|
||||
pocName: draft.pocName,
|
||||
pocPhone: draft.pocPhone,
|
||||
pocNotes: draft.pocNotes,
|
||||
additionalContacts: draft.additionalContacts ?? [],
|
||||
scheduleWeekOnly,
|
||||
targetWeek: draft.targetWeek || "",
|
||||
scheduleEnd: draft.scheduleEnd || "",
|
||||
schedMode: draft.schedMode,
|
||||
isAddOnOverride: draft.isAddOnOverride,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
|||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
||||
className="group/cell flex w-full min-w-0 items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
||||
style={
|
||||
highlight
|
||||
? {
|
||||
|
|
@ -43,13 +43,13 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
|||
{selected ? (
|
||||
<DispatcherPill user={selected} />
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
||||
<Users
|
||||
size={13}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
Select dispatcher
|
||||
<span className="truncate whitespace-nowrap">Select dispatcher</span>
|
||||
</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { CalendarDays, Calendar as CalendarIcon, CalendarRange } from "lucide-react";
|
||||
import { fmtDateRange, fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type DateCellValueArgs = {
|
||||
showRange: boolean;
|
||||
showWeek: boolean;
|
||||
value: string;
|
||||
scheduleEnd: string;
|
||||
targetWeek: string;
|
||||
q?: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
export function DateCellValue({
|
||||
showRange,
|
||||
showWeek,
|
||||
value,
|
||||
scheduleEnd,
|
||||
targetWeek,
|
||||
q,
|
||||
highlight,
|
||||
}: DateCellValueArgs) {
|
||||
if (showRange) {
|
||||
return (
|
||||
<>
|
||||
<CalendarRange size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
{fmtDateRange(value, scheduleEnd)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (showWeek) {
|
||||
return (
|
||||
<>
|
||||
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
{fmtWeekOf(targetWeek)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
||||
import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state";
|
||||
import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs";
|
||||
import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel";
|
||||
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type DateCellProps = {
|
||||
value: string;
|
||||
|
|
@ -15,6 +18,9 @@ type DateCellProps = {
|
|||
targetWeek?: string;
|
||||
onSaveWeek?: (weekStart: string) => void;
|
||||
defaultMode?: "week" | "date";
|
||||
scheduleEnd?: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
onSaveRange?: (start: string, end: string) => void;
|
||||
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
|
||||
onClear?: () => void;
|
||||
/** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */
|
||||
|
|
@ -30,14 +36,34 @@ export function DateCell({
|
|||
targetWeek = "",
|
||||
onSaveWeek,
|
||||
defaultMode,
|
||||
scheduleEnd = "",
|
||||
schedMode,
|
||||
onSaveRange,
|
||||
onClear,
|
||||
srStatusSuffix,
|
||||
}: DateCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
||||
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
|
||||
const open = Boolean(anchorEl);
|
||||
const allowRange = Boolean(allowWeekMode && onSaveRange);
|
||||
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
|
||||
{
|
||||
open,
|
||||
value,
|
||||
scheduleEnd,
|
||||
allowRange,
|
||||
allowWeekMode,
|
||||
targetWeek,
|
||||
schedMode,
|
||||
defaultMode,
|
||||
},
|
||||
);
|
||||
const showWeek = allowWeekMode && !value && !!targetWeek;
|
||||
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
|
||||
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
|
||||
const modes: WizardSchedMode[] = allowRange
|
||||
? ["week", "date", "range"]
|
||||
: allowWeekMode
|
||||
? ["week", "date"]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -60,29 +86,15 @@ export function DateCell({
|
|||
}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
{showWeek ? (
|
||||
<>
|
||||
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
{fmtWeekOf(targetWeek)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DateCellValue
|
||||
showRange={showRange}
|
||||
showWeek={showWeek}
|
||||
value={value}
|
||||
scheduleEnd={scheduleEnd}
|
||||
targetWeek={targetWeek}
|
||||
q={q}
|
||||
highlight={highlight}
|
||||
/>
|
||||
</span>
|
||||
{Boolean(srStatusSuffix) && <span className="sr-only">{srStatusSuffix}</span>}
|
||||
<ChevronDown
|
||||
|
|
@ -92,36 +104,35 @@ export function DateCell({
|
|||
/>
|
||||
</button>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
||||
>
|
||||
{Boolean(allowWeekMode) && (
|
||||
<div className="mb-2.5 flex items-center gap-1.5">
|
||||
{(["week", "date"] as const).map((m) => {
|
||||
const active = cellMode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setCellMode(m)}
|
||||
className="rounded-full border px-3 text-[11.5px] font-medium"
|
||||
style={{
|
||||
height: 26,
|
||||
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
|
||||
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
|
||||
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{m === "week" ? "Week" : "Specific date"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mb-1.5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-0.5 text-xs font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={12} />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<WizardDateModeTabs
|
||||
modes={modes}
|
||||
cellMode={cellMode}
|
||||
allowRange={allowRange}
|
||||
onSelect={setCellMode}
|
||||
/>
|
||||
{allowWeekMode && cellMode === "week" ? (
|
||||
<WeekPicker
|
||||
selected={!value ? targetWeek : ""}
|
||||
|
|
@ -130,6 +141,16 @@ export function DateCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
/>
|
||||
) : allowRange && cellMode === "range" ? (
|
||||
<WizardDateRangePanel
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
onRangeClick={handleRangeClick}
|
||||
onDone={(start, end) => {
|
||||
onSaveRange?.(start, end);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MiniCalendar
|
||||
value={value}
|
||||
|
|
@ -139,21 +160,6 @@ export function DateCell({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="text-[11.5px] font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ type EditableWONumberCellProps = {
|
|||
carriedOver?: number;
|
||||
isAddOn?: boolean;
|
||||
originalDate?: string;
|
||||
originalWeek?: string;
|
||||
scheduledOn?: string;
|
||||
status?: string;
|
||||
q?: string;
|
||||
currentId: string | number;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
|
|
@ -37,6 +40,9 @@ export function EditableWONumberCell({
|
|||
carriedOver = 0,
|
||||
isAddOn = false,
|
||||
originalDate,
|
||||
originalWeek,
|
||||
scheduledOn,
|
||||
status,
|
||||
q,
|
||||
currentId,
|
||||
allOrders,
|
||||
|
|
@ -118,7 +124,13 @@ export function EditableWONumberCell({
|
|||
</span>
|
||||
<AddOnPill when={isAddOn} />
|
||||
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
|
||||
<CarriedOverCounterPill count={carriedOver} />
|
||||
<CarriedOverCounterPill
|
||||
count={carriedOver}
|
||||
originalWeek={originalWeek}
|
||||
originalDate={originalDate}
|
||||
scheduledOn={scheduledOn}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -155,7 +167,13 @@ export function EditableWONumberCell({
|
|||
)}
|
||||
<AddOnPill when={isAddOn} />
|
||||
<RescheduleCounterPill count={rescheduleCount} originalDate={originalDate} />
|
||||
<CarriedOverCounterPill count={carriedOver} />
|
||||
<CarriedOverCounterPill
|
||||
count={carriedOver}
|
||||
originalWeek={originalWeek}
|
||||
originalDate={originalDate}
|
||||
scheduledOn={scheduledOn}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ export function SeverityField({ value, onSelect }: SeverityFieldProps) {
|
|||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
|
||||
>
|
||||
Severity (from APM)
|
||||
<Text as="span" variant="label" tone="error">
|
||||
{" "}
|
||||
*
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
|
|
|
|||
|
|
@ -2,16 +2,19 @@ import { useState } from "react";
|
|||
import { AlertTriangle, Check } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
BOARD_INLINE_STATUSES,
|
||||
PAST_DUE_BADGE_TOOLTIP,
|
||||
PAST_DUE_POPOVER_BODY,
|
||||
PAST_DUE_RESCHEDULE_CTA,
|
||||
getStatusOptionState,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||
|
||||
|
|
@ -24,6 +27,7 @@ type StatusCellProps = {
|
|||
onPastDueAttempt?: () => void;
|
||||
onRequestComplete?: () => void;
|
||||
readOnly?: boolean;
|
||||
statusOptions?: readonly WizardWOStatus[];
|
||||
};
|
||||
|
||||
function ReadOnlyStatusLabel({ status }: { status: string }) {
|
||||
|
|
@ -64,7 +68,7 @@ function PastDueStatusCell({
|
|||
onPastDueAttempt?.();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||
title={PAST_DUE_BADGE_TOOLTIP}
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
|
|
@ -97,7 +101,7 @@ function PastDueStatusCell({
|
|||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. Update Schedule On before the status can be updated.
|
||||
{PAST_DUE_POPOVER_BODY}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -108,7 +112,7 @@ function PastDueStatusCell({
|
|||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Update Schedule On →
|
||||
{PAST_DUE_RESCHEDULE_CTA}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,6 +128,7 @@ export function StatusCell({
|
|||
onPastDueAttempt,
|
||||
onRequestComplete,
|
||||
readOnly = false,
|
||||
statusOptions = BOARD_INLINE_STATUSES,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = rowClosabilityGaps(wo);
|
||||
|
|
@ -162,22 +167,19 @@ export function StatusCell({
|
|||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||
slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
const missingTitle = isCompletedBlocked
|
||||
? formatClosabilityMissingTitle(closabilityGaps)
|
||||
: undefined;
|
||||
{statusOptions.map((status) => {
|
||||
const option = getStatusOptionState(wo, status, closabilityGaps);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
disabled={isCompletedBlocked}
|
||||
title={missingTitle}
|
||||
disabled={option.disabled}
|
||||
title={option.title}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
if (status === "Completed" && wo.status !== "Completed") {
|
||||
|
|
@ -189,9 +191,9 @@ export function StatusCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
className={
|
||||
isCompletedBlocked
|
||||
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
option.disabled
|
||||
? "flex w-full cursor-not-allowed items-center justify-between gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
}
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
|
|
|
|||
|
|
@ -18,14 +18,32 @@ type TypeCellProps = {
|
|||
value: WOType | "";
|
||||
severity?: WorkOrderSeverity | null;
|
||||
q?: string;
|
||||
viewOnly?: boolean;
|
||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||
};
|
||||
|
||||
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
||||
function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
|
||||
if (value) {
|
||||
return <WoTableTypeLabel type={value} q={q} />;
|
||||
}
|
||||
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
|
||||
}
|
||||
|
||||
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
||||
const [resetSeverity, setResetSeverity] = useState(false);
|
||||
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||
|
||||
if (viewOnly) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<TypeCellValue value={value} q={q} />
|
||||
{Boolean(showSeverityBadge && severity != null) && <SeverityBadge severity={severity!} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pick(t: WOType) {
|
||||
setAnchorEl(null);
|
||||
|
|
@ -43,23 +61,19 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
|||
onSave(t, needsSeverity ? severity : null);
|
||||
}
|
||||
|
||||
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
|
||||
title={value ? undefined : "Select type of WO"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{value ? (
|
||||
<WoTableTypeLabel type={value} q={q} />
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
||||
)}
|
||||
<TypeCellValue value={value} q={q} />
|
||||
{Boolean(showSeverityBadge && severity != null) && (
|
||||
<SeverityBadge
|
||||
severity={severity!}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
|
|
@ -76,6 +84,10 @@ export function TypeSeverityModal({
|
|||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingRight: 1,
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
|
|
@ -83,6 +95,9 @@ export function TypeSeverityModal({
|
|||
}}
|
||||
>
|
||||
Type & severity
|
||||
<IconButton aria-label="Close" onClick={() => onOpenChange(false)} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -93,6 +108,10 @@ export function TypeSeverityModal({
|
|||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
|
||||
>
|
||||
Type
|
||||
<Text as="span" variant="label" tone="error">
|
||||
{" "}
|
||||
*
|
||||
</Text>
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WO_TYPES.map((t) => (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import {
|
||||
buildCompanyOptions,
|
||||
buildTechnicianOptions,
|
||||
phoneForOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import {
|
||||
mergeVendorDropdownItems,
|
||||
resolveVendorCompanyAnchor,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
|
||||
import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
type UseVendorDialogFormArgs = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
vendorId: string | number;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
vendors: VendorDropdownItem[];
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
export function useVendorDialogForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
vendorId,
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
onSave,
|
||||
}: UseVendorDialogFormArgs) {
|
||||
const createVendor = useCreateVendor();
|
||||
const [selectedCompany, setSelectedCompany] = useState(company);
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
);
|
||||
const [t, setT] = useState(tech);
|
||||
const [phone, setPhone] = useState(techPhone);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const allVendors = useMemo(
|
||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||
[vendors, addedVendors],
|
||||
);
|
||||
|
||||
const companyOptions = useMemo(
|
||||
() => buildCompanyOptions(allVendors, company),
|
||||
[allVendors, company],
|
||||
);
|
||||
|
||||
const technicianOptions = useMemo(
|
||||
() => buildTechnicianOptions(allVendors, selectedCompany, tech, company),
|
||||
[allVendors, selectedCompany, tech, company],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||
const initialOptions = buildTechnicianOptions(vendors, company, tech, company);
|
||||
setSelectedCompany(company);
|
||||
setSelectedKey(key);
|
||||
setT(tech);
|
||||
setPhone(techPhone || phoneForOption(tech, initialOptions));
|
||||
setNotes("");
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSaveError(null);
|
||||
}
|
||||
// invariant: rebuilding options after Add must not clear staged technician drafts.
|
||||
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||
|
||||
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||
const resolvedCompany = selectedCompany;
|
||||
const selectedVendor =
|
||||
selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany);
|
||||
|
||||
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||
company: selectedCompany,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
vendors: allVendors,
|
||||
});
|
||||
|
||||
const canSubmit = computeVendorDialogCanSubmit({
|
||||
current: {
|
||||
company: selectedCompany,
|
||||
selectedKey,
|
||||
phone,
|
||||
notes,
|
||||
pendingCreateCount: Object.keys(pendingCreates).length,
|
||||
},
|
||||
baseline: {
|
||||
company,
|
||||
selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
phone:
|
||||
techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)),
|
||||
notes: "",
|
||||
pendingCreateCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSave } = useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
selectedKey,
|
||||
selectedVendor,
|
||||
resolvedCompany,
|
||||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
canSubmit,
|
||||
});
|
||||
|
||||
return {
|
||||
companyOptions,
|
||||
technicianOptions,
|
||||
selected,
|
||||
t,
|
||||
phone,
|
||||
notes,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
selectedCompany,
|
||||
selectedKey,
|
||||
anchorCompanyName,
|
||||
companyVendor,
|
||||
saveError,
|
||||
canSubmit,
|
||||
isPending: createVendor.isPending,
|
||||
handleSave,
|
||||
handleCompanyPick: (nextCompany: string) => {
|
||||
const selectedOption = technicianOptions.find((option) => option.key === selectedKey);
|
||||
setSelectedCompany(nextCompany);
|
||||
if (selectedOption?.company !== nextCompany) {
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSelectedKey("");
|
||||
setT("");
|
||||
setPhone("");
|
||||
}
|
||||
},
|
||||
handlePick: (key: string) => {
|
||||
const option = technicianOptions.find((o) => o.key === key);
|
||||
setSelectedKey(key);
|
||||
setT(option?.name ?? key);
|
||||
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
|
||||
},
|
||||
handleTechnicianCreated: (
|
||||
created: VendorDropdownItem,
|
||||
createdPhone: string,
|
||||
payload: VendorFormValues,
|
||||
) => {
|
||||
setAddedVendors((current) => [...current, created]);
|
||||
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||
setSelectedKey(String(created.id));
|
||||
setT(created.contactName);
|
||||
setPhone(createdPhone);
|
||||
},
|
||||
setPhone,
|
||||
setNotes,
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = {
|
|||
onSave: (patch: VendorPatch) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setSaveError: (error: string | null) => void;
|
||||
canSubmit: boolean;
|
||||
};
|
||||
|
||||
export function useVendorDialogSave({
|
||||
|
|
@ -33,6 +34,7 @@ export function useVendorDialogSave({
|
|||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
canSubmit,
|
||||
}: UseVendorDialogSaveArgs) {
|
||||
const resolveSaveVendor = () =>
|
||||
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||
|
|
@ -44,6 +46,7 @@ export function useVendorDialogSave({
|
|||
selectedVendor;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSaveError(null);
|
||||
let saveVendor = resolveSaveVendor();
|
||||
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
||||
|
|
@ -65,8 +68,8 @@ export function useVendorDialogSave({
|
|||
onSave({
|
||||
vendorId: saveVendor ? String(saveVendor.id) : "",
|
||||
company: resolvedCompany || anchorCompanyName,
|
||||
tech: saveVendor?.contactName ?? tech,
|
||||
techPhone: phone,
|
||||
tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
|
||||
techPhone: selectedKey ? phone : "",
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Wrench } from "lucide-react";
|
||||
import { Building2, Wrench } from "lucide-react";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/
|
|||
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import type {
|
||||
CompanyOption,
|
||||
TechnicianOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
|
||||
type VendorDialogBodyProps = {
|
||||
selectedCompany: string;
|
||||
companyOptions: CompanyOption[];
|
||||
selectedKey: string;
|
||||
technicianOptions: TechnicianOption[];
|
||||
anchorCompanyName: string;
|
||||
|
|
@ -23,6 +28,7 @@ type VendorDialogBodyProps = {
|
|||
resolvedCompany: string;
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
saveError: string | null;
|
||||
onCompanyPick: (company: string) => void;
|
||||
onPick: (key: string) => void;
|
||||
onPhoneChange: (phone: string) => void;
|
||||
onNotesChange: (notes: string) => void;
|
||||
|
|
@ -34,6 +40,8 @@ type VendorDialogBodyProps = {
|
|||
};
|
||||
|
||||
export function VendorDialogBody({
|
||||
selectedCompany,
|
||||
companyOptions,
|
||||
selectedKey,
|
||||
technicianOptions,
|
||||
anchorCompanyName,
|
||||
|
|
@ -44,6 +52,7 @@ export function VendorDialogBody({
|
|||
resolvedCompany,
|
||||
selectedVendor,
|
||||
saveError,
|
||||
onCompanyPick,
|
||||
onPick,
|
||||
onPhoneChange,
|
||||
onNotesChange,
|
||||
|
|
@ -53,17 +62,46 @@ export function VendorDialogBody({
|
|||
<>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<FieldLabel>Technician</FieldLabel>
|
||||
<FieldLabel required>Company</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={selectedCompany}
|
||||
placeholder="Select company …"
|
||||
icon={
|
||||
<Building2
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
}
|
||||
searchPlaceholder="Search company…"
|
||||
options={companyOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
}))}
|
||||
onPick={onCompanyPick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company info</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Technician (optional)</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={selectedKey}
|
||||
placeholder="Search technician …"
|
||||
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
|
||||
disabled={!selectedCompany}
|
||||
icon={
|
||||
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
label: option.name,
|
||||
}))}
|
||||
onPick={onPick}
|
||||
/>
|
||||
|
|
@ -80,7 +118,7 @@ export function VendorDialogBody({
|
|||
|
||||
{Boolean(tech) && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<FieldLabel>Technician Phone (optional)</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
|
|
@ -91,15 +129,8 @@ export function VendorDialogBody({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<FieldLabel>Notes (optional)</FieldLabel>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
export type VendorDialogSubmitSnapshot = {
|
||||
company: string;
|
||||
selectedKey: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
pendingCreateCount: number;
|
||||
};
|
||||
|
||||
type VendorDialogCanSubmitInput = {
|
||||
current: VendorDialogSubmitSnapshot;
|
||||
baseline: VendorDialogSubmitSnapshot;
|
||||
};
|
||||
|
||||
export function computeVendorDialogCanSubmit({
|
||||
current,
|
||||
baseline,
|
||||
}: VendorDialogCanSubmitInput): boolean {
|
||||
const valid = current.company.trim() !== "";
|
||||
const hasNewTechs = current.pendingCreateCount > 0;
|
||||
const dirty =
|
||||
current.company !== baseline.company ||
|
||||
current.selectedKey !== baseline.selectedKey ||
|
||||
current.phone !== baseline.phone ||
|
||||
current.pendingCreateCount !== baseline.pendingCreateCount;
|
||||
return valid && (dirty || hasNewTechs);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { ExternalLink, Mail, MapPin } from "lucide-react";
|
||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
|
|
@ -30,10 +30,7 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
|
||||
{Boolean(vendor?.tradeSpecialties) && (
|
||||
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
|
||||
)}
|
||||
{Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
|
||||
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
||||
{vendor != null && Boolean(vendor.address) && (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ type VendorDialogFooterProps = {
|
|||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
viewOnly?: boolean;
|
||||
canSubmit?: boolean;
|
||||
};
|
||||
|
||||
const lookDisabledSx = {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
|
||||
};
|
||||
|
||||
export function VendorDialogFooter({
|
||||
|
|
@ -12,6 +19,7 @@ export function VendorDialogFooter({
|
|||
onCancel,
|
||||
onSave,
|
||||
viewOnly = false,
|
||||
canSubmit = false,
|
||||
}: VendorDialogFooterProps) {
|
||||
if (viewOnly) {
|
||||
return (
|
||||
|
|
@ -36,7 +44,13 @@ export function VendorDialogFooter({
|
|||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={isPending}
|
||||
onClick={() => void onSave()}
|
||||
aria-disabled={!canSubmit && !isPending}
|
||||
// why: AAP Save stays clickable; native disabled would change the no-op look-disabled gate.
|
||||
sx={canSubmit || isPending ? undefined : lookDisabledSx}
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
void onSave();
|
||||
}}
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,44 @@ export type TechnicianOption = {
|
|||
vendor?: VendorDropdownItem;
|
||||
};
|
||||
|
||||
export type CompanyOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
vendor?: VendorDropdownItem;
|
||||
};
|
||||
|
||||
export function buildCompanyOptions(
|
||||
vendors: VendorDropdownItem[],
|
||||
currentCompany: string,
|
||||
): CompanyOption[] {
|
||||
const companies = new Map<string, CompanyOption>();
|
||||
|
||||
for (const vendor of vendors) {
|
||||
const companyName = vendor.companyName.trim();
|
||||
if (companyName && !companies.has(companyName)) {
|
||||
companies.set(companyName, {
|
||||
key: companyName,
|
||||
label: companyName,
|
||||
vendor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCompany && !companies.has(currentCompany)) {
|
||||
companies.set(currentCompany, {
|
||||
key: currentCompany,
|
||||
label: currentCompany,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(companies.values());
|
||||
}
|
||||
|
||||
export function buildTechnicianOptions(
|
||||
vendors: VendorDropdownItem[],
|
||||
selectedCompany: string,
|
||||
tech: string,
|
||||
company: string,
|
||||
currentCompany: string,
|
||||
): TechnicianOption[] {
|
||||
const options = new Map<string, TechnicianOption>();
|
||||
|
||||
|
|
@ -25,6 +59,9 @@ export function buildTechnicianOptions(
|
|||
};
|
||||
|
||||
for (const vendor of vendors) {
|
||||
if (vendor.companyName !== selectedCompany) {
|
||||
continue;
|
||||
}
|
||||
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||
add({
|
||||
key: String(vendor.id),
|
||||
|
|
@ -36,17 +73,16 @@ export function buildTechnicianOptions(
|
|||
});
|
||||
}
|
||||
|
||||
if (tech) {
|
||||
if (tech && selectedCompany === currentCompany) {
|
||||
const vendor =
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
||||
vendors.find((v) => v.contactName === tech) ??
|
||||
vendors.find((v) => v.companyName === company);
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === currentCompany) ??
|
||||
vendors.find((v) => v.contactName === tech);
|
||||
const key = vendor ? String(vendor.id) : tech;
|
||||
add({
|
||||
key,
|
||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||
label: tech,
|
||||
name: tech,
|
||||
company: company || tech,
|
||||
company: currentCompany,
|
||||
phone: "",
|
||||
vendor,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor";
|
||||
import {
|
||||
buildTechnicianOptions,
|
||||
phoneForOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
|
||||
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||
import {
|
||||
mergeVendorDropdownItems,
|
||||
resolveVendorCompanyAnchor,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor";
|
||||
import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key";
|
||||
import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save";
|
||||
import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
|
||||
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
||||
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||
|
|
@ -44,85 +33,15 @@ export function VendorDialog({
|
|||
viewOnly = false,
|
||||
onSave,
|
||||
}: VendorDialogProps) {
|
||||
const createVendor = useCreateVendor();
|
||||
const [selectedKey, setSelectedKey] = useState(() =>
|
||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
||||
);
|
||||
const [t, setT] = useState(tech);
|
||||
const [phone, setPhone] = useState(techPhone);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
||||
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const allVendors = useMemo(
|
||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
||||
[vendors, addedVendors],
|
||||
);
|
||||
|
||||
const technicianOptions = useMemo(
|
||||
() => buildTechnicianOptions(allVendors, tech, company),
|
||||
[allVendors, tech, company],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
||||
setSelectedKey(key);
|
||||
setT(tech);
|
||||
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
|
||||
setNotes("");
|
||||
setAddedVendors([]);
|
||||
setPendingCreates({});
|
||||
setSaveError(null);
|
||||
}
|
||||
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
|
||||
}, [open, tech, techPhone, vendors, company, vendorId]);
|
||||
|
||||
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
||||
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
|
||||
const selectedVendor =
|
||||
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
|
||||
|
||||
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
||||
company,
|
||||
resolvedCompany,
|
||||
selectedVendor,
|
||||
vendors: allVendors,
|
||||
});
|
||||
|
||||
const handlePick = (key: string) => {
|
||||
const option = technicianOptions.find((o) => o.key === key);
|
||||
setSelectedKey(key);
|
||||
setT(option?.name ?? key);
|
||||
setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions));
|
||||
};
|
||||
|
||||
const handleTechnicianCreated = (
|
||||
created: VendorDropdownItem,
|
||||
createdPhone: string,
|
||||
payload: VendorFormValues,
|
||||
) => {
|
||||
setAddedVendors((current) => [...current, created]);
|
||||
setPendingCreates((current) => ({ ...current, [String(created.id)]: payload }));
|
||||
setSelectedKey(String(created.id));
|
||||
setT(created.contactName);
|
||||
setPhone(createdPhone);
|
||||
};
|
||||
|
||||
const { handleSave } = useVendorDialogSave({
|
||||
allVendors,
|
||||
pendingCreates,
|
||||
selectedKey,
|
||||
selectedVendor,
|
||||
resolvedCompany,
|
||||
anchorCompanyName,
|
||||
tech: selected?.name ?? t,
|
||||
phone,
|
||||
createVendor,
|
||||
onSave,
|
||||
const form = useVendorDialogForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
vendorId,
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
onSave,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -134,43 +53,47 @@ export function VendorDialog({
|
|||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
style: { borderRadius: 12, maxWidth: 560 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||
Technician
|
||||
Vendor
|
||||
</DialogTitle>
|
||||
<Text as="p" className="sr-only">
|
||||
Search and select the technician
|
||||
Search and select the vendor company
|
||||
</Text>
|
||||
|
||||
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
selectedCompany={form.selectedCompany}
|
||||
companyOptions={form.companyOptions}
|
||||
selectedKey={form.selectedKey}
|
||||
technicianOptions={form.technicianOptions}
|
||||
anchorCompanyName={form.anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
|
||||
tech={form.selected?.name ?? form.t}
|
||||
phone={form.phone}
|
||||
notes={form.notes}
|
||||
resolvedCompany={form.resolvedCompany}
|
||||
selectedVendor={form.selectedVendor}
|
||||
saveError={form.saveError}
|
||||
onCompanyPick={form.handleCompanyPick}
|
||||
onPick={form.handlePick}
|
||||
onPhoneChange={form.setPhone}
|
||||
onNotesChange={form.setNotes}
|
||||
onTechnicianCreated={form.handleTechnicianCreated}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<VendorDialogFooter
|
||||
viewOnly={viewOnly}
|
||||
isPending={createVendor.isPending}
|
||||
isPending={form.isPending}
|
||||
canSubmit={form.canSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={handleSave}
|
||||
onSave={form.handleSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { RefreshCw } from "lucide-react";
|
||||
import { Tooltip } from "@mui/material";
|
||||
import {
|
||||
formatCarriedOverHover,
|
||||
resolveCarryOverSourceWeek,
|
||||
shouldShowCarriedOverPill,
|
||||
} from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { formatRescheduleHover } from "@/domain/work-orders/utils/reschedule-indicator";
|
||||
|
||||
const RESCHEDULE_PILL_STYLE = {
|
||||
|
|
@ -46,17 +51,30 @@ export function RescheduleCounterPill({
|
|||
);
|
||||
}
|
||||
|
||||
export function CarriedOverCounterPill({ count }: { count: number }) {
|
||||
if (count < 2) {
|
||||
export function CarriedOverCounterPill({
|
||||
count,
|
||||
originalWeek,
|
||||
originalDate,
|
||||
scheduledOn,
|
||||
status,
|
||||
}: {
|
||||
count: number;
|
||||
originalWeek?: string;
|
||||
originalDate?: string;
|
||||
scheduledOn?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
if (!shouldShowCarriedOverPill(count, status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = `Carried over ${count} times`;
|
||||
const sourceWeek = resolveCarryOverSourceWeek({ originalWeek, originalDate, scheduledOn });
|
||||
const hover = formatCarriedOverHover(count, sourceWeek);
|
||||
|
||||
return (
|
||||
<Tooltip title="Carried over" arrow placement="top">
|
||||
<Tooltip title={hover} arrow placement="top">
|
||||
<span
|
||||
aria-label={label}
|
||||
aria-label={hover}
|
||||
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
||||
style={MUTED_PILL_STYLE}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,15 @@ type NotifyNavigate = {
|
|||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
function showRescheduleToast(message: string, onNavigate: () => void) {
|
||||
function showRescheduleToast(
|
||||
message: string,
|
||||
onNavigate: () => void,
|
||||
linkLabel = "Click to view it.",
|
||||
) {
|
||||
const toastId = toast(
|
||||
createElement(RescheduleToast, {
|
||||
message,
|
||||
linkLabel: "Click to view it.",
|
||||
linkLabel,
|
||||
onNavigate: () => {
|
||||
toast.dismiss(toastId);
|
||||
onNavigate();
|
||||
|
|
@ -35,6 +39,13 @@ function showRescheduleToast(message: string, onNavigate: () => void) {
|
|||
}
|
||||
|
||||
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
||||
if (!weekStart) {
|
||||
return showRescheduleToast(
|
||||
"Work order moved to Unscheduled.",
|
||||
onNavigate,
|
||||
"View in Advanced Filters.",
|
||||
);
|
||||
}
|
||||
return showRescheduleToast(
|
||||
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
||||
onNavigate,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { toast } from "react-toastify";
|
||||
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import {
|
||||
formatSeverityEscalationToast,
|
||||
shouldAnnounceSeverityEscalation,
|
||||
} from "@/domain/work-orders/utils/severity-escalation";
|
||||
|
||||
type PersistPatchFn = (
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
options?: { onSuccess?: () => void },
|
||||
) => void;
|
||||
|
||||
export function saveExistingRowType(
|
||||
onPatch: PersistPatchFn,
|
||||
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
|
||||
type: WOType,
|
||||
severity: WorkOrderSeverity | null,
|
||||
) {
|
||||
const patch = { type, severity };
|
||||
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
|
||||
onPatch(row.id, patch);
|
||||
return;
|
||||
}
|
||||
onPatch(row.id, patch, {
|
||||
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
|
||||
});
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
|||
dispatcherId: draft.dispatcherId,
|
||||
dispatcherName: "",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly: draft.scheduleWeekOnly,
|
||||
scheduleEnd: draft.scheduleEnd,
|
||||
schedMode: draft.schedMode,
|
||||
vendorId: draft.vendorId,
|
||||
completedDate: "",
|
||||
dueDate: draft.dueDate,
|
||||
pm: draft.pm,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo } from "react";
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
applyScheduleRangePatch,
|
||||
type WorkOrderTablePatchFn,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||
|
|
@ -9,6 +10,27 @@ import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
|||
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
|
||||
function revealOrNotifyScheduledDay(args: {
|
||||
id: string | number;
|
||||
date: string;
|
||||
weekMonday: string;
|
||||
onNavigateToDay: (dateIso: string) => void;
|
||||
revealScheduledRow: (id: string | number) => void;
|
||||
}) {
|
||||
const { id, date, weekMonday, onNavigateToDay, revealScheduledRow } = args;
|
||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||
rescheduleNotify(date, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
}
|
||||
|
||||
type UseWoTableRowHandlersOptions = {
|
||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||
weekMonday: string;
|
||||
|
|
@ -81,29 +103,45 @@ export function useWoTableRowHandlers({
|
|||
},
|
||||
onScheduleDate: (id: string | number, date: string) => {
|
||||
onPatch(id, applySchedulePatch(date, false));
|
||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||
rescheduleNotify(date, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
revealOrNotifyScheduledDay({
|
||||
id,
|
||||
date,
|
||||
weekMonday,
|
||||
onNavigateToDay,
|
||||
revealScheduledRow,
|
||||
});
|
||||
},
|
||||
onScheduleRange: (id: string | number, start: string, end: string) => {
|
||||
onPatch(id, applyScheduleRangePatch(start, end));
|
||||
revealOrNotifyScheduledDay({
|
||||
id,
|
||||
date: start,
|
||||
weekMonday,
|
||||
onNavigateToDay,
|
||||
revealScheduledRow,
|
||||
});
|
||||
},
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPatch(id, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const isClear = week === "";
|
||||
onPatch(
|
||||
id,
|
||||
isClear
|
||||
? applySchedulePatch("", false)
|
||||
: {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
},
|
||||
);
|
||||
weekMoveNotify(week, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(week);
|
||||
if (!isClear) {
|
||||
onNavigateToDay(week);
|
||||
}
|
||||
onOpenAdvancedFilters?.();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { ApptTimeCell } from "./cells/appt-time-cell";
|
|||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import {
|
||||
CREATION_INLINE_STATUSES,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { TypeCell } from "./cells/type-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
|
|
@ -74,7 +78,8 @@ function InlineScheduleOnCell({
|
|||
weekDefault: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
}) {
|
||||
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<DateCell
|
||||
|
|
@ -83,31 +88,57 @@ function InlineScheduleOnCell({
|
|||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onSave={(date) =>
|
||||
onPatch({
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onSaveWeek={(week) =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onSaveRange={(start, end) =>
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: start,
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -215,6 +246,7 @@ export function WoTableInlineRowCells({
|
|||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<StatusCell
|
||||
wo={draftRow as never}
|
||||
statusOptions={CREATION_INLINE_STATUSES}
|
||||
onChangeStatus={(status) => onPatch({ status })}
|
||||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
import { saveExistingRowType } from "./save-existing-row-type";
|
||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
|
|
@ -40,6 +43,9 @@ export function WoTableRowCells({
|
|||
handlers,
|
||||
onPatchRow,
|
||||
}: WoTableRowCellsProps) {
|
||||
const hasSchedule =
|
||||
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
|
|
@ -82,6 +88,9 @@ export function WoTableRowCells({
|
|||
carriedOver={row.carriedOver}
|
||||
isAddOn={row.isAddOn}
|
||||
originalDate={row.originalDate}
|
||||
originalWeek={row.originalWeek}
|
||||
scheduledOn={row.scheduledOn}
|
||||
status={row.status}
|
||||
q={search}
|
||||
currentId={row.id}
|
||||
allOrders={allRows}
|
||||
|
|
@ -99,7 +108,8 @@ export function WoTableRowCells({
|
|||
value={row.type}
|
||||
severity={row.severity}
|
||||
q={search}
|
||||
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
|
||||
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -111,9 +121,7 @@ export function WoTableRowCells({
|
|||
dispatcherId={row.dispatcherId}
|
||||
users={users}
|
||||
highlight={!row.dispatcherId}
|
||||
onSave={(id, name) =>
|
||||
handlers.onPatch(row.id, { dispatcherId: id, dispatcherName: name })
|
||||
}
|
||||
onSave={(id, name) => applyAssignedToTableSave(handlers.onPatch, row.id, id, name)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -127,8 +135,12 @@ export function WoTableRowCells({
|
|||
highlight={forceScheduleHighlight}
|
||||
allowWeekMode
|
||||
targetWeek={row.targetWeek}
|
||||
scheduleEnd={row.scheduleEnd}
|
||||
schedMode={row.schedMode}
|
||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
||||
onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)}
|
||||
onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { GripVertical } from "lucide-react";
|
||||
|
||||
type WoTableRowReorderHandleProps = {
|
||||
disabled: boolean;
|
||||
isDragging: boolean;
|
||||
woNumber: string;
|
||||
onArrowReorder: (delta: -1 | 1) => void;
|
||||
};
|
||||
|
||||
export function WoTableRowReorderHandle({
|
||||
disabled,
|
||||
isDragging,
|
||||
woNumber,
|
||||
onArrowReorder,
|
||||
}: WoTableRowReorderHandleProps) {
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={woNumber ? `Reorder work order ${woNumber}` : "Reorder work order"}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArrowReorder(event.key === "ArrowUp" ? -1 : 1);
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { GripVertical } from "lucide-react";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
|
|
@ -7,6 +6,7 @@ import type {
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderTablePatchOptions } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||
import { neighborIdForReorder } from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import {
|
||||
canApplyWorkOrderTablePatch,
|
||||
canMutateWorkOrderFlag,
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { FlagCell } from "./cells/flag-cell";
|
||||
import { WoTableRowActions } from "./wo-table-row-actions";
|
||||
import { WoTableRowCells } from "./wo-table-row-cells";
|
||||
import { WoTableRowReorderHandle } from "./wo-table-row-reorder-handle";
|
||||
import { WoTableRowServiceCells } from "./wo-table-row-service-cells";
|
||||
import {
|
||||
MUTATION_CELL_STYLE,
|
||||
|
|
@ -37,6 +38,7 @@ export type WoTableRowHandlers = {
|
|||
) => void;
|
||||
onScheduleDate: (id: string | number, date: string) => void;
|
||||
onScheduleWeek: (id: string | number, week: string) => void;
|
||||
onScheduleRange: (id: string | number, start: string, end: string) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -65,6 +67,31 @@ type WoTableRowProps = {
|
|||
handlers: WoTableRowHandlers;
|
||||
};
|
||||
|
||||
function guardScheduleHandlers(
|
||||
row: WorkOrderTableRow,
|
||||
handlers: WoTableRowHandlers,
|
||||
): WoTableRowHandlers {
|
||||
return {
|
||||
...handlers,
|
||||
onPatch: (id, patch, options) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
handlers.onPatch(id, patch, options);
|
||||
},
|
||||
onScheduleDate: (id, date) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||
handlers.onScheduleDate(id, date);
|
||||
},
|
||||
onScheduleWeek: (id, week) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
|
||||
handlers.onScheduleWeek(id, week);
|
||||
},
|
||||
onScheduleRange: (id, start, end) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return;
|
||||
handlers.onScheduleRange(id, start, end);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function WoTableRow({
|
||||
row,
|
||||
allRows,
|
||||
|
|
@ -87,21 +114,7 @@ export function WoTableRow({
|
|||
const isCoreLocked = isWorkOrderCoreLocked(row.status);
|
||||
const closedForEdit = isCanceled || isCoreLocked;
|
||||
|
||||
const guardedHandlers: WoTableRowHandlers = {
|
||||
...handlers,
|
||||
onPatch: (id, patch, options) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
handlers.onPatch(id, patch, options);
|
||||
},
|
||||
onScheduleDate: (id, date) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||
handlers.onScheduleDate(id, date);
|
||||
},
|
||||
onScheduleWeek: (id, week) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
|
||||
handlers.onScheduleWeek(id, week);
|
||||
},
|
||||
};
|
||||
const guardedHandlers = guardScheduleHandlers(row, handlers);
|
||||
|
||||
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
|
|
@ -155,15 +168,21 @@ export function WoTableRow({
|
|||
style={{ padding: "0 0 0 6px", boxShadow: resolveFirstCellShadow(row) }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
cursor: isCanceled ? "default" : isDragging ? "grabbing" : "grab",
|
||||
color: "var(--color-text-muted)",
|
||||
<WoTableRowReorderHandle
|
||||
disabled={isCanceled}
|
||||
isDragging={isDragging}
|
||||
woNumber={row.woNumber}
|
||||
onArrowReorder={(delta) => {
|
||||
const overId = neighborIdForReorder(
|
||||
allRows,
|
||||
row,
|
||||
delta,
|
||||
(item) => !isWorkOrderFullyLocked(item.status),
|
||||
);
|
||||
if (overId == null) return;
|
||||
handlers.onReorder(row.id, overId);
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: "0 2px 0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<FlagCell
|
||||
|
|
@ -182,7 +201,7 @@ export function WoTableRow({
|
|||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={forceDueDateHighlight}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
@ -193,7 +212,7 @@ export function WoTableRow({
|
|||
pmTypes={pmTypes}
|
||||
search={search}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
|
@ -35,14 +34,12 @@ function resolveWorkOrderVersion(
|
|||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||
|
||||
const pending =
|
||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
|
|
@ -69,14 +66,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
|
||||
<ImageIcon size={18} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle
|
||||
sx={{ p: 0, fontSize: 16, lineHeight: 1.25, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
Photos & Videos
|
||||
</DialogTitle>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-muted)" }}>
|
||||
#{row.woNumber}
|
||||
</span>
|
||||
</div>
|
||||
<Text className="sr-only" variant="description">
|
||||
Upload photos and videos for this work order, then assign Before, After, or Extra.
|
||||
Upload photos and videos for this work order.
|
||||
</Text>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -101,11 +102,6 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
if (!workOrderVersion) return;
|
||||
deleteMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
}}
|
||||
onCategorize={(id, category) => {
|
||||
const workOrderVersion = requireVersion();
|
||||
if (!workOrderVersion) return;
|
||||
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
||||
}}
|
||||
onRetry={retryUpload}
|
||||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import {
|
|||
type MediaUploaderDropzoneProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
pending: boolean;
|
||||
categorizeAfterUpload?: boolean;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
pending,
|
||||
categorizeAfterUpload = false,
|
||||
onFilesSelected,
|
||||
}: MediaUploaderDropzoneProps) {
|
||||
return (
|
||||
|
|
@ -27,7 +29,7 @@ export function MediaUploaderDropzone({
|
|||
e.preventDefault();
|
||||
onFilesSelected(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -39,8 +41,8 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
||||
upload
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -17,12 +17,13 @@ export function MediaUploaderGridItemMeta({
|
|||
onCategorize,
|
||||
}: MediaUploaderGridItemMetaProps) {
|
||||
const failed = file.uploadStatus === "failed";
|
||||
const managesPurpose = Boolean(onCategorize);
|
||||
const needsCategory =
|
||||
!readOnly &&
|
||||
!file.isLegacy &&
|
||||
file.uploadStatus !== "uploading" &&
|
||||
file.uploadStatus !== "failed" &&
|
||||
Boolean(onCategorize);
|
||||
managesPurpose;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 px-1 py-1">
|
||||
|
|
@ -34,10 +35,10 @@ export function MediaUploaderGridItemMeta({
|
|||
{file.errorMessage || "Upload failed"}
|
||||
</div>
|
||||
)}
|
||||
{needsCategory ? (
|
||||
{needsCategory && (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
value={file.purpose ?? ""}
|
||||
value={file.purpose === "Extra" ? "" : (file.purpose ?? "")}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as UploadableMediaCategory;
|
||||
|
|
@ -57,7 +58,8 @@ export function MediaUploaderGridItemMeta({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
)}
|
||||
{Boolean(!needsCategory && managesPurpose) && (
|
||||
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,7 +91,12 @@ export function MediaUploader({
|
|||
return (
|
||||
<div>
|
||||
{!readOnly && (
|
||||
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
onFilesSelected={addFiles}
|
||||
/>
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
uploads={uploads}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type UseWizardDateFieldStateArgs = {
|
|||
allowWeekMode?: boolean;
|
||||
targetWeek: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
defaultMode?: "week" | "date";
|
||||
};
|
||||
|
||||
function resolveInitialMode({
|
||||
|
|
@ -17,9 +18,10 @@ function resolveInitialMode({
|
|||
allowWeekMode,
|
||||
value,
|
||||
targetWeek,
|
||||
defaultMode,
|
||||
}: Pick<
|
||||
UseWizardDateFieldStateArgs,
|
||||
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek"
|
||||
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek" | "defaultMode"
|
||||
>): WizardSchedMode {
|
||||
if (allowRange && schedMode === "range") {
|
||||
return "range";
|
||||
|
|
@ -27,6 +29,9 @@ function resolveInitialMode({
|
|||
if (allowWeekMode && !value && targetWeek) {
|
||||
return "week";
|
||||
}
|
||||
if (allowWeekMode && defaultMode) {
|
||||
return defaultMode;
|
||||
}
|
||||
return "date";
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +44,7 @@ export function useWizardDateFieldState({
|
|||
allowWeekMode,
|
||||
targetWeek,
|
||||
schedMode,
|
||||
defaultMode,
|
||||
}: UseWizardDateFieldStateArgs) {
|
||||
const initialMode = resolveInitialMode({
|
||||
allowRange,
|
||||
|
|
@ -46,6 +52,7 @@ export function useWizardDateFieldState({
|
|||
allowWeekMode,
|
||||
value,
|
||||
targetWeek,
|
||||
defaultMode,
|
||||
});
|
||||
|
||||
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { withAutoScheduleStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
|
|
@ -146,7 +147,11 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
|
||||
onClear={
|
||||
hasWizardSchedule(draft)
|
||||
? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch()))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Schedule On
|
||||
</WizLabelRow>
|
||||
|
|
@ -158,34 +163,40 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onChange={(iso) =>
|
||||
onPatch({
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onChangeWeek={(w) =>
|
||||
onPatch({
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onChangeRange={(s, e) =>
|
||||
onPatch({
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
TIME_SLOTS,
|
||||
WIZARD_STATUS_DOTS,
|
||||
type WizardWOStatus,
|
||||
|
|
@ -15,12 +13,15 @@ import {
|
|||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
wizardDraftToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
CREATION_INLINE_STATUSES,
|
||||
getStatusOptionState,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -173,15 +174,15 @@ function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFields
|
|||
);
|
||||
}
|
||||
|
||||
function buildWizardStatusOptions(closabilityGaps: string[]) {
|
||||
return ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
function buildWizardStatusOptions(draft: WorkOrderWizardDraft, closabilityGaps: string[]) {
|
||||
return CREATION_INLINE_STATUSES.map((status) => {
|
||||
const option = getStatusOptionState(draft, status, closabilityGaps);
|
||||
return {
|
||||
key: status,
|
||||
label: status,
|
||||
left: <ColorDot color={WIZARD_STATUS_DOTS[status]} />,
|
||||
disabled: isCompletedBlocked,
|
||||
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
|
||||
disabled: option.disabled,
|
||||
title: option.title,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -192,7 +193,6 @@ export function WizardStepVendorTime({
|
|||
onUpdate,
|
||||
onPatch,
|
||||
}: WizardStepVendorTimeProps) {
|
||||
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
||||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||
|
|
@ -272,27 +272,16 @@ export function WizardStepVendorTime({
|
|||
placeholder="Select status…"
|
||||
search={false}
|
||||
icon={<ColorDot color={statusDotColor} />}
|
||||
options={buildWizardStatusOptions(closabilityGaps)}
|
||||
options={buildWizardStatusOptions(draft, closabilityGaps)}
|
||||
onPick={(status) => {
|
||||
if (status === "Completed" && closabilityGaps.length > 0) return;
|
||||
if (status === "Completed" && draft.status !== "Completed") {
|
||||
setConfirmComplete(true);
|
||||
const option = getStatusOptionState(draft, status as WizardWOStatus, closabilityGaps);
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
onUpdate("status", status as WizardWOStatus);
|
||||
onPatch(withAutoScheduleStatus(draft, { status: status as WizardWOStatus }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WorkOrderCompleteDialog
|
||||
open={confirmComplete}
|
||||
isPending={false}
|
||||
onDialogClose={() => setConfirmComplete(false)}
|
||||
onKeepOpen={() => setConfirmComplete(false)}
|
||||
onConfirm={() => {
|
||||
onUpdate("status", "Completed");
|
||||
setConfirmComplete(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { getAuthUser } from "@/lib/auth/auth-storage";
|
||||
|
||||
export function resolveBoardRowOrderOwnerId(): string {
|
||||
const user = getAuthUser();
|
||||
return user?.id == null ? "" : String(user.id);
|
||||
}
|
||||
|
|
@ -1,25 +1,13 @@
|
|||
import { useMemo } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { resolveBoardRowOrderOwnerId } from "@/app/(protected)/workorders/_hooks/resolve-board-row-order-owner-id";
|
||||
import { useSyncOutOfRangeSearchPage } from "@/app/(protected)/workorders/_hooks/use-sync-out-of-range-search-page";
|
||||
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||
import { useWorkOrdersTableDayView } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-day-view";
|
||||
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import {
|
||||
pickBoardPager,
|
||||
pageSelectedDayGroups,
|
||||
type DayPageSize,
|
||||
} from "@/domain/work-orders/utils/day-pagination";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
isDayScopedSearchEmpty,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import {
|
||||
filterByBarAndSearch,
|
||||
splitScheduledUnscheduled,
|
||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { pickBoardPager, type DayPageSize } from "@/domain/work-orders/utils/day-pagination";
|
||||
import { filterByBarAndSearch } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||
|
||||
|
|
@ -33,12 +21,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
useWorkOrdersBoardSource(filters);
|
||||
|
||||
const weeklyItems = data?.items ?? EMPTY_WEEKLY_ITEMS;
|
||||
const ownerId = resolveBoardRowOrderOwnerId();
|
||||
|
||||
const local = useWorkOrdersTableLocalState({
|
||||
items: weeklyItems,
|
||||
weekMonday: filters.weekMonday,
|
||||
advActive,
|
||||
isFetching,
|
||||
ownerId,
|
||||
});
|
||||
const { displayRows, ...localUi } = local;
|
||||
|
||||
|
|
@ -54,57 +44,22 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
return displayRows;
|
||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||
|
||||
const { scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
return splitScheduledUnscheduled(advResults);
|
||||
}
|
||||
return splitScheduledUnscheduled(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(
|
||||
() =>
|
||||
advActive
|
||||
? []
|
||||
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||
);
|
||||
const pagination = useWorkOrdersDayPagination({
|
||||
weekMonday: filters.weekMonday,
|
||||
selectedDayIso: filters.selectedDayIso,
|
||||
const dayView = useWorkOrdersTableDayView({
|
||||
filters,
|
||||
advActive,
|
||||
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
|
||||
filtered,
|
||||
advResults,
|
||||
onRevealed: local.triggerFlash,
|
||||
});
|
||||
const pagedDay = useMemo(
|
||||
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
|
||||
[dayGroups, pagination.page, pagination.pageSize],
|
||||
);
|
||||
|
||||
const searchActive = filters.search.trim() !== "";
|
||||
const displayGroups = pagedDay.groups;
|
||||
const dayTotalCount = pagedDay.totalCount;
|
||||
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
|
||||
const noSearchResults =
|
||||
!advActive &&
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: dayTotalCount,
|
||||
unassignedQueueReady: true,
|
||||
});
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
|
||||
const todayKey =
|
||||
filters.weekMonday === currentWeekMonday()
|
||||
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
|
||||
: null;
|
||||
|
||||
const selectedRow = useMemo(
|
||||
() => displayRows.find((row) => String(row.id) === String(local.selectedId)) ?? null,
|
||||
[displayRows, local.selectedId],
|
||||
);
|
||||
|
||||
const scopeTotalCount = advActive ? (data?.totalCount ?? advResults.length) : dayTotalCount;
|
||||
const scopeTotalCount = advActive
|
||||
? (data?.totalCount ?? advResults.length)
|
||||
: dayView.dayTotalCount;
|
||||
useSyncOutOfRangeSearchPage({
|
||||
enabled: advActive,
|
||||
ready: !isLoading,
|
||||
|
|
@ -113,11 +68,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
totalCount: scopeTotalCount,
|
||||
setPage: filters.setPage,
|
||||
});
|
||||
const listTotalCount = advActive ? advResults.length : dayVisibleCount;
|
||||
const listTotalCount = advActive ? advResults.length : dayView.dayVisibleCount;
|
||||
const pager = pickBoardPager({
|
||||
advActive,
|
||||
isLoading,
|
||||
noSearchResults,
|
||||
noSearchResults: dayView.noSearchResults,
|
||||
advResultCount: advResults.length,
|
||||
search: {
|
||||
page: filters.page,
|
||||
|
|
@ -125,11 +80,11 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
totalCount: scopeTotalCount,
|
||||
},
|
||||
day: {
|
||||
pageSize: pagination.pageSize,
|
||||
pageIndex: pagedDay.pageIndex,
|
||||
pageCount: pagedDay.pageCount,
|
||||
itemCount: dayTotalCount,
|
||||
footerLabel: displayGroups[0]?.label ?? "",
|
||||
pageSize: dayView.pagination.pageSize,
|
||||
pageIndex: dayView.pagedDay.pageIndex,
|
||||
pageCount: dayView.pagedDay.pageCount,
|
||||
itemCount: dayView.dayTotalCount,
|
||||
footerLabel: dayView.displayGroups[0]?.label ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -145,12 +100,12 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
unassignedTotalCount: 0,
|
||||
unassignedError: null,
|
||||
unassignedLoading: false,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
noSearchResults,
|
||||
isEmptyWeek,
|
||||
todayKey,
|
||||
unscheduled: dayView.unscheduled,
|
||||
displayGroups: dayView.displayGroups,
|
||||
searchActive: dayView.searchActive,
|
||||
noSearchResults: dayView.noSearchResults,
|
||||
isEmptyWeek: dayView.isEmptyWeek,
|
||||
todayKey: dayView.todayKey,
|
||||
...localUi,
|
||||
selectedRow,
|
||||
scopeTotalCount,
|
||||
|
|
@ -167,14 +122,14 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
|||
filters.setPageSize(next);
|
||||
return;
|
||||
}
|
||||
pagination.setPageSize(next);
|
||||
dayView.pagination.setPageSize(next);
|
||||
},
|
||||
goToPrevDayPage: advActive
|
||||
? () => filters.setPage(Math.max(0, pager.pageIndex - 1))
|
||||
: pagination.goToPrevPage,
|
||||
: dayView.pagination.goToPrevPage,
|
||||
goToNextDayPage: advActive
|
||||
? () => filters.setPage(Math.min(pager.pageIndex + 1, Math.max(0, pager.pageCount - 1)))
|
||||
: pagination.goToNextPage,
|
||||
revealScheduledRow: pagination.revealRow,
|
||||
: dayView.pagination.goToNextPage,
|
||||
revealScheduledRow: dayView.pagination.revealRow,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { useMemo } from "react";
|
||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/use-work-orders-day-pagination";
|
||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination";
|
||||
import {
|
||||
buildSelectedDayGroup,
|
||||
isDayScopedSearchEmpty,
|
||||
} from "@/domain/work-orders/utils/week-day-nav-utils";
|
||||
import { splitScheduledUnscheduled } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
|
||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||
|
||||
export type UseWorkOrdersTableDayViewParams = {
|
||||
filters: FiltersReturn;
|
||||
advActive: boolean;
|
||||
filtered: WorkOrderTableRow[];
|
||||
advResults: WorkOrderTableRow[];
|
||||
onRevealed: (id: string | number) => void;
|
||||
};
|
||||
|
||||
const EMPTY_WEEKLY_ITEMS: WorkOrderTableRow[] = [];
|
||||
|
||||
export function useWorkOrdersTableDayView({
|
||||
filters,
|
||||
advActive,
|
||||
filtered,
|
||||
advResults,
|
||||
onRevealed,
|
||||
}: UseWorkOrdersTableDayViewParams) {
|
||||
const { scheduled, unscheduled } = useMemo(() => {
|
||||
if (advActive) {
|
||||
return splitScheduledUnscheduled(advResults);
|
||||
}
|
||||
return splitScheduledUnscheduled(filtered);
|
||||
}, [advActive, advResults, filtered]);
|
||||
|
||||
const dayGroups = useMemo(
|
||||
() =>
|
||||
advActive
|
||||
? []
|
||||
: [buildSelectedDayGroup(filters.weekMonday, filters.selectedDayIso, scheduled)],
|
||||
[advActive, filters.selectedDayIso, filters.weekMonday, scheduled],
|
||||
);
|
||||
const pagination = useWorkOrdersDayPagination({
|
||||
weekMonday: filters.weekMonday,
|
||||
selectedDayIso: filters.selectedDayIso,
|
||||
advActive,
|
||||
dayItems: dayGroups[0]?.items ?? EMPTY_WEEKLY_ITEMS,
|
||||
onRevealed,
|
||||
});
|
||||
const pagedDay = useMemo(
|
||||
() => pageSelectedDayGroups(dayGroups, pagination.page, pagination.pageSize),
|
||||
[dayGroups, pagination.page, pagination.pageSize],
|
||||
);
|
||||
|
||||
const searchActive = filters.search.trim() !== "";
|
||||
const displayGroups = pagedDay.groups;
|
||||
const dayTotalCount = pagedDay.totalCount;
|
||||
const dayVisibleCount = pagedDay.groups[0]?.items.length ?? 0;
|
||||
const noSearchResults =
|
||||
!advActive &&
|
||||
isDayScopedSearchEmpty({
|
||||
searchActive,
|
||||
unassignedCount: 0,
|
||||
dayItemCount: dayTotalCount,
|
||||
unassignedQueueReady: true,
|
||||
});
|
||||
const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive;
|
||||
const todayKey =
|
||||
filters.weekMonday === currentWeekMonday()
|
||||
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
|
||||
: null;
|
||||
|
||||
return {
|
||||
scheduled,
|
||||
unscheduled,
|
||||
displayGroups,
|
||||
searchActive,
|
||||
noSearchResults,
|
||||
isEmptyWeek,
|
||||
todayKey,
|
||||
dayTotalCount,
|
||||
dayVisibleCount,
|
||||
pagination,
|
||||
pagedDay,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { applyBoardRowOrderOnScheduleChange } from "@/domain/work-orders/utils/apply-board-row-order-on-schedule-change";
|
||||
import { applyCarryOverDisplayDayFields } from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import { isWorkOrderFullyLocked } from "@/domain/work-orders/utils/work-order-lock";
|
||||
import {
|
||||
applyRowOrderByDay,
|
||||
boardRowOrderStorageKey,
|
||||
loadBoardRowOrder,
|
||||
mergeDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
reorderIdsInList,
|
||||
saveBoardRowOrder,
|
||||
type BoardRowOrderByDay,
|
||||
|
|
@ -23,6 +27,7 @@ export type UseWorkOrdersTableLocalStateParams = {
|
|||
weekMonday: string;
|
||||
advActive: boolean;
|
||||
isFetching: boolean;
|
||||
ownerId?: string;
|
||||
};
|
||||
|
||||
function usePrunePatchesOnFetchSettle(
|
||||
|
|
@ -44,6 +49,7 @@ export function useWorkOrdersTableLocalState({
|
|||
weekMonday,
|
||||
advActive,
|
||||
isFetching,
|
||||
ownerId = "",
|
||||
}: UseWorkOrdersTableLocalStateParams) {
|
||||
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
||||
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
||||
|
|
@ -51,8 +57,19 @@ export function useWorkOrdersTableLocalState({
|
|||
const ui = useWorkOrdersTableUiState(weekMonday);
|
||||
|
||||
useEffect(() => {
|
||||
setRowOrderByDay(loadBoardRowOrder(weekMonday));
|
||||
}, [weekMonday]);
|
||||
setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
|
||||
}, [ownerId, weekMonday]);
|
||||
|
||||
useEffect(() => {
|
||||
const storageKey = boardRowOrderStorageKey(weekMonday, ownerId);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== storageKey) return;
|
||||
setRowOrderByDay(loadBoardRowOrder(weekMonday, ownerId));
|
||||
toast.info("Board order was updated in another session. Refreshed.");
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [ownerId, weekMonday]);
|
||||
|
||||
const apiRows = useMemo(
|
||||
() => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
||||
|
|
@ -62,8 +79,11 @@ export function useWorkOrdersTableLocalState({
|
|||
const displayRows = useMemo(() => {
|
||||
const apiIds = new Set(apiRows.map((r) => String(r.id)));
|
||||
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
|
||||
return applyRowOrderByDay([...apiRows, ...extra.map(enrichTableRow)], rowOrderByDay);
|
||||
}, [apiRows, localRows, rowOrderByDay]);
|
||||
const merged = [...apiRows, ...extra.map(enrichTableRow)].map((row) =>
|
||||
applyCarryOverDisplayDayFields(row, weekMonday),
|
||||
);
|
||||
return applyRowOrderByDay(merged, rowOrderByDay);
|
||||
}, [apiRows, localRows, rowOrderByDay, weekMonday]);
|
||||
|
||||
const clearRowPatch = useCallback((id: string | number) => {
|
||||
const key = String(id);
|
||||
|
|
@ -100,17 +120,17 @@ export function useWorkOrdersTableLocalState({
|
|||
|
||||
if (patch.scheduledOn === undefined || advActive) return;
|
||||
|
||||
const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
|
||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
|
||||
|
||||
setRowOrderByDay((prev) => {
|
||||
const next = removeIdFromRowOrder(prev, key);
|
||||
if (next === prev) return prev;
|
||||
saveBoardRowOrder(weekMonday, next);
|
||||
return next;
|
||||
});
|
||||
setRowOrderByDay((prev) =>
|
||||
applyBoardRowOrderOnScheduleChange(prev, {
|
||||
weekMonday,
|
||||
ownerId,
|
||||
id: key,
|
||||
scheduledOn: patch.scheduledOn!,
|
||||
previousDayGroup,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[advActive, displayRows, weekMonday, localRows],
|
||||
[advActive, displayRows, localRows, ownerId, weekMonday],
|
||||
);
|
||||
|
||||
const reorderRows = useCallback(
|
||||
|
|
@ -120,6 +140,7 @@ export function useWorkOrdersTableLocalState({
|
|||
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
|
||||
const overRow = displayRows.find((r) => String(r.id) === overKey);
|
||||
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) return;
|
||||
if (isWorkOrderFullyLocked(dragRow.status) || isWorkOrderFullyLocked(overRow.status)) return;
|
||||
|
||||
const dayGroup = dragRow.dayGroup;
|
||||
const dayIds = displayRows
|
||||
|
|
@ -130,11 +151,11 @@ export function useWorkOrdersTableLocalState({
|
|||
|
||||
setRowOrderByDay((prev) => {
|
||||
const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) };
|
||||
if (!advActive) saveBoardRowOrder(weekMonday, next);
|
||||
if (!advActive) saveBoardRowOrder(weekMonday, next, ownerId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[advActive, displayRows, weekMonday],
|
||||
[advActive, displayRows, ownerId, weekMonday],
|
||||
);
|
||||
|
||||
const { triggerFlash } = ui;
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ export function useLogout(): UseMutationResult<void, Error, void> {
|
|||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
toast.info("You have been logged out");
|
||||
navigate("/login");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
toast.error(error.message || "Logout failed");
|
||||
navigate("/login");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { HTTPError } from "ky";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper";
|
||||
import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||
|
|
@ -91,7 +91,12 @@ export const upliftsApi = {
|
|||
): Promise<void> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await api.get(evidenceUrl(id));
|
||||
response = await apiRequestRaw(
|
||||
"get",
|
||||
evidenceUrl(id),
|
||||
undefined,
|
||||
"upliftsApi.downloadEvidence",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
throw evidenceHttpError(error.response.status);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { api, apiGet, apiPut } from "@/api/api";
|
||||
import { apiGet, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import type {
|
||||
SiteVendorPreference,
|
||||
VendorAssignmentResult,
|
||||
|
|
@ -27,7 +27,13 @@ type InsightParams = {
|
|||
};
|
||||
|
||||
async function saveDownload(path: string, fileName: string, params: InsightParams): Promise<void> {
|
||||
const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob();
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
`${base}/${path}`,
|
||||
{ searchParams: searchParams(params) },
|
||||
"vendorOperationsApi.saveDownload",
|
||||
);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { env } from "@/lib/env";
|
||||
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import type {
|
||||
VendorPortalComment,
|
||||
VendorPortalDispatchDetail,
|
||||
|
|
@ -38,30 +39,45 @@ function uploadVendorDocument({
|
|||
purpose,
|
||||
onProgress,
|
||||
}: UploadVendorDocumentOptions): Promise<VendorPortalDocument> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)));
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => reject(new Error("Upload failed")));
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
});
|
||||
return traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`),
|
||||
entryPoint: "vendorPortalApi.uploadDocument",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
({ reportStatus }) =>
|
||||
new Promise<VendorPortalDocument>((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`));
|
||||
request.setRequestHeader("X-Vendor-Token", token);
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
reportStatus(request.status);
|
||||
const payload = JSON.parse(request.responseText || "{}") as Record<string, unknown>;
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(
|
||||
new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument);
|
||||
});
|
||||
request.addEventListener("error", () => {
|
||||
reportStatus(request.status);
|
||||
reject(new Error("Upload failed"));
|
||||
});
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
if (replacesDocumentId !== undefined) {
|
||||
body.append("replacesDocumentId", String(replacesDocumentId));
|
||||
}
|
||||
body.append("purpose", purpose ?? "Completion");
|
||||
request.send(body);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadVendorDocument(
|
||||
|
|
@ -69,19 +85,32 @@ async function downloadVendorDocument(
|
|||
dispatchId: string | number,
|
||||
document: VendorPortalDocument,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
await traceHttpOperation(
|
||||
{
|
||||
method: "GET",
|
||||
url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
entryPoint: "vendorPortalApi.downloadDocument",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const response = await fetch(
|
||||
resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`),
|
||||
{ headers: { "X-Vendor-Token": token } },
|
||||
);
|
||||
reportStatus(response.status);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
response.status === 423 ? "File is still being scanned" : "Download failed",
|
||||
);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = document.originalFileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function vendorPortalCall<T>(
|
||||
|
|
@ -89,30 +118,41 @@ async function vendorPortalCall<T>(
|
|||
path: string,
|
||||
options?: { method?: string; body?: unknown },
|
||||
): Promise<T> {
|
||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
||||
method: options?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vendor-Token": token,
|
||||
return traceHttpOperation<T>(
|
||||
{
|
||||
method: options?.method ?? "GET",
|
||||
url: resolveVendorPortalUrl(path),
|
||||
entryPoint: "vendorPortalApi.call",
|
||||
actorType: "vendor_link",
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
async ({ reportStatus }) => {
|
||||
const response = await fetch(resolveVendorPortalUrl(path), {
|
||||
method: options?.method ?? "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Vendor-Token": token,
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
reportStatus(response.status);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error("Invalid or expired token");
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(typeof payload.message === "string" ? payload.message : undefined) ??
|
||||
(typeof payload.Message === "string" ? payload.Message : undefined) ??
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return (payload.data ?? payload.Data ?? payload) as T;
|
||||
return (payload.data ?? payload.Data ?? payload) as T;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isNoApprovalRequired(payload: unknown): boolean {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiGet, apiPost } from "@/api/api";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
|
|
@ -33,10 +35,10 @@ async function readResponseJson(response: Response): Promise<unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
function throwDocumentHttpError(response: Response, data: unknown): never {
|
||||
function throwDocumentHttpError(status: number, data: unknown): never {
|
||||
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
|
||||
if (response.status === 409) {
|
||||
if (status === 409) {
|
||||
throw new BoardPatchConflictError(
|
||||
typeof record.message === "string"
|
||||
? record.message
|
||||
|
|
@ -45,14 +47,14 @@ function throwDocumentHttpError(response: Response, data: unknown): never {
|
|||
);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
if (status === 422) {
|
||||
throw new BoardPatchValidationError(
|
||||
typeof record.code === "string" ? record.code : "ValidationError",
|
||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
throw new ApiError(mapHttpStatusToMessage(status, data), status);
|
||||
}
|
||||
|
||||
export const workOrderBoardDocumentsApi = {
|
||||
|
|
@ -81,10 +83,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
commentId: string | number,
|
||||
text: string,
|
||||
): Promise<WorkOrderComment> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
|
||||
json: { text },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.commentItem(workOrderId, commentId),
|
||||
{ json: { text }, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.updateComment",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -94,7 +98,7 @@ export const workOrderBoardDocumentsApi = {
|
|||
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
||||
}
|
||||
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
/** @deprecated Prefer createComment — modern board endpoint. */
|
||||
|
|
@ -119,21 +123,26 @@ export const workOrderBoardDocumentsApi = {
|
|||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
? (progress) => {
|
||||
options.onProgress?.(Math.round(progress.percent * 100));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
const { ok, status, data } = await traceHttpOperation(
|
||||
{
|
||||
method: "POST",
|
||||
url: API_PATHS.workOrder.media(workOrderId),
|
||||
entryPoint: "workOrderBoardDocumentsApi.uploadMedia",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const result = await uploadFormWithProgress(
|
||||
API_PATHS.workOrder.media(workOrderId),
|
||||
formData,
|
||||
{ signal: options?.signal, onProgress: options?.onProgress },
|
||||
);
|
||||
reportStatus(result.status);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
if (ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(status, data);
|
||||
},
|
||||
|
||||
updateMediaCategory: async (
|
||||
|
|
@ -152,15 +161,17 @@ export const workOrderBoardDocumentsApi = {
|
|||
const formData = new FormData();
|
||||
formData.append("category", category);
|
||||
formData.append("workOrderVersion", workOrderVersion);
|
||||
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.mediaItem(workOrderId, mediaId),
|
||||
{ body: formData, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.updateMediaCategory",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
deleteMedia: async (
|
||||
|
|
@ -183,10 +194,12 @@ export const workOrderBoardDocumentsApi = {
|
|||
);
|
||||
}
|
||||
|
||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
||||
searchParams: { workOrderVersion },
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"delete",
|
||||
API_PATHS.workOrder.mediaItem(workOrderId, numericId),
|
||||
{ searchParams: { workOrderVersion }, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.deleteMedia",
|
||||
);
|
||||
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
|
|
@ -196,7 +209,30 @@ export const workOrderBoardDocumentsApi = {
|
|||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
getMediaContent: async (
|
||||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
): Promise<Blob> => {
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
||||
{ credentials: "include", throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.getMediaContent",
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new ApiError(mapHttpStatusToMessage(404), 404);
|
||||
}
|
||||
|
||||
const data = await readResponseJson(response);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
uploadCompletionDoc: async (
|
||||
|
|
@ -216,14 +252,16 @@ export const workOrderBoardDocumentsApi = {
|
|||
}
|
||||
|
||||
const formData = buildCompletionDocFormData(params);
|
||||
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"post",
|
||||
API_PATHS.workOrder.completionDoc(workOrderId),
|
||||
{ body: formData, throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.uploadCompletionDoc",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapCompletion(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { api, apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api";
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
|
|
@ -314,10 +314,12 @@ export const workOrdersApi = {
|
|||
id: string | number,
|
||||
payload: WorkOrderBoardPatchPayload,
|
||||
): Promise<WorkOrderTableRow> => {
|
||||
const response = await api.patch(API_PATHS.workOrder.boardPatch(id), {
|
||||
json: payload,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const response = await apiRequestRaw(
|
||||
"patch",
|
||||
API_PATHS.workOrder.boardPatch(id),
|
||||
{ json: payload, throwHttpErrors: false },
|
||||
"workOrdersApi.patchBoardField",
|
||||
);
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -21,3 +21,11 @@ export class BoardPatchValidationError extends Error {
|
|||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** why: backend field names must never be exposed through user-facing board toasts. */
|
||||
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
|
||||
if (error.code === "DispatchRequired") {
|
||||
return "Select a vendor company before saving.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
WorkOrderComment,
|
||||
} from "@/domain/work-orders/types/work-order-detail";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name";
|
||||
import type {
|
||||
FileCategory,
|
||||
MediaCategory,
|
||||
|
|
@ -220,7 +221,7 @@ export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
|
|||
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
|
||||
category: mapMediaCategory(item.category ?? item.Category),
|
||||
url: readString(item, "url", "Url"),
|
||||
fileName: readString(item, "fileName", "FileName") || "file",
|
||||
fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"),
|
||||
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
|
||||
isLegacy: isLegacyFlag || (id != null && id <= 0),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
|||
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
|
||||
"isAddOn",
|
||||
"primaryDispatchStatus",
|
||||
/** Client-only Schedule On range overlay — API persists range start as scheduledDate. */
|
||||
"scheduleEnd",
|
||||
"schedMode",
|
||||
]);
|
||||
|
||||
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||
|
|
@ -84,7 +87,7 @@ export function buildBoardPatchPayload(
|
|||
if (row.dispatchRowVersion) {
|
||||
payload.dispatchVersion = row.dispatchRowVersion;
|
||||
}
|
||||
if (row.primaryDispatchId != null) {
|
||||
if (row.primaryDispatchId != null && row.primaryDispatchId > 0) {
|
||||
payload.primaryDispatchId = row.primaryDispatchId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { WizardSchedMode, WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||
import type { UpliftSummary } from "./work-order-uplift";
|
||||
|
||||
|
|
@ -65,6 +65,10 @@ export interface WorkOrderTableRow {
|
|||
originalDate: string;
|
||||
isPastDue: boolean;
|
||||
scheduleWeekOnly: boolean;
|
||||
/** Optimistic range end; API persists only `scheduledOn` (range start). */
|
||||
scheduleEnd?: string;
|
||||
/** Last Schedule On mode used in this session. */
|
||||
schedMode?: WizardSchedMode;
|
||||
mediaCount: number;
|
||||
title: string;
|
||||
priority: string;
|
||||
|
|
@ -105,6 +109,8 @@ export interface WorkOrderTableDraft {
|
|||
additionalContacts: WorkOrderAdditionalContact[];
|
||||
scheduleWeekOnly: boolean;
|
||||
targetWeek: string;
|
||||
scheduleEnd?: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
/** Manual Add-On override when schedule is empty at inline create. */
|
||||
isAddOnOverride: boolean;
|
||||
}
|
||||
|
|
@ -137,6 +143,8 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
additionalContacts: [],
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOnOverride: false,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import {
|
||||
boardPatchValidationMessage,
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
|
|
@ -19,9 +20,15 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import {
|
||||
maybeAutoSchedulePatch,
|
||||
mergeAutoSchedulePatch,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export { maybeAutoSchedulePatch };
|
||||
|
||||
export type { WorkOrderTablePatch };
|
||||
|
||||
export type WorkOrderTablePatchOptions = {
|
||||
|
|
@ -124,10 +131,16 @@ export function useWorkOrderTableMutations(
|
|||
|
||||
const patchMutation = useMutation({
|
||||
meta: { suppressErrorToast: true },
|
||||
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
||||
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
||||
onMutate: async ({ id, patch }) => {
|
||||
onPatch(id, patch);
|
||||
mutationFn: ({
|
||||
id,
|
||||
persistPatch,
|
||||
}: {
|
||||
id: string | number;
|
||||
patch: WorkOrderTablePatch;
|
||||
persistPatch: WorkOrderTablePatch;
|
||||
}) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
|
||||
onMutate: async ({ id, persistPatch }) => {
|
||||
onPatch(id, persistPatch);
|
||||
},
|
||||
onSuccess: (result, { id }) => {
|
||||
if (result.kind === "board") {
|
||||
|
|
@ -149,7 +162,7 @@ export function useWorkOrderTableMutations(
|
|||
toast.error(error.message || "Conflict — refreshed with latest data");
|
||||
} else if (error instanceof BoardPatchValidationError) {
|
||||
clearPatch(id);
|
||||
toast.error(error.message);
|
||||
toast.error(boardPatchValidationMessage(error));
|
||||
} else {
|
||||
clearPatch(id);
|
||||
toast.error(error.message || "Failed to update work order");
|
||||
|
|
@ -158,15 +171,21 @@ export function useWorkOrderTableMutations(
|
|||
},
|
||||
});
|
||||
|
||||
const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
mergeAutoSchedulePatch(getRow?.(id), patch);
|
||||
|
||||
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
|
||||
void patchMutation.mutateAsync({ id, patch }).then(
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
|
||||
() => options?.onSuccess?.(),
|
||||
(error: Error) => options?.onError?.(error),
|
||||
);
|
||||
};
|
||||
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
patchMutation.mutateAsync({ id, patch });
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
return patchMutation.mutateAsync({ id, patch, persistPatch });
|
||||
};
|
||||
|
||||
return {
|
||||
patchField,
|
||||
|
|
@ -189,19 +208,9 @@ export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersA
|
|||
);
|
||||
}
|
||||
|
||||
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
|
||||
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
||||
const hasDate = !!row.scheduledOn;
|
||||
const hasDispatcher = !!row.dispatcherId;
|
||||
if (hasDate && hasDispatcher && row.status === "Incomplete") {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schedule table patch. Clearing the schedule optimistically clears
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response.
|
||||
*/
|
||||
export function applySchedulePatch(
|
||||
scheduledOn: string,
|
||||
|
|
@ -212,6 +221,8 @@ export function applySchedulePatch(
|
|||
scheduledOn: "",
|
||||
targetWeek: scheduledOn,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
...deriveDayFields(""),
|
||||
};
|
||||
}
|
||||
|
|
@ -221,6 +232,8 @@ export function applySchedulePatch(
|
|||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOn: false,
|
||||
...deriveDayFields(""),
|
||||
};
|
||||
|
|
@ -229,6 +242,17 @@ export function applySchedulePatch(
|
|||
scheduledOn: trimmed,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
...deriveDayFields(trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
/** Range lands on the first day; end/mode stay optimistic (no board API field). */
|
||||
export function applyScheduleRangePatch(start: string, end: string): WorkOrderTablePatch {
|
||||
return {
|
||||
...applySchedulePatch(start, false),
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import type { BoardRowOrderByDay } from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import {
|
||||
moveIdToDayOrder,
|
||||
removeIdFromRowOrder,
|
||||
saveBoardRowOrder,
|
||||
transferIdToDestinationWeekOrder,
|
||||
} from "@/domain/work-orders/utils/board-row-order-storage";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
export type ApplyBoardRowOrderOnScheduleChangeInput = {
|
||||
weekMonday: string;
|
||||
ownerId: string;
|
||||
id: string;
|
||||
scheduledOn: string;
|
||||
previousDayGroup?: string;
|
||||
};
|
||||
|
||||
export function applyBoardRowOrderOnScheduleChange(
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
input: ApplyBoardRowOrderOnScheduleChangeInput,
|
||||
): BoardRowOrderByDay {
|
||||
const { weekMonday, ownerId, id, scheduledOn, previousDayGroup } = input;
|
||||
const nextDayGroup = deriveDayFields(scheduledOn).dayGroup;
|
||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) {
|
||||
return orderByDay;
|
||||
}
|
||||
|
||||
const destinationWeekMonday = mondayOf(scheduledOn.slice(0, 10));
|
||||
if (destinationWeekMonday !== weekMonday) {
|
||||
transferIdToDestinationWeekOrder({
|
||||
sourceWeekMonday: weekMonday,
|
||||
destinationWeekMonday,
|
||||
ownerId,
|
||||
id,
|
||||
nextDayGroup,
|
||||
});
|
||||
return removeIdFromRowOrder(orderByDay, id);
|
||||
}
|
||||
|
||||
const next = moveIdToDayOrder(orderByDay, id, nextDayGroup);
|
||||
if (next !== orderByDay) {
|
||||
saveBoardRowOrder(weekMonday, next, ownerId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { SEVERITY_LEVELS } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
export type AuditActionSource = Pick<
|
||||
|
|
@ -26,11 +27,51 @@ export type AuditActionRuleContext = {
|
|||
|
||||
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
|
||||
|
||||
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
function formatSeverityAuditLabel(raw: string): string {
|
||||
const n = Number(raw.trim());
|
||||
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
|
||||
return SEVERITY_LEVELS[n].label;
|
||||
}
|
||||
const sevMatch = raw.trim().match(/^sev\s*([1-5])$/i);
|
||||
if (sevMatch) {
|
||||
return `SEV ${sevMatch[1]}`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey === "type" ||
|
||||
fieldKey === "workordertype" ||
|
||||
fieldKey === "wotype" ||
|
||||
actionKey === "typechanged" ||
|
||||
actionKey.includes("workordertype");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
return `Changed Type to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
|
||||
return `Changed Severity to ${wrapAuditValue(labeled)}`;
|
||||
}
|
||||
|
||||
function tryStatusRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
if (oldValue.text) {
|
||||
return `Changed status from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
return `Changed status to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +160,8 @@ function tryAssignedToRule({
|
|||
/** Applied in order; the first non-null result wins. */
|
||||
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
|
||||
tryStatusRule,
|
||||
tryTypeRule,
|
||||
trySeverityRule,
|
||||
tryApptRule,
|
||||
tryScheduleRule,
|
||||
tryCarriedOverRule,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ export type BoardRowOrderByDay = Record<string, string[]>;
|
|||
|
||||
const STORAGE_PREFIX = "shoc:wo-board-row-order:v1:";
|
||||
|
||||
export function boardRowOrderStorageKey(weekMonday: string): string {
|
||||
export function boardRowOrderStorageKey(weekMonday: string, ownerId = ""): string {
|
||||
const owner = ownerId.trim() || "local";
|
||||
return `${STORAGE_PREFIX}${owner}:${weekMonday}`;
|
||||
}
|
||||
|
||||
export function legacyBoardRowOrderStorageKey(weekMonday: string): string {
|
||||
return `${STORAGE_PREFIX}${weekMonday}`;
|
||||
}
|
||||
|
||||
|
|
@ -17,13 +22,9 @@ function isBoardRowOrderByDay(value: unknown): value is BoardRowOrderByDay {
|
|||
return Object.values(value).every(isStringArray);
|
||||
}
|
||||
|
||||
export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
|
||||
if (typeof window === "undefined" || !weekMonday) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function readStoredOrder(key: string): BoardRowOrderByDay {
|
||||
try {
|
||||
const raw = localStorage.getItem(boardRowOrderStorageKey(weekMonday));
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -34,13 +35,29 @@ export function loadBoardRowOrder(weekMonday: string): BoardRowOrderByDay {
|
|||
}
|
||||
}
|
||||
|
||||
export function saveBoardRowOrder(weekMonday: string, orderByDay: BoardRowOrderByDay): void {
|
||||
export function loadBoardRowOrder(weekMonday: string, ownerId = ""): BoardRowOrderByDay {
|
||||
if (typeof window === "undefined" || !weekMonday) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const scoped = readStoredOrder(boardRowOrderStorageKey(weekMonday, ownerId));
|
||||
if (Object.keys(scoped).length) {
|
||||
return scoped;
|
||||
}
|
||||
return readStoredOrder(legacyBoardRowOrderStorageKey(weekMonday));
|
||||
}
|
||||
|
||||
export function saveBoardRowOrder(
|
||||
weekMonday: string,
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
ownerId = "",
|
||||
): void {
|
||||
if (typeof window === "undefined" || !weekMonday) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(boardRowOrderStorageKey(weekMonday), JSON.stringify(orderByDay));
|
||||
localStorage.setItem(boardRowOrderStorageKey(weekMonday, ownerId), JSON.stringify(orderByDay));
|
||||
} catch {
|
||||
// Quota exceeded or storage blocked — keep in-memory order only.
|
||||
}
|
||||
|
|
@ -105,6 +122,27 @@ export function applyRowOrderByDay<T extends { id: string | number; dayGroup: st
|
|||
});
|
||||
}
|
||||
|
||||
/** Same-day neighbor for keyboard reorder; null at list ends or if the row is missing. */
|
||||
export function neighborIdForReorder<T extends { id: string | number; dayGroup: string }>(
|
||||
rows: T[],
|
||||
row: T,
|
||||
delta: -1 | 1,
|
||||
isReorderTarget: (item: T) => boolean = () => true,
|
||||
): string | number | null {
|
||||
const dayRows = rows.filter((item) => item.dayGroup === row.dayGroup);
|
||||
const index = dayRows.findIndex((item) => String(item.id) === String(row.id));
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
for (let cursor = index + delta; cursor >= 0 && cursor < dayRows.length; cursor += delta) {
|
||||
const candidate = dayRows[cursor];
|
||||
if (candidate && isReorderTarget(candidate)) {
|
||||
return candidate.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reorderIdsInList(ids: string[], dragId: string, overId: string): string[] | null {
|
||||
const dragIdx = ids.indexOf(dragId);
|
||||
const overIdx = ids.indexOf(overId);
|
||||
|
|
@ -138,6 +176,60 @@ export function removeIdFromRowOrder(
|
|||
return changed ? next : orderByDay;
|
||||
}
|
||||
|
||||
/** Drop the id from every day, then append it to the destination day. */
|
||||
export type TransferIdToDestinationWeekOrderInput = {
|
||||
sourceWeekMonday: string;
|
||||
destinationWeekMonday: string;
|
||||
ownerId: string;
|
||||
id: string;
|
||||
nextDayGroup: string;
|
||||
};
|
||||
|
||||
/** Persist a moved row under the destination week when schedule crosses week boundaries. */
|
||||
export function transferIdToDestinationWeekOrder({
|
||||
sourceWeekMonday,
|
||||
destinationWeekMonday,
|
||||
ownerId,
|
||||
id,
|
||||
nextDayGroup,
|
||||
}: TransferIdToDestinationWeekOrderInput): void {
|
||||
if (!sourceWeekMonday || !destinationWeekMonday || !nextDayGroup) {
|
||||
return;
|
||||
}
|
||||
if (sourceWeekMonday === destinationWeekMonday) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceOrder = loadBoardRowOrder(sourceWeekMonday, ownerId);
|
||||
const sourceWithoutId = removeIdFromRowOrder(sourceOrder, id);
|
||||
if (sourceWithoutId !== sourceOrder) {
|
||||
saveBoardRowOrder(sourceWeekMonday, sourceWithoutId, ownerId);
|
||||
}
|
||||
|
||||
const destinationOrder = loadBoardRowOrder(destinationWeekMonday, ownerId);
|
||||
saveBoardRowOrder(
|
||||
destinationWeekMonday,
|
||||
moveIdToDayOrder(destinationOrder, id, nextDayGroup),
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
|
||||
export function moveIdToDayOrder(
|
||||
orderByDay: BoardRowOrderByDay,
|
||||
id: string,
|
||||
nextDayGroup: string,
|
||||
): BoardRowOrderByDay {
|
||||
if (!nextDayGroup) {
|
||||
return removeIdFromRowOrder(orderByDay, id);
|
||||
}
|
||||
const without = removeIdFromRowOrder(orderByDay, id);
|
||||
const dest = without[nextDayGroup] ?? [];
|
||||
if (dest.includes(id)) {
|
||||
return without;
|
||||
}
|
||||
return { ...without, [nextDayGroup]: [...dest, id] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a visible day's new order into stored order: visible ids take the new sequence,
|
||||
* ids that were stored but not currently visible stay after the visible block (preserving
|
||||
|
|
|
|||
109
src/domain/work-orders/utils/carry-over-display.ts
Normal file
109
src/domain/work-orders/utils/carry-over-display.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { compactWeekLabel } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import { formatRescheduleCountLabel } from "@/domain/work-orders/utils/reschedule-indicator";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const WEEKDAY_COUNT = 7;
|
||||
const FRIDAY_OFFSET = 4;
|
||||
|
||||
export type CarryOverRow = {
|
||||
scheduledOn: string;
|
||||
status?: string;
|
||||
carriedOver?: number;
|
||||
};
|
||||
|
||||
export function isTerminalCarryOverStatus(status?: string): boolean {
|
||||
return status === "Completed" || status === "Canceled";
|
||||
}
|
||||
|
||||
export function shouldShowCarriedOverPill(count: number, status?: string): boolean {
|
||||
return count >= 2 && !isTerminalCarryOverStatus(status);
|
||||
}
|
||||
|
||||
export function toIsoDate(value?: string): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const iso = value.slice(0, 10);
|
||||
return ISO_DATE.test(iso) ? iso : "";
|
||||
}
|
||||
|
||||
export function resolveCarryOverSourceWeek(input: {
|
||||
originalWeek?: string;
|
||||
originalDate?: string;
|
||||
scheduledOn?: string;
|
||||
}): string {
|
||||
const originalWeek = toIsoDate(input.originalWeek);
|
||||
if (originalWeek) {
|
||||
return mondayOf(originalWeek);
|
||||
}
|
||||
const originalDate = toIsoDate(input.originalDate);
|
||||
if (originalDate) {
|
||||
return mondayOf(originalDate);
|
||||
}
|
||||
const scheduledOn = toIsoDate(input.scheduledOn);
|
||||
return scheduledOn ? mondayOf(scheduledOn) : "";
|
||||
}
|
||||
|
||||
export function formatCarriedOverHover(count: number, sourceWeekIso?: string): string {
|
||||
const times = formatRescheduleCountLabel(count);
|
||||
const base = `Carried over ${times} — this WO was not completed and moved to the next period`;
|
||||
const sourceLabel = sourceWeekIso ? compactWeekLabel(sourceWeekIso) : "";
|
||||
if (!sourceLabel) {
|
||||
return base;
|
||||
}
|
||||
return `${base}. Source week: ${sourceLabel}.`;
|
||||
}
|
||||
|
||||
/** Non-terminal WOs scheduled before the viewed week are display carry-overs. */
|
||||
export function isCarryOverDisplayRow(row: CarryOverRow, weekMonday: string): boolean {
|
||||
if (isTerminalCarryOverStatus(row.status)) {
|
||||
return false;
|
||||
}
|
||||
const scheduled = toIsoDate(row.scheduledOn);
|
||||
return Boolean(scheduled) && scheduled < weekMonday;
|
||||
}
|
||||
|
||||
export function mapCarryOverDisplayDay(scheduledOn: string, weekMonday: string): string {
|
||||
const scheduled = toIsoDate(scheduledOn);
|
||||
if (!scheduled || !ISO_DATE.test(weekMonday)) {
|
||||
return "";
|
||||
}
|
||||
const sourceMonday = mondayOf(scheduled);
|
||||
const offsetMs =
|
||||
new Date(`${scheduled}T12:00:00`).getTime() - new Date(`${sourceMonday}T12:00:00`).getTime();
|
||||
const offset = Math.round(offsetMs / 86_400_000);
|
||||
if (offset < 0 || offset >= WEEKDAY_COUNT) {
|
||||
return weekMonday;
|
||||
}
|
||||
if (offset > FRIDAY_OFFSET) {
|
||||
return weekMonday;
|
||||
}
|
||||
return addDaysIso(weekMonday, offset);
|
||||
}
|
||||
|
||||
export function rowMatchesCarryOverDay(
|
||||
row: CarryOverRow,
|
||||
selectedDayIso: string,
|
||||
weekMonday: string,
|
||||
): boolean {
|
||||
if (!isCarryOverDisplayRow(row, weekMonday)) {
|
||||
return false;
|
||||
}
|
||||
return mapCarryOverDisplayDay(row.scheduledOn, weekMonday) === selectedDayIso;
|
||||
}
|
||||
|
||||
/** Aligns UI day keys with the mapped weekday so reorder stays on the displayed tab. */
|
||||
export function applyCarryOverDisplayDayFields<
|
||||
T extends CarryOverRow & { dayGroup: string; dayLabel: string },
|
||||
>(row: T, weekMonday: string): T {
|
||||
if (!isCarryOverDisplayRow(row, weekMonday)) {
|
||||
return row;
|
||||
}
|
||||
const displayDay = mapCarryOverDisplayDay(row.scheduledOn, weekMonday);
|
||||
if (!displayDay) {
|
||||
return row;
|
||||
}
|
||||
return { ...row, ...deriveDayFields(displayDay) };
|
||||
}
|
||||
10
src/domain/work-orders/utils/display-file-name.ts
Normal file
10
src/domain/work-orders/utils/display-file-name.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* why: uploads are stored as `{guid}_{original name}`, so the raw storage name shown in media
|
||||
* tiles reads as an id. The original name (and its extension) survive after the prefix.
|
||||
*/
|
||||
const STORED_NAME_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i;
|
||||
|
||||
export function toDisplayFileName(fileName: string): string {
|
||||
const withoutPrefix = fileName.replace(STORED_NAME_PREFIX, "").trim();
|
||||
return withoutPrefix || fileName;
|
||||
}
|
||||
44
src/domain/work-orders/utils/partition-slide-over-media.ts
Normal file
44
src/domain/work-orders/utils/partition-slide-over-media.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
||||
const COMPLETION_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||
|
||||
export type MediaSurfaceOwnership = {
|
||||
completionOwnedIds: ReadonlySet<string>;
|
||||
extraOwnedIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
const EMPTY_OWNERSHIP: MediaSurfaceOwnership = {
|
||||
completionOwnedIds: new Set(),
|
||||
extraOwnedIds: new Set(),
|
||||
};
|
||||
|
||||
function isPhotoOrVideo(item: MediaFile): boolean {
|
||||
return item.fileType === "image" || item.fileType === "video";
|
||||
}
|
||||
|
||||
function isInFlight(item: MediaFile): boolean {
|
||||
return item.uploadStatus === "uploading" || item.uploadStatus === "failed";
|
||||
}
|
||||
|
||||
/** Extra photo/video from Completion Doc POST (no category) until Extra is chosen. */
|
||||
export function isCompletionSurfaceMedia(
|
||||
item: MediaFile,
|
||||
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
|
||||
): boolean {
|
||||
if (isInFlight(item)) return true;
|
||||
if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true;
|
||||
if (ownership.completionOwnedIds.has(item.id)) return true;
|
||||
if (ownership.extraOwnedIds.has(item.id)) return false;
|
||||
return item.purpose === "Extra" && isPhotoOrVideo(item);
|
||||
}
|
||||
|
||||
export function isExtrasSurfaceMedia(
|
||||
item: MediaFile,
|
||||
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
|
||||
): boolean {
|
||||
if (item.purpose === "Aveta") return true;
|
||||
if (item.purpose !== "Extra") return false;
|
||||
if (ownership.completionOwnedIds.has(item.id)) return false;
|
||||
if (ownership.extraOwnedIds.has(item.id)) return true;
|
||||
return !isPhotoOrVideo(item);
|
||||
}
|
||||
20
src/domain/work-orders/utils/severity-escalation.ts
Normal file
20
src/domain/work-orders/utils/severity-escalation.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
SEVERITY_LEVELS,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
|
||||
function isCriticalSeverity(severity: WorkOrderSeverity | null | undefined): severity is 1 | 2 {
|
||||
return severity === 1 || severity === 2;
|
||||
}
|
||||
|
||||
/** AAP: toast only when severity newly enters SEV 1/2 — not on re-save of the same value. */
|
||||
export function shouldAnnounceSeverityEscalation(
|
||||
previous: WorkOrderSeverity | null | undefined,
|
||||
next: WorkOrderSeverity | null | undefined,
|
||||
): next is 1 | 2 {
|
||||
return isCriticalSeverity(next) && !isCriticalSeverity(previous);
|
||||
}
|
||||
|
||||
export function formatSeverityEscalationToast(woNumber: string, severity: 1 | 2): string {
|
||||
return `WO #${woNumber} escalated to ${SEVERITY_LEVELS[severity].label}.`;
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"scheduledOn",
|
||||
"targetWeek",
|
||||
"scheduleWeekOnly",
|
||||
"scheduleEnd",
|
||||
"schedMode",
|
||||
"dueDate",
|
||||
"completedDate",
|
||||
"apptTime",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates";
|
||||
import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||
|
||||
|
|
@ -96,7 +97,7 @@ export function filterByBarAndSearch(
|
|||
|
||||
if (row.scheduledOn) {
|
||||
if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
|
||||
return false;
|
||||
return isCarryOverDisplayRow(row, bar.weekMonday);
|
||||
}
|
||||
} else if (row.targetWeek) {
|
||||
if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
WorkOrderDayGroup,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { rowMatchesCarryOverDay } from "@/domain/work-orders/utils/carry-over-display";
|
||||
import { isLiveWeek } from "@/domain/work-orders/utils/list-week-utils";
|
||||
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
|
@ -65,10 +66,17 @@ export function formatDayTabLabel(iso: string): string {
|
|||
}
|
||||
|
||||
export function rowMatchesSelectedDay(
|
||||
row: Pick<WorkOrderTableRow, "scheduledOn">,
|
||||
row: Pick<WorkOrderTableRow, "scheduledOn" | "status" | "carriedOver">,
|
||||
selectedDayIso: string,
|
||||
weekMonday?: string,
|
||||
): boolean {
|
||||
return Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso;
|
||||
if (Boolean(row.scheduledOn) && row.scheduledOn.slice(0, 10) === selectedDayIso) {
|
||||
return true;
|
||||
}
|
||||
if (!weekMonday) {
|
||||
return false;
|
||||
}
|
||||
return rowMatchesCarryOverDay(row, selectedDayIso, weekMonday);
|
||||
}
|
||||
|
||||
export function buildSelectedDayGroup(
|
||||
|
|
@ -83,6 +91,6 @@ export function buildSelectedDayGroup(
|
|||
key: `${month}/${day}`,
|
||||
label: formatDayTabLabel(date),
|
||||
date,
|
||||
items: scheduled.filter((row) => rowMatchesSelectedDay(row, date)),
|
||||
items: scheduled.filter((row) => rowMatchesSelectedDay(row, date, weekMonday)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
PENDING_UPLIFT_GAP,
|
||||
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
/** AAP `ALL_STATUSES` — Canceled is kebab-only. */
|
||||
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES;
|
||||
|
||||
/** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */
|
||||
export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
|
||||
"Incomplete",
|
||||
"Pending",
|
||||
"Scheduled",
|
||||
];
|
||||
|
||||
export const SCHEDULED_NEEDS_DATE_MSG =
|
||||
"Set a Schedule On date before marking this work order as Scheduled.";
|
||||
|
||||
export const PAST_DUE_BADGE_TOOLTIP =
|
||||
"This WO missed its service window. Reschedule to unlock status updates.";
|
||||
|
||||
export const PAST_DUE_POPOVER_BODY =
|
||||
"This work order is past due. A new date must be scheduled before the status can be updated.";
|
||||
|
||||
export const PAST_DUE_RESCHEDULE_CTA = "Reschedule →";
|
||||
|
||||
export const COMPLETED_DOC_BLOCK_MSG =
|
||||
"Generate or mark the completion document as not required before completing this work order";
|
||||
|
||||
export const COMPLETED_MEDIA_BLOCK_MSG =
|
||||
"Upload at least one completion photo before completing this work order.";
|
||||
|
||||
const COMPLETE_ACTION = "completing this work order";
|
||||
|
||||
const EMPTY_FIELD_TOKENS = new Set(["", "-", "—", "N/A", "n/a"]);
|
||||
|
||||
const PRE_SCHEDULE_STATUSES = new Set<string>(["Incomplete", "Pending", ""]);
|
||||
|
||||
const FIELD_GAP_TO_AAP_LABEL: [string, string][] = [
|
||||
["Dispatcher", "Assigned To"],
|
||||
["Company", "Vendor"],
|
||||
["Completed Date", "Completed Date"],
|
||||
["Service", "Service"],
|
||||
];
|
||||
|
||||
export type ScheduleDateInput = {
|
||||
scheduledOn?: string;
|
||||
scheduleWeekOnly?: boolean;
|
||||
schedMode?: string;
|
||||
};
|
||||
|
||||
export type StatusOptionState = {
|
||||
disabled: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function isLifecycleFieldEmpty(value?: string): boolean {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return EMPTY_FIELD_TOKENS.has(trimmed);
|
||||
}
|
||||
|
||||
/** AAP `hasScheduleDate`: concrete day/range only — week-only does not count. */
|
||||
export function hasConcreteScheduleDate(row: ScheduleDateInput): boolean {
|
||||
if (row.scheduleWeekOnly === true || row.schedMode === "week") {
|
||||
return false;
|
||||
}
|
||||
return !isLifecycleFieldEmpty(row.scheduledOn);
|
||||
}
|
||||
|
||||
export function isPreScheduleStatus(status: string): boolean {
|
||||
return PRE_SCHEDULE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function formatAapCompletedBlockTitle(gaps: string[]): string | undefined {
|
||||
if (gaps.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fields = FIELD_GAP_TO_AAP_LABEL.filter(([gap]) => gaps.includes(gap)).map(
|
||||
([, label]) => label,
|
||||
);
|
||||
|
||||
if (fields.length === 1 && fields[0] === "Service") {
|
||||
return `Select a service before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
return `Fill in ${fields.join(", ")} before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (gaps.includes("WO number")) {
|
||||
return `Assign a real APM number (not SH) before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (gaps.includes(PENDING_UPLIFT_GAP)) {
|
||||
return UPLIFT_PENDING_COMPLETED_TITLE;
|
||||
}
|
||||
if (gaps.includes("Completion document")) {
|
||||
return COMPLETED_DOC_BLOCK_MSG;
|
||||
}
|
||||
if (gaps.includes("At least 1 photo")) {
|
||||
return COMPLETED_MEDIA_BLOCK_MSG;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStatusOptionState(
|
||||
row: ScheduleDateInput,
|
||||
status: WizardWOStatus,
|
||||
closabilityGaps: string[],
|
||||
): StatusOptionState {
|
||||
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
|
||||
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
|
||||
}
|
||||
if (status === "Completed") {
|
||||
const title = formatAapCompletedBlockTitle(closabilityGaps);
|
||||
if (title) {
|
||||
return { disabled: true, title };
|
||||
}
|
||||
}
|
||||
return { disabled: false };
|
||||
}
|
||||
|
||||
/** AAP `maybeAutoSchedule` — no dispatcher requirement. */
|
||||
export function maybeAutoSchedulePatch(
|
||||
row: ScheduleDateInput & { status?: string },
|
||||
): WorkOrderTablePatch {
|
||||
const hasDate = hasConcreteScheduleDate(row);
|
||||
if (hasDate && isPreScheduleStatus(row.status ?? "")) {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
if (!hasDate && row.status === "Scheduled") {
|
||||
return { status: "Pending" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean {
|
||||
return (
|
||||
patch.status !== undefined ||
|
||||
patch.scheduledOn !== undefined ||
|
||||
patch.scheduleWeekOnly !== undefined ||
|
||||
patch.targetWeek !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeAutoSchedulePatch(
|
||||
row: WorkOrderTableRow | undefined,
|
||||
patch: WorkOrderTablePatch,
|
||||
): WorkOrderTablePatch {
|
||||
if (!row || !patchTouchesScheduleOrStatus(patch)) {
|
||||
return patch;
|
||||
}
|
||||
const merged = {
|
||||
status: patch.status ?? row.status,
|
||||
scheduledOn: patch.scheduledOn !== undefined ? patch.scheduledOn : row.scheduledOn,
|
||||
scheduleWeekOnly:
|
||||
patch.scheduleWeekOnly !== undefined ? patch.scheduleWeekOnly : row.scheduleWeekOnly,
|
||||
};
|
||||
return { ...patch, ...maybeAutoSchedulePatch(merged) };
|
||||
}
|
||||
|
||||
/** Apply schedule fields and the AAP auto-status for that next schedule. */
|
||||
export function withAutoScheduleStatus<T extends ScheduleDateInput & { status?: string }>(
|
||||
current: T,
|
||||
schedulePatch: Partial<T> & ScheduleDateInput,
|
||||
): Partial<T> {
|
||||
const next = {
|
||||
status: schedulePatch.status ?? current.status ?? "",
|
||||
scheduledOn:
|
||||
schedulePatch.scheduledOn !== undefined ? schedulePatch.scheduledOn : current.scheduledOn,
|
||||
scheduleWeekOnly:
|
||||
schedulePatch.scheduleWeekOnly !== undefined
|
||||
? schedulePatch.scheduleWeekOnly
|
||||
: current.scheduleWeekOnly,
|
||||
};
|
||||
return { ...schedulePatch, ...maybeAutoSchedulePatch(next) };
|
||||
}
|
||||
|
||||
/** AAP create: apply maybeAutoSchedule to the draft before POST. */
|
||||
export function resolveCreationStatus(row: ScheduleDateInput & { status: string }): WizardWOStatus {
|
||||
const autoStatus = maybeAutoSchedulePatch(row).status;
|
||||
if (autoStatus === "Scheduled" || autoStatus === "Pending") {
|
||||
return autoStatus;
|
||||
}
|
||||
return (row.status || "Incomplete") as WizardWOStatus;
|
||||
}
|
||||
|
|
@ -80,3 +80,12 @@ export function applyVendorTableSave(
|
|||
onSuccess: isAssignedVendorId(fields.vendorId) ? notifyVendorAssigned : notifyWorkOrderSaved,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyAssignedToTableSave(
|
||||
onPatch: PersistPatchFn,
|
||||
id: string | number,
|
||||
dispatcherId: string,
|
||||
dispatcherName: string,
|
||||
) {
|
||||
onPatch(id, { dispatcherId, dispatcherName }, { onSuccess: notifyWorkOrderSaved });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,38 @@
|
|||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000;
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
|
||||
const MEDIA_MIME_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
]);
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
|
||||
const EXTRA_DOC_MIME_TYPES = new Set([
|
||||
...MEDIA_MIME_TYPES,
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]);
|
||||
|
||||
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
|
||||
|
||||
function fileExtension(file: File): string {
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.slice(dot) : "";
|
||||
}
|
||||
|
||||
function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>): boolean {
|
||||
const mimeOk = Boolean(file.type) && mimes.has(file.type.toLowerCase());
|
||||
const extOk = extensions.has(fileExtension(file));
|
||||
return mimeOk || extOk;
|
||||
}
|
||||
|
||||
export function isWorkOrderFileTooLarge(
|
||||
file: File,
|
||||
|
|
@ -13,24 +42,25 @@ export function isWorkOrderFileTooLarge(
|
|||
}
|
||||
|
||||
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||
const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? name.slice(dot) : "";
|
||||
const extOk = ALLOWED_EXTENSIONS.has(ext);
|
||||
return mimeOk || extOk;
|
||||
return isTypeAllowed(file, MEDIA_MIME_TYPES, MEDIA_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
|
||||
return isTypeAllowed(file, EXTRA_DOC_MIME_TYPES, EXTRA_DOC_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function workOrderMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
}
|
||||
|
||||
export function workOrderExtraDocsAcceptAttribute(): string {
|
||||
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
|
||||
}
|
||||
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||
return `${Math.round(maxBytes / 1_000_000)} MB`;
|
||||
}
|
||||
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||
return isWorkOrderCompletionSurfaceReadOnly(status);
|
||||
|
|
|
|||
12
src/lib/auth/expire-session.ts
Normal file
12
src/lib/auth/expire-session.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { clearAuth } from "@/lib/auth/auth-storage";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
|
||||
export function expireSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
stopQueryBroadcast(queryClient);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
10
src/main.tsx
|
|
@ -1,3 +1,7 @@
|
|||
import * as Sentry from "@sentry/react";
|
||||
|
||||
import "@/observability/sentry";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { AppRoot } from "@/app-root";
|
||||
|
|
@ -10,7 +14,11 @@ if (!rootElement) {
|
|||
throw new Error("Root element #root not found");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
ReactDOM.createRoot(rootElement, {
|
||||
onCaughtError: Sentry.reactErrorHandler(),
|
||||
onRecoverableError: Sentry.reactErrorHandler(),
|
||||
onUncaughtError: Sentry.reactErrorHandler(),
|
||||
}).render(
|
||||
<React.StrictMode>
|
||||
<AppProviders>
|
||||
<AppRoot />
|
||||
|
|
|
|||
111
src/observability/http-transaction.ts
Normal file
111
src/observability/http-transaction.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { setHttpStatus, spanToJSON, startSpan, type Span } from "@sentry/react";
|
||||
import { isHTTPError } from "ky";
|
||||
|
||||
const INTEGER_SEGMENT = /^\d+$/;
|
||||
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const HEX_SEGMENT = /^[0-9a-f]+$/i;
|
||||
const TOKEN_SEGMENT = /^[0-9a-z._~-]+$/i;
|
||||
const OPAQUE_HEX_MIN_LENGTH = 8;
|
||||
const OPAQUE_TOKEN_MIN_LENGTH = 16;
|
||||
|
||||
function isIdentifierSegment(segment: string): boolean {
|
||||
if (segment.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (INTEGER_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
if (UUID_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
if (segment.length >= OPAQUE_HEX_MIN_LENGTH && HEX_SEGMENT.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
segment.length >= OPAQUE_TOKEN_MIN_LENGTH &&
|
||||
TOKEN_SEGMENT.test(segment) &&
|
||||
/\d/.test(segment) &&
|
||||
/[a-z]/i.test(segment)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeHttpRoute(input: string): string {
|
||||
let path: string;
|
||||
try {
|
||||
path = new URL(input).pathname;
|
||||
} catch {
|
||||
path = input.split("#")[0] ?? input;
|
||||
path = path.split("?")[0] ?? path;
|
||||
}
|
||||
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => (isIdentifierSegment(segment) ? ":id" : segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export type HttpTraceOptions = {
|
||||
method: string;
|
||||
url: string;
|
||||
entryPoint: string;
|
||||
actorType?: string;
|
||||
};
|
||||
|
||||
export type HttpTraceContext = {
|
||||
reportStatus: (status: number) => void;
|
||||
};
|
||||
|
||||
export async function traceHttpOperation<T>(
|
||||
options: HttpTraceOptions,
|
||||
callback: (context: HttpTraceContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const route = normalizeHttpRoute(options.url);
|
||||
|
||||
return startSpan(
|
||||
{
|
||||
name: `${options.method.toUpperCase()} ${route}`,
|
||||
op: "http.client",
|
||||
kind: 2,
|
||||
forceTransaction: true,
|
||||
attributes: {
|
||||
"http.request.method": options.method.toUpperCase(),
|
||||
"http.route": route,
|
||||
"code.function": options.entryPoint,
|
||||
"operation.type": "http.client",
|
||||
...(options.actorType ? { "actor.type": options.actorType } : {}),
|
||||
},
|
||||
},
|
||||
async (span: Span) => {
|
||||
const spanJson = spanToJSON(span);
|
||||
if (spanJson.trace_id) {
|
||||
span.setAttribute("trace_id", spanJson.trace_id);
|
||||
}
|
||||
if (spanJson.span_id) {
|
||||
span.setAttribute("span_id", spanJson.span_id);
|
||||
span.setAttribute("transaction_id", spanJson.span_id);
|
||||
}
|
||||
|
||||
let statusReported = false;
|
||||
const reportStatus = (status: number): void => {
|
||||
if (statusReported) {
|
||||
return;
|
||||
}
|
||||
statusReported = true;
|
||||
setHttpStatus(span, status);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await callback({ reportStatus });
|
||||
if (!statusReported && result instanceof Response) {
|
||||
reportStatus(result.status);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!statusReported) {
|
||||
reportStatus(isHTTPError(error) ? error.response.status : 500);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue