From c1863c4274f3c1fb669de75da8f7986baaebfd78 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Thu, 3 Sep 2026 13:59:13 -0300 Subject: [PATCH 01/37] feat: add browser Sentry tracing --- .env.example | 4 + .env.production | 3 + docs/adr/0002-sentry-observability.md | 56 ++++++++++++++ package-lock.json | 107 ++++++++++++++++++++++++++ package.json | 1 + src/main.tsx | 2 + src/observability/sentry.ts | 30 ++++++++ src/routing/router.tsx | 4 +- 8 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0002-sentry-observability.md create mode 100644 src/observability/sentry.ts diff --git a/.env.example b/.env.example index 1bd84acf..8dba92c3 100644 --- a/.env.example +++ b/.env.example @@ -8,5 +8,9 @@ # 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= + # Development proxy target (used by vite.config.ts) #VITE_API_TARGET=http://localhost:5141 diff --git a/.env.production b/.env.production index 1183d018..205c2f1a 100644 --- a/.env.production +++ b/.env.production @@ -2,3 +2,6 @@ # 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; deployment must provide the environment's Sentry DSN. +VITE_SENTRY_DSN= diff --git a/docs/adr/0002-sentry-observability.md b/docs/adr/0002-sentry-observability.md new file mode 100644 index 00000000..d29ee772 --- /dev/null +++ b/docs/adr/0002-sentry-observability.md @@ -0,0 +1,56 @@ +# 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. + +## 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. + +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. A missing or blank DSN leaves telemetry inactive without preventing +the application from starting. Default personally identifiable information +collection remains disabled. + +Source-map upload is outside this change. It requires a Sentry organization, +project, and scoped upload token that are not currently available, plus an +explicit deployment design for handling that credential without exposing it in +the browser bundle. + +### 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. +- **Add the Sentry Vite plugin now** — rejected until the organization, project, + upload credential, and deployment ownership are defined. + +## Consequences + +- Browser navigations and API requests are recorded as connected transactions + when the deployment supplies a valid DSN. +- 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. +- Production stack traces remain minified until source-map upload is designed. +- Rollback is isolated: remove `@sentry/react`, the initialization module and + import, restore the unwrapped router factory, and remove the Sentry env entries. diff --git a/package-lock.json b/package-lock.json index 33da7afe..6fa1e42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -1974,6 +1975,112 @@ "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/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", diff --git a/package.json b/package.json index fc91c435..fc2ecbf4 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,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", diff --git a/src/main.tsx b/src/main.tsx index 6643603c..deebc060 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,5 @@ +import "@/observability/sentry"; + import React from "react"; import ReactDOM from "react-dom/client"; import { AppRoot } from "@/app-root"; diff --git a/src/observability/sentry.ts b/src/observability/sentry.ts new file mode 100644 index 00000000..9a37cf51 --- /dev/null +++ b/src/observability/sentry.ts @@ -0,0 +1,30 @@ +import * as Sentry from "@sentry/react"; +import { useEffect } from "react"; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from "react-router"; + +import { env } from "@/lib/env"; + +const sentryDsn = import.meta.env.VITE_SENTRY_DSN?.trim() || undefined; +const apiBaseUrl = new URL(env.apiUrl, window.location.origin).toString(); + +Sentry.init({ + dsn: sentryDsn, + environment: import.meta.env.MODE, + integrations: [ + Sentry.reactRouterBrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ], + sendDefaultPii: false, + tracesSampleRate: 1.0, + tracePropagationTargets: [window.location.origin, apiBaseUrl], +}); diff --git a/src/routing/router.tsx b/src/routing/router.tsx index bd11fd73..5fd7bf58 100644 --- a/src/routing/router.tsx +++ b/src/routing/router.tsx @@ -1,8 +1,10 @@ -import { createBrowserRouter } from "react-router"; +import * as Sentry from "@sentry/react"; +import { createBrowserRouter as createBrowserRouterBase } from "react-router"; import { RouterProvider } from "react-router/dom"; import { routes } from "@/routing/build-route-tree"; +const createBrowserRouter = Sentry.wrapCreateBrowserRouter(createBrowserRouterBase); const router = createBrowserRouter(routes); export function Routes() { From a24002bc240f4334db855688e0aa667ef0b10ab7 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Thu, 3 Sep 2026 15:10:01 -0300 Subject: [PATCH 02/37] feat: activate Sentry deployment environments --- .env.example | 4 ++++ .env.production | 7 +++++-- .github/workflows/deploy-staging.yml | 8 +++++--- docs/adr/0002-sentry-observability.md | 22 +++++++++++++++++----- src/observability/sentry.ts | 3 ++- src/vite-env.d.ts | 2 ++ 6 files changed, 35 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 8dba92c3..1b59d08b 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,9 @@ VITE_API_URL=https://api.seahavenindustries.com/api # 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 diff --git a/.env.production b/.env.production index 205c2f1a..6ca55486 100644 --- a/.env.production +++ b/.env.production @@ -3,5 +3,8 @@ # 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; deployment must provide the environment's Sentry DSN. -VITE_SENTRY_DSN= +# 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 diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index eed6460e..708fac98 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -32,6 +32,7 @@ jobs: environment: staging env: VITE_API_URL: https://api.staging.seahaven.com/api + VITE_SENTRY_ENVIRONMENT: staging AWS_REGION: us-east-1 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -68,9 +69,10 @@ 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: diff --git a/docs/adr/0002-sentry-observability.md b/docs/adr/0002-sentry-observability.md index d29ee772..02c0b549 100644 --- a/docs/adr/0002-sentry-observability.md +++ b/docs/adr/0002-sentry-observability.md @@ -22,14 +22,23 @@ the configured `VITE_API_URL` base. 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. A missing or blank DSN leaves telemetry inactive without preventing +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. -Source-map upload is outside this change. It requires a Sentry organization, -project, and scoped upload token that are not currently available, plus an -explicit deployment design for handling that credential without exposing it in -the browser bundle. +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. + +Source-map upload is outside this change. The Sentry organization and the +`shoc-frontend` project now exist and receive telemetry through the committed +public DSN, but upload still requires a scoped upload token that is not +currently available, plus an explicit deployment design for handling that +credential without exposing it in the browser bundle. ### Alternatives considered @@ -46,6 +55,9 @@ the browser bundle. - 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. diff --git a/src/observability/sentry.ts b/src/observability/sentry.ts index 9a37cf51..156e44b1 100644 --- a/src/observability/sentry.ts +++ b/src/observability/sentry.ts @@ -10,11 +10,12 @@ import { import { env } from "@/lib/env"; const sentryDsn = import.meta.env.VITE_SENTRY_DSN?.trim() || undefined; +const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT?.trim() || import.meta.env.MODE; const apiBaseUrl = new URL(env.apiUrl, window.location.origin).toString(); Sentry.init({ dsn: sentryDsn, - environment: import.meta.env.MODE, + environment: sentryEnvironment, integrations: [ Sentry.reactRouterBrowserTracingIntegration({ useEffect, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4df1f98f..c7b43287 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_API_TARGET: string; + readonly VITE_SENTRY_DSN?: string; + readonly VITE_SENTRY_ENVIRONMENT?: string; } interface ImportMeta { From dc313dc50a7b797df77805f6d72e4134a6aefe78 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Thu, 3 Sep 2026 15:53:50 -0300 Subject: [PATCH 03/37] fix: capture React root errors in Sentry --- docs/adr/0002-sentry-observability.md | 5 ++++- src/main.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/adr/0002-sentry-observability.md b/docs/adr/0002-sentry-observability.md index 02c0b549..fc6bce5f 100644 --- a/docs/adr/0002-sentry-observability.md +++ b/docs/adr/0002-sentry-observability.md @@ -18,7 +18,10 @@ 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. +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. The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public browser configuration because every `VITE_` value is embedded in the built diff --git a/src/main.tsx b/src/main.tsx index deebc060..4e683029 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,5 @@ +import * as Sentry from "@sentry/react"; + import "@/observability/sentry"; import React from "react"; @@ -12,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( From 207199559ffd2a2516e4d9124da44dee1aa39ad7 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Thu, 3 Sep 2026 17:12:49 -0300 Subject: [PATCH 04/37] feat(observability): identify and scrub Sentry transactions --- .github/workflows/deploy-staging.yml | 6 + .github/workflows/deploy.yml | 22 ++ config/sentry-release.test.ts | 26 +++ config/sentry-release.ts | 32 +++ docs/adr/0002-sentry-observability.md | 41 +++- package-lock.json | 201 ++++++++++++++++++ package.json | 1 + scripts/deploy-web.sh | 2 + scripts/upload-sourcemaps.sh | 24 +++ src/api/api.ts | 125 ++++++++--- src/domain/auth/use-cases/use-login.ts | 2 + src/domain/uplifts/api/uplifts-api.ts | 9 +- .../api/vendor-operations-api.ts | 10 +- .../vendor-portal/api/vendor-portal-api.ts | 150 ++++++++----- .../api/work-order-board-documents-api.ts | 67 +++--- src/domain/work-orders/api/work-orders-api.ts | 12 +- src/observability/http-transaction.ts | 110 ++++++++++ src/observability/sentry-scrubber.ts | 151 +++++++++++++ src/observability/sentry.ts | 15 ++ src/providers/auth-provider.tsx | 5 + .../work-orders/api/work-orders-api.test.ts | 9 +- .../observability/http-transaction.test.ts | 137 ++++++++++++ .../observability/sentry-scrubber.test.ts | 148 +++++++++++++ src/vite-env.d.ts | 3 + tsconfig.node.json | 2 + vite.config.ts | 18 ++ 26 files changed, 1200 insertions(+), 128 deletions(-) create mode 100644 config/sentry-release.test.ts create mode 100644 config/sentry-release.ts create mode 100755 scripts/upload-sourcemaps.sh create mode 100644 src/observability/http-transaction.ts create mode 100644 src/observability/sentry-scrubber.ts create mode 100644 src/test/observability/http-transaction.test.ts create mode 100644 src/test/observability/sentry-scrubber.test.ts diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 708fac98..c00f749e 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -33,6 +33,7 @@ jobs: 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 @@ -79,6 +80,11 @@ jobs: 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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aa72c01d..6ad9bde2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,3 +36,25 @@ jobs: post-deploy-script: scripts/deploy-web.sh secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + + upload-sourcemaps: + name: Upload private source maps + needs: deploy + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + env: + 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: Build exact deployed release + run: npm ci && npm run build + - name: Upload source maps to Sentry + run: bash scripts/upload-sourcemaps.sh + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/config/sentry-release.test.ts b/config/sentry-release.test.ts new file mode 100644 index 00000000..15f8cc5c --- /dev/null +++ b/config/sentry-release.test.ts @@ -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, + }); + }); +}); diff --git a/config/sentry-release.ts b/config/sentry-release.ts new file mode 100644 index 00000000..8bf0c90a --- /dev/null +++ b/config/sentry-release.ts @@ -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, + }; +} diff --git a/docs/adr/0002-sentry-observability.md b/docs/adr/0002-sentry-observability.md index fc6bce5f..3aeee86c 100644 --- a/docs/adr/0002-sentry-observability.md +++ b/docs/adr/0002-sentry-observability.md @@ -10,7 +10,10 @@ 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. +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 @@ -23,6 +26,14 @@ the configured `VITE_API_URL` base. Pass `Sentry.reactErrorHandler()` as the `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 @@ -37,11 +48,22 @@ non-blank, falling back to the Vite build mode otherwise. The committed overrides the label to `staging`, and a future production pipeline can set `production` without code changes. -Source-map upload is outside this change. The Sentry organization and the -`shoc-frontend` project now exist and receive telemetry through the committed -public DSN, but upload still requires a scoped upload token that is not -currently available, plus an explicit deployment design for handling that -credential without exposing it in the browser bundle. +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 @@ -51,8 +73,8 @@ credential without exposing it in the browser bundle. - **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. -- **Add the Sentry Vite plugin now** — rejected until the organization, project, - upload credential, and deployment ownership are defined. +- **Publish source maps with the SPA** — rejected because source code belongs in + Sentry's private artifact store, not the public application bucket. ## Consequences @@ -66,6 +88,7 @@ credential without exposing it in the browser bundle. 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. -- Production stack traces remain minified until source-map upload is designed. +- 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. diff --git a/package-lock.json b/package-lock.json index 6fa1e42f..f6fe736b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,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", @@ -2005,6 +2006,189 @@ "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", @@ -5843,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", @@ -5860,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", diff --git a/package.json b/package.json index fc2ecbf4..ed95210d 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,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", diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh index 59b302ef..2dfb66a2 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -16,6 +16,7 @@ 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 @@ -41,6 +42,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)..." diff --git a/scripts/upload-sourcemaps.sh b/scripts/upload-sourcemaps.sh new file mode 100755 index 00000000..c557b6c5 --- /dev/null +++ b/scripts/upload-sourcemaps.sh @@ -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 diff --git a/src/api/api.ts b/src/api/api.ts index 51897b61..44f40aee 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -6,6 +6,7 @@ import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-erro import queryClient from "@/lib/query/query-client"; import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; +import { traceHttpOperation } from "@/observability/http-transaction"; function clearSessionAndRedirectToLogin(): void { clearAuth(); @@ -58,28 +59,92 @@ export const api: KyInstance = ky.create({ }, }); +export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete"; + +export async function apiRequestRaw( + method: ApiRawMethod, + url: string, + options?: Options, + entryPoint?: string, +): Promise { + 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(url: string, options?: Options): Promise { - return api.get(url, options).json(); + return traceHttpOperation( + { method: "GET", url, entryPoint: "api.apiGet" }, + async ({ reportStatus }) => { + const response = await api.get(url, options); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPost(url: string, body?: unknown, options?: Options): Promise { - return api.post(url, { ...options, json: body }).json(); + 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(); + }, + ); } export async function apiPostForm(url: string, body: FormData, options?: Options): Promise { - return api.post(url, { ...options, body }).json(); + return traceHttpOperation( + { method: "POST", url, entryPoint: "api.apiPostForm" }, + async ({ reportStatus }) => { + const response = await api.post(url, { ...options, body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPut(url: string, body?: unknown, options?: Options): Promise { - return api.put(url, { ...options, json: body }).json(); + 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(); + }, + ); } export async function apiPatch(url: string, body?: unknown, options?: Options): Promise { - return api.patch(url, { ...options, json: body }).json(); + 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(); + }, + ); } export async function apiDelete(url: string, options?: Options): Promise { - return api.delete(url, options).json(); + return traceHttpOperation( + { method: "DELETE", url, entryPoint: "api.apiDelete" }, + async ({ reportStatus }) => { + const response = await api.delete(url, options); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPostNoContent( @@ -87,31 +152,43 @@ export async function apiPostNoContent( body?: unknown, options?: Options, ): Promise { - 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 { - 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 }; diff --git a/src/domain/auth/use-cases/use-login.ts b/src/domain/auth/use-cases/use-login.ts index 6c4617de..55766b90 100644 --- a/src/domain/auth/use-cases/use-login.ts +++ b/src/domain/auth/use-cases/use-login.ts @@ -46,11 +46,13 @@ export function useLogout(): UseMutationResult { 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"); }, diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index 27aa1676..890d7116 100644 --- a/src/domain/uplifts/api/uplifts-api.ts +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -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 => { 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); diff --git a/src/domain/vendor-operations/api/vendor-operations-api.ts b/src/domain/vendor-operations/api/vendor-operations-api.ts index 6e8aae0d..9c982840 100644 --- a/src/domain/vendor-operations/api/vendor-operations-api.ts +++ b/src/domain/vendor-operations/api/vendor-operations-api.ts @@ -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 { - 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; diff --git a/src/domain/vendor-portal/api/vendor-portal-api.ts b/src/domain/vendor-portal/api/vendor-portal-api.ts index a8669484..5e16c3e0 100644 --- a/src/domain/vendor-portal/api/vendor-portal-api.ts +++ b/src/domain/vendor-portal/api/vendor-portal-api.ts @@ -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 { - 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; - 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((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; + 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 { - 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( @@ -89,30 +118,41 @@ async function vendorPortalCall( path: string, options?: { method?: string; body?: unknown }, ): Promise { - const response = await fetch(resolveVendorPortalUrl(path), { - method: options?.method ?? "GET", - headers: { - "Content-Type": "application/json", - "X-Vendor-Token": token, + return traceHttpOperation( + { + 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; + const payload = (await response.json().catch(() => ({}))) as Record; - 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 { diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts index 97ca0d2b..0256dbe3 100644 --- a/src/domain/work-orders/api/work-order-board-documents-api.ts +++ b/src/domain/work-orders/api/work-order-board-documents-api.ts @@ -1,6 +1,6 @@ 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 { BoardPatchConflictError, @@ -81,10 +81,12 @@ export const workOrderBoardDocumentsApi = { commentId: string | number, text: string, ): Promise => { - 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) { @@ -119,16 +121,21 @@ export const workOrderBoardDocumentsApi = { options?: { signal?: AbortSignal; onProgress?: (percent: number) => void }, ): Promise => { 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 response = await apiRequestRaw( + "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, + }, + "workOrderBoardDocumentsApi.uploadMedia", + ); const data = await readResponseJson(response); if (response.ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); @@ -152,10 +159,12 @@ 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); @@ -183,10 +192,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; @@ -216,10 +227,12 @@ 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); diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index ddebf957..fe6068f5 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -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 => { - 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 { diff --git a/src/observability/http-transaction.ts b/src/observability/http-transaction.ts new file mode 100644 index 00000000..8f3b1507 --- /dev/null +++ b/src/observability/http-transaction.ts @@ -0,0 +1,110 @@ +import { setHttpStatus, spanToJSON, startSpan, type Span } from "@sentry/react"; + +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( + options: HttpTraceOptions, + callback: (context: HttpTraceContext) => Promise, +): Promise { + 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(500); + } + throw error; + } + }, + ); +} diff --git a/src/observability/sentry-scrubber.ts b/src/observability/sentry-scrubber.ts new file mode 100644 index 00000000..558dce9f --- /dev/null +++ b/src/observability/sentry-scrubber.ts @@ -0,0 +1,151 @@ +import type { Breadcrumb, ErrorEvent, SpanJSON, TransactionEvent } from "@sentry/core"; + +import { normalizeHttpRoute } from "@/observability/http-transaction"; + +function sanitizeUrl(value: string): string { + try { + const parsed = new URL(value, window.location.origin); + return `${parsed.origin}${normalizeHttpRoute(parsed.pathname)}`; + } catch { + return normalizeHttpRoute(value); + } +} + +function keepOpaqueUserId(event: ErrorEvent | TransactionEvent): void { + event.user = event.user?.id == null ? undefined : { id: String(event.user.id) }; +} + +function scrubRequest(event: ErrorEvent | TransactionEvent): void { + if (!event.request) { + return; + } + + event.request = { + method: event.request.method, + url: typeof event.request.url === "string" ? sanitizeUrl(event.request.url) : undefined, + }; +} + +const SAFE_SPAN_DATA_KEYS = new Set([ + "actor.type", + "code.function", + "db.operation.name", + "db.system", + "http.method", + "http.request.method", + "http.response.status_code", + "http.route", + "http.status_code", + "operation.type", + "sentry.op", + "sentry.origin", + "server.address", +]); + +function safeSpanData(data: SpanJSON["data"] | undefined): SpanJSON["data"] { + return Object.fromEntries( + Object.entries(data ?? {}).filter( + ([key, value]) => + SAFE_SPAN_DATA_KEYS.has(key) && ["string", "number", "boolean"].includes(typeof value), + ), + ); +} + +function valueAsString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function sanitizeHttpDescription(description: string | undefined): string | undefined { + if (!description) { + return undefined; + } + + const match = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/i.exec(description); + return match ? `${match[1]!.toUpperCase()} ${sanitizeUrl(match[2]!)}` : undefined; +} + +function scrubTransactionSpans(event: TransactionEvent): void { + event.spans = event.spans?.map((span) => ({ + ...span, + data: safeSpanData(span.data), + description: span.op?.includes("http") ? sanitizeHttpDescription(span.description) : undefined, + })); + + const trace = event.contexts?.trace; + if (trace?.data) { + trace.data = safeSpanData(trace.data); + } +} + +function setStringTag( + tags: NonNullable, + key: string, + value: unknown, +): void { + const stringValue = valueAsString(value); + if (stringValue) { + tags[key] = stringValue; + } +} + +function transactionFunction(event: ErrorEvent | TransactionEvent): string | undefined { + const traceFunction = valueAsString(event.contexts?.trace?.data?.["code.function"]); + return traceFunction ?? (event.type === "transaction" ? event.transaction : undefined); +} + +function actorType(event: ErrorEvent | TransactionEvent): string { + return ( + valueAsString(event.tags?.["actor.type"]) ?? + valueAsString(event.contexts?.trace?.data?.["actor.type"]) ?? + (event.user?.id == null ? "anonymous" : "authenticated") + ); +} + +function attachTraceIdentifiers(event: ErrorEvent | TransactionEvent): void { + const trace = event.contexts?.trace; + const tags = { ...event.tags }; + + setStringTag(tags, "trace_id", trace?.trace_id); + setStringTag(tags, "transaction_id", trace?.span_id); + setStringTag(tags, "operation.type", valueAsString(trace?.data?.["operation.type"]) ?? trace?.op); + setStringTag(tags, "code.function", transactionFunction(event)); + tags["actor.type"] = actorType(event); + event.tags = tags; +} + +export function scrubErrorEvent(event: ErrorEvent): ErrorEvent { + keepOpaqueUserId(event); + scrubRequest(event); + attachTraceIdentifiers(event); + return event; +} + +export function scrubTransactionEvent(event: TransactionEvent): TransactionEvent { + keepOpaqueUserId(event); + scrubRequest(event); + attachTraceIdentifiers(event); + scrubTransactionSpans(event); + return event; +} + +export function scrubBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb { + const safe: Breadcrumb = { + timestamp: breadcrumb.timestamp, + type: breadcrumb.type, + category: breadcrumb.category, + level: breadcrumb.level, + }; + + if (breadcrumb.category === "fetch" || breadcrumb.category === "xhr") { + const method = breadcrumb.data?.method; + const statusCode = breadcrumb.data?.status_code; + const url = breadcrumb.data?.url; + safe.data = { + ...(typeof method === "string" ? { method } : {}), + ...(typeof statusCode === "number" ? { status_code: statusCode } : {}), + ...(typeof url === "string" ? { url: sanitizeUrl(url) } : {}), + }; + } + + return safe; +} diff --git a/src/observability/sentry.ts b/src/observability/sentry.ts index 156e44b1..23cdbfcb 100644 --- a/src/observability/sentry.ts +++ b/src/observability/sentry.ts @@ -8,6 +8,11 @@ import { } from "react-router"; import { env } from "@/lib/env"; +import { + scrubBreadcrumb, + scrubErrorEvent, + scrubTransactionEvent, +} from "@/observability/sentry-scrubber"; const sentryDsn = import.meta.env.VITE_SENTRY_DSN?.trim() || undefined; const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT?.trim() || import.meta.env.MODE; @@ -16,6 +21,13 @@ const apiBaseUrl = new URL(env.apiUrl, window.location.origin).toString(); Sentry.init({ dsn: sentryDsn, environment: sentryEnvironment, + release: __SENTRY_RELEASE__, + initialScope: { + tags: { + service: "shoc-frontend", + "app.commit": __APP_COMMIT_SHA__, + }, + }, integrations: [ Sentry.reactRouterBrowserTracingIntegration({ useEffect, @@ -26,6 +38,9 @@ Sentry.init({ }), ], sendDefaultPii: false, + beforeSend: scrubErrorEvent, + beforeSendTransaction: scrubTransactionEvent, + beforeBreadcrumb: scrubBreadcrumb, tracesSampleRate: 1.0, tracePropagationTargets: [window.location.origin, apiBaseUrl], }); diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx index c5687abd..3b8b086a 100644 --- a/src/providers/auth-provider.tsx +++ b/src/providers/auth-provider.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, type ReactNode } from "react"; +import * as Sentry from "@sentry/react"; import { useQueryClient } from "@tanstack/react-query"; import { useChangePassword } from "@/domain/auth/use-cases/use-change-password"; import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login"; @@ -14,6 +15,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const userId = sessionQuery.data?.id; + useEffect(() => { + Sentry.setUser(userId == null ? null : { id: String(userId) }); + }, [userId]); + useEffect(() => { if (userId == null) { return; diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index d383aefc..a9ad4241 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -18,10 +18,11 @@ vi.mock("@/api/api", () => ({ apiPatch: (...args: unknown[]) => apiPatch(...args), apiDelete: vi.fn(), apiPostNoContent: vi.fn(), - api: { - patch: (...args: unknown[]) => apiPatchFn(...args), - post: (...args: unknown[]) => apiPostFn(...args), - delete: (...args: unknown[]) => apiDeleteFn(...args), + apiRequestRaw: (method: string, url: unknown, options?: unknown) => { + if (method === "patch") return apiPatchFn(url, options); + if (method === "post") return apiPostFn(url, options); + if (method === "delete") return apiDeleteFn(url, options); + throw new Error(`Unexpected apiRequestRaw method: ${method}`); }, })); diff --git a/src/test/observability/http-transaction.test.ts b/src/test/observability/http-transaction.test.ts new file mode 100644 index 00000000..e08b2209 --- /dev/null +++ b/src/test/observability/http-transaction.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const capturedSpan = vi.hoisted(() => ({ setAttribute: vi.fn() })); + +vi.mock("@sentry/react", () => ({ + startSpan: vi.fn((options: unknown, callback: (span: unknown) => unknown) => + callback(capturedSpan), + ), + spanToJSON: vi.fn(() => ({ trace_id: "trace-1", span_id: "span-1" })), + setHttpStatus: vi.fn(), +})); + +import * as Sentry from "@sentry/react"; +import { normalizeHttpRoute, traceHttpOperation } from "@/observability/http-transaction"; + +const startSpanMock = vi.mocked(Sentry.startSpan); +const setHttpStatusMock = vi.mocked(Sentry.setHttpStatus); + +function lastSpanOptions(): Record { + const calls = startSpanMock.mock.calls; + const [options] = calls[calls.length - 1] ?? []; + return (options ?? {}) as unknown as Record; +} + +describe("normalizeHttpRoute", () => { + it("replaces integer path segments with :id", () => { + expect(normalizeHttpRoute("/api/workorders/9/media")).toBe("/api/workorders/:id/media"); + }); + + it("replaces UUID path segments with :id", () => { + expect(normalizeHttpRoute("/uplifts/3f2504e0-4f89-11d3-9a0c-0305e82c3301/evidence")).toBe( + "/uplifts/:id/evidence", + ); + }); + + it("replaces long hex and opaque token segments with :id", () => { + expect(normalizeHttpRoute("/files/deadbeefcafe1234")).toBe("/files/:id"); + expect(normalizeHttpRoute("/share/Ab12Cd34Ef56Gh78")).toBe("/share/:id"); + }); + + it("strips query strings and fragments without using their values", () => { + expect(normalizeHttpRoute("/vendor-portal/dispatches?status=pending#x")).toBe( + "/vendor-portal/dispatches", + ); + }); + + it("keeps descriptive segments untouched", () => { + expect(normalizeHttpRoute("/vendor-operations/insights.csv")).toBe( + "/vendor-operations/insights.csv", + ); + }); + + it("uses only the pathname of absolute URLs", () => { + expect(normalizeHttpRoute("https://api.example.com/workorders/9/media?q=1")).toBe( + "/workorders/:id/media", + ); + }); +}); + +describe("traceHttpOperation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts a forced http.client transaction with safe attributes", async () => { + await traceHttpOperation( + { method: "get", url: "/workorders/9/media?include=x", entryPoint: "api.apiGet" }, + async () => "ok", + ); + + const options = lastSpanOptions(); + expect(startSpanMock).toHaveBeenCalledTimes(1); + expect(options.name).toBe("GET /workorders/:id/media"); + expect(options.op).toBe("http.client"); + expect(options.kind).toBe(2); + expect(options.forceTransaction).toBe(true); + expect(options.attributes).toEqual({ + "http.request.method": "GET", + "http.route": "/workorders/:id/media", + "code.function": "api.apiGet", + "operation.type": "http.client", + }); + }); + + it("adds actor.type only when provided and native span ids as attributes", async () => { + await traceHttpOperation( + { + method: "POST", + url: "/vendor-portal/dispatches", + entryPoint: "vendorPortalApi.call", + actorType: "vendor_link", + }, + async () => null, + ); + + expect(lastSpanOptions().attributes).toMatchObject({ + "actor.type": "vendor_link", + }); + expect(capturedSpan.setAttribute).toHaveBeenCalledWith("trace_id", "trace-1"); + expect(capturedSpan.setAttribute).toHaveBeenCalledWith("span_id", "span-1"); + expect(capturedSpan.setAttribute).toHaveBeenCalledWith("transaction_id", "span-1"); + }); + + it("reports the status of a returned Response", async () => { + const response = new Response("{}", { status: 201 }); + + await traceHttpOperation({ method: "POST", url: "/x", entryPoint: "fn" }, async () => response); + + expect(setHttpStatusMock).toHaveBeenCalledTimes(1); + expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 201); + }); + + it("exposes a single-shot status reporter for non-Response results", async () => { + const result = await traceHttpOperation( + { method: "GET", url: "/x", entryPoint: "fn" }, + async ({ reportStatus }) => { + reportStatus(204); + reportStatus(500); + return "done"; + }, + ); + + expect(result).toBe("done"); + expect(setHttpStatusMock).toHaveBeenCalledTimes(1); + expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 204); + }); + + it("reports status 500 when the callback throws", async () => { + await expect( + traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 500); + }); +}); diff --git a/src/test/observability/sentry-scrubber.test.ts b/src/test/observability/sentry-scrubber.test.ts new file mode 100644 index 00000000..2726d22f --- /dev/null +++ b/src/test/observability/sentry-scrubber.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { + scrubBreadcrumb, + scrubErrorEvent, + scrubTransactionEvent, +} from "@/observability/sentry-scrubber"; + +describe("Sentry telemetry scrubbing", () => { + it("keeps only an opaque user id and safe request method/path on errors", () => { + const event = scrubErrorEvent({ + type: undefined, + user: { + id: "opaque-user-123", + email: "secret.user@example.com", + username: "Secret User", + ip_address: "203.0.113.10", + }, + request: { + method: "GET", + url: "https://app.example.com/workorders/123?token=secret", + headers: { Authorization: "Bearer secret" }, + cookies: { session: "secret" }, + data: "secret-body", + }, + contexts: { + trace: { + trace_id: "0123456789abcdef0123456789abcdef", + span_id: "0123456789abcdef", + op: "http.client", + }, + }, + }); + + expect(event.user).toEqual({ id: "opaque-user-123" }); + expect(event.request).toEqual({ + method: "GET", + url: "https://app.example.com/workorders/:id", + }); + expect(event.tags).toMatchObject({ + trace_id: "0123456789abcdef0123456789abcdef", + transaction_id: "0123456789abcdef", + "operation.type": "http.client", + "actor.type": "authenticated", + }); + }); + + it("applies the same request/user contract to transactions", () => { + const event = scrubTransactionEvent({ + type: "transaction", + transaction: "GET /workorders/:id", + user: { id: "opaque-user-123", email: "secret.user@example.com" }, + request: { + method: "GET", + url: "/workorders/123?token=secret", + query_string: "token=secret", + }, + contexts: { + trace: { + trace_id: "fedcba9876543210fedcba9876543210", + span_id: "fedcba9876543210", + op: "navigation", + data: { + "actor.type": "vendor_link", + "operation.type": "http.client", + "code.function": "vendorPortalApi.call", + "http.query": "token=secret", + }, + }, + }, + spans: [ + { + trace_id: "fedcba9876543210fedcba9876543210", + span_id: "1111111111111111", + parent_span_id: "fedcba9876543210", + op: "http.client", + start_timestamp: 1, + timestamp: 2, + description: "GET https://api.example.com/workorders/123?token=secret", + data: { + "http.request.method": "GET", + "http.route": "/workorders/:id", + "http.response.status_code": 200, + "code.function": "vendorPortalApi.call", + "http.query": "token=secret", + "url.full": "https://api.example.com/workorders/123?token=secret", + request_body: "secret-body", + }, + }, + ], + }); + + expect(event.user).toEqual({ id: "opaque-user-123" }); + expect(event.request).toEqual({ + method: "GET", + url: `${window.location.origin}/workorders/:id`, + }); + expect(event.tags).toMatchObject({ + trace_id: "fedcba9876543210fedcba9876543210", + transaction_id: "fedcba9876543210", + "operation.type": "http.client", + "code.function": "vendorPortalApi.call", + "actor.type": "vendor_link", + }); + expect(event.contexts?.trace?.data).toEqual({ + "actor.type": "vendor_link", + "operation.type": "http.client", + "code.function": "vendorPortalApi.call", + }); + expect(event.spans).toEqual([ + expect.objectContaining({ + trace_id: "fedcba9876543210fedcba9876543210", + span_id: "1111111111111111", + parent_span_id: "fedcba9876543210", + op: "http.client", + start_timestamp: 1, + timestamp: 2, + description: "GET https://api.example.com/workorders/:id", + data: { + "http.request.method": "GET", + "http.route": "/workorders/:id", + "http.response.status_code": 200, + "code.function": "vendorPortalApi.call", + }, + }), + ]); + }); + + it("retains only safe network breadcrumb details", () => { + const breadcrumb = scrubBreadcrumb({ + category: "fetch", + message: "secret free-form text", + data: { + method: "POST", + status_code: 201, + url: "/uplifts/3f2504e0-4f89-11d3-9a0c-0305e82c3301?token=secret", + request_body: "secret", + }, + }); + + expect(breadcrumb.message).toBeUndefined(); + expect(breadcrumb.data).toEqual({ + method: "POST", + status_code: 201, + url: `${window.location.origin}/uplifts/:id`, + }); + }); +}); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index c7b43287..820c0ca5 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -10,3 +10,6 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +declare const __APP_COMMIT_SHA__: string; +declare const __SENTRY_RELEASE__: string; diff --git a/tsconfig.node.json b/tsconfig.node.json index 5fc395e4..8e08c499 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -21,6 +21,8 @@ "vitest.config.ts", "config/api-url-contract.ts", "config/api-url-contract.test.ts", + "config/sentry-release.ts", + "config/sentry-release.test.ts", "config/pwa-manifest.test.ts" ] } diff --git a/vite.config.ts b/vite.config.ts index 57ee9fcf..79ced947 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,33 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { execFileSync } from "node:child_process"; import path from "node:path"; import { defineConfig, loadEnv } from "vite"; import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract"; +import { resolveSentryRelease } from "./config/sentry-release"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); assertAbsoluteApiUrlHasApiSegment(env.VITE_API_URL); + let gitCommit: string | undefined; + try { + gitCommit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + } catch { + gitCommit = undefined; + } + const sentryRelease = resolveSentryRelease( + env.VITE_APP_COMMIT_SHA || process.env.GITHUB_SHA || gitCommit, + process.env.CI === "true", + ); + return { plugins: [react(), tailwindcss()], + define: { + __APP_COMMIT_SHA__: JSON.stringify(sentryRelease.commitSha), + __SENTRY_RELEASE__: JSON.stringify(sentryRelease.release), + }, resolve: { alias: { "@": path.resolve(__dirname, "src"), @@ -28,6 +45,7 @@ export default defineConfig(({ mode }) => { }, build: { outDir: "dist", + sourcemap: "hidden", }, }; }); From fa866c5ea7e3e25966f3cb158eb8d8fd879cbe74 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Thu, 3 Sep 2026 21:51:40 -0300 Subject: [PATCH 05/37] fix(observability): preserve HTTP error status --- src/observability/http-transaction.ts | 3 ++- .../observability/http-transaction.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/observability/http-transaction.ts b/src/observability/http-transaction.ts index 8f3b1507..c6b224ab 100644 --- a/src/observability/http-transaction.ts +++ b/src/observability/http-transaction.ts @@ -1,4 +1,5 @@ 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; @@ -101,7 +102,7 @@ export async function traceHttpOperation( return result; } catch (error) { if (!statusReported) { - reportStatus(500); + reportStatus(isHTTPError(error) ? error.response.status : 500); } throw error; } diff --git a/src/test/observability/http-transaction.test.ts b/src/test/observability/http-transaction.test.ts index e08b2209..c90bc9bf 100644 --- a/src/test/observability/http-transaction.test.ts +++ b/src/test/observability/http-transaction.test.ts @@ -11,6 +11,7 @@ vi.mock("@sentry/react", () => ({ })); import * as Sentry from "@sentry/react"; +import { HTTPError } from "ky"; import { normalizeHttpRoute, traceHttpOperation } from "@/observability/http-transaction"; const startSpanMock = vi.mocked(Sentry.startSpan); @@ -125,6 +126,23 @@ describe("traceHttpOperation", () => { expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 204); }); + it("reports the Ky HTTPError response status instead of 500", async () => { + const httpError = new HTTPError( + new Response("Unauthorized", { status: 401 }), + new Request("https://api.example.test/x"), + {} as never, + ); + + await expect( + traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => { + throw httpError; + }), + ).rejects.toThrow(); + + expect(setHttpStatusMock).toHaveBeenCalledTimes(1); + expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 401); + }); + it("reports status 500 when the callback throws", async () => { await expect( traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => { From 74e5c58e5decc80d842607c042cbec22f3bfb23c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 7 Sep 2026 15:19:39 -0300 Subject: [PATCH 06/37] fix(work-orders): restore completion media uploads over HTTP/1.1 Use XHR for media progress so Ky does not stream the body on HTTP/1.1 (Vite proxy), and keep completion vs extra tiles from mixing after upload. --- src/api/api.ts | 16 +- src/api/upload-form-with-progress.ts | 103 ++++++++++ .../detail/use-slide-over-media-state.ts | 72 +++++-- .../_components/media/media-upload-modal.tsx | 22 +-- .../media/media-uploader-dropzone.tsx | 6 +- .../media/media-uploader-grid-item-meta.tsx | 8 +- .../_components/media/media-uploader.tsx | 7 +- .../api/work-order-board-documents-api.ts | 37 ++-- .../mappers/work-order-board-detail-mapper.ts | 3 +- .../work-orders/utils/display-file-name.ts | 10 + .../utils/partition-slide-over-media.ts | 44 +++++ .../utils/work-order-media-limits.ts | 10 +- src/lib/auth/expire-session.ts | 12 ++ .../api/upload-form-with-progress.test.ts | 184 ++++++++++++++++++ .../media-upload-modal-api.test.tsx | 34 +++- .../use-slide-over-media-state.test.tsx | 81 +++++++- ...ork-orders-board-core-affordances.test.tsx | 24 +++ .../work-orders/api/work-orders-api.test.ts | 44 +++-- .../work-order-board-detail-mapper.test.ts | 14 ++ .../work-order-table-row-mapper.test.ts | 19 ++ .../utils/display-file-name.test.ts | 24 +++ .../utils/partition-slide-over-media.test.ts | 79 ++++++++ .../utils/work-order-media-limits.test.ts | 1 + 23 files changed, 757 insertions(+), 97 deletions(-) create mode 100644 src/api/upload-form-with-progress.ts create mode 100644 src/domain/work-orders/utils/display-file-name.ts create mode 100644 src/domain/work-orders/utils/partition-slide-over-media.ts create mode 100644 src/lib/auth/expire-session.ts create mode 100644 src/test/api/upload-form-with-progress.test.ts create mode 100644 src/test/domain/work-orders/utils/display-file-name.test.ts create mode 100644 src/test/domain/work-orders/utils/partition-slide-over-media.test.ts diff --git a/src/api/api.ts b/src/api/api.ts index 51897b61..3cb8a7f0 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3,18 +3,8 @@ 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"; function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); @@ -51,7 +41,7 @@ export const api: KyInstance = ky.create({ return response; } - clearSessionAndRedirectToLogin(); + expireSessionAndRedirectToLogin(); return response; }, ], diff --git a/src/api/upload-form-with-progress.ts b/src/api/upload-form-with-progress.ts new file mode 100644 index 00000000..90a2a254 --- /dev/null +++ b/src/api/upload-form-with-progress.ts @@ -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 { + 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); + }); +} diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 94b60262..fe011400 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, 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,12 @@ 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, +} from "@/domain/work-orders/utils/partition-slide-over-media"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -20,6 +23,20 @@ type UseSlideOverMediaStateArgs = { categorizeMediaMutation: ReturnType; }; +function addOwnedId(previous: Set, id: string | undefined): Set { + if (!id) return previous; + const next = new Set(previous); + next.add(id); + return next; +} + +function dropOwnedId(previous: Set, id: string): Set { + if (!previous.has(id)) return previous; + const next = new Set(previous); + next.delete(id); + return next; +} + export function useSlideOverMediaState({ detailForRow, uploadMediaMutation, @@ -27,28 +44,38 @@ export function useSlideOverMediaState({ categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { const [mediaBatchPending, setMediaBatchPending] = useState(false); + const [completionOwnedIds, setCompletionOwnedIds] = useState>(() => new Set()); + const [extraOwnedIds, setExtraOwnedIds] = useState>(() => new Set()); + + const persistCompletionUpload = useCallback( + async (input: UploadWorkOrderMediaInput) => { + const item = await uploadMediaMutation.mutateAsync(input); + setCompletionOwnedIds((previous) => addOwnedId(previous, item.id)); + setExtraOwnedIds((previous) => dropOwnedId(previous, item.id)); + return item; + }, + [uploadMediaMutation], + ); + const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = - useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input)); + useMediaUploadSession(detailForRow, persistCompletionUpload); + + const ownership = useMemo( + () => ({ completionOwnedIds, extraOwnedIds }), + [completionOwnedIds, extraOwnedIds], + ); 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 +95,9 @@ 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" }); + setExtraOwnedIds((previous) => addOwnedId(previous, item.id)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, item.id)); } } finally { setMediaBatchPending(false); @@ -81,6 +110,13 @@ export function useSlideOverMediaState({ toast.error("Missing work order version. Refresh and try again."); return; } + if (category === "Extra") { + setExtraOwnedIds((previous) => addOwnedId(previous, mediaId)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, mediaId)); + } else { + setExtraOwnedIds((previous) => dropOwnedId(previous, mediaId)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, mediaId)); + } categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion }); }; diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx index ba84fa1c..e3aa23ee 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -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 > e.stopPropagation()}>
- - + + Photos & Videos - #{row.woNumber} + + #{row.woNumber} +
- Upload photos and videos for this work order, then assign Before, After, or Extra. + Upload photos and videos for this work order.
@@ -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} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx index e6b3aa19..439b4693 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -8,12 +8,14 @@ import { type MediaUploaderDropzoneProps = { inputRef: RefObject; pending: boolean; + categorizeAfterUpload?: boolean; onFilesSelected: (files: FileList | null) => void; }; export function MediaUploaderDropzone({ inputRef, pending, + categorizeAfterUpload = false, onFilesSelected, }: MediaUploaderDropzoneProps) { return ( @@ -39,8 +41,8 @@ export function MediaUploaderDropzone({ {pending ? "Uploading…" : "Drag files here or click to browse"} - Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after - upload + Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file + {categorizeAfterUpload ? " · categorize after upload" : ""} @@ -34,7 +35,7 @@ export function MediaUploaderGridItemMeta({ {file.errorMessage || "Upload failed"}
)} - {needsCategory ? ( + {needsCategory && ( - ) : ( + )} + {Boolean(!needsCategory && managesPurpose) && (
{file.purpose || (file.isLegacy ? "Legacy" : "")}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 69f4c364..aed81332 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -91,7 +91,12 @@ export function MediaUploader({ return (
{!readOnly && ( - + )} { } } -function throwDocumentHttpError(response: Response, data: unknown): never { +function throwDocumentHttpError(status: number, data: unknown): never { const record = typeof data === "object" && data !== null ? (data as Record) : {}; - if (response.status === 409) { + if (status === 409) { throw new BoardPatchConflictError( typeof record.message === "string" ? record.message @@ -45,14 +46,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 = { @@ -94,7 +95,7 @@ export const workOrderBoardDocumentsApi = { return mapBoardDetailComment(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, /** @deprecated Prefer createComment — modern board endpoint. */ @@ -119,21 +120,15 @@ export const workOrderBoardDocumentsApi = { options?: { signal?: AbortSignal; onProgress?: (percent: number) => void }, ): Promise => { 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 uploadFormWithProgress( + API_PATHS.workOrder.media(workOrderId), + formData, + { signal: options?.signal, onProgress: options?.onProgress }, + ); + if (ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(status, data); }, updateMediaCategory: async ( @@ -160,7 +155,7 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, deleteMedia: async ( @@ -196,7 +191,7 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return; } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, uploadCompletionDoc: async ( @@ -224,6 +219,6 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return mapCompletion(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, }; diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index 950f8fd2..e2ae7411 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -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), }; diff --git a/src/domain/work-orders/utils/display-file-name.ts b/src/domain/work-orders/utils/display-file-name.ts new file mode 100644 index 00000000..65659c24 --- /dev/null +++ b/src/domain/work-orders/utils/display-file-name.ts @@ -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; +} diff --git a/src/domain/work-orders/utils/partition-slide-over-media.ts b/src/domain/work-orders/utils/partition-slide-over-media.ts new file mode 100644 index 00000000..5de1e4ad --- /dev/null +++ b/src/domain/work-orders/utils/partition-slide-over-media.ts @@ -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; + extraOwnedIds: ReadonlySet; +}; + +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); +} diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index 58cc358a..d93fb7cb 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,7 +1,13 @@ /** Backend RequestSizeLimit for media and completion-doc uploads. */ export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024; -const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]); +const ALLOWED_MIME_TYPES = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "video/mp4", + "video/quicktime", +]); const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); @@ -22,7 +28,7 @@ export function isWorkOrderMediaTypeAllowed(file: File): boolean { } 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 formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { diff --git a/src/lib/auth/expire-session.ts b/src/lib/auth/expire-session.ts new file mode 100644 index 00000000..0c7c8900 --- /dev/null +++ b/src/lib/auth/expire-session.ts @@ -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"); + } +} diff --git a/src/test/api/upload-form-with-progress.test.ts b/src/test/api/upload-form-with-progress.test.ts new file mode 100644 index 00000000..18a4f4dc --- /dev/null +++ b/src/test/api/upload-form-with-progress.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const expireSession = vi.fn(); + +vi.mock("@/lib/auth/expire-session", () => ({ + expireSessionAndRedirectToLogin: () => expireSession(), +})); + +import { uploadFormWithProgress } from "@/api/upload-form-with-progress"; + +type Listener = (event?: unknown) => void; + +class ListenerBag { + private readonly listeners = new Map(); + + addEventListener(type: string, listener: Listener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener(type: string, listener: Listener): void { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((entry) => entry !== listener), + ); + } + + emit(type: string, event?: unknown): void { + for (const listener of [...(this.listeners.get(type) ?? [])]) { + listener(event); + } + } +} + +class FakeXhr extends ListenerBag { + static last: FakeXhr | undefined; + + readonly upload = new ListenerBag(); + readonly headers: Record = {}; + status = 0; + responseText = ""; + method = ""; + url = ""; + body: FormData | undefined; + abortCount = 0; + + constructor() { + super(); + FakeXhr.last = this; + } + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.headers[name] = value; + } + + send(body: FormData): void { + this.body = body; + } + + abort(): void { + this.abortCount += 1; + this.emit("abort"); + } + + respond(status: number, responseText = ""): void { + this.status = status; + this.responseText = responseText; + this.emit("load"); + } +} + +function currentXhr(): FakeXhr { + const instance = FakeXhr.last; + if (!instance) throw new Error("No XMLHttpRequest was created"); + return instance; +} + +function formWithFile(): FormData { + const body = new FormData(); + body.append("file", new File(["bytes"], "after.jpg", { type: "image/jpeg" })); + return body; +} + +describe("uploadFormWithProgress", () => { + const originalXhr = globalThis.XMLHttpRequest; + + beforeEach(() => { + expireSession.mockReset(); + FakeXhr.last = undefined; + globalThis.XMLHttpRequest = FakeXhr as unknown as typeof XMLHttpRequest; + localStorage.setItem("auth", JSON.stringify({ token: "abc123" })); + }); + + afterEach(() => { + globalThis.XMLHttpRequest = originalXhr; + localStorage.clear(); + }); + + it("POSTs the FormData with the stored bearer token and no streamed body", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + const request = currentXhr(); + + expect(request.method).toBe("POST"); + expect(request.url.endsWith("/api/workorders/10/media")).toBe(true); + expect(request.headers.Authorization).toBe("Bearer abc123"); + expect(request.body).toBeInstanceOf(FormData); + + request.respond(201, JSON.stringify({ data: { id: 12 } })); + + await expect(pending).resolves.toEqual({ + ok: true, + status: 201, + data: { data: { id: 12 } }, + }); + }); + + it("reports upload progress as whole percentages", async () => { + const onProgress = vi.fn(); + const pending = uploadFormWithProgress("workorders/10/media", formWithFile(), { onProgress }); + const request = currentXhr(); + + request.upload.emit("progress", { lengthComputable: true, loaded: 25, total: 200 }); + request.upload.emit("progress", { lengthComputable: false, loaded: 30, total: 200 }); + request.respond(200, "{}"); + await pending; + + expect(onProgress.mock.calls).toEqual([[13]]); + }); + + it("resolves non-2xx responses so callers can map API errors", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().respond( + 422, + JSON.stringify({ code: "ReadOnly", message: "Work order is closed." }), + ); + + await expect(pending).resolves.toEqual({ + ok: false, + status: 422, + data: { code: "ReadOnly", message: "Work order is closed." }, + }); + expect(expireSession).not.toHaveBeenCalled(); + }); + + it("expires the session on 401", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().respond(401); + + await expect(pending).resolves.toMatchObject({ ok: false, status: 401 }); + expect(expireSession).toHaveBeenCalledTimes(1); + }); + + it("rejects transport failures with an actionable message", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().emit("error"); + + await expect(pending).rejects.toThrow(/Upload failed/); + }); + + it("aborts the request when the caller signal aborts", async () => { + const controller = new AbortController(); + const pending = uploadFormWithProgress("workorders/10/media", formWithFile(), { + signal: controller.signal, + }); + + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(currentXhr().abortCount).toBe(1); + }); + + it("rejects immediately when the signal is already aborted", async () => { + await expect( + uploadFormWithProgress("workorders/10/media", formWithFile(), { + signal: AbortSignal.abort(), + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(FakeXhr.last).toBeUndefined(); + }); +}); diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index fd628353..b1fb4451 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -116,24 +116,42 @@ describe("MediaUploadModal API wiring", () => { expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); - expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); }); - it("categorizes with workOrderVersion from detail.info.rowVersion", async () => { + it("offers no purpose picker — the table modal only uploads and removes", async () => { renderModal(); await waitFor(() => { - expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); + expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); - fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), { - target: { value: "After" }, + expect(screen.queryByLabelText(/category for before\.jpg/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); + expect(categorizeMutate).not.toHaveBeenCalled(); + }); + + it("shows only the file name under each tile — no purpose label", async () => { + renderModal(); + + await waitFor(() => { + expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); - expect(categorizeMutate).toHaveBeenCalledWith({ + expect(screen.queryByText("Before")).not.toBeInTheDocument(); + expect(screen.queryByText("Extra")).not.toBeInTheDocument(); + }); + + it("removes media with workOrderVersion from detail.info.rowVersion", async () => { + renderModal(); + + await waitFor(() => { + expect(screen.getByText("before.jpg")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /^remove$/i })); + + expect(deleteMutate).toHaveBeenCalledWith({ mediaId: "11", - category: "After", workOrderVersion: "DETAIL-VER==", }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index d3504fbc..de7e96ff 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; @@ -119,6 +119,14 @@ describe("useSlideOverMediaState completion filter", () => { uploadedAt: "2026-07-15T18:00:00.0000000Z", isLegacy: false, }, + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, ]); const { result } = renderHook(() => @@ -134,7 +142,9 @@ describe("useSlideOverMediaState completion filter", () => { "Before", "After", "Completion", + "Extra", ]); + expect(result.current.completionMedia.map((item) => item.id)).toContain("10"); expect( result.current.completionMedia.every((item) => ["Photo", "Video", "Report", "Other"].includes(item.category), @@ -142,6 +152,32 @@ describe("useSlideOverMediaState completion filter", () => { ).toBe(true); expect(result.current.extrasUploads).toHaveLength(1); expect(result.current.extrasUploads[0]?.purpose).toBe("Extra"); + expect(result.current.extrasUploads[0]?.id).toBe("4"); + }); + + it("keeps Extra photos on Completion Doc until Extra is assigned", () => { + const detail = detailWithMedia([ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ]); + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.completionMedia.map((item) => item.id)).toEqual(["10"]); + expect(result.current.extrasUploads).toHaveLength(0); }); it("includes Aveta-categorized extra documents in extrasUploads", () => { @@ -177,4 +213,47 @@ describe("useSlideOverMediaState completion filter", () => { expect(result.current.extrasUploads.map((item) => item.purpose)).toEqual(["Extra", "Aveta"]); expect(result.current.extrasUploads[1]?.category).toBe("Aveta"); }); + + it("moves an Extra photo to Extra Docs after assigning Extra", () => { + const detail = detailWithMedia([ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ]); + const categorizeMutate = vi.fn(); + const categorize = { + mutate: categorizeMutate, + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + } as never; + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: categorize, + }), + ); + + expect(result.current.completionMedia).toHaveLength(1); + expect(result.current.extrasUploads).toHaveLength(0); + + act(() => { + result.current.categorizeMedia("10", "Extra"); + }); + + expect(result.current.completionMedia).toHaveLength(0); + expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]); + expect(categorizeMutate).toHaveBeenCalledWith({ + mediaId: "10", + category: "Extra", + workOrderVersion: "1", + }); + }); }); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index e273324c..c466867d 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -271,4 +271,28 @@ describe("CompDocCell slice affordances", () => { expect(screen.getByRole("button", { name: /view doc/i })).toBeInTheDocument(); }); + + it("shows the uploaded file count for media work orders", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /2 files/i })).toBeInTheDocument(); + }); + + it("shows a singular label with one file and the Media prompt with none", () => { + const { unmount } = renderWithProviders( + , + { withAuth: false }, + ); + expect(screen.getByRole("button", { name: /^1 file$/i })).toBeInTheDocument(); + unmount(); + + renderWithProviders( + , + { withAuth: false }, + ); + expect(screen.getByRole("button", { name: /^media$/i })).toBeInTheDocument(); + }); }); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index d383aefc..7d7a3d1f 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -9,6 +9,11 @@ const apiPatch = vi.fn(); const apiPatchFn = vi.fn(); const apiPostFn = vi.fn(); const apiDeleteFn = vi.fn(); +const uploadFormFn = vi.fn(); + +vi.mock("@/api/upload-form-with-progress", () => ({ + uploadFormWithProgress: (...args: unknown[]) => uploadFormFn(...args), +})); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), @@ -654,44 +659,55 @@ describe("workOrdersApi.updateComment", () => { describe("workOrdersApi.uploadMedia", () => { beforeEach(() => { - apiPostFn.mockReset(); + uploadFormFn.mockReset(); }); - it("POSTs multipart FormData with file and category", async () => { - apiPostFn.mockResolvedValue({ + it("POSTs multipart FormData with file and category, forwarding progress and signal", async () => { + uploadFormFn.mockResolvedValue({ ok: true, - json: async () => ({ + status: 201, + data: { id: 12, category: 3, url: "https://cdn.example.com/extra.jpg", fileName: "extra.jpg", uploadedAt: "2026-07-16T12:00:00Z", isLegacy: false, - }), + }, }); const file = new File(["bytes"], "extra.jpg", { type: "image/jpeg" }); - const item = await workOrdersApi.uploadMedia(10, file, "Extra"); + const onProgress = vi.fn(); + const signal = new AbortController().signal; + const item = await workOrdersApi.uploadMedia(10, file, "Extra", { signal, onProgress }); - expect(apiPostFn).toHaveBeenCalledWith( - API_PATHS.workOrder.media(10), - expect.objectContaining({ throwHttpErrors: false }), - ); - const body = apiPostFn.mock.calls[0]?.[1]?.body as FormData; + expect(uploadFormFn).toHaveBeenCalledWith(API_PATHS.workOrder.media(10), expect.any(FormData), { + signal, + onProgress, + }); + const body = uploadFormFn.mock.calls[0]?.[1] as FormData; expect(body.get("category")).toBe("Extra"); expect(body.get("file")).toBeInstanceOf(File); expect(item.id).toBe("12"); expect(item.category).toBe("Extra"); }); + it("does not upload through Ky, whose streamed body breaks HTTP/1.1 transports", async () => { + uploadFormFn.mockResolvedValue({ ok: true, status: 200, data: { id: 12, category: 3 } }); + + await workOrdersApi.uploadMedia(10, new File(["x"], "a.jpg", { type: "image/jpeg" }), "Before"); + + expect(apiPostFn).not.toHaveBeenCalled(); + }); + it("throws BoardPatchValidationError on 422", async () => { - apiPostFn.mockResolvedValue({ + uploadFormFn.mockResolvedValue({ ok: false, status: 422, - json: async () => ({ + data: { code: "ReadOnly", message: "Work order is read-only.", - }), + }, }); const { BoardPatchValidationError } = diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts index 21867fa4..7f34bcc8 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts @@ -236,6 +236,20 @@ describe("mapWorkOrderBoardDetail", () => { }); }); + it("shows the uploaded file name instead of the stored guid name", () => { + const stored = mapBoardDetailMediaItem({ + id: 30, + category: 3, + url: "https://cdn.example.com/7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg", + fileName: "7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg", + }); + expect(stored.fileName).toBe("6370_1_Before.jpg"); + expect(boardMediaItemsToMediaFiles([stored])[0]).toMatchObject({ + name: "6370_1_Before.jpg", + fileType: "image", + }); + }); + it("maps numeric category and marks negative ids as legacy", () => { const legacy = mapBoardDetailMediaItem({ id: -1, diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 81b8e3e8..0b0e78c5 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -90,6 +90,25 @@ describe("mapWorkOrderTableRow avetaRequired", () => { }); }); +describe("mapWorkOrderTableRow mediaCount", () => { + it("maps the board mediaCount that feeds the CompDoc file count", () => { + const row = mapWorkOrderTableRow({ + id: 11, + workOrderType: WORK_ORDER_TYPE_API.Emergency, + mediaCount: 2, + }); + expect(row.mediaCount).toBe(2); + }); + + it("defaults mediaCount to zero when the board omits it", () => { + const row = mapWorkOrderTableRow({ + id: 12, + workOrderType: WORK_ORDER_TYPE_API.Emergency, + }); + expect(row.mediaCount).toBe(0); + }); +}); + describe("mapWorkOrderTableRow primaryDispatchStatus", () => { it.each([ ["primaryDispatchStatus", "Refused"], diff --git a/src/test/domain/work-orders/utils/display-file-name.test.ts b/src/test/domain/work-orders/utils/display-file-name.test.ts new file mode 100644 index 00000000..5c51f417 --- /dev/null +++ b/src/test/domain/work-orders/utils/display-file-name.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name"; + +describe("toDisplayFileName", () => { + it("drops the storage guid prefix and keeps the original name", () => { + expect(toDisplayFileName("7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg")).toBe( + "6370_1_Before.jpg", + ); + }); + + it("matches the prefix regardless of guid casing", () => { + expect(toDisplayFileName("7A0CF3B5-3EEB-4C4F-A86E-8E2DA1B0C9F4_report.pdf")).toBe("report.pdf"); + }); + + it("keeps names that do not carry a guid prefix", () => { + expect(toDisplayFileName("before.jpg")).toBe("before.jpg"); + expect(toDisplayFileName("2026_summary_report.pdf")).toBe("2026_summary_report.pdf"); + }); + + it("falls back to the raw name when nothing follows the prefix", () => { + const onlyPrefix = "7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_"; + expect(toDisplayFileName(onlyPrefix)).toBe(onlyPrefix); + }); +}); diff --git a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts new file mode 100644 index 00000000..077eff57 --- /dev/null +++ b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { + isCompletionSurfaceMedia, + isExtrasSurfaceMedia, +} from "@/domain/work-orders/utils/partition-slide-over-media"; + +function file( + overrides: Partial & Pick, +): MediaFile { + return { + name: overrides.name ?? overrides.id, + url: "https://cdn.example.com/file", + category: "Photo", + uploadStatus: "done", + ...overrides, + }; +} + +describe("partition-slide-over-media", () => { + it("keeps unowned Extra photos on Completion Doc, not Extra Docs", () => { + const photo = file({ + id: "10", + fileType: "image", + purpose: "Extra", + name: "site.jpg", + }); + + expect(isCompletionSurfaceMedia(photo)).toBe(true); + expect(isExtrasSurfaceMedia(photo)).toBe(false); + }); + + it("keeps Extra PDFs on Extra Docs, not Completion Doc", () => { + const pdf = file({ + id: "4", + fileType: "pdf", + purpose: "Extra", + name: "extra.pdf", + category: "Other", + }); + + expect(isCompletionSurfaceMedia(pdf)).toBe(false); + expect(isExtrasSurfaceMedia(pdf)).toBe(true); + }); + + it("moves Extra photo to Extra Docs after explicit Extra ownership", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(), + extraOwnedIds: new Set(["10"]), + }; + + expect(isCompletionSurfaceMedia(photo, ownership)).toBe(false); + expect(isExtrasSurfaceMedia(photo, ownership)).toBe(true); + }); + + it("keeps Extra photo on Completion Doc while completion-owned", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(["10"]), + extraOwnedIds: new Set(), + }; + + expect(isCompletionSurfaceMedia(photo, ownership)).toBe(true); + expect(isExtrasSurfaceMedia(photo, ownership)).toBe(false); + }); + + it("keeps Before/After/Completion and in-flight tiles on Completion Doc", () => { + expect(isCompletionSurfaceMedia(file({ id: "1", fileType: "image", purpose: "Before" }))).toBe( + true, + ); + expect( + isCompletionSurfaceMedia({ + ...file({ id: "local-1", fileType: "image", purpose: null }), + uploadStatus: "uploading", + }), + ).toBe(true); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index 2ae1f827..49af4e90 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -27,6 +27,7 @@ describe("work-order-media-limits", () => { it("allows only JPG PNG MP4 MOV by MIME or extension", () => { expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true); + expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpg" } as File)).toBe(true); expect(isWorkOrderMediaTypeAllowed({ name: "a.png", type: "image/png" } as File)).toBe(true); expect(isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "video/mp4" } as File)).toBe(true); expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe( From 747515870e107dedbe64d7646d6c45b60d5e0b23 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 7 Sep 2026 20:48:30 -0300 Subject: [PATCH 07/37] fix(workorders): correct vendor save behavior --- .../table/cells/use-vendor-dialog-form.ts | 177 ++++++++++++++++++ .../table/cells/use-vendor-dialog-save.ts | 7 +- .../list/table/cells/vendor-dialog-body.tsx | 59 ++++-- .../table/cells/vendor-dialog-can-submit.ts | 27 +++ .../cells/vendor-dialog-company-card.tsx | 7 +- .../list/table/cells/vendor-dialog-footer.tsx | 15 +- .../list/table/cells/vendor-dialog-options.ts | 50 ++++- .../list/table/cells/vendor-dialog.tsx | 143 ++++---------- .../work-orders/errors/board-patch-errors.ts | 8 + .../use-work-order-table-mutations.ts | 3 +- .../uplifts/reject-revoke-dialogs.test.tsx | 7 +- .../vendor-dialog-can-submit.test.ts | 95 ++++++++++ .../vendor-dialog-save-gate.test.tsx | 158 ++++++++++++++++ .../vendor-dialog-stable-identity.test.tsx | 4 +- ...er-table-mutations-assigned-toast.test.tsx | 35 ++++ 15 files changed, 652 insertions(+), 143 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit.ts create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-can-submit.test.ts create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts new file mode 100644 index 00000000..3bd9104b --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts @@ -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([]); + const [pendingCreates, setPendingCreates] = useState>({}); + const [saveError, setSaveError] = useState(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); + setAddedVendors([]); + setPendingCreates({}); + if (selectedOption?.company !== nextCompany) { + 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, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts index 502714c6..619bc6c9 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts @@ -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); }; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx index 5b58de5e..928bfe51 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx @@ -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({ <>
- Technician + Company + + } + searchPlaceholder="Search company…" + options={companyOptions.map((option) => ({ + key: option.key, + label: option.label, + }))} + onPick={onCompanyPick} + /> +
+ + {Boolean(resolvedCompany) && ( +
+ Company info + +
+ )} + +
+ Technician (optional) } 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) && (
- Technician Phone + Technician Phone (optional) )} - {Boolean(resolvedCompany) && ( -
- Company - -
- )} -
- Notes + Notes (optional)