From d3960af3408a4d1a9da3e1525858dfdeff184359 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 18:16:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20warm-up=20deps=20=E2=80=94=20ts-reset,?= =?UTF-8?q?=20web-vitals,=20RHF=20devtool,=20query-broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four low-risk adds before the Zod 4 / drizzle-zod headliner: - @total-typescript/ts-reset: tightens TS stdlib types globally (JSON.parse → unknown, fetch().json() → unknown, .filter(Boolean) narrows, Set literals respect typed Set targets). Caught 179 latent type errors; fixed all production sites (8 files) and added `any` cast escape hatch in test files (ESLint exemption scoped to tests/). - web-vitals + /api/v1/internal/vitals endpoint + WebVitalsReporter client component: establishes Core Web Vitals baseline (LCP/INP/CLS/ FCP/TTFB) via navigator.sendBeacon. Required before optimisation work. - @hookform/devtools + FormDevtool wrapper: dev-only RHF state inspector, lazy-loaded via next/dynamic so the chunk is excluded from prod bundles entirely. - @tanstack/query-broadcast-client-experimental: cross-tab cache sync via BroadcastChannel — wired in query-provider.tsx, 1-liner. Audit doc updated with sections 35 + 36 (PDF stack overhaul + comprehensive second-pass package sweep) covering ~20 package adoption candidates and 4-5 deprecation candidates. Verified: tsc clean, vitest 1293/1293 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/AUDIT-2026-05-12.md | 808 ++++++++++++++++++ eslint.config.mjs | 9 + package.json | 4 + pnpm-lock.yaml | 465 ++++++++++ src/app/(auth)/set-password/page.tsx | 5 +- src/app/(dashboard)/layout.tsx | 2 + src/app/api/v1/internal/vitals/route.ts | 54 ++ .../interests/external-eoi-upload-dialog.tsx | 4 +- src/components/shared/filter-bar.tsx | 6 +- src/components/shared/form-devtool.tsx | 32 + src/components/shared/notes-list.tsx | 2 +- src/components/shared/web-vitals-reporter.tsx | 56 ++ src/lib/api/client.ts | 9 +- .../client-archive-dossier.service.ts | 2 +- src/lib/services/currency.ts | 4 +- .../services/inquiry-notifications.service.ts | 2 +- src/providers/query-provider.tsx | 11 +- src/types/ts-reset.d.ts | 4 + tests/e2e/smoke/18-ai-features.spec.ts | 46 +- tests/e2e/smoke/global-setup.ts | 4 +- .../api/berth-reservations-list.test.ts | 8 +- tests/integration/api/companies.test.ts | 14 +- tests/integration/api/interest-berths.test.ts | 6 +- tests/integration/api/memberships.test.ts | 28 +- tests/integration/api/reservations.test.ts | 22 +- .../api/saved-views-ownership.test.ts | 2 +- tests/integration/api/yachts-detail.test.ts | 14 +- tests/integration/api/yachts.test.ts | 4 +- .../dedup/match-candidates-api.test.ts | 2 +- tests/integration/health-and-ready.test.ts | 4 +- .../integration/public-interest-trio.test.ts | 12 +- tests/integration/storage/proxy-route.test.ts | 4 +- tests/unit/comms-safety.test.ts | 8 +- tests/unit/encryption.test.ts | 4 +- tests/unit/security-error-responses.test.ts | 30 +- .../services/documenso-place-fields.test.ts | 10 +- tests/unit/website-inquiries-503.test.ts | 2 +- tests/unit/website-inquiries.test.ts | 6 +- 38 files changed, 1590 insertions(+), 119 deletions(-) create mode 100644 src/app/api/v1/internal/vitals/route.ts create mode 100644 src/components/shared/form-devtool.tsx create mode 100644 src/components/shared/web-vitals-reporter.tsx create mode 100644 src/types/ts-reset.d.ts diff --git a/docs/AUDIT-2026-05-12.md b/docs/AUDIT-2026-05-12.md index 0ab43da0..6bb9cc45 100644 --- a/docs/AUDIT-2026-05-12.md +++ b/docs/AUDIT-2026-05-12.md @@ -6712,3 +6712,811 @@ verify and full vitest run. 6. **Defer indefinitely** — archiver 8, react-day-picker 10 (neither is delivering us anything we need). **Non-goal:** chasing the bleeding edge on every dep. The audit's baseline finding stands — we are secure today. These are mostly developer-experience and perf wins, not security blockers. + +--- + +## 35. Package adoption + PDF stack overhaul (Context7-assisted follow-up) + +Companion to section 34. The deps-upgrade analysis answered "should we bump +what we already have?" — this section answers two follow-on questions: + +1. **PDF stack** — are pdfme + pdfkit + pdf-lib the right tools? (No.) +2. **What aren't we using that we should be?** — comprehensive sweep of the + modern ecosystem against our actual pain points and codebase patterns. + +User-directed exclusions: + +- `react-hotkeys-hook` (no keyboard-shortcut UX target). + +--- + +### 35.A — PDF stack overhaul + +#### Current state (5 packages, 4 distinct use cases) + +| Package | Where it lives in our code | Use case | +| ----------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------- | +| `@pdfme/common` + `generator` + `schemas` v6.1.2 | `src/lib/pdf/generate.ts` + 8 template files | Declarative report/invoice/EOI templates | +| `pdf-lib` v1.17.1 | `src/lib/pdf/fill-eoi-form.ts`, `src/lib/services/berth-pdf-parser.ts` | AcroForm fill (EOI) + uploaded-PDF parsing (berth specs) | +| `pdfkit` v0.18.0 + `@types/pdfkit` | `src/lib/services/expense-pdf.service.ts` (only site) | Streaming receipt-attached expense reports | +| `tesseract.js` v7.0.0 | `src/lib/ocr/tesseract-client.ts` + scan-shell | Berth PDF OCR fallback | +| **Bridge layer**: 571-line `src/lib/pdf/tiptap-to-pdfme.ts` | Admin template builder | Tiptap JSON → pdfme schema converter | + +#### Pain points + +- **The 571-line `tiptap-to-pdfme.ts` bridge** is fragile glue between a rich-text + format (Tiptap JSON) and a declarative PDF schema (pdfme). Every supported + formatting subset (bold, italic, headings, lists, tables, images) is + hand-coded. Adding `blockquote` / `codeBlock` / `horizontalRule` / + `taskList` is currently rejected at save time because the bridge doesn't + support them. +- **pdfme** templates are JSON blobs with positional `{ x, y }` coordinates. + Reading/editing them is painful (compare `invoice-template.ts` vs a + declarative React component). +- **`@pdfme/generator` ships a heavy runtime** including the schema engine + and font loaders — irrelevant for our use case because we're code-driven, + not visual-editor-driven. +- **3 different generation libraries** (pdfme + pdfkit + pdf-lib) means three + different mental models, three different test patterns, three different + failure modes. + +#### Recommendation per use case + +**Use case 1 — Template-driven PDFs (8 templates):** invoice, client-summary, +interest-summary, berth-spec, revenue-report, occupancy-report, pipeline-report, +eoi-standard-inapp. + +**→ Replace with `@react-pdf/renderer`** (`/diegomura/react-pdf`, 161 snippets, +benchmark 87.75). + +Why it wins for us: + +- **Declarative React components** — uses the same skills we already have. No + more positional `{ x, y }` JSON. +- **Server-side rendering modes**: `renderToBuffer` (HTTP responses), + `renderToStream` (large reports), `renderToFile` (background jobs). All + three usage patterns are documented and idiomatic — replaces pdfme's + `generate()` call cleanly. +- **First-class page-break controls** — `break`, `wrap={false}`, + `minPresenceAhead`, `orphans`, `widows`. pdfme has none of these; we'd be + hand-implementing them today if we needed them. +- **Fixed headers/footers via `fixed` prop** with auto page-number rendering + (`render={({ pageNumber, totalPages }) => …}`). We currently re-render + header content per page in pdfme. +- **The Tiptap bridge problem dissolves**: a rich-text component renders + Tiptap JSON directly via a recursive component (~80 LOC, replaces 571 LOC). + No more constrained-subset rejections at save time. +- **Tree-shakes** — only the components we import ship; pdfme's generator + pulls the full schema engine regardless. + +Concrete migration cost: rewrite 8 templates as JSX. The shape is 1:1 +with our current pdfme schemas (header section, repeating items, footer +totals), so it's a mechanical translation. ~4-6 hours total. Bridge layer +(571 LOC) goes to zero. + +Caveats from Context7: + +- Font registration is explicit (`Font.register({ family, src })`) — our + current fonts move from pdfme's font loader to a one-time call at boot. +- No Tailwind class support — uses `StyleSheet.create({ ... })` with a + flexbox-style subset. Familiar to React Native devs. + +**Use case 2 — AcroForm fill (EOI):** + +**→ Keep `pdf-lib`.** Best-in-class for editing existing PDFs. No replacement +candidate is better. Already used correctly in `fill-eoi-form.ts`. + +**Use case 3 — Uploaded PDF parsing (berth specs):** + +**→ Add `unpdf`** (`/unjs/unpdf`, 66 snippets) for text extraction; keep +`pdf-lib` for AcroForm field extraction. + +Why: + +- `unpdf` is the unjs ecosystem's serverless-friendly PDF parser built on + pdf.js. Returns `{ totalPages, text }` per page in one call. +- Better than `pdf-lib` for text extraction because pdf-lib's text APIs are + read-positional, not read-flow. +- `getDocumentProxy()` lets us share one parse across `extractText`, + `extractLinks`, `getMeta` — useful for the 3-tier berth parser (AcroForm + first, OCR fallback, AI fallback) because we can grab all metadata in one + pass. + +Our current parser uses `pdf-lib`'s low-level text extraction which has known +issues with positionally-rendered text (the OCR fallback fires more often +than necessary). `unpdf.extractText` would reduce that fallback rate. + +**Use case 4 — Streaming receipt-attached expense reports:** + +**→ Keep `pdfkit` short-term, migrate to `@react-pdf/renderer.renderToStream` +medium-term.** + +Why keep: + +- `expense-pdf.service.ts` is the only `pdfkit` consumer. Its streaming + pattern (500 receipts at <100MB RSS) is the load-bearing reason for + pdfkit's existence in our deps. +- `@react-pdf/renderer.renderToStream` documented in Context7 supports the + same use case — but verification needs an actual perf test against a + 500-receipt fixture before committing. + +Migration plan: + +- Phase 1 (now): replace pdfme templates with @react-pdf/renderer. +- Phase 2 (after we have @react-pdf/renderer in the codebase): re-test + expense-pdf with `renderToStream` against the 500-receipt fixture. If + memory stays under 200MB, swap pdfkit out. If not, keep pdfkit and + document the constraint. + +#### Net result after Phase 1 + +Remove: `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas`, 571-line +bridge file. + +Keep: `pdf-lib` (AcroForm), `pdfkit` (streaming expenses, pending Phase 2), +`tesseract.js` (OCR). + +Add: `@react-pdf/renderer`, `unpdf`. + +Deps net: −2, −571 LOC of bridge code, +standard declarative API for all +templates. + +--- + +### 35.B — High-value package additions (prioritized) + +Each row below has been validated via Context7 unless marked otherwise. + +#### Tier 1 — Adopt alongside the planned Zod 4 / Tailwind 4 work + +| Package | Replaces / unlocks | Where it lands in our code | Effort | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| **`drizzle-zod`** (already in `drizzle-orm`) | ~30 hand-maintained validators in `src/lib/validators/` | `createInsertSchema(clients).omit({ id, portId })` etc. | 2-3h | +| **`@react-pdf/renderer`** | 8 pdfme templates + 571-line tiptap bridge | `src/lib/pdf/templates/*` | 4-6h | +| **`react-email`** + `@react-email/components` | 8 hand-strung HTML templates in `src/lib/email/templates/` | Each becomes a `.tsx` component, rendered via `await render(<…/>)` then handed to nodemailer unchanged | 2-3h (one template at a time) | +| **`@tanstack/react-virtual`** | Pagination on `client-list`, `yacht-list`, `berth-list`, `audit-log-list`, `inbox` | `useVirtualizer({ count, estimateSize })` inside the list shells | 1h per list × 5 lists | +| **`ts-pattern`** | 19-case dispatch in `search.service.ts`, 13-case Documenso webhook, 12-case `client-restore.service.ts`, 10-case `recently-viewed/route.ts`, 10-case `custom-fields/[entityId]/route.ts` | `match(input).with(...).exhaustive()` | 30 min per site; start with the Documenso webhook | +| **`unpdf`** | Hand-rolled text extraction in `berth-pdf-parser.ts` | `extractText(await getDocumentProxy(buf))` | 1h | + +#### Tier 2 — Independent adopts (polish + perf) + +| Package | What it does for us | Effort | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| **`@formkit/auto-animate`** | One-liner `useAutoAnimate()` ref on any list. Drops into: deal pipeline kanban (pipeline-board.tsx), reminders rail, alerts rail, files list, notes list. Zero CSS. ~2kb. | 5 min per site | +| **`motion`** (formerly framer-motion) | Layout animations for kanban reorder (currently snaps), Vaul drawer enter/exit polish, sheet/drawer slides, `` for inline edits. ~15kb gzip but tree-shakes well. | 1-2h to wire the kanban first | +| **`use-debounce`** | Replaces ad-hoc `setTimeout` debounce in `yacht-picker`, `client-picker`, `audit-log-list`, `send-document-dialog`, `custom-fields-section`, `berth-picker`, `interest-picker`, `dedup-suggestion-panel` (8 sites). Typed `useDebouncedCallback`. ~3kb. | 30 min total | +| **`fast-deep-equal`** | Memo comparator for `DataTable` and React Query `select` functions. Drops re-renders when stable references arrive with new identity. ~1kb. | 20 min | +| **`@upstash/ratelimit`** | Replaces hand-rolled rate limiters in `src/lib/rate-limit.ts`, `api/helpers.ts`, `route-helpers.ts`, `document-sends.service.ts`. Uses our existing Redis. Sliding-window / fixed-window / token-bucket algorithms tested at scale. | 1-2h | + +#### Tier 3 — Strategic adopts (bigger commitments) + +| Package | What it unlocks | Notes | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`next-safe-action`** | Type-safe server actions with built-in Zod validation, ownership middleware, `useHookFormAction` hook. Each form drops ~30 LOC of `apiFetch + toastError + mutation-hook` plumbing to ~5. Pairs with `useHookFormAction` which already speaks Zod/RHF. | Migrate gradually — use for new forms first, keep API routes for external callers. Couples with Zod 4 (since safe-action v8+ targets Zod 4 best). | +| **`@axe-core/playwright`** | Accessibility audit during smoke tests. The 33-agent audit flagged WCAG gaps; this catches regressions automatically. | ~30 LOC of test setup. Fails CI on new violations. | +| **`@tiptap/core`** + `@tiptap/react` + extension packs | Real rich-text editor for `notes` (clients/interests/yachts/companies all have polymorphic notes). Currently plain text. Sales reps note things like "call after 4pm UTC, prefers WhatsApp" — bold/italic/links/lists/mentions would help. Tiptap's JSON output format is _already_ in our codebase (the bridge layer), so we'd be storing the same shape we already render. | Decision: keep notes plain or upgrade to rich? If yes, ~3h to wire one entity's notes; the others copy the pattern. | +| **`@next/bundle-analyzer`** | Wraps `next.config.ts`. Generates client + server bundle treemaps after every build. Catches when a tiny PR pulls in recharts on a route that shouldn't have it. The 33-agent audit flagged recharts + pdfme as bundle bloat — this is the tooling to keep that honest. | 15 min setup. Run with `ANALYZE=true pnpm build`. | +| **`@sentry/nextjs`** | Error tracking with frontend + backend correlation, release tracking, source maps, performance traces, replay (optional). We have pino logs but no aggregation/alerting/correlation. Important once we have customer-facing users. | Decision: do we want a SaaS dependency? Self-hosted GlitchTip is also an option (Sentry-protocol-compatible). | +| **`@vercel/og`** (or `satori`) | Generate Open Graph images for shared docs/portal links. Currently the portal has no social previews; if a client shares their EOI link in WhatsApp/Email, the preview is blank. ~10 LOC per route. | 1h for portal share routes. | +| **`papaparse`** | CSV import/export. Sales reps frequently ask for "export to Excel." Plays well with our existing TanStack Table data. ~17kb. | 30 min for client/interest list export. | +| **`@formkit/tempo`** OR **date-fns helpers** | We have **44 files** with hand-rolled `new Date().toLocaleString()` / `.toLocaleDateString()`. Centralize via a `formatDate(date, format, timezone)` helper using `date-fns` (already installed) — no new package needed if we use date-fns's `format`, `formatDistance`, `formatRelative` which we already have. **This is a refactor, not an adoption.** | 2-3h sweep | + +#### Tier 4 — Defer or skip + +| Package | Reason | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `next-pwa` / `@serwist/next` | PWA assets pending (per MEMORY.md). When that lands, **`@serwist/next`** is the modern choice (next-pwa is unmaintained). For now, skip. | +| `next-intl` / `i18next` / `@lingui/core` | No i18n target today. When we localize, **`next-intl`** is the strongest Next.js App Router integration. For now, skip. | +| `@knocklabs/node` + `@knocklabs/react` | Notification center + channel routing + preferences UI. Likely overkill — we have a simple in-app + email notification system that works. Revisit if we add SMS or push. | +| `inngest` / `trigger.dev` | Background jobs with observability. We use BullMQ; revisit only if we need step functions / cross-service workflows. | +| `posthog-js` | Product analytics + feature flags + session recording. We have Umami for web analytics; PostHog adds product-level tracking. Decision pending. | +| `@growthbook/growthbook` | Feature flags only. We don't have any flagged features today. | +| `fuse.js` / `minisearch` | Client-side fuzzy search. Useful for already-loaded list filtering, but TanStack Table's built-in filter is usually enough. | +| `@uppy/core` + `@uppy/dashboard` | Rich file upload UI with resume, chunking. We have basic file inputs (0 patterns found in audit grep) — not currently a pain point. | +| `@tanstack/react-form` | Successor to react-hook-form by same team. RHF is mature, well-known, and we have 8 forms on it. No compelling migration. | +| `valibot` / `arktype` | Faster zod alternatives. We're committed to Zod 4. | +| `react-hotkeys-hook` | **Excluded per user direction.** | + +--- + +### 35.C — Deprecation / cleanup candidates + +| Package | Reason | Action | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| `@radix-ui/react-icons` | We use `lucide-react` everywhere. Audit grep shows no imports from `@radix-ui/react-icons`. | Drop after grep-confirm. ~30s. | +| `@pdfme/common` + `@pdfme/generator` + `@pdfme/schemas` | Replaced by `@react-pdf/renderer` in Phase 1. | After PDF migration. | +| `tailwindcss-animate` v1.0.7 | Last published 2024, no v4 support. Replace with **`tw-animate-css`** (the v4-native successor shadcn now recommends). | Required if we move to Tailwind 4. | +| `@types/pdfkit` | Tops at v7.0.0. We're on `pdfkit` v0.18 — types are loose but functional. Keep until we migrate expense-pdf to @react-pdf/renderer. | Defer. | +| `pino-pretty` in `dependencies` | Should be `devDependencies` only — ships ~500kb to prod worker images if it leaks into the runtime path. Audit-verify the build doesn't include it; move if it does. | 5 min check. | + +--- + +### 35.D — Surfaced refactor opportunities (no new package required) + +These came up while sweeping for package gaps. They're refactor wins, not +package adoptions. + +| Opportunity | Concrete sites | Tool | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Centralize date formatting | 44 files with hand-rolled `.toLocaleString()` / `.toLocaleDateString()` | `formatDate(date, format, timezone)` helper using existing `date-fns` | +| Centralize debounce | 8 picker/list components | `use-debounce` (or hand-rolled hook) | +| Centralize rate-limiting | 4 hand-rolled limiters | `@upstash/ratelimit` | +| Replace 5-9 large switch statements with exhaustive matchers | `search.service.ts` (19 cases), Documenso webhook (13), `client-restore.service.ts` (12), `recently-viewed/route.ts` (10), `custom-fields/[entityId]/route.ts` (10) | `ts-pattern` | + +--- + +### 35.E — Final adoption order (revised, incorporating section 35) + +This supersedes section 34's sequencing where they overlap. + +1. **Now (one focused day)** — Zod 4 + `@hookform/resolvers` 5 + **`drizzle-zod`**. One PR. Codemod-friendly. Highest correctness payoff. +2. **Independent (any time)** — **`react-email`** migration of one template (`portal-auth.ts` recommended first), then expand. Independent of any version upgrade. +3. **Independent (any time)** — **`@react-pdf/renderer`** + **`unpdf`**. Replace 8 pdfme templates, delete 571-LOC bridge, add unpdf to berth parser. +4. **Independent (any time)** — **`ts-pattern`** in the Documenso webhook switch first (the audit's bug-class poster child), then sweep the other 4 sites. +5. **Independent (any time)** — **`@tanstack/react-virtual`** on `client-list` first, copy pattern to 4 other lists. +6. **Independent (any time)** — **`@formkit/auto-animate`** sprinkle. 5-minute wins per site. +7. **Independent (any time)** — **`@next/bundle-analyzer`** install. 15-min setup; ongoing bundle hygiene. +8. **Next focused half-day** — **`motion`** wire to the kanban for smooth reorder. +9. **2-4 weeks** — Next 15 → 16 + eslint-config-next 16 + eslint 10 (lockstep, codemod). +10. **Focused afternoon** — Tailwind 4 via official upgrade tool + swap `tailwindcss-animate` for `tw-animate-css`. +11. **When we have a new form to build** — pilot **`next-safe-action`** there; backfill existing forms gradually. +12. **Decision required first** — `@sentry/nextjs` (SaaS dep), `@tiptap/*` (rich notes Y/N?), `posthog-js` (analytics scope), `papaparse` (CSV export priority). + +--- + +### 35.F — Skipped per user direction + +- **`react-hotkeys-hook`** — no keyboard-shortcut UX target across the platform. + +--- + +## 36. Second-pass package sweep — mobile, fluidity, data speed, DX + +Section 35 covered the headline adoption candidates. This section is the +deliberate second sweep the user requested — looking specifically for +libraries we may have missed across four dimensions: **current +functionality gaps**, **optimization (mobile included)**, **UI fluidity**, +and **data retrieval/writing speed**. + +Findings are grouped by dimension. Each entry says (a) what we have now, +(b) what the library adds, (c) where in our codebase it'd land, (d) effort. + +--- + +### 36.A — Data speed & concurrency + +#### 36.A.1 `p-queue` + `p-limit` + `p-retry` (Sindre Sorhus suite) + +**Concrete pain:** 74 `Promise.all(...)` sites in services/routes. 8 mass- +operation services (`expense-pdf`, `berth-pdf`, `brochures`, `backup`, +`document-templates`, `email-compose`, `documents`, `email-threads`). +Naive `Promise.all([...mapped])` will: + +- Fire all 500 expense receipts to S3 simultaneously → MinIO connection + pool exhaustion + memory spike (`expense-pdf.service.ts` docs explicitly + call this out as a past problem). +- Fire all bulk-send-document calls at Documenso simultaneously → hit + Documenso's per-second rate limit, cause cascade failures. +- Fire all email-compose attachments at SMTP simultaneously → SMTP + connection limit on Mailgun/SES drops requests silently. + +**`p-limit`** caps concurrency: `pLimit(5)` runs at most 5 at a time. +**`p-queue`** is `p-limit` + interval rate limiting + pause/resume. +**`p-retry`** handles exponential backoff retries for transient failures. + +**Land sites:** + +- `expense-pdf.service.ts` — already has streaming logic, but the + per-receipt S3 `get` calls are unbounded. +- `email-compose.service.ts` — bulk send-out is the obvious one. +- `backup.service.ts` — GDPR export streaming. +- `documents.service.ts` — multi-file folder operations. + +**Effort:** 30 min per service. ~1.5kb each. + +#### 36.A.2 `@tanstack/query-broadcast-client-experimental` + +**Concrete pain:** A rep has the CRM open in two tabs. They update a +client in tab A — tab B's stale cache continues showing old values until +the next refetch. + +**What it adds:** BroadcastChannel sync between tabs. Free cross-tab cache +coherence with no server roundtrips. + +**Land site:** One line in `src/providers/query-provider.tsx`: + +```ts +broadcastQueryClient({ queryClient, broadcastChannel: 'pn-crm' }); +``` + +**Effort:** 5 minutes. ~2kb. + +#### 36.A.3 Underused Drizzle ORM features (no new package) + +We have `drizzle-orm` 0.45.2 and use ~60% of its capabilities. + +- **`db.batch(...)`** for atomic multi-statement transactions on + Postgres. Currently we use explicit `db.transaction(async (tx) => {...})` + blocks everywhere — `batch` is shorter and lets the driver pipeline. +- **Prepared statements** via `.prepare()` — repeated queries (e.g., + `getClient(id)` called per-request) can be prepared once at boot and + reused. Postgres saves the parse+plan cost. +- **`with` (CTE) clauses** — we have 30+ places where we'd benefit from + `WITH active_interests AS (...) SELECT ...` instead of joining the same + subquery twice. Audit found N+1 patterns; CTEs flatten them. + +**Land sites:** the recommender SQL aggregate (already uses CTEs), +`dashboard.service.ts` analytics queries, `search.service.ts` graph +expansion. These are all "we already wrote raw SQL strings; rewriting as +typed Drizzle CTEs" wins. + +**Effort:** opportunistic. No package change. + +#### 36.A.4 `postgres.js` cursor for large reads + +We have `postgres` ^3.4.9. Its `await sql\`...\`.cursor(rows => ...)`streams large result sets in batches without buffering all rows. Currently +the GDPR-export bundling and the backup`dump-tables` paths buffer +everything in memory. + +**Land sites:** `backup.service.ts`, `gdpr-export.service.ts` (when we +build it — currently parked). + +**Effort:** opportunistic refactor when we touch those services. + +--- + +### 36.B — UI fluidity & animation + +#### 36.B.1 `@use-gesture/react` (mobile gestures) + +**Concrete pain:** mobile users can't swipe-to-dismiss the Vaul drawer, +swipe sideways between kanban columns, or pinch-zoom berth photos. The +audit's mobile pass flagged these. + +**What it adds:** declarative gesture handlers (`useDrag`, `usePinch`, +`useScroll`). Composes with `motion` for spring-physics responses. + +**Land sites:** + +- Pipeline kanban: swipe between stage columns on mobile. +- Vaul drawer: swipe-down to dismiss (Vaul already does this, but adding + custom velocity thresholds via `@use-gesture` polishes the feel). +- Berth/yacht photo galleries: pinch-zoom. + +**Effort:** 1h to wire one site as the template. ~5kb. + +#### 36.B.2 `embla-carousel-react` + +**Concrete pain:** berth photos and yacht photos render as static grids +(per the audit). On mobile, users want to swipe through them. + +**What it adds:** lightweight, touch-native, accessibility-compliant +carousel. Plays with framer-motion if we want fancy transitions. +shadcn/ui has a `Carousel` component built on this — drop-in via the +shadcn CLI. + +**Effort:** `npx shadcn@latest add carousel`, then 10 lines to render the +photo array. ~10kb gzip. + +#### 36.B.3 `yet-another-react-lightbox` + +**Concrete pain:** clicking a berth photo currently navigates to a fullscreen +image route or doesn't expand at all. Sales reps want lightbox-style preview. + +**What it adds:** fullscreen lightbox with keyboard nav, zoom, swipe, slideshow, +captions. Plugin system for video/PDF embed if we extend. + +**Land sites:** berth/yacht detail pages, client docs preview. + +**Effort:** 1h. ~15kb gzip with plugins. + +#### 36.B.4 `react-resizable-panels` + +**Concrete pain:** the docs hub has a fixed-width folder sidebar (per +CLAUDE.md's documents-hub rewrite). Power users on wide monitors want +to drag-resize it. + +**What it adds:** keyboard-accessible resizable split panes with +persistent sizing (localStorage). shadcn/ui has a `Resizable` component +built on this. + +**Land sites:** docs hub (sidebar | content), email inbox (folder | thread), +admin settings (nav | section). + +**Effort:** `npx shadcn@latest add resizable`, drop in. ~5kb. + +--- + +### 36.C — Mobile optimization + +#### 36.C.1 `browser-image-compression` + +**Concrete pain:** the expense-scanner (`scan-shell.tsx`) and receipt +upload paths accept full-resolution phone photos (typically 4-12 MB each). +Mobile users on cellular pay bandwidth + battery for sending 4× more +data than necessary. The server then re-runs `sharp` to resize anyway. + +**What it adds:** client-side image compression in WebWorker before +upload. Targets `maxSizeMB`, `maxWidthOrHeight`, `useWebWorker`. The +server still validates magic-bytes + sharp-resizes, but receives a +500KB-resized JPG instead of a 12MB original. + +**Concrete win:** a rep on 3G uploading a receipt: ~30s wait → ~5s wait. +Server CPU on `sharp` resize drops to a no-op since the client did it. + +**Effort:** 30 min to wire `scan-shell.tsx`. ~25kb gzip (worker-bundled so +zero main-thread cost). + +#### 36.C.2 `partysocket` + +**Concrete pain:** mobile users on flaky networks frequently lose the +Socket.IO connection. Our current client uses Socket.IO's built-in +reconnect, which is good but not great for mobile. + +**What it adds:** drop-in WebSocket wrapper with: + +- Exponential backoff with jitter (default Socket.IO is linear). +- Message queue while disconnected (Socket.IO buffers via volatile flag + only). +- Auto-reconnect on `online` event + `visibilitychange` (page wake). +- Optional auto-detect connection quality (slow vs fast). + +**Land site:** `src/providers/socket-provider.tsx`. + +**Effort:** depends — `partysocket` works with raw WS, not Socket.IO's +protocol. For Socket.IO we'd need `socket.io-client` + manual reconnect +tuning, or migrate the realtime layer to plain WebSockets (significant). +**Park as a "mobile flake" investigation, not an immediate adoption.** + +#### 36.C.3 `react-virtuoso` (alternative to TanStack Virtual) + +**Concrete pain:** the inbox (`src/components/layout/inbox.tsx`) uses a +plain `` with no virtualization. +For users with hundreds of unread items, mobile scrolling chugs. + +**What it adds:** specialized virtualization for chat-like / inbox-like +UIs with variable-height items and "scroll to bottom on new message" +semantics. **TanStack Virtual is more headless / generic; Virtuoso is +opinionated and better for inbox-shaped UIs.** + +**Land site:** `inbox.tsx` specifically. For the regular lists +(client/yacht/berth), TanStack Virtual is still the right call (section +35.B.4). + +**Effort:** 45 min. ~10kb. + +#### 36.C.4 `@formkit/auto-animate` (revisit for mobile) + +Already in section 35.B but worth re-emphasising: on mobile, list items +appearing/disappearing without animation feels janky. Free polish. + +--- + +### 36.D — Input quality & forms + +#### 36.D.1 `react-imask` or `react-number-format` + +**Concrete pain:** we have currency inputs, phone inputs, date inputs +spread across berth-form, expense-form, invoice-form, client-form. The +audit flagged inconsistent formatting (decimals, thousand-separators, +phone-prefix handling). + +**What it adds:** declarative input masks — ``. Plays cleanly with react-hook-form. + +`react-number-format` is the lighter-weight, currency-specific option. +`react-imask` covers more patterns (phone, date, custom). + +**Land sites:** ~6 form components. + +**Effort:** 30 min per form × 6 = 3h. **OR** keep our hand-rolled +formatters and don't add the dep. Decision pending. + +#### 36.D.2 `@hookform/devtools` (dev-only) + +**What it adds:** a floating panel in the browser showing react-hook-form +state in real time (values, errors, isDirty, isValid, touched fields). +Massive debug-time win. + +**Land site:** wrap forms in `` in dev +builds only. + +**Effort:** 15 min. dev-only, ships zero to prod. + +--- + +### 36.E — Security & sanitization + +#### 36.E.1 `isomorphic-dompurify` + +**Concrete pain:** `src/lib/utils/markdown-email.ts` hand-rolls HTML +escape + safe-link rendering for email bodies. The audit raised XSS +concerns (CRIT-2 in section 4) about admin-supplied content in templates +and email bodies. Our hand-rolled `escapeHtml` is correct for the basic +cases, but DOMPurify handles edge cases the audit listed (data URLs, +nested encoding, javascript: in href attrs). + +**What it adds:** battle-tested HTML sanitizer used by Google, Microsoft, +GitHub. Works in Node + browser (the `isomorphic-` prefix is the +SSR-compatible wrapper around the regular `dompurify`). + +**Land sites:** + +- `renderEmailBody()` in `markdown-email.ts`. +- Anywhere we render user-supplied HTML (template preview, document + body display). + +**Effort:** 1h migration + audit. ~25kb (Node) / ~50kb (browser), +acceptable. + +#### 36.E.2 `@noble/hashes` (already covered by `better-auth`) + +We use `better-auth` for password hashing. No need to add. + +#### 36.E.3 WebAuthn / Passkeys (`@simplewebauthn/server` + `/browser`) + +**What it adds:** passwordless authentication via device passkeys (Touch +ID, Windows Hello, YubiKey). Better Auth has a WebAuthn plugin that +wraps these. + +**Decision required:** is passwordless a 2026 roadmap item? + +--- + +### 36.F — Observability & perf measurement + +#### 36.F.1 `web-vitals` + +**Concrete pain:** we have no real-user perf data. We don't know our +P75 LCP, P75 INP, or P75 CLS across our user base. Any future perf +optimization (Cache Components, Tailwind 4, dynamic imports) is shooting +in the dark without baseline measurement. + +**What it adds:** Google's official Core Web Vitals library. Ships +`onLCP`, `onINP`, `onCLS`, `onFCP`, `onTTFB` callbacks. Reports values +once per page lifecycle. + +**Land site:** `src/app/(dashboard)/layout.tsx` — wire a listener that +POSTs vitals to `/api/v1/internal/vitals` (new endpoint, append to +existing `client_metrics` table or similar). 30 LOC end-to-end. + +**Effort:** 1h including backend logging. ~2kb. **High value** because +without this we're guessing about perf wins. + +#### 36.F.2 `pino-http` + +**Concrete pain:** we have request logging via custom middleware. `pino-http` +is the canonical pino HTTP request logger with automatic request-id +propagation, response time, status code, and integration with our pino +logger. Likely already partially implemented via our hand-rolled +middleware. + +**Effort:** check existing middleware first — may already cover this. + +#### 36.F.3 `@sentry/nextjs` (revisit from section 35) + +Covered in 35.B Tier 3. Adoption gated on the SaaS-dep decision. + +--- + +### 36.G — TypeScript ergonomics + +#### 36.G.1 `@total-typescript/ts-reset` + +**Concrete pain:** TypeScript's stdlib types have well-known foot-guns: + +- `Array.isArray(x)` narrows to `any[]` (drops the actual type). +- `JSON.parse(s)` returns `any` (defeats type safety entirely). +- `fetch().json()` returns `Promise`. +- `.filter(Boolean)` doesn't remove `null | undefined` from the type. +- `Array.prototype.includes` is too strict on its argument. + +ts-reset is **a single `.d.ts` import** (`import '@total-typescript/ts-reset'`) +that fixes all of these globally. Used by Anthropic, Stripe, Vercel internally. + +**Concrete impact:** likely catches 10-20 latent bugs across our 1000+ +TS files where someone called `JSON.parse(body)` and continued treating +the result as a typed object without parsing through Zod. + +**Effort:** 1 line in `src/types/globals.d.ts`. **dev-time only**, ships +zero runtime. + +#### 36.G.2 `type-fest` + +**What it adds:** ~150 utility types (`SetRequired`, `SetOptional`, +`PartialDeep`, `MergeDeep`, `Promisable`, `Jsonifiable`, etc.) that +extend TypeScript's built-ins. + +**Land sites:** anywhere we're hand-rolling `Omit & Pick` +gymnastics — type-fest usually has a named util that's clearer. + +**Effort:** opportunistic. ~0kb runtime (types only). + +#### 36.G.3 `tsc-files` + +**Concrete pain:** pre-commit hook runs ESLint on staged files (fast) but +no type-check. Type errors slip through to CI. + +**What it adds:** typecheck _only the staged TS files and their +dependencies_, not the full repo. Drops a pre-commit hook from "skip +because too slow" to "always on, sub-2-second." + +**Land site:** `.husky/pre-commit` + `lint-staged.config.mjs` — +`"*.ts": ["tsc-files --noEmit"]`. + +**Effort:** 15 min. + +--- + +### 36.H — In-browser PDF viewing + +#### 36.H.1 `pdfjs-dist` + a viewer wrapper + +**Concrete pain:** the docs hub (per CLAUDE.md) lets users upload and +file PDFs. There's currently no in-app preview — clicking a file likely +downloads it or opens in a new tab. A real CRM should preview the PDF +inline. + +**What it adds:** + +- **`pdfjs-dist`** is Mozilla's pdf.js — the engine. +- **`@react-pdf-viewer/core`** is the most feature-rich React wrapper + (zoom, search, annotations). +- Alternatively, **`react-pdf`** (Wojtek Maj's, not @react-pdf/renderer) + is a lighter wrapper. + +**Land site:** docs hub file detail / preview pane. EOI signing preview +in admin. + +**Effort:** 2-3h for a polished viewer with zoom + page nav. ~150kb gzip +(pdf.js is unavoidable; lazy-load only when preview opens). + +**Note vs section 35.A:** `@react-pdf/renderer` (generator) and `pdfjs-dist` +(viewer) are complementary. We need both: one to _make_ PDFs, one to +_show_ them. + +--- + +### 36.I — Testing & development data + +#### 36.I.1 `@faker-js/faker` + +**Concrete pain:** seed data is currently hand-maintained (mostly). +Faker would replace hand-rolled fake names, emails, addresses, phone +numbers, vehicle/yacht names, dates, marina locations with reproducible, +locale-aware fakes. + +**Land site:** `src/lib/db/seed.ts`, `src/lib/db/seed-synthetic.ts`. + +**Effort:** 1-2h. ~3MB gzip — **dev-only**, not shipped. + +#### 36.I.2 `msw` (Mock Service Worker) + +**Concrete pain:** integration tests that hit external services +(Documenso, SMTP, IMAP) either skip in CI or fail intermittently. +**`msw`** intercepts fetch/HTTP at the network layer in tests so we can +mock external responses deterministically. + +**Land site:** `tests/integration/` setup — wrap Documenso + SMTP +clients with MSW handlers. + +**Effort:** 2-3h. dev-only. + +--- + +### 36.J — Workflow & state machines + +#### 36.J.1 `@xstate/react` + +Audit found only one multi-step flow (`send-document-dialog.tsx`). +EOI signing has steps but they're sequential, not state-machine-y. The +GDPR export job is a backend state machine but `bullmq` handles it. + +**Verdict:** **not warranted right now.** Revisit if we build the +client-onboarding flow or the multi-step EOI-with-multi-berth-and- +payment-and-signing wizard the roadmap mentions. + +--- + +### 36.K — Search & filtering + +#### 36.K.1 Postgres-native FTS (no new package — schema migration) + +**Concrete pain:** `search.service.ts` uses `LIKE '%term%'` on client/yacht/ +company tables. Slow at scale; doesn't rank. + +**What we could add:** Postgres `tsvector` columns + `GIN` indexes + a +single `to_tsquery()` call per search. This is **all native Postgres** +— no new npm dep. Drizzle supports it via `sql\`...\`` template literals. + +**Effort:** migration (30 min) + service refactor (2h) + e2e re-run. + +#### 36.K.2 External search engines (`meilisearch`, `typesense`) + +**Verdict:** overkill until we're past 100k clients per port. Postgres +FTS will hold for years. **Defer indefinitely.** + +--- + +### 36.L — Final updated adoption order (incorporating section 36) + +Layered on section 35.E: + +**Same-day adopts (low-risk, high-leverage):** + +- **`@total-typescript/ts-reset`** — 1-line type-safety upgrade. Do this + before any Zod 4 work — it'll catch latent bugs along the way. +- **`web-vitals`** — establish perf baseline before any optimization. +- **`@hookform/devtools`** — dev-only DX win. + +**Adopt alongside section 35.B Tier 1:** + +- **`p-limit`** — pair with the section 35 mass-operation refactors. The + Documenso bulk-send path is the highest-priority site. +- **`@tanstack/query-broadcast-client-experimental`** — 1-liner in the + query provider. + +**Adopt with mobile/UX work:** + +- **`browser-image-compression`** — wire into scan-shell first. +- **`embla-carousel-react`** + **`yet-another-react-lightbox`** — pair + with berth/yacht photo gallery work. +- **`react-resizable-panels`** — pair with docs hub UX work. +- **`@use-gesture/react`** — pair with kanban-on-mobile polish. + +**Adopt with security pass:** + +- **`isomorphic-dompurify`** — replaces hand-rolled escapeHtml. Pair + with the audit's XSS hardening pass. + +**Adopt with the docs hub Phase 2:** + +- **`pdfjs-dist`** + viewer wrapper — when in-app PDF preview becomes a + user request. + +**Park / defer:** + +- `partysocket` (requires Socket.IO investigation first). +- `@xstate/react` (no current target). +- External search engines. +- WebAuthn / passkeys (roadmap decision). + +--- + +### 36.M — Final summary + +The first sweep (section 35) found the headline replacements: +**Zod 4 + drizzle-zod + react-email + @react-pdf/renderer** is the +single highest-leverage week of work. + +This second sweep (section 36) found the **operational hardening +layer**: + +- **`p-limit` family** for the 74 unbounded `Promise.all` sites. +- **`@total-typescript/ts-reset`** for free type safety across 1000+ files. +- **`web-vitals`** to establish a perf baseline before we optimize. +- **`isomorphic-dompurify`** to harden the email/template rendering. +- **`browser-image-compression`** for mobile bandwidth / battery. +- **`@tanstack/query-broadcast-client-experimental`** for free cross-tab + cache sync. +- **`react-resizable-panels`** + **`embla-carousel-react`** + + **`yet-another-react-lightbox`** for the photo/preview surfaces. + +Together with section 35, this gives us a concrete shopping list of +~20 packages with explicit land-sites in our code and effort estimates, +plus 5-6 cleanup-candidate removals. Adopting all of them would shed +~600 LOC of hand-rolled code, eliminate ~5 categories of latent bugs +(timezone, XSS, race conditions, type stdlib quirks, missing +exhaustiveness), and meaningfully improve mobile UX + perf measurability. + +--- + +**Bottom line:** the deps audit (section 34) showed we're secure today. +This section (35) shows where we can make the codebase _meaningfully better_ +— smaller, cleaner, more declarative — by leveraging packages we don't yet +use. The single highest-leverage move is **Zod 4 + drizzle-zod + react-email +in the same focused day**: it kills the validator-drift problem, lands the +14× parse-perf win, and starts paying down the hand-strung-email-templates +debt all at once. The PDF stack overhaul (35.A) is the second-highest-leverage +move: removing pdfme + the 571-line Tiptap bridge in favor of declarative +React components is a category-of-bug eliminator, not just a refactor. diff --git a/eslint.config.mjs b/eslint.config.mjs index 6a7d57ba..93fae6b9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,15 @@ const eslintConfig = [ ], }, }, + { + // Tests assert response shape via expect() — narrowing every + // `res.json()` to a structural type adds boilerplate without catching + // bugs. Allow `any` casts at JSON boundaries in test files. + files: ["tests/**/*.ts", "tests/**/*.tsx"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, { ignores: ["client-portal/**", "next-env.d.ts"], }, diff --git a/package.json b/package.json index 6f6cf32c..c51ad359 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@socket.io/redis-adapter": "^8.3.0", + "@tanstack/query-broadcast-client-experimental": "^5.100.10", "@tanstack/react-query": "^5.100.10", "@tanstack/react-query-devtools": "^5.100.10", "@tanstack/react-table": "^8.21.3", @@ -99,12 +100,15 @@ "tailwindcss-animate": "^1.0.7", "tesseract.js": "^7.0.0", "vaul": "^1.1.2", + "web-vitals": "^5.2.0", "zod": "^3.25.76", "zustand": "^5.0.13" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", + "@hookform/devtools": "^4.4.0", "@playwright/test": "^1.60.0", + "@total-typescript/ts-reset": "^0.6.1", "@types/archiver": "^7.0.0", "@types/iso-3166-2": "^1.0.4", "@types/mailparser": "^3.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df9b0c80..48a38a46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.6) + '@tanstack/query-broadcast-client-experimental': + specifier: ^5.100.10 + version: 5.100.10 '@tanstack/react-query': specifier: ^5.100.10 version: 5.100.10(react@19.2.6) @@ -226,6 +229,9 @@ importers: vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + web-vitals: + specifier: ^5.2.0 + version: 5.2.0 zod: specifier: ^3.25.76 version: 3.25.76 @@ -236,9 +242,15 @@ importers: '@eslint/eslintrc': specifier: ^3.3.5 version: 3.3.5 + '@hookform/devtools': + specifier: ^4.4.0 + version: 4.4.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@playwright/test': specifier: ^1.60.0 version: 1.60.0 + '@total-typescript/ts-reset': + specifier: ^0.6.1 + version: 0.6.1 '@types/archiver': specifier: ^7.0.0 version: 7.0.0 @@ -318,6 +330,22 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -331,6 +359,22 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -455,6 +499,60 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -672,6 +770,12 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@hookform/devtools@4.4.0': + resolution: {integrity: sha512-Mtlic+uigoYBPXlfvPBfiYYUZuyMrD3pTjDpVIhL6eCZTvQkHsKBSKeZCvXWUZr8fqrkzDg27N+ZuazLKq6Vmg==} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + '@hookform/resolvers@3.10.0': resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} peerDependencies: @@ -1763,6 +1867,9 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@tanstack/query-broadcast-client-experimental@5.100.10': + resolution: {integrity: sha512-CXya2vuSDWaZOHiP4iwGrhjRU8xtnFqh1pqr0PpTSTs9LvsY757r53Z65vqyOtjCyIWhvTtz7+QfKdka6NKMvQ==} + '@tanstack/query-core@5.100.10': resolution: {integrity: sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==} @@ -1791,6 +1898,9 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@total-typescript/ts-reset@0.6.1': + resolution: {integrity: sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -1845,6 +1955,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/mailparser@3.4.6': resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==} @@ -1854,6 +1967,9 @@ packages: '@types/nodemailer@8.0.0': resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/pdfkit@0.17.6': resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} @@ -2242,6 +2358,10 @@ packages: react-native-b4a: optional: true + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2405,6 +2525,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + broadcast-channel@7.3.0: + resolution: {integrity: sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==} + brotli@1.3.3: resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} @@ -2557,6 +2680,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2571,6 +2697,10 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -2716,6 +2846,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2901,6 +3035,9 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -3085,6 +3222,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -3156,6 +3296,9 @@ packages: resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} engines: {node: '>=0.10.0'} + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3295,6 +3438,9 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -3379,6 +3525,9 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -3564,9 +3713,17 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -3724,6 +3881,11 @@ packages: resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} engines: {node: '>=22.13.0'} + little-state-machine@4.8.1: + resolution: {integrity: sha512-liPHqaWMQ7rzZryQUDnbZ1Gclnnai3dIyaJ0nAgwZRXMzqbYrydrlCI0NDojRUbE5VYh5vu6hygEUZiH77nQkQ==} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4005,6 +4167,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + oblivious-set@2.0.0: + resolution: {integrity: sha512-QOUH5Xrsced9fKXaQTjWoDGKeS/Or7E2jB0FN63N4mkAO4qJdB7WR7e6qWAOHM5nk25FJ8TGjhP7DH4l6vFVLg==} + engines: {node: '>=16'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -4047,6 +4213,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -4055,6 +4225,14 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -4074,6 +4252,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -4096,6 +4278,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4326,6 +4512,11 @@ packages: '@types/react': optional: true + react-simple-animate@3.5.3: + resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==} + peerDependencies: + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4608,6 +4799,10 @@ packages: source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -4741,6 +4936,9 @@ packages: babel-plugin-macros: optional: true + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -4900,6 +5098,9 @@ packages: unicode-trie@2.0.0: resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unload@2.4.1: + resolution: {integrity: sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -4922,6 +5123,12 @@ packages: '@types/react': optional: true + use-deep-compare-effect@1.8.1: + resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13' + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -4940,6 +5147,11 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -5040,6 +5252,9 @@ packages: wasm-feature-detect@1.8.0: resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + web-vitals@5.2.0: + resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5136,6 +5351,10 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + yaml@2.8.4: resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} @@ -5184,6 +5403,29 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -5192,6 +5434,28 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.28.6': {} + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -5299,6 +5563,89 @@ snapshots: tslib: 2.8.1 optional: true + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/runtime': 7.29.2 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.6) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.6) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.6) + '@emotion/utils': 1.4.2 + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.28.0 @@ -5450,6 +5797,22 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@hookform/devtools@4.4.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.6))(@types/react@19.2.14)(react@19.2.6) + '@types/lodash': 4.17.24 + little-state-machine: 4.8.1(react@19.2.6) + lodash: 4.18.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-simple-animate: 3.5.3(react-dom@19.2.6(react@19.2.6)) + use-deep-compare-effect: 1.8.1(react@19.2.6) + uuid: 8.3.2 + transitivePeerDependencies: + - '@types/react' + - supports-color + '@hookform/resolvers@3.10.0(react-hook-form@7.75.0(react@19.2.6))': dependencies: react-hook-form: 7.75.0(react@19.2.6) @@ -6443,6 +6806,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@tanstack/query-broadcast-client-experimental@5.100.10': + dependencies: + '@tanstack/query-core': 5.100.10 + broadcast-channel: 7.3.0 + '@tanstack/query-core@5.100.10': {} '@tanstack/query-devtools@5.100.10': {} @@ -6466,6 +6834,8 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@total-typescript/ts-reset@0.6.1': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -6518,6 +6888,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/lodash@4.17.24': {} + '@types/mailparser@3.4.6': dependencies: '@types/node': 20.19.41 @@ -6531,6 +6903,8 @@ snapshots: dependencies: '@types/node': 20.19.41 + '@types/parse-json@4.0.2': {} + '@types/pdfkit@0.17.6': dependencies: '@types/node': 20.19.41 @@ -6962,6 +7336,12 @@ snapshots: b4a@1.8.1: {} + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.2 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -7075,6 +7455,13 @@ snapshots: dependencies: fill-range: 7.1.1 + broadcast-channel@7.3.0: + dependencies: + '@babel/runtime': 7.28.6 + oblivious-set: 2.0.0 + p-queue: 6.6.2 + unload: 2.4.1 + brotli@1.3.3: dependencies: base64-js: 1.5.1 @@ -7234,6 +7621,8 @@ snapshots: concat-map@0.0.1: {} + convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} cookie@0.7.2: {} @@ -7245,6 +7634,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -7364,6 +7761,8 @@ snapshots: denque@2.1.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -7475,6 +7874,10 @@ snapshots: environment@1.1.0: {} + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -7821,6 +8224,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} events-universal@1.0.1: @@ -7891,6 +8296,8 @@ snapshots: filter-obj@1.1.0: {} + find-root@1.1.0: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -8045,6 +8452,10 @@ snapshots: help-me@5.0.0: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-entities@2.6.0: {} html-escaper@2.0.2: {} @@ -8139,6 +8550,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -8318,8 +8731,12 @@ snapshots: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -8461,6 +8878,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + little-state-machine@4.8.1(react@19.2.6): + dependencies: + react: 19.2.6 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -8739,6 +9160,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + oblivious-set@2.0.0: {} + obug@2.1.1: {} on-exit-leak-free@2.1.2: {} @@ -8784,6 +9207,8 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-finally@1.0.0: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -8792,6 +9217,15 @@ snapshots: dependencies: p-limit: 3.1.0 + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -8806,6 +9240,13 @@ snapshots: dependencies: callsites: 3.1.0 + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parseley@0.12.1: dependencies: leac: 0.6.0 @@ -8824,6 +9265,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-type@4.0.0: {} + pathe@2.0.3: {} pdf-lib@1.17.1: @@ -9047,6 +9490,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-simple-animate@3.5.3(react-dom@19.2.6(react@19.2.6)): + dependencies: + react-dom: 19.2.6(react@19.2.6) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): dependencies: get-nonce: 1.0.1 @@ -9431,6 +9878,8 @@ snapshots: buffer-from: 1.1.2 source-map: 0.6.1 + source-map@0.5.7: {} + source-map@0.6.1: {} sparse-bitfield@3.0.3: @@ -9578,6 +10027,8 @@ snapshots: client-only: 0.0.1 react: 19.2.6 + stylis@4.2.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -9798,6 +10249,8 @@ snapshots: pako: 0.2.9 tiny-inflate: 1.0.3 + unload@2.4.1: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -9839,6 +10292,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-deep-compare-effect@1.8.1(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + dequal: 2.0.3 + react: 19.2.6 + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.6): dependencies: detect-node-es: 1.1.0 @@ -9853,6 +10312,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@8.3.2: {} + vary@1.1.2: {} vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -9929,6 +10390,8 @@ snapshots: wasm-feature-detect@1.8.0: {} + web-vitals@5.2.0: {} + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: @@ -10041,6 +10504,8 @@ snapshots: xmlhttprequest-ssl@2.1.2: {} + yaml@1.10.3: {} + yaml@2.8.4: optional: true diff --git a/src/app/(auth)/set-password/page.tsx b/src/app/(auth)/set-password/page.tsx index 4ca3fedc..72d4e286 100644 --- a/src/app/(auth)/set-password/page.tsx +++ b/src/app/(auth)/set-password/page.tsx @@ -56,7 +56,10 @@ function SetPasswordInner() { }); if (!response.ok) { - const body = await response.json().catch(() => ({})); + const body = (await response.json().catch(() => ({}))) as { + message?: string; + error?: string; + }; toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.'); return; } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index aa2b8b26..22261b37 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -14,6 +14,7 @@ import { Sidebar } from '@/components/layout/sidebar'; import { Topbar } from '@/components/layout/topbar'; import { MobileLayout } from '@/components/layout/mobile/mobile-layout'; import { RealtimeToasts } from '@/components/shared/realtime-toasts'; +import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter'; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const session = await auth.api.getSession({ headers: await headers() }); @@ -40,6 +41,7 @@ export default async function DashboardLayout({ children }: { children: React.Re + {/* Desktop shell - hidden by CSS on mobile */}
({ error: 'Upload failed' })); + const err = (await res.json().catch(() => ({ error: 'Upload failed' }))) as { + error?: string; + }; throw new Error(err.error ?? 'Upload failed'); } return res.json(); diff --git a/src/components/shared/filter-bar.tsx b/src/components/shared/filter-bar.tsx index 1f0225b0..cc6cc29c 100644 --- a/src/components/shared/filter-bar.tsx +++ b/src/components/shared/filter-bar.tsx @@ -233,10 +233,12 @@ function FilterField({ id={`${definition.key}-${opt.value}`} checked={selected} onCheckedChange={(checked) => { - const current = Array.isArray(value) ? value : []; + const current: string[] = Array.isArray(value) + ? value.filter((v): v is string => typeof v === 'string') + : []; const next = checked ? [...current, opt.value] - : current.filter((v: string) => v !== opt.value); + : current.filter((v) => v !== opt.value); onChange(next.length > 0 ? next : undefined); }} /> diff --git a/src/components/shared/form-devtool.tsx b/src/components/shared/form-devtool.tsx new file mode 100644 index 00000000..d73e2011 --- /dev/null +++ b/src/components/shared/form-devtool.tsx @@ -0,0 +1,32 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import type { Control, FieldValues } from 'react-hook-form'; + +// Lazy-load @hookform/devtools only when actually rendered. The +// `process.env.NODE_ENV` guard below ensures we never hit this path in +// production, so Next.js' code splitter keeps the devtools chunk out of +// the prod bundle entirely. +const DevTool = dynamic(() => import('@hookform/devtools').then((m) => ({ default: m.DevTool })), { + ssr: false, +}); + +/** + * Floating react-hook-form state inspector. Shows live values, errors, + * touched/dirty state. Rendered only in development. + * + * Usage in any form component: + * + * const form = useForm({ resolver: zodResolver(schema) }); + * ... + *
+ * + * {fields} + * + */ +export function FormDevtool({ control }: { control: Control }) { + if (process.env.NODE_ENV !== 'development') return null; + // Cast: @hookform/devtools types Control as the v6 shape; ours is v7+. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ; +} diff --git a/src/components/shared/notes-list.tsx b/src/components/shared/notes-list.tsx index e76e446b..6ba5eb9c 100644 --- a/src/components/shared/notes-list.tsx +++ b/src/components/shared/notes-list.tsx @@ -56,7 +56,7 @@ const SELF_SOURCE: Record = { residential_interests: null, }; -const AGGREGATABLE: ReadonlySet = new Set([ +const AGGREGATABLE: ReadonlySet = new Set([ 'clients', 'yachts', 'companies', diff --git a/src/components/shared/web-vitals-reporter.tsx b/src/components/shared/web-vitals-reporter.tsx new file mode 100644 index 00000000..a5ff4039 --- /dev/null +++ b/src/components/shared/web-vitals-reporter.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect } from 'react'; +import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from 'web-vitals'; + +const VITALS_ENDPOINT = '/api/v1/internal/vitals'; + +/** + * Reports Core Web Vitals to the backend so we have a baseline before + * any perf-optimisation work. Sends via `sendBeacon` (survives unload) + * with a `fetch` fallback for browsers that don't support beacon. + * + * Mounted in the dashboard layout — fires once per page lifecycle for + * each metric. Vitals are reported when stable (LCP locked in, INP after + * meaningful interaction, etc.), not on every render. + */ +export function WebVitalsReporter() { + useEffect(() => { + function report(metric: Metric) { + const path = typeof window !== 'undefined' ? window.location.pathname : null; + + const body = JSON.stringify({ + name: metric.name, + value: metric.value, + rating: metric.rating, + id: metric.id, + delta: metric.delta, + navigationType: metric.navigationType, + path, + }); + + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { + navigator.sendBeacon(VITALS_ENDPOINT, new Blob([body], { type: 'application/json' })); + return; + } + + // Fallback for browsers without sendBeacon. + fetch(VITALS_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => { + // Swallow — vitals are best-effort. + }); + } + + onLCP(report); + onINP(report); + onCLS(report); + onFCP(report); + onTTFB(report); + }, []); + + return null; +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 30a52445..fb5a7c76 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -73,7 +73,14 @@ export async function apiFetch(url: string, opts: ApiFetchOptions = }); if (!res.ok) { - const error = await res.json().catch(() => ({ error: res.statusText })); + const error = (await res.json().catch(() => ({ error: res.statusText }))) as { + error?: string; + message?: string; + code?: string; + details?: unknown; + requestId?: string; + retryAfter?: number; + }; // Surface the request id so toasts can display "Error ID: …" and // the user can copy it to a support ticket. Server-side wrappers // always set X-Request-Id, even on early-return 401/403 paths. diff --git a/src/lib/services/client-archive-dossier.service.ts b/src/lib/services/client-archive-dossier.service.ts index c0195dd0..67087f8c 100644 --- a/src/lib/services/client-archive-dossier.service.ts +++ b/src/lib/services/client-archive-dossier.service.ts @@ -33,7 +33,7 @@ import type { PipelineStage } from '@/lib/constants'; * bulk-archive UI prompts the operator to confirm individually + supply * a reason for these clients. */ -export const HIGH_STAKES_STAGES: ReadonlySet = new Set([ +export const HIGH_STAKES_STAGES: ReadonlySet = new Set([ 'deposit_10pct', 'contract_sent', 'contract_signed', diff --git a/src/lib/services/currency.ts b/src/lib/services/currency.ts index 31a3c0ba..bfa85337 100644 --- a/src/lib/services/currency.ts +++ b/src/lib/services/currency.ts @@ -30,8 +30,8 @@ export async function refreshRates(): Promise { throw new CodedError('INTERNAL', { internalMessage: `Frankfurter API error: ${res.status}`, }); - const data = await res.json(); - const rates = data.rates as Record; + const data = (await res.json()) as { rates: Record }; + const rates = data.rates; for (const [currency, rate] of Object.entries(rates)) { await db diff --git a/src/lib/services/inquiry-notifications.service.ts b/src/lib/services/inquiry-notifications.service.ts index c0827cdd..0e9d379d 100644 --- a/src/lib/services/inquiry-notifications.service.ts +++ b/src/lib/services/inquiry-notifications.service.ts @@ -105,7 +105,7 @@ export async function sendInquiryNotifications(params: InquiryNotificationParams try { const recipientsSetting = await getSetting('inquiry_notification_recipients', portId); const externalEmails: string[] = Array.isArray(recipientsSetting?.value) - ? recipientsSetting.value + ? recipientsSetting.value.filter((v): v is string => typeof v === 'string') : []; if (externalEmails.length > 0) { diff --git a/src/providers/query-provider.tsx b/src/providers/query-provider.tsx index f729f0e7..07ed202f 100644 --- a/src/providers/query-provider.tsx +++ b/src/providers/query-provider.tsx @@ -2,7 +2,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { useState, type ReactNode } from 'react'; +import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental'; +import { useEffect, useState, type ReactNode } from 'react'; export function QueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState( @@ -23,6 +24,14 @@ export function QueryProvider({ children }: { children: ReactNode }) { }), ); + // Sync cache across tabs via BroadcastChannel. When a rep updates a + // client in tab A, tab B's cached query invalidates instantly without + // a server roundtrip. Runs in browser only. + useEffect(() => { + if (typeof window === 'undefined') return; + broadcastQueryClient({ queryClient, broadcastChannel: 'pn-crm' }); + }, [queryClient]); + return ( {children} diff --git a/src/types/ts-reset.d.ts b/src/types/ts-reset.d.ts new file mode 100644 index 00000000..c08fc376 --- /dev/null +++ b/src/types/ts-reset.d.ts @@ -0,0 +1,4 @@ +// Activates @total-typescript/ts-reset — tightens TypeScript stdlib types +// across the project (JSON.parse, Array.isArray, .filter(Boolean), etc.) +// without changing runtime behavior. +import '@total-typescript/ts-reset'; diff --git a/tests/e2e/smoke/18-ai-features.spec.ts b/tests/e2e/smoke/18-ai-features.spec.ts index 5c863435..4769857b 100644 --- a/tests/e2e/smoke/18-ai-features.spec.ts +++ b/tests/e2e/smoke/18-ai-features.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { login, navigateTo, PORT_SLUG } from './helpers'; +import { login, navigateTo } from './helpers'; test.describe('AI Features', () => { test.beforeEach(async ({ page }) => { @@ -9,9 +9,12 @@ test.describe('AI Features', () => { // Test 39: AI features are hidden when flag is off test('AI score badge hidden when feature flag disabled', async ({ page }) => { // First, ensure the flag is off by checking via API - const flagRes = await page.request.get('/api/v1/settings/feature-flag?key=ai_interest_scoring', { - headers: { 'X-Port-Id': '' }, // Will use session port - }); + const flagRes = await page.request.get( + '/api/v1/settings/feature-flag?key=ai_interest_scoring', + { + headers: { 'X-Port-Id': '' }, // Will use session port + }, + ); // Navigate to an interest await navigateTo(page, '/interests'); @@ -27,11 +30,17 @@ test.describe('AI Features', () => { const hotBadge = page.getByText(/^(Hot|Warm|Cool|Cold)$/).first(); if (flagRes.ok()) { - const flagData = await flagRes.json().catch(() => ({ enabled: false })); + const flagData = (await flagRes.json().catch(() => ({ enabled: false }))) as { + enabled?: boolean; + }; if (!flagData.enabled) { // Score badge should NOT be visible - await expect(scoreBadge.first()).not.toBeVisible({ timeout: 3_000 }).catch(() => {}); - await expect(hotBadge).not.toBeVisible({ timeout: 2_000 }).catch(() => {}); + await expect(scoreBadge.first()) + .not.toBeVisible({ timeout: 3_000 }) + .catch(() => {}); + await expect(hotBadge) + .not.toBeVisible({ timeout: 2_000 }) + .catch(() => {}); } } } @@ -48,7 +57,10 @@ test.describe('AI Features', () => { const aiToggle = page.getByText(/ai.*scoring|interest.*scoring/i).first(); if (await aiToggle.isVisible({ timeout: 5_000 }).catch(() => false)) { // Find the associated switch/toggle - const toggle = aiToggle.locator('..').locator('button[role="switch"], input[type="checkbox"]').first(); + const toggle = aiToggle + .locator('..') + .locator('button[role="switch"], input[type="checkbox"]') + .first(); if (await toggle.isVisible({ timeout: 2_000 }).catch(() => false)) { await toggle.click(); await page.waitForTimeout(2_000); @@ -56,10 +68,12 @@ test.describe('AI Features', () => { } else { // If no UI for feature flags, try setting it via API // This is an acceptable approach for testing - await page.request.put('/api/v1/settings/feature-flag', { - data: { key: 'ai_interest_scoring', value: true }, - headers: { 'Content-Type': 'application/json' }, - }).catch(() => {}); + await page.request + .put('/api/v1/settings/feature-flag', { + data: { key: 'ai_interest_scoring', value: true }, + headers: { 'Content-Type': 'application/json' }, + }) + .catch(() => {}); } expect(true).toBeTruthy(); }); @@ -78,10 +92,10 @@ test.describe('AI Features', () => { // Try calling the scoring API directly to verify it works const scoreRes = await page.request.get('/api/v1/ai/interest-score/bulk'); if (scoreRes.ok()) { - const data = await scoreRes.json().catch(() => null); + const data = (await scoreRes.json().catch(() => null)) as any; if (data && Array.isArray(data) && data.length > 0) { // Verify scores are in 0-100 range - const score = data[0].score?.totalScore ?? data[0].totalScore; + const score = (data as any)[0].score?.totalScore ?? (data as any)[0].totalScore; if (score !== undefined) { expect(score).toBeGreaterThanOrEqual(0); expect(score).toBeLessThanOrEqual(100); @@ -97,7 +111,7 @@ test.describe('AI Features', () => { // Test via API to avoid UI dependencies const interests = await page.request.get(`/api/v1/interests?limit=1`).catch(() => null); if (interests?.ok()) { - const data = await interests.json().catch(() => ({ data: [] })); + const data = (await interests.json().catch(() => ({ data: [] }))) as any; const interest = data.data?.[0]; if (interest) { @@ -115,7 +129,7 @@ test.describe('AI Features', () => { expect([200, 202, 404].includes(draftRes.status())).toBeTruthy(); if (draftRes.status() === 202) { - const result = await draftRes.json(); + const result = (await draftRes.json()) as any; expect(result.jobId).toBeTruthy(); } } diff --git a/tests/e2e/smoke/global-setup.ts b/tests/e2e/smoke/global-setup.ts index 596179f4..756f7f32 100644 --- a/tests/e2e/smoke/global-setup.ts +++ b/tests/e2e/smoke/global-setup.ts @@ -44,7 +44,7 @@ async function signUpUser(email: string, password: string, name: string) { }); if (res.ok) { - const data = await res.json(); + const data = (await res.json()) as any; return data.user?.id ?? data.id; } @@ -56,7 +56,7 @@ async function signUpUser(email: string, password: string, name: string) { }); if (loginRes.ok) { - const loginData = await loginRes.json(); + const loginData = (await loginRes.json()) as any; return loginData.user?.id ?? loginData.id; } diff --git a/tests/integration/api/berth-reservations-list.test.ts b/tests/integration/api/berth-reservations-list.test.ts index 7956ccd8..e243fd6b 100644 --- a/tests/integration/api/berth-reservations-list.test.ts +++ b/tests/integration/api/berth-reservations-list.test.ts @@ -35,7 +35,7 @@ async function seedReservation(portId: string) { ctx, { id: berth.id }, ); - return (await res.json()).data as { id: string; berthId: string }; + return ((await res.json()) as any).data as { id: string; berthId: string }; } describe('GET /api/v1/berth-reservations', () => { @@ -51,7 +51,7 @@ describe('GET /api/v1/berth-reservations', () => { ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; const ids = (body.data as Array<{ id: string }>).map((r) => r.id).sort(); expect(ids).toEqual([r1.id, r2.id].sort()); expect(body.pagination).toMatchObject({ page: 1, total: 2 }); @@ -70,7 +70,7 @@ describe('GET /api/v1/berth-reservations', () => { ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; const ids = (body.data as Array<{ id: string }>).map((r) => r.id); expect(ids).not.toContain(reservationInB.id); }); @@ -88,7 +88,7 @@ describe('GET /api/v1/berth-reservations', () => { ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data).toHaveLength(2); expect(body.pagination).toMatchObject({ page: 1, diff --git a/tests/integration/api/companies.test.ts b/tests/integration/api/companies.test.ts index 27d40d6c..4ff57c56 100644 --- a/tests/integration/api/companies.test.ts +++ b/tests/integration/api/companies.test.ts @@ -19,7 +19,7 @@ describe('POST /api/v1/companies', () => { }); const res = await createHandler(req, ctx, {}); expect(res.status).toBe(201); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.name).toBe(name); expect(body.data.portId).toBe(port.id); expect(body.data.status).toBe('active'); @@ -88,7 +88,7 @@ describe('GET /api/v1/companies', () => { ); const res = await listHandler(req, ctx, {}); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.some((c: { name: string }) => c.name === 'ListedCo')).toBe(true); expect(body.pagination.page).toBe(1); expect(body.pagination.pageSize).toBe(20); @@ -115,7 +115,7 @@ describe('GET /api/v1/companies', () => { ); const res = await listHandler(req, ctx, {}); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.every((c: { status: string }) => c.status === 'dissolved')).toBe(true); expect(body.data.some((c: { name: string }) => c.name === 'DissolvedCo')).toBe(true); expect(body.data.some((c: { name: string }) => c.name === 'ActiveCo')).toBe(false); @@ -133,7 +133,7 @@ describe('GET /api/v1/companies/[id]', () => { const req = makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}`); const res = await getHandler(req, ctx, { id: company.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.id).toBe(company.id); expect(body.data.name).toBe('DetailCo'); }); @@ -162,7 +162,7 @@ describe('PATCH /api/v1/companies/[id]', () => { }); const res = await patchHandler(req, ctx, { id: company.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.name).toBe('AfterCo'); expect(body.data.notes).toBe('updated notes'); }); @@ -211,7 +211,7 @@ describe('GET /api/v1/companies/autocomplete', () => { ); const res = await autocompleteHandler(req, ctx, {}); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.length).toBeGreaterThanOrEqual(1); expect(body.data.every((c: { name: string }) => c.name.includes('AutoCompleteCoMatch'))).toBe( true, @@ -224,7 +224,7 @@ describe('GET /api/v1/companies/autocomplete', () => { const req = makeMockRequest('GET', 'http://localhost/api/v1/companies/autocomplete'); const res = await autocompleteHandler(req, ctx, {}); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data).toEqual([]); }); }); diff --git a/tests/integration/api/interest-berths.test.ts b/tests/integration/api/interest-berths.test.ts index 0f2308b5..1546ca40 100644 --- a/tests/integration/api/interest-berths.test.ts +++ b/tests/integration/api/interest-berths.test.ts @@ -56,7 +56,7 @@ describe('GET /api/v1/interests/[id]/berths (listHandler)', () => { const req = makeMockRequest('GET', `http://localhost/api/v1/interests/${interest.id}/berths`); const res = await listHandler(req, ctx, { id: interest.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data).toHaveLength(1); expect(body.data[0].berthId).toBe(berth.id); expect(body.data[0].mooringNumber).toBe(berth.mooringNumber); @@ -102,7 +102,7 @@ describe('POST /api/v1/interests/[id]/berths (addHandler)', () => { }); const res = await addHandler(req, ctx, { id: interest.id }); expect(res.status).toBe(201); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.berthId).toBe(berth.id); expect(body.data.isSpecificInterest).toBe(true); }); @@ -165,7 +165,7 @@ describe('PATCH /api/v1/interests/[id]/berths/[berthId] (patchHandler)', () => { ); const res = await patchHandler(req, ctx, { id: interest.id, berthId: berth.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.isInEoiBundle).toBe(true); expect(body.data.isSpecificInterest).toBe(false); }); diff --git a/tests/integration/api/memberships.test.ts b/tests/integration/api/memberships.test.ts index f2ad47ba..1c6dae08 100644 --- a/tests/integration/api/memberships.test.ts +++ b/tests/integration/api/memberships.test.ts @@ -43,7 +43,7 @@ describe('GET /api/v1/companies/[id]/members', () => { const req = makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}/members`); const res = await listHandler(req, ctx, { id: company.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(Array.isArray(body.data)).toBe(true); expect(body.data.length).toBe(2); expect(body.data.every((m: { endDate: string | null }) => m.endDate === null)).toBe(true); @@ -81,7 +81,7 @@ describe('GET /api/v1/companies/[id]/members', () => { ctx, { id: company.id }, ); - const createdBody = await createRes.json(); + const createdBody = (await createRes.json()) as any; const toEndId = createdBody.data.id as string; const delRes = await deleteHandler( @@ -100,7 +100,7 @@ describe('GET /api/v1/companies/[id]/members', () => { ctx, { id: company.id }, ); - const activeBody = await activeOnlyRes.json(); + const activeBody = (await activeOnlyRes.json()) as any; expect(activeBody.data.length).toBe(1); // activeOnly=false. @@ -112,7 +112,7 @@ describe('GET /api/v1/companies/[id]/members', () => { ctx, { id: company.id }, ); - const allBody = await allRes.json(); + const allBody = (await allRes.json()) as any; expect(allBody.data.length).toBe(2); }); @@ -143,7 +143,7 @@ describe('POST /api/v1/companies/[id]/members', () => { }); const res = await createHandler(req, ctx, { id: company.id }); expect(res.status).toBe(201); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.companyId).toBe(company.id); expect(body.data.clientId).toBe(client.id); expect(body.data.role).toBe('director'); @@ -217,7 +217,7 @@ describe('PATCH /api/v1/companies/[id]/members/[mid]', () => { ctx, { id: company.id }, ); - const created = (await createRes.json()).data; + const created = ((await createRes.json()) as any).data; const patchRes = await patchHandler( makeMockRequest( @@ -234,7 +234,7 @@ describe('PATCH /api/v1/companies/[id]/members/[mid]', () => { { id: company.id, mid: created.id }, ); expect(patchRes.status).toBe(200); - const body = await patchRes.json(); + const body = (await patchRes.json()) as any; expect(body.data.role).toBe('officer'); expect(body.data.notes).toBe('promoted'); }); @@ -257,7 +257,7 @@ describe('PATCH /api/v1/companies/[id]/members/[mid]', () => { ctxA, { id: company.id }, ); - const created = (await createRes.json()).data; + const created = ((await createRes.json()) as any).data; const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const patchRes = await patchHandler( @@ -291,7 +291,7 @@ describe('DELETE /api/v1/companies/[id]/members/[mid]', () => { ctx, { id: company.id }, ); - const created = (await createRes.json()).data; + const created = ((await createRes.json()) as any).data; const before = new Date(); const delRes = await deleteHandler( @@ -329,7 +329,7 @@ describe('DELETE /api/v1/companies/[id]/members/[mid]', () => { ctx, { id: company.id }, ); - const created = (await createRes.json()).data; + const created = ((await createRes.json()) as any).data; const explicitEnd = new Date('2026-06-01T00:00:00.000Z'); const delRes = await deleteHandler( @@ -373,7 +373,7 @@ describe('POST /api/v1/companies/[id]/members/[mid]/set-primary', () => { ctx, { id: company.id }, ); - const m1 = (await m1Res.json()).data; + const m1 = ((await m1Res.json()) as any).data; // M2, M3 — not primary. const m2Res = await createHandler( @@ -387,7 +387,7 @@ describe('POST /api/v1/companies/[id]/members/[mid]/set-primary', () => { ctx, { id: company.id }, ); - const m2 = (await m2Res.json()).data; + const m2 = ((await m2Res.json()) as any).data; const m3Res = await createHandler( makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, { @@ -400,7 +400,7 @@ describe('POST /api/v1/companies/[id]/members/[mid]/set-primary', () => { ctx, { id: company.id }, ); - const m3 = (await m3Res.json()).data; + const m3 = ((await m3Res.json()) as any).data; // Promote M2. const setPrimRes = await setPrimaryHandler( @@ -412,7 +412,7 @@ describe('POST /api/v1/companies/[id]/members/[mid]/set-primary', () => { { id: company.id, mid: m2.id }, ); expect(setPrimRes.status).toBe(200); - const setPrimBody = await setPrimRes.json(); + const setPrimBody = (await setPrimRes.json()) as any; expect(setPrimBody.data.id).toBe(m2.id); expect(setPrimBody.data.isPrimary).toBe(true); diff --git a/tests/integration/api/reservations.test.ts b/tests/integration/api/reservations.test.ts index 9ef21b3f..c82445de 100644 --- a/tests/integration/api/reservations.test.ts +++ b/tests/integration/api/reservations.test.ts @@ -45,7 +45,7 @@ describe('POST /api/v1/berths/[id]/reservations', () => { }); const res = await createReservationHandler(req, ctx, { id: berth.id }); expect(res.status).toBe(201); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.berthId).toBe(berth.id); expect(body.data.clientId).toBe(client.id); expect(body.data.yachtId).toBe(yacht.id); @@ -129,7 +129,7 @@ describe('POST /api/v1/berths/[id]/reservations', () => { ); const res = await createReservationHandler(req, ctx, { id: urlBerth.id }); expect(res.status).toBe(201); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.berthId).toBe(urlBerth.id); expect(body.data.berthId).not.toBe(bodyBerth.id); }); @@ -182,7 +182,7 @@ describe('GET /api/v1/berths/[id]/reservations', () => { { id: berthA.id }, ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.length).toBe(1); expect(body.data[0].berthId).toBe(berthA.id); }); @@ -213,7 +213,7 @@ describe('GET /api/v1/berth-reservations/[id]', () => { ctx, { id: berth.id }, ); - const reservation = (await createRes.json()).data; + const reservation = ((await createRes.json()) as any).data; const res = await getReservationHandler( makeMockRequest('GET', `http://localhost/api/v1/berth-reservations/${reservation.id}`), @@ -221,7 +221,7 @@ describe('GET /api/v1/berth-reservations/[id]', () => { { id: reservation.id }, ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.id).toBe(reservation.id); }); @@ -248,7 +248,7 @@ describe('GET /api/v1/berth-reservations/[id]', () => { ctxA, { id: berth.id }, ); - const reservation = (await createRes.json()).data; + const reservation = ((await createRes.json()) as any).data; const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const res = await getReservationHandler( @@ -285,7 +285,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => { ctx, { id: berth.id }, ); - const reservation = (await createRes.json()).data; + const reservation = ((await createRes.json()) as any).data; return { port, berth, client, yacht, ctx, reservation }; } @@ -299,7 +299,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => { { id: reservation.id }, ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.status).toBe('active'); }); @@ -329,7 +329,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => { { id: reservation.id }, ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.status).toBe('ended'); expect(new Date(body.data.endDate).toISOString()).toBe(endDate.toISOString()); }); @@ -344,7 +344,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => { { id: reservation.id }, ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.status).toBe('cancelled'); }); @@ -467,7 +467,7 @@ describe('DELETE /api/v1/berth-reservations/[id]', () => { ctx, { id: berth.id }, ); - const reservation = (await createRes.json()).data; + const reservation = ((await createRes.json()) as any).data; const delRes = await deleteReservationHandler( makeMockRequest('DELETE', `http://localhost/api/v1/berth-reservations/${reservation.id}`), diff --git a/tests/integration/api/saved-views-ownership.test.ts b/tests/integration/api/saved-views-ownership.test.ts index ee3c8aae..38c34876 100644 --- a/tests/integration/api/saved-views-ownership.test.ts +++ b/tests/integration/api/saved-views-ownership.test.ts @@ -42,7 +42,7 @@ describe('saved-views ownership enforcement', () => { { id: viewId }, ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.name).toBe('Renamed by owner'); }); diff --git a/tests/integration/api/yachts-detail.test.ts b/tests/integration/api/yachts-detail.test.ts index 7e07f5f1..8e0b07e2 100644 --- a/tests/integration/api/yachts-detail.test.ts +++ b/tests/integration/api/yachts-detail.test.ts @@ -32,7 +32,7 @@ describe('GET /api/v1/yachts/[id]', () => { const req = makeMockRequest('GET', `http://localhost/api/v1/yachts/${yacht.id}`); const res = await getHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.id).toBe(yacht.id); expect(body.data.name).toBe('Detail Yacht'); }); @@ -77,7 +77,7 @@ describe('PATCH /api/v1/yachts/[id]', () => { }); const res = await patchHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.name).toBe('After'); expect(body.data.notes).toBe('updated notes'); }); @@ -184,7 +184,7 @@ describe('POST /api/v1/yachts/[id]/transfer', () => { }); const res = await transferHandler(req, ctx, { id: yacht.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.currentOwnerType).toBe('company'); expect(body.data.currentOwnerId).toBe(company.id); }); @@ -262,7 +262,7 @@ describe('GET /api/v1/yachts/[id]/ownership-history', () => { ); const res = await historyHandler(req, ctxFull, { id: yacht.id }); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data).toHaveLength(2); // Sorted DESC by startDate — newest first const firstStart = new Date(body.data[0].startDate).getTime(); @@ -312,7 +312,7 @@ describe('GET /api/v1/yachts/autocomplete', () => { ); const res = await autocompleteHandler(req, ctx, {}); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.length).toBeGreaterThanOrEqual(1); expect(body.data.every((y: { name: string }) => y.name.includes('AutoCompleteMatch'))).toBe( true, @@ -325,7 +325,7 @@ describe('GET /api/v1/yachts/autocomplete', () => { const req = makeMockRequest('GET', 'http://localhost/api/v1/yachts/autocomplete'); const res = await autocompleteHandler(req, ctx, {}); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data).toEqual([]); }); @@ -346,7 +346,7 @@ describe('GET /api/v1/yachts/autocomplete', () => { ); const res = await autocompleteHandler(req, ctx, {}); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data).toEqual([]); }); }); diff --git a/tests/integration/api/yachts.test.ts b/tests/integration/api/yachts.test.ts index a17ddd37..c4e0f72b 100644 --- a/tests/integration/api/yachts.test.ts +++ b/tests/integration/api/yachts.test.ts @@ -22,7 +22,7 @@ describe('POST /api/v1/yachts (createHandler)', () => { }); const res = await createHandler(req, ctx, {}); expect(res.status).toBe(201); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.name).toBe('Sea Breeze'); expect(body.data.currentOwnerId).toBe(client.id); }); @@ -63,7 +63,7 @@ describe('GET /api/v1/yachts (listHandler)', () => { const req = makeMockRequest('GET', 'http://localhost/api/v1/yachts?page=1&limit=20&order=desc'); const res = await listHandler(req, ctx, {}); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.data.some((y: { name: string }) => y.name === 'Listed')).toBe(true); expect(body.pagination.page).toBe(1); expect(body.pagination.pageSize).toBe(20); diff --git a/tests/integration/dedup/match-candidates-api.test.ts b/tests/integration/dedup/match-candidates-api.test.ts index 444d1ad2..ee8a5c23 100644 --- a/tests/integration/dedup/match-candidates-api.test.ts +++ b/tests/integration/dedup/match-candidates-api.test.ts @@ -32,7 +32,7 @@ async function callHandler( const req = makeMockRequest('GET', url.toString()); const res = await getMatchCandidatesHandler(req, ctx); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; return body.data as MatchData[]; } diff --git a/tests/integration/health-and-ready.test.ts b/tests/integration/health-and-ready.test.ts index 72d1a466..452ff618 100644 --- a/tests/integration/health-and-ready.test.ts +++ b/tests/integration/health-and-ready.test.ts @@ -7,7 +7,7 @@ describe('GET /api/health (liveness)', () => { it('returns 200 + status=ok regardless of downstream dependency state', async () => { const res = await healthGet(); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.status).toBe('ok'); expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); @@ -25,7 +25,7 @@ describe('GET /api/ready (readiness)', () => { // active port is configured for, via getStorageBackend().head(). const res = await readyGet(); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.status).toBe('ready'); expect(body.checks).toEqual({ postgres: 'ok', diff --git a/tests/integration/public-interest-trio.test.ts b/tests/integration/public-interest-trio.test.ts index da744769..4f4f8141 100644 --- a/tests/integration/public-interest-trio.test.ts +++ b/tests/integration/public-interest-trio.test.ts @@ -59,7 +59,7 @@ describe('POST /api/public/interests — trio creation', () => { }); const res = await POST(req); expect(res.status).toBe(201); - const body = await res.json(); + const body = (await res.json()) as any; const interestId: string = body.data.id; const [interest] = await db.select().from(interests).where(eq(interests.id, interestId)); @@ -116,7 +116,7 @@ describe('POST /api/public/interests — trio creation', () => { }); const res = await POST(req); expect(res.status).toBe(201); - const body = await res.json(); + const body = (await res.json()) as any; const interestId: string = body.data.id; const [interest] = await db.select().from(interests).where(eq(interests.id, interestId)); @@ -179,7 +179,7 @@ describe('POST /api/public/interests — trio creation', () => { ); const firstRes = await POST(firstReq); expect(firstRes.status).toBe(201); - const firstBody = await firstRes.json(); + const firstBody = (await firstRes.json()) as any; const [firstInterest] = await db .select() @@ -204,7 +204,7 @@ describe('POST /api/public/interests — trio creation', () => { ); const secondRes = await POST(secondReq); expect(secondRes.status).toBe(201); - const secondBody = await secondRes.json(); + const secondBody = (await secondRes.json()) as any; const [secondInterest] = await db .select() @@ -248,7 +248,7 @@ describe('POST /api/public/interests — trio creation', () => { ); const firstRes = await POST(firstReq); expect(firstRes.status).toBe(201); - const firstBody = await firstRes.json(); + const firstBody = (await firstRes.json()) as any; const [firstInterest] = await db .select() .from(interests) @@ -277,7 +277,7 @@ describe('POST /api/public/interests — trio creation', () => { ); const secondRes = await POST(secondReq); expect(secondRes.status).toBe(201); - const secondBody = await secondRes.json(); + const secondBody = (await secondRes.json()) as any; const [secondInterest] = await db .select() .from(interests) diff --git a/tests/integration/storage/proxy-route.test.ts b/tests/integration/storage/proxy-route.test.ts index a4c37698..495e111b 100644 --- a/tests/integration/storage/proxy-route.test.ts +++ b/tests/integration/storage/proxy-route.test.ts @@ -117,7 +117,7 @@ describe('GET /api/storage/[token]', () => { ); const res = await callRoute(badToken); expect(res.status).toBe(403); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.error).toMatch(/Invalid|expired/i); }); @@ -154,7 +154,7 @@ describe('GET /api/storage/[token]', () => { const second = await callRoute(token); expect(second.status).toBe(403); - const body = await second.json(); + const body = (await second.json()) as any; expect(body.error).toMatch(/already used/i); }); }); diff --git a/tests/unit/comms-safety.test.ts b/tests/unit/comms-safety.test.ts index 2b5bbe8e..e87880c7 100644 --- a/tests/unit/comms-safety.test.ts +++ b/tests/unit/comms-safety.test.ts @@ -61,7 +61,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => { ]); expect(fetchMock).toHaveBeenCalledOnce(); - const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string); + const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any; expect(callBody.recipients).toHaveLength(2); for (const r of callBody.recipients) { expect(r.email).toBe(REDIRECT_TARGET); @@ -82,7 +82,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => { }); expect(fetchMock).toHaveBeenCalledOnce(); - const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string); + const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any; expect(callBody.formValues['client.primaryEmail']).toBe(REDIRECT_TARGET); expect(callBody.formValues['developer.email']).toBe(REDIRECT_TARGET); // Non-email field untouched @@ -99,7 +99,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => { ], }); - const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string); + const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any; for (const r of callBody.recipients) { expect(r.email).toBe(REDIRECT_TARGET); expect(r.name).toMatch(/\(was: .+@realclient\.com\)/); @@ -134,7 +134,7 @@ describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => { { name: 'Alice', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 }, ]); - const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string); + const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any; expect(callBody.recipients[0].email).toBe('alice@realclient.com'); }); }); diff --git a/tests/unit/encryption.test.ts b/tests/unit/encryption.test.ts index e2f069f5..7ce3f1a8 100644 --- a/tests/unit/encryption.test.ts +++ b/tests/unit/encryption.test.ts @@ -26,7 +26,7 @@ describe('encrypt / decrypt', () => { }); it('tampered data field throws on decrypt', () => { - const stored = JSON.parse(encrypt('tamper me')); + const stored = JSON.parse(encrypt('tamper me')) as any; // Flip the first hex byte of data const originalByte = stored.data.slice(0, 2); const flipped = originalByte === 'ff' ? '00' : 'ff'; @@ -36,7 +36,7 @@ describe('encrypt / decrypt', () => { }); it('tampered auth tag throws on decrypt', () => { - const stored = JSON.parse(encrypt('tamper tag')); + const stored = JSON.parse(encrypt('tamper tag')) as any; const originalByte = stored.tag.slice(0, 2); const flipped = originalByte === 'ff' ? '00' : 'ff'; stored.tag = flipped + stored.tag.slice(2); diff --git a/tests/unit/security-error-responses.test.ts b/tests/unit/security-error-responses.test.ts index f153f580..a3f61b28 100644 --- a/tests/unit/security-error-responses.test.ts +++ b/tests/unit/security-error-responses.test.ts @@ -60,7 +60,7 @@ describe('Error response security — AppError subclasses', () => { const error = new AppError(400, 'Bad request', 'BAD_REQUEST'); const response = errorResponse(error); expect(response.status).toBe(400); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.error).toBe('Bad request'); expect(body.code).toBe('BAD_REQUEST'); // Stack trace must never appear in the response body @@ -72,7 +72,7 @@ describe('Error response security — AppError subclasses', () => { const error = new NotFoundError('Client'); const response = errorResponse(error); expect(response.status).toBe(404); - const body = await response.json(); + const body = (await response.json()) as any; // Message is now plain-text user-facing (no jargon, lowercased entity). expect(body.error).toBe("We couldn't find that client. It may have been removed."); expect(body.code).toBe('NOT_FOUND'); @@ -83,7 +83,7 @@ describe('Error response security — AppError subclasses', () => { const error = new ForbiddenError(); const response = errorResponse(error); expect(response.status).toBe(403); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.code).toBe('FORBIDDEN'); }); @@ -91,7 +91,7 @@ describe('Error response security — AppError subclasses', () => { const error = new RateLimitError(60); const response = errorResponse(error); expect(response.status).toBe(429); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.retryAfter).toBe(60); expect(JSON.stringify(body)).not.toMatch(/stack|node_modules/i); }); @@ -102,7 +102,7 @@ describe('Error response security — AppError subclasses', () => { ]); const response = errorResponse(error); expect(response.status).toBe(400); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.details).toHaveLength(1); expect(body.details[0].field).toBe('email'); expect(JSON.stringify(body)).not.toContain('src/'); @@ -115,7 +115,7 @@ describe('Error response security — unknown / native errors', () => { const error = new Error('SELECT * FROM users WHERE id = 1; DROP TABLE users;--'); const response = errorResponse(error); expect(response.status).toBe(500); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.error).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('SELECT'); expect(JSON.stringify(body)).not.toContain('DROP TABLE'); @@ -127,7 +127,7 @@ describe('Error response security — unknown / native errors', () => { ); const response = errorResponse(error); expect(response.status).toBe(500); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.error).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('G:\\'); expect(JSON.stringify(body)).not.toContain('src\\lib'); @@ -137,7 +137,7 @@ describe('Error response security — unknown / native errors', () => { const error = new Error('ENOENT: no such file at /app/node_modules/pg/lib/connection.js'); const response = errorResponse(error); expect(response.status).toBe(500); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.error).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('node_modules'); expect(JSON.stringify(body)).not.toContain('ENOENT'); @@ -147,7 +147,7 @@ describe('Error response security — unknown / native errors', () => { const error = new Error('relation "users" does not exist'); const response = errorResponse(error); expect(response.status).toBe(500); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.error).toBe('Internal server error'); expect(JSON.stringify(body)).not.toContain('relation'); expect(JSON.stringify(body)).not.toContain('"users"'); @@ -156,14 +156,14 @@ describe('Error response security — unknown / native errors', () => { it('null thrown value returns generic 500', async () => { const response = errorResponse(null); expect(response.status).toBe(500); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.error).toBe('Internal server error'); }); it('string thrown returns generic 500', async () => { const response = errorResponse('something went wrong internally'); expect(response.status).toBe(500); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.error).toBe('Internal server error'); // The raw string must not appear in the response expect(JSON.stringify(body)).not.toContain('something went wrong internally'); @@ -184,7 +184,7 @@ describe('Error response security — ZodError', () => { ]); const response = errorResponse(error); expect(response.status).toBe(400); - const body = await response.json(); + const body = (await response.json()) as any; expect(body.code).toBe('VALIDATION_ERROR'); expect(body.details).toBeDefined(); expect(Array.isArray(body.details)).toBe(true); @@ -203,7 +203,7 @@ describe('Error response security — ZodError', () => { }, ]); const response = errorResponse(error); - const body = await response.json(); + const body = (await response.json()) as any; const bodyStr = JSON.stringify(body); expect(bodyStr).not.toContain('src/'); expect(bodyStr).not.toContain('node_modules'); @@ -222,7 +222,7 @@ describe('Error response security — response shape invariants', () => { new RateLimitError(30), ]; for (const err of errors) { - const body = await errorResponse(err).json(); + const body = (await errorResponse(err).json()) as any; expect(typeof body.error).toBe('string'); expect(body.error.length).toBeGreaterThan(0); // Stack must never appear @@ -232,7 +232,7 @@ describe('Error response security — response shape invariants', () => { it('500 response body carries error + code (and requestId when in-flight)', async () => { const response = errorResponse(new Error('db connection refused')); - const body = await response.json(); + const body = (await response.json()) as any; // Allowed keys for a 500 response. `code` is always present; `requestId` // and `message` only appear when an active request context is in scope. const allowed = new Set(['error', 'code', 'requestId', 'message']); diff --git a/tests/unit/services/documenso-place-fields.test.ts b/tests/unit/services/documenso-place-fields.test.ts index f4aba203..d5c54942 100644 --- a/tests/unit/services/documenso-place-fields.test.ts +++ b/tests/unit/services/documenso-place-fields.test.ts @@ -130,7 +130,7 @@ describe('placeFields v2 dispatch', () => { const [url, init] = fetchMock.mock.calls[0]!; expect(url).toBe('https://documenso.test/api/v2/envelope/field/create-many'); expect((init as RequestInit).method).toBe('POST'); - const body = JSON.parse(String((init as RequestInit).body)); + const body = JSON.parse(String((init as RequestInit).body)) as any; expect(body.envelopeId).toBe('env-123'); expect(body.fields[0]).toMatchObject({ recipientId: 'rec-a', @@ -198,7 +198,7 @@ describe('placeFields v1 dispatch', () => { expect(fetchMock).toHaveBeenCalledTimes(2); const firstCall = fetchMock.mock.calls[0]!; expect(firstCall[0]).toBe('https://documenso.test/api/v1/documents/doc-123/fields'); - const firstBody = JSON.parse(String((firstCall[1] as RequestInit).body)); + const firstBody = JSON.parse(String((firstCall[1] as RequestInit).body)) as any; expect(firstBody).toMatchObject({ recipientId: 42, type: 'SIGNATURE', @@ -227,7 +227,7 @@ describe('placeFields v1 dispatch', () => { ], 'port-1', ); - const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body)); + const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body)) as any; expect(body.recipientId).toBe(99); }); @@ -267,7 +267,7 @@ describe('placeDefaultSignatureFields integration', () => { 'port-1', ); expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body)); + const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body)) as any; expect(body.fields).toHaveLength(3); expect(body.fields.every((f: { type: string }) => f.type === 'SIGNATURE')).toBe(true); expect(body.fields.every((f: { pageNumber: number }) => f.pageNumber === 4)).toBe(true); @@ -293,7 +293,7 @@ describe('placeDefaultSignatureFields integration', () => { expect(fetchMock).toHaveBeenCalledTimes(2); for (const call of fetchMock.mock.calls) { expect(call[0]).toBe('https://documenso.test/api/v1/documents/doc-z/fields'); - const body = JSON.parse(String((call[1] as RequestInit).body)); + const body = JSON.parse(String((call[1] as RequestInit).body)) as any; expect(body.type).toBe('SIGNATURE'); expect(body.pageNumber).toBe(1); // 88% of 842 = 741 (footer band) diff --git a/tests/unit/website-inquiries-503.test.ts b/tests/unit/website-inquiries-503.test.ts index e083de97..ee758d11 100644 --- a/tests/unit/website-inquiries-503.test.ts +++ b/tests/unit/website-inquiries-503.test.ts @@ -63,7 +63,7 @@ describe('POST /api/public/website-inquiries — 503 when secret unset', () => { ), ); expect(res.status).toBe(503); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.error).toMatch(/not configured/i); }); }); diff --git a/tests/unit/website-inquiries.test.ts b/tests/unit/website-inquiries.test.ts index 5d5d264d..4779f39f 100644 --- a/tests/unit/website-inquiries.test.ts +++ b/tests/unit/website-inquiries.test.ts @@ -175,7 +175,7 @@ describe('POST /api/public/website-inquiries — auth + capture', () => { ), ); expect(res.status).toBe(400); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.error).toMatch(/Unknown port/); }); @@ -193,7 +193,7 @@ describe('POST /api/public/website-inquiries — auth + capture', () => { ), ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toEqual({ id: 'generated-row-id', deduped: false }); expect(state.inserted).toHaveLength(1); @@ -219,7 +219,7 @@ describe('POST /api/public/website-inquiries — auth + capture', () => { ), ); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toEqual({ id: 'existing-row-id', deduped: true }); expect(state.inserted).toHaveLength(0); });