Compare commits

...

12 commits

Author SHA1 Message Date
Arthur Bassi
c7aec49f05 fix(work-orders): keep completion freeze on live registry edits 2026-09-10 18:47:32 -03:00
Arthur Bassi
1809694973 Merge branch 'feat/SH-191-completion-freeze' of origin into feat/SH-191-completion-freeze 2026-09-10 18:16:27 -03:00
Arthur Bassi
2fbf2ed380
Merge branch 'dev' into feat/SH-191-completion-freeze 2026-09-10 18:15:15 -03:00
Arthur Bassi
99c758872a fix(work-orders): extract helpers to satisfy governance complexity 2026-09-10 18:15:02 -03:00
Alexandre Brandizzi
73346371e5
Merge pull request #167 from Sea-Haven-Industries/feat/sh-298-sentry
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy / deploy (push) Waiting to run
Deploy / Upload private source maps (push) Blocked by required conditions
feat: add browser Sentry tracing (SH-298)
2026-09-10 15:52:28 -03:00
Codex Review Integration
fdd97c24f9 Merge branch 'dev' into feat/sh-298-sentry
Three conflicts, all where dev refactored code this branch had instrumented:

- api.ts — dev extracted the session-expiry helper into
  lib/auth/expire-session. Took dev's import, dropped the now-duplicate local
  copy, kept the tracing import.
- work-order-board-documents-api.ts — dev replaced the ky upload with
  uploadFormWithProgress, an XHR path that exists because ky's
  onUploadProgress streams the body and browsers refuse that over HTTP/1.1.
  Kept dev's helper and wrapped it in traceHttpOperation so the upload stays
  instrumented; neither change is lost.
- work-orders-api.test.ts — kept both mock surfaces, since the merged
  work-orders-api calls apiRequestRaw while other code uses the ky instance.

getMediaContent arrived from dev calling `api.get` directly, which this file
no longer imports; routed it through apiRequestRaw like its siblings, which
also brings it under tracing.
2026-09-10 14:44:12 -03:00
Alexandre Brandizzi
d6163e7eb6
Merge branch 'dev' into feat/sh-298-sentry 2026-09-04 14:23:41 -03:00
Codex Review Integration
fa866c5ea7 fix(observability): preserve HTTP error status 2026-09-03 21:51:40 -03:00
Codex Review Integration
207199559f feat(observability): identify and scrub Sentry transactions 2026-09-03 17:12:49 -03:00
Codex Review Integration
dc313dc50a fix: capture React root errors in Sentry 2026-09-03 15:53:50 -03:00
Codex Review Integration
a24002bc24 feat: activate Sentry deployment environments 2026-09-03 15:10:01 -03:00
Codex Review Integration
c1863c4274 feat: add browser Sentry tracing 2026-09-03 13:59:13 -03:00
51 changed files with 1913 additions and 285 deletions

View file

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

View file

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

View file

@ -32,6 +32,8 @@ jobs:
environment: staging
env:
VITE_API_URL: https://api.staging.seahaven.com/api
VITE_SENTRY_ENVIRONMENT: staging
VITE_APP_COMMIT_SHA: ${{ github.sha }}
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -68,15 +70,21 @@ jobs:
role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging
aws-region: us-east-1
# Builds the SPA with the staging VITE_API_URL (process env overrides the
# dev value committed in .env.production), syncs to the staging bucket,
# and invalidates CloudFront.
# Builds the SPA with the staging VITE_API_URL and Sentry environment
# label (process env overrides the dev values committed in
# .env.production), syncs to the staging bucket, and invalidates
# CloudFront.
- name: Build and publish SPA
run: bash scripts/deploy-web.sh
env:
STACK_NAME: shoc-frontend-staging
WAIT_FOR_INVALIDATION: "true"
- name: Upload private source maps
run: bash scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Verify deployment
run: |
set -euo pipefail

View file

@ -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 }}

View file

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

32
config/sentry-release.ts Normal file
View file

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

View file

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

308
package-lock.json generated
View file

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

View file

@ -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",
@ -79,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",

View file

@ -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)..."

24
scripts/upload-sourcemaps.sh Executable file
View file

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

View file

@ -5,6 +5,7 @@ import { API_PATHS } from "@/api/api-paths";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { getAccessToken } from "@/lib/auth/auth-storage";
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
import { traceHttpOperation } from "@/observability/http-transaction";
function isAuthLoginRequest(request: Request): boolean {
return request.url.includes(API_PATHS.authentication.login);
@ -48,28 +49,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<Response> {
return traceHttpOperation(
{
method: method.toUpperCase(),
url,
entryPoint: entryPoint ?? `api.${method}`,
},
async ({ reportStatus }) => {
const response = await api[method](url, options);
reportStatus(response.status);
return response;
},
);
}
export async function apiGet<T>(url: string, options?: Options): Promise<T> {
return api.get(url, options).json<T>();
return traceHttpOperation(
{ method: "GET", url, entryPoint: "api.apiGet" },
async ({ reportStatus }) => {
const response = await api.get(url, options);
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPost<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.post(url, { ...options, json: body }).json<T>();
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPost" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
return api.post(url, { ...options, body }).json<T>();
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPostForm" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.put(url, { ...options, json: body }).json<T>();
return traceHttpOperation(
{ method: "PUT", url, entryPoint: "api.apiPut" },
async ({ reportStatus }) => {
const response = await api.put(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
return api.patch(url, { ...options, json: body }).json<T>();
return traceHttpOperation(
{ method: "PATCH", url, entryPoint: "api.apiPatch" },
async ({ reportStatus }) => {
const response = await api.patch(url, { ...options, json: body });
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
return api.delete(url, options).json<T>();
return traceHttpOperation(
{ method: "DELETE", url, entryPoint: "api.apiDelete" },
async ({ reportStatus }) => {
const response = await api.delete(url, options);
reportStatus(response.status);
return response.json<T>();
},
);
}
export async function apiPostNoContent(
@ -77,31 +142,43 @@ export async function apiPostNoContent(
body?: unknown,
options?: Options,
): Promise<void> {
const response = await api.post(url, { ...options, json: body });
if (response.status === 204) {
return;
}
return traceHttpOperation(
{ method: "POST", url, entryPoint: "api.apiPostNoContent" },
async ({ reportStatus }) => {
const response = await api.post(url, { ...options, json: body });
reportStatus(response.status);
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
JSON.parse(text);
},
);
}
export async function apiDeleteNoContent(url: string, options?: Options): Promise<void> {
const response = await api.delete(url, options);
if (response.status === 204) {
return;
}
return traceHttpOperation(
{ method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" },
async ({ reportStatus }) => {
const response = await api.delete(url, options);
reportStatus(response.status);
if (response.status === 204) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
const text = await response.text();
if (!text.trim()) {
return;
}
JSON.parse(text);
JSON.parse(text);
},
);
}
export { mapHttpStatusToMessage };

View file

@ -46,7 +46,7 @@ export function CompDocDetailsSection({
<CompDocDetailRow
icon={MapPin}
label="Site Code"
value={`${row.site} · Amazon.com Services LLC`}
value={`${site.code} · Amazon.com Services LLC`}
/>
<CompDocEditableRow
label="Address"

View file

@ -43,7 +43,7 @@ export function CompDocPreviewDocument({
isGenerated={isGenerated}
generatedAt={generatedAt}
/>
<CompDocPreviewVendorSafety row={row} />
<CompDocPreviewVendorSafety site={site} />
<CompDocPreviewProcedureNotes pm={row.pm} notes={notes} />
</div>
<div

View file

@ -72,10 +72,10 @@ export function CompDocPreviewServiceInfo({
margin: "2px 0",
}}
>
Site ID — {row.site}
Site ID — {site.code}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
Amazon.com Services LLC {site.code}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},

View file

@ -73,10 +73,10 @@ export function CompDocPreviewSummary({
margin: "2px 0",
}}
>
Site ID — {row.site}
Site ID — {site.code}
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
Amazon.com Services LLC {site.code}
</Text>
<Text variant="body" sx={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},
@ -88,8 +88,8 @@ export function CompDocPreviewSummary({
<Text variant="body" sx={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
{site.tech || "—"}
{site.company ? ` · ${site.company}` : ""}
</Text>
</>
);

View file

@ -1,18 +1,18 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
type CompDocPreviewVendorSafetyProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
};
export function CompDocPreviewVendorSafety({ row }: CompDocPreviewVendorSafetyProps) {
export function CompDocPreviewVendorSafety({ site }: CompDocPreviewVendorSafetyProps) {
return (
<>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
{site.tech || "—"}
{site.company ? ` · ${site.company}` : ""}
</Text>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>

View file

@ -100,8 +100,9 @@ export function useCompDocDialog({
setEditDateCompleted,
});
const company = effectiveCompany(info);
const amazonRep = effectivePoc(info).contacts[0]?.name || "";
const seaRep = effectiveCompany(info).contactName || "";
const seaRep = company.contactName || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
@ -114,6 +115,9 @@ export function useCompDocDialog({
dateCompleted: editDateCompleted,
amazonRep,
seaRep,
siteCode: site.code,
tech: company.contactName || "",
company: company.name || "",
};
const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =

View file

@ -16,6 +16,7 @@ import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizar
import {
effectiveCompany,
effectivePoc,
effectiveSite,
} from "@/domain/work-orders/utils/effective-completion-snapshot";
import { DocBadge } from "./slide-over-doc-badge";
import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field";
@ -32,6 +33,153 @@ type SlideOverInfoTabDisplayViewProps = {
pmTypes: string[];
};
type FrozenPoc = ReturnType<typeof effectivePoc>;
function formatScheduleOn(source: WorkOrderTableRow): string {
if (source.scheduleWeekOnly && source.targetWeek) {
return `Week of ${source.targetWeek}`;
}
return source.scheduledOn || "—";
}
function formatApptDisplay(apptTime: string): string {
const appt = parseApptTime(apptTime);
if (appt.start) return formatApptRange(appt.start, appt.end);
return apptTime || "—";
}
function formatPocLine(poc: FrozenPoc): string {
const primary = poc.contacts[0];
const name = primary?.name || "—";
if (!primary?.phone) return name;
return `${name} · ${primary.phone}`;
}
function SlideOverTypeField({ type }: { type: WorkOrderTableRow["type"] }) {
if (!type) {
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}
return <WoTableTypeLabel type={type as WOType} />;
}
function SlideOverCompletedSite({
source,
poc,
sites,
}: {
source: WorkOrderTableRow;
poc: FrozenPoc;
sites: LocationOption[];
}) {
const primaryPoc = poc.contacts[0];
const frozenSite = effectiveSite(source);
return (
<SiteCell
value={frozenSite?.code ?? source.site}
location={frozenSite?.label ?? source.location}
locationId={source.locationId}
pocName={primaryPoc?.name ?? ""}
pocPhone={primaryPoc?.phone ?? ""}
pocNotes={poc.notes ?? ""}
additionalContacts={poc.contacts.slice(1).map((contact) => ({
name: contact.name ?? "",
phone: contact.phone ?? "",
notes: contact.notes ?? "",
}))}
sites={sites}
viewOnly
frozenSite={frozenSite}
onSave={() => undefined}
/>
);
}
function SlideOverSiteField({
source,
isCompleted,
poc,
sites,
}: {
source: WorkOrderTableRow;
isCompleted: boolean;
poc: FrozenPoc;
sites: LocationOption[];
}) {
if (!isCompleted) {
return (
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
{source.site || "—"}
</span>
);
}
return <SlideOverCompletedSite source={source} poc={poc} sites={sites} />;
}
function SlideOverOpenService({ source }: { source: WorkOrderTableRow }) {
if (!source.pm) {
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}
return (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
<PmTypeIcon pmType={source.pm} size={14} />
{source.pm}
{Boolean(source.avetaRequired) && (
<AvetaBadge size={11} attached={Boolean(source.hasAvetaDocument)} />
)}
</span>
);
}
function SlideOverServiceField({
source,
isCompleted,
pmTypes,
}: {
source: WorkOrderTableRow;
isCompleted: boolean;
pmTypes: string[];
}) {
if (!isCompleted) return <SlideOverOpenService source={source} />;
return (
<PMTypeCell
value={source.pm}
pmTypes={pmTypes}
serviceNotes={source.serviceNotes}
extraServices={source.extraServices}
avetaRequired={source.avetaRequired}
avetaAttached={source.hasAvetaDocument}
woNumber={source.woNumber}
viewOnly
onSave={() => undefined}
/>
);
}
function SlideOverVendorField({
source,
isCompleted,
vendors,
}: {
source: WorkOrderTableRow;
isCompleted: boolean;
vendors: VendorDropdownItem[];
}) {
if (!isCompleted) return <SlideOverInfoTabVendorField source={source} />;
const company = effectiveCompany(source);
return (
<VendorCell
vendorId={source.vendorId}
company={company.name ?? source.company}
tech={company.contactName ?? source.tech}
techPhone={company.phone ?? source.techPhone}
vendors={vendors}
viewOnly
frozenCompany={source.frozenCompany}
onSave={() => undefined}
/>
);
}
export function SlideOverInfoTabDisplayView({
source,
isCancelled,
@ -41,11 +189,7 @@ export function SlideOverInfoTabDisplayView({
vendors,
pmTypes,
}: SlideOverInfoTabDisplayViewProps) {
const appt = parseApptTime(source.apptTime);
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
const company = effectiveCompany(source);
const poc = effectivePoc(source);
const primaryPoc = poc.contacts[0];
return (
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
@ -53,38 +197,10 @@ export function SlideOverInfoTabDisplayView({
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Site">
{isCompleted ? (
<SiteCell
value={source.site}
location={source.location}
locationId={source.locationId}
pocName={primaryPoc?.name ?? ""}
pocPhone={primaryPoc?.phone ?? ""}
pocNotes={poc.notes ?? ""}
additionalContacts={poc.contacts.slice(1).map((contact) => ({
name: contact.name ?? "",
phone: contact.phone ?? "",
}))}
sites={sites}
viewOnly
frozenSite={source.frozenSite}
onSave={() => undefined}
/>
) : (
<span
className="font-mono text-[12.5px] font-bold"
style={{ color: "var(--foreground)" }}
>
{source.site || "—"}
</span>
)}
<SlideOverSiteField source={source} isCompleted={isCompleted} poc={poc} sites={sites} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
{source.type ? (
<WoTableTypeLabel type={source.type as WOType} />
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
<SlideOverTypeField type={source.type} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
<SlideOverInfoTabAssignedField
@ -93,58 +209,19 @@ export function SlideOverInfoTabDisplayView({
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<span className="text-[12.5px]">
{source.scheduleWeekOnly && source.targetWeek
? `Week of ${source.targetWeek}`
: source.scheduledOn || "—"}
</span>
<span className="text-[12.5px]">{formatScheduleOn(source)}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<span className="text-[12.5px]">{source.dueDate || "—"}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<span className="text-[12.5px]">{apptDisplay}</span>
<span className="text-[12.5px]">{formatApptDisplay(source.apptTime)}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
{isCompleted ? (
<PMTypeCell
value={source.pm}
pmTypes={pmTypes}
serviceNotes={source.serviceNotes}
extraServices={source.extraServices}
avetaRequired={source.avetaRequired}
avetaAttached={source.hasAvetaDocument}
woNumber={source.woNumber}
viewOnly
onSave={() => undefined}
/>
) : source.pm ? (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
<PmTypeIcon pmType={source.pm} size={14} />
{source.pm}
{Boolean(source.avetaRequired) && (
<AvetaBadge size={11} attached={Boolean(source.hasAvetaDocument)} />
)}
</span>
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
<SlideOverServiceField source={source} isCompleted={isCompleted} pmTypes={pmTypes} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
{isCompleted ? (
<VendorCell
vendorId={source.vendorId}
company={company.name ?? source.company}
tech={company.contactName ?? source.tech}
techPhone={company.phone ?? source.techPhone}
vendors={vendors}
viewOnly
frozenCompany={source.frozenCompany}
onSave={() => undefined}
/>
) : (
<SlideOverInfoTabVendorField source={source} />
)}
<SlideOverVendorField source={source} isCompleted={isCompleted} vendors={vendors} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
<SlideOverInfoTabStatusField source={source} />
@ -154,8 +231,7 @@ export function SlideOverInfoTabDisplayView({
</SlideOverInfoRow>
<SlideOverInfoRow label="POC">
<span className="text-[12.5px]" style={{ color: "var(--color-text-secondary)" }}>
{primaryPoc?.name || "—"}
{primaryPoc?.phone ? ` · ${primaryPoc.phone}` : ""}
{formatPocLine(poc)}
</span>
</SlideOverInfoRow>
</div>

View file

@ -98,21 +98,31 @@ export function VendorDialogBody({
<div>
<FieldLabel>Technician (optional)</FieldLabel>
<WizardFieldSelect
value={selectedKey}
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
disabled={!selectedCompany}
icon={
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.name,
}))}
onPick={onPick}
/>
{companyVendorForAdd != null && (
{frozenCompany != null ? (
<div className={DLG_INPUT_CLS} style={DLG_INPUT_STYLE}>
{frozenCompany.contactName?.trim() || tech || "—"}
</div>
) : (
<WizardFieldSelect
value={selectedKey}
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
disabled={!selectedCompany}
icon={
<Wrench
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.name,
}))}
onPick={onPick}
/>
)}
{frozenCompany == null && companyVendorForAdd != null && (
<div className="mt-2">
<VendorDialogAddTechnician
companyName={anchorCompanyName}

View file

@ -10,6 +10,16 @@ type VendorDialogCompanyCardProps = {
frozenCompany?: WorkOrderFrozenCompany;
};
type CompanyCardModel = {
title: string;
distance: string;
contactName: string;
email: string;
address: string;
mapsUrl: string;
phone: string;
};
function formatFrozenCompanyAddress(company: WorkOrderFrozenCompany): string {
const region = [company.city, company.state]
.map((part) => part?.trim())
@ -19,21 +29,68 @@ function formatFrozenCompanyAddress(company: WorkOrderFrozenCompany): string {
return [company.address?.trim(), region, company.zip?.trim()].filter(Boolean).join(", ");
}
function liveMapsUrl(address: string): string {
if (!address) return "";
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
}
function resolveFrozenCompanyCard(
company: string,
frozen: WorkOrderFrozenCompany,
): CompanyCardModel {
return {
title: company,
distance: "",
contactName: frozen.contactName?.trim() ?? "",
email: frozen.email?.trim() ?? "",
address: formatFrozenCompanyAddress(frozen),
mapsUrl: frozen.maps ?? "",
phone: frozen.phone ?? "",
};
}
function resolveLiveCompanyCard(
company: string,
vendor: VendorDropdownItem | undefined,
): CompanyCardModel {
const address = vendor?.address ?? "";
return {
title: company,
distance: formatVendorDistance(vendor?.distanceMiles),
contactName: "",
email: vendor?.email?.trim() ?? "",
address,
mapsUrl: liveMapsUrl(address),
phone: "",
};
}
function resolveCompanyCardModel(
company: string,
vendor: VendorDropdownItem | undefined,
frozenCompany?: WorkOrderFrozenCompany,
): CompanyCardModel {
if (frozenCompany != null) return resolveFrozenCompanyCard(company, frozenCompany);
return resolveLiveCompanyCard(company, vendor);
}
function CompanyCardDetails({ model }: { model: CompanyCardModel }) {
return (
<>
{Boolean(model.address) && <Detail icon={MapPin} text={model.address} />}
{Boolean(model.contactName) && <Detail icon={User} text={model.contactName} />}
{Boolean(model.phone) && <Detail icon={Phone} text={model.phone} />}
{Boolean(model.email) && <Detail icon={Mail} text={model.email} />}
</>
);
}
export function VendorDialogCompanyCard({
company,
vendor,
frozenCompany,
}: VendorDialogCompanyCardProps) {
const isFrozen = frozenCompany != null;
const distance = isFrozen ? "" : formatVendorDistance(vendor?.distanceMiles);
const contactName = isFrozen ? (frozenCompany.contactName?.trim() ?? "") : "";
const email = isFrozen ? (frozenCompany.email?.trim() ?? "") : (vendor?.email?.trim() ?? "");
const address = isFrozen ? formatFrozenCompanyAddress(frozenCompany) : (vendor?.address ?? "");
const mapsUrl =
(isFrozen ? frozenCompany.maps : undefined) ||
(!isFrozen && address
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`
: "");
const model = resolveCompanyCardModel(company, vendor, frozenCompany);
return (
<div
@ -45,21 +102,18 @@ export function VendorDialogCompanyCard({
>
<div className="flex items-start justify-between gap-2">
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{company || "—"}
{model.title || "—"}
</div>
{Boolean(distance) && (
{Boolean(model.distance) && (
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
{distance}
{model.distance}
</span>
)}
</div>
{Boolean(address) && <Detail icon={MapPin} text={address} />}
{Boolean(contactName) && <Detail icon={User} text={contactName} />}
{Boolean(frozenCompany?.phone) && <Detail icon={Phone} text={frozenCompany?.phone ?? ""} />}
{Boolean(email) && <Detail icon={Mail} text={email} />}
{Boolean(mapsUrl) && (
<CompanyCardDetails model={model} />
{Boolean(model.mapsUrl) && (
<a
href={mapsUrl}
href={model.mapsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"

View file

@ -77,8 +77,8 @@ export function VendorDialog({
technicianOptions={form.technicianOptions}
anchorCompanyName={form.anchorCompanyName}
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
tech={form.selected?.name ?? form.t}
phone={form.phone}
tech={frozenCompany?.contactName ?? form.selected?.name ?? form.t}
phone={frozenCompany?.phone ?? form.phone}
notes={form.notes}
resolvedCompany={form.resolvedCompany}
selectedVendor={form.selectedVendor}

View file

@ -4,6 +4,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
import {
effectiveAdditionalContacts,
effectivePoc,
effectiveSite,
} from "@/domain/work-orders/utils/effective-completion-snapshot";
@ -69,10 +70,7 @@ export function WoTableRowCells({
pocName={primaryPoc?.name ?? ""}
pocPhone={primaryPoc?.phone ?? ""}
pocNotes={poc.notes ?? ""}
additionalContacts={poc.contacts.slice(1).map((contact) => ({
name: contact.name ?? "",
phone: contact.phone ?? "",
}))}
additionalContacts={effectiveAdditionalContacts(row)}
sites={sites}
q={search}
viewOnly={viewOnly}

View file

@ -105,8 +105,9 @@ export function useCompDocDialog({
setEditAddress(value);
};
const company = effectiveCompany(row);
const amazonRep = effectivePoc(row).contacts[0]?.name || "";
const seaRep = effectiveCompany(row).contactName || "";
const seaRep = company.contactName || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated";
@ -120,6 +121,9 @@ export function useCompDocDialog({
dateCompleted: editDateCompleted,
amazonRep,
seaRep,
siteCode: site.code,
tech: company.contactName || "",
company: company.name || "",
};
const previewSite = useMemo(

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,9 @@
import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { api, apiGet, apiPost } from "@/api/api";
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
import { traceHttpOperation } from "@/observability/http-transaction";
import {
BoardPatchConflictError,
BoardPatchValidationError,
@ -82,10 +83,12 @@ export const workOrderBoardDocumentsApi = {
commentId: string | number,
text: string,
): Promise<WorkOrderComment> => {
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
json: { text },
throwHttpErrors: false,
});
const response = await apiRequestRaw(
"patch",
API_PATHS.workOrder.commentItem(workOrderId, commentId),
{ json: { text }, throwHttpErrors: false },
"workOrderBoardDocumentsApi.updateComment",
);
const data = await readResponseJson(response);
if (response.ok) {
@ -120,10 +123,21 @@ export const workOrderBoardDocumentsApi = {
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
): Promise<WorkOrderBoardMediaItem> => {
const formData = buildMediaFormData(file, category);
const { ok, status, data } = await uploadFormWithProgress(
API_PATHS.workOrder.media(workOrderId),
formData,
{ signal: options?.signal, onProgress: options?.onProgress },
const { ok, status, data } = await traceHttpOperation(
{
method: "POST",
url: API_PATHS.workOrder.media(workOrderId),
entryPoint: "workOrderBoardDocumentsApi.uploadMedia",
},
async ({ reportStatus }) => {
const result = await uploadFormWithProgress(
API_PATHS.workOrder.media(workOrderId),
formData,
{ signal: options?.signal, onProgress: options?.onProgress },
);
reportStatus(result.status);
return result;
},
);
if (ok) {
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
@ -147,10 +161,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);
@ -178,10 +194,12 @@ export const workOrderBoardDocumentsApi = {
);
}
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
searchParams: { workOrderVersion },
throwHttpErrors: false,
});
const response = await apiRequestRaw(
"delete",
API_PATHS.workOrder.mediaItem(workOrderId, numericId),
{ searchParams: { workOrderVersion }, throwHttpErrors: false },
"workOrderBoardDocumentsApi.deleteMedia",
);
if (response.status === 204) {
return;
@ -198,10 +216,12 @@ export const workOrderBoardDocumentsApi = {
workOrderId: string | number,
mediaId: string | number,
): Promise<Blob> => {
const response = await api.get(API_PATHS.workOrder.mediaContent(workOrderId, mediaId), {
credentials: "include",
throwHttpErrors: false,
});
const response = await apiRequestRaw(
"get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
{ credentials: "include", throwHttpErrors: false },
"workOrderBoardDocumentsApi.getMediaContent",
);
if (response.ok) {
return response.blob();
@ -232,10 +252,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);

View file

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

View file

@ -314,6 +314,7 @@ function readFrozenPoc(item: Record<string, unknown>): WorkOrderFrozenPoc | unde
? rawContacts.map(asRecord).map((contact) => ({
name: readString(contact, "name", "Name"),
phone: readString(contact, "phone", "Phone"),
notes: readString(contact, "notes", "Notes"),
}))
: [];
return {

View file

@ -14,6 +14,9 @@ export interface DocFields {
dateCompleted: string;
amazonRep: string;
seaRep: string;
siteCode: string;
tech: string;
company: string;
}
export type DocState = "draft" | "generated" | "not-required";
@ -31,12 +34,15 @@ export interface CompletionDocStateConfig {
}
export interface CompDocSiteInfo {
code: string;
street: string;
city: string;
state: string;
zip: string;
label: string;
fullAddress: string;
tech: string;
company: string;
}
export type CompDocSaveStatus = Exclude<CompletionDocStatus, "">;

View file

@ -38,7 +38,7 @@ export type WorkOrderFrozenCompany = {
};
export type WorkOrderFrozenPoc = {
contacts: { name?: string; phone?: string }[];
contacts: { name?: string; phone?: string; notes?: string }[];
notes?: string;
};

View file

@ -75,11 +75,11 @@ export function buildDocHtml(
<div class="rule"></div>
<h1>${esc(serviceTitle(row).toUpperCase())}</h1>
<p style="margin:2px 0;" class="wo">WO# ${esc(row.woNumber)}</p>
<p style="margin:14px 0 0;">Site ID — ${esc(row.site)}</p>
<p style="margin:2px 0;">Amazon.com Services LLC ${esc(row.site)}</p>
<p style="margin:14px 0 0;">Site ID — ${esc(fields.siteCode)}</p>
<p style="margin:2px 0;">Amazon.com Services LLC ${esc(fields.siteCode)}</p>
<p style="margin:2px 0;" class="muted">${esc(fields.street)},</p>
<p style="margin:2px 0;" class="muted">${esc(cityStateZip)}</p>
<p style="margin:14px 0 4px;"><b>Vendor / Technician:</b> ${esc(row.tech || "—")}${row.company ? ` &middot; ${esc(row.company)}` : ""}</p>
<p style="margin:14px 0 4px;"><b>Vendor / Technician:</b> ${esc(fields.tech || "—")}${fields.company ? ` &middot; ${esc(fields.company)}` : ""}</p>
<ul style="padding-left:20px;">${safety}</ul>
${completedBlock}
<h3 style="margin:16px 0 4px;">${esc(proc.heading)}:</h3>

View file

@ -1,43 +1,87 @@
import type { Location } from "@/domain/locations/types/location";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { effectiveSite } from "@/domain/work-orders/utils/effective-completion-snapshot";
import {
effectiveCompany,
effectiveSite,
} from "@/domain/work-orders/utils/effective-completion-snapshot";
type CompDocAddress = Pick<
CompDocSiteInfo,
"street" | "city" | "state" | "zip" | "label" | "fullAddress"
>;
type CompDocIdentityRow = Pick<WorkOrderTableRow, "site" | "location" | "status" | "frozenSite"> &
Partial<Pick<WorkOrderTableRow, "company" | "tech" | "techPhone" | "frozenCompany">>;
function buildLabel(city?: string, state?: string): string {
return [city, state].filter(Boolean).join(", ");
}
export function resolveCompDocSite(
row: Pick<WorkOrderTableRow, "site" | "location" | "status" | "frozenSite">,
location?: Location | null,
): CompDocSiteInfo {
function toCompDocAddress(
street: string,
city: string,
state: string,
zip: string,
): CompDocAddress {
const label = buildLabel(city, state);
const fullAddress = [street, label, zip].filter(Boolean).join(", ") || "—";
return { street, city, state, zip, label, fullAddress };
}
function withCompDocIdentity(address: CompDocAddress, row: CompDocIdentityRow): CompDocSiteInfo {
const frozen = effectiveSite(row);
if (frozen != null) {
const city = frozen.city ?? "";
const state = frozen.state ?? "";
const zip = frozen.zip ?? "";
const label = buildLabel(city, state);
const street = frozen.address ?? "";
const fullAddress = [street, label, zip].filter(Boolean).join(", ") || "—";
return { street, city, state, zip, label, fullAddress };
}
const company = effectiveCompany({
status: row.status,
company: row.company ?? "",
tech: row.tech ?? "",
techPhone: row.techPhone ?? "",
frozenCompany: row.frozenCompany,
});
return {
...address,
code: frozen?.code ?? row.site,
tech: company.contactName ?? "",
company: company.name ?? "",
};
}
if (location?.address || location?.city) {
const city = location.city ?? "";
const state = location.state ?? "";
const zip = location.zipCode ?? "";
const label = buildLabel(city, state);
const street = location.address ?? "";
const fullAddress = [street, label, zip].filter(Boolean).join(", ") || "—";
return { street, city, state, zip, label, fullAddress };
}
function resolveLiveLocationSite(location: Location): CompDocAddress {
return toCompDocAddress(
location.address ?? "",
location.city ?? "",
location.state ?? "",
location.zipCode ?? "",
);
}
const locationParts = row.location.split(",").map((s) => s.trim());
function resolveRowLocationSite(row: Pick<WorkOrderTableRow, "location">): CompDocAddress {
const locationParts = row.location.split(",").map((part) => part.trim());
const city = locationParts[0] ?? "";
const state = locationParts[1] ?? "";
const label = buildLabel(city, state);
const street = row.location && !label ? row.location : "";
const fullAddress = [street, label].filter(Boolean).join(", ") || "—";
return { street, city, state, zip: "", label, fullAddress };
return toCompDocAddress(street, city, state, "");
}
export function resolveCompDocSite(
row: CompDocIdentityRow,
location?: Location | null,
): CompDocSiteInfo {
const frozen = effectiveSite(row);
if (frozen != null) {
return withCompDocIdentity(
toCompDocAddress(
frozen.address ?? "",
frozen.city ?? "",
frozen.state ?? "",
frozen.zip ?? "",
),
row,
);
}
if (location?.address || location?.city) {
return withCompDocIdentity(resolveLiveLocationSite(location), row);
}
return withCompDocIdentity(resolveRowLocationSite(row), row);
}

View file

@ -1,3 +1,4 @@
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type {
WorkOrderFrozenCompany,
WorkOrderFrozenPoc,
@ -48,8 +49,20 @@ export function effectivePoc(row: PocSnapshotRow): WorkOrderFrozenPoc {
...(row.additionalContacts ?? []).map((contact) => ({
name: contact.name,
phone: contact.phone,
notes: contact.notes,
})),
],
notes: row.pocNotes,
};
}
export function effectiveAdditionalContacts(row: PocSnapshotRow): WorkOrderAdditionalContact[] {
if (row.status === "Completed" && row.frozenPoc != null) {
return row.frozenPoc.contacts.slice(1).map((contact) => ({
name: contact.name ?? "",
phone: contact.phone ?? "",
notes: contact.notes ?? "",
}));
}
return row.additionalContacts ?? [];
}

View file

@ -1,3 +1,7 @@
import * as Sentry from "@sentry/react";
import "@/observability/sentry";
import React from "react";
import ReactDOM from "react-dom/client";
import { AppRoot } from "@/app-root";
@ -10,7 +14,11 @@ if (!rootElement) {
throw new Error("Root element #root not found");
}
ReactDOM.createRoot(rootElement).render(
ReactDOM.createRoot(rootElement, {
onCaughtError: Sentry.reactErrorHandler(),
onRecoverableError: Sentry.reactErrorHandler(),
onUncaughtError: Sentry.reactErrorHandler(),
}).render(
<React.StrictMode>
<AppProviders>
<AppRoot />

View file

@ -0,0 +1,111 @@
import { setHttpStatus, spanToJSON, startSpan, type Span } from "@sentry/react";
import { isHTTPError } from "ky";
const INTEGER_SEGMENT = /^\d+$/;
const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HEX_SEGMENT = /^[0-9a-f]+$/i;
const TOKEN_SEGMENT = /^[0-9a-z._~-]+$/i;
const OPAQUE_HEX_MIN_LENGTH = 8;
const OPAQUE_TOKEN_MIN_LENGTH = 16;
function isIdentifierSegment(segment: string): boolean {
if (segment.length === 0) {
return false;
}
if (INTEGER_SEGMENT.test(segment)) {
return true;
}
if (UUID_SEGMENT.test(segment)) {
return true;
}
if (segment.length >= OPAQUE_HEX_MIN_LENGTH && HEX_SEGMENT.test(segment)) {
return true;
}
return (
segment.length >= OPAQUE_TOKEN_MIN_LENGTH &&
TOKEN_SEGMENT.test(segment) &&
/\d/.test(segment) &&
/[a-z]/i.test(segment)
);
}
export function normalizeHttpRoute(input: string): string {
let path: string;
try {
path = new URL(input).pathname;
} catch {
path = input.split("#")[0] ?? input;
path = path.split("?")[0] ?? path;
}
return path
.split("/")
.map((segment) => (isIdentifierSegment(segment) ? ":id" : segment))
.join("/");
}
export type HttpTraceOptions = {
method: string;
url: string;
entryPoint: string;
actorType?: string;
};
export type HttpTraceContext = {
reportStatus: (status: number) => void;
};
export async function traceHttpOperation<T>(
options: HttpTraceOptions,
callback: (context: HttpTraceContext) => Promise<T>,
): Promise<T> {
const route = normalizeHttpRoute(options.url);
return startSpan(
{
name: `${options.method.toUpperCase()} ${route}`,
op: "http.client",
kind: 2,
forceTransaction: true,
attributes: {
"http.request.method": options.method.toUpperCase(),
"http.route": route,
"code.function": options.entryPoint,
"operation.type": "http.client",
...(options.actorType ? { "actor.type": options.actorType } : {}),
},
},
async (span: Span) => {
const spanJson = spanToJSON(span);
if (spanJson.trace_id) {
span.setAttribute("trace_id", spanJson.trace_id);
}
if (spanJson.span_id) {
span.setAttribute("span_id", spanJson.span_id);
span.setAttribute("transaction_id", spanJson.span_id);
}
let statusReported = false;
const reportStatus = (status: number): void => {
if (statusReported) {
return;
}
statusReported = true;
setHttpStatus(span, status);
};
try {
const result = await callback({ reportStatus });
if (!statusReported && result instanceof Response) {
reportStatus(result.status);
}
return result;
} catch (error) {
if (!statusReported) {
reportStatus(isHTTPError(error) ? error.response.status : 500);
}
throw error;
}
},
);
}

View file

@ -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<TransactionEvent["tags"]>,
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;
}

View file

@ -0,0 +1,46 @@
import * as Sentry from "@sentry/react";
import { useEffect } from "react";
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} 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;
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,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
sendDefaultPii: false,
beforeSend: scrubErrorEvent,
beforeSendTransaction: scrubTransactionEvent,
beforeBreadcrumb: scrubBreadcrumb,
tracesSampleRate: 1.0,
tracePropagationTargets: [window.location.origin, apiBaseUrl],
});

View file

@ -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;

View file

@ -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() {

View file

@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
@ -48,4 +49,42 @@ describe("VendorDialogCompanyCard completion snapshot", () => {
expect(screen.getByText("200 Vendor Ave, Chicago, IL, 60602")).toBeInTheDocument();
expect(screen.queryByText("900 Live St")).not.toBeInTheDocument();
});
it("shows the frozen technician instead of a renamed live roster contact", () => {
render(
<VendorDialogBody
selectedCompany="Frozen Vendor"
companyOptions={[{ key: "Frozen Vendor", label: "Frozen Vendor" }]}
selectedKey={String(LIVE_VENDOR.id)}
technicianOptions={[
{
key: String(LIVE_VENDOR.id),
label: LIVE_VENDOR.contactName,
name: LIVE_VENDOR.contactName,
company: LIVE_VENDOR.companyName,
phone: "",
vendor: LIVE_VENDOR,
},
]}
anchorCompanyName="Frozen Vendor"
companyVendorForAdd={LIVE_VENDOR}
tech="Frozen Tech"
phone="111"
notes=""
resolvedCompany="Frozen Vendor"
selectedVendor={LIVE_VENDOR}
frozenCompany={{ name: "Frozen Vendor", contactName: "Frozen Tech", phone: "111" }}
saveError={null}
onCompanyPick={() => undefined}
onPick={() => undefined}
onPhoneChange={() => undefined}
onNotesChange={() => undefined}
onTechnicianCreated={() => undefined}
/>,
);
expect(screen.getAllByText("Frozen Tech").length).toBeGreaterThan(0);
expect(screen.queryByText("Live Contact")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /\+ add technician/i })).not.toBeInTheDocument();
});
});

View file

@ -24,6 +24,13 @@ vi.mock("@/api/api", () => ({
apiPatch: (...args: unknown[]) => apiPatch(...args),
apiDelete: vi.fn(),
apiPostNoContent: vi.fn(),
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);
if (method === "get") return apiGetFn(url, options);
throw new Error(`Unexpected apiRequestRaw method: ${method}`);
},
api: {
patch: (...args: unknown[]) => apiPatchFn(...args),
post: (...args: unknown[]) => apiPostFn(...args),

View file

@ -8,12 +8,16 @@ describe("resolveCompDocSite", () => {
site: "BK5",
location: "Live City, WI",
status: "Completed",
company: "Live Co",
tech: "Live Tech",
frozenSite: {
code: "FROZEN",
address: "100 Frozen Ave",
city: "Chicago",
state: "IL",
zip: "60601",
},
frozenCompany: { name: "Frozen Co", contactName: "Frozen Tech" },
},
{
address: "900 Live St",
@ -24,12 +28,15 @@ describe("resolveCompDocSite", () => {
);
expect(result).toEqual({
code: "FROZEN",
street: "100 Frozen Ave",
city: "Chicago",
state: "IL",
zip: "60601",
label: "Chicago, IL",
fullAddress: "100 Frozen Ave, Chicago, IL, 60601",
tech: "Frozen Tech",
company: "Frozen Co",
});
});

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
effectiveAdditionalContacts,
effectiveCompany,
effectivePoc,
effectiveSite,
@ -61,15 +62,42 @@ describe("effective completion snapshot", () => {
pocName: "Live POC",
pocPhone: "222",
pocNotes: "live",
additionalContacts: [{ name: "Backup", phone: "333" }],
additionalContacts: [{ name: "Backup", phone: "333", notes: "gate hours" }],
}),
).toEqual({
contacts: [
{ name: "Live POC", phone: "222" },
{ name: "Backup", phone: "333" },
{ name: "Backup", phone: "333", notes: "gate hours" },
],
notes: "live",
});
expect(
effectiveAdditionalContacts({
status: "Scheduled",
pocName: "Live POC",
pocPhone: "222",
pocNotes: "live",
additionalContacts: [{ name: "Backup", phone: "333", notes: "gate hours" }],
}),
).toEqual([{ name: "Backup", phone: "333", notes: "gate hours" }]);
});
it("keeps extra-contact notes on the open work-order path", () => {
expect(
effectiveAdditionalContacts({
status: "Scheduled",
pocName: "Primary",
pocPhone: "111",
pocNotes: "",
additionalContacts: [{ name: "Backup", phone: "333", notes: "gate hours" }],
frozenPoc: {
contacts: [
{ name: "Frozen", phone: "000" },
{ name: "Frozen Extra", phone: "999", notes: "ignored while open" },
],
},
}),
).toEqual([{ name: "Backup", phone: "333", notes: "gate hours" }]);
});
it("treats missing additional contacts as an empty live contact list", () => {

View file

@ -0,0 +1,155 @@
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 { HTTPError } from "ky";
import { normalizeHttpRoute, traceHttpOperation } from "@/observability/http-transaction";
const startSpanMock = vi.mocked(Sentry.startSpan);
const setHttpStatusMock = vi.mocked(Sentry.setHttpStatus);
function lastSpanOptions(): Record<string, unknown> {
const calls = startSpanMock.mock.calls;
const [options] = calls[calls.length - 1] ?? [];
return (options ?? {}) as unknown as Record<string, unknown>;
}
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 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 () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 500);
});
});

View file

@ -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`,
});
});
});

5
src/vite-env.d.ts vendored
View file

@ -3,8 +3,13 @@
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_TARGET: string;
readonly VITE_SENTRY_DSN?: string;
readonly VITE_SENTRY_ENVIRONMENT?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare const __APP_COMMIT_SHA__: string;
declare const __SENTRY_RELEASE__: string;

View file

@ -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"
]
}

View file

@ -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",
},
};
});