feat: warm-up deps — ts-reset, web-vitals, RHF devtool, query-broadcast

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 18:16:18 +02:00
parent 82049eea92
commit d3960af340
38 changed files with 1590 additions and 119 deletions

View File

@@ -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, `<AnimatePresence>` 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 `<ScrollArea className="max-h-[400px]">` 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 — `<IMaskInput mask="$num"
scale={2} thousandsSeparator="," />`. 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 `<DevTool control={form.control} />` 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<any>`.
- `.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<X, Y> & Pick<Z, W>`
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.

View File

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

View File

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

465
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -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
<PermissionsProvider>
<SocketProvider>
<RealtimeToasts />
<WebVitalsReporter />
{/* Desktop shell - hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
/**
* Ingest Core Web Vitals from the client. Called via `navigator.sendBeacon`
* from `WebVitalsReporter` so it survives page unload. Body shape matches
* the `Metric` type from `web-vitals` v4.
*
* For now we log structured to pino — once we have a perf-tracking table
* (or external aggregator) wired, this can persist instead. The key value
* today is establishing the baseline before optimisation work.
*/
export async function POST(req: Request) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'invalid json' }, { status: 400 });
}
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'invalid body' }, { status: 400 });
}
const m = body as {
name?: string;
value?: number;
rating?: 'good' | 'needs-improvement' | 'poor';
id?: string;
delta?: number;
navigationType?: string;
path?: string;
};
if (typeof m.name !== 'string' || typeof m.value !== 'number') {
return NextResponse.json({ error: 'missing name/value' }, { status: 400 });
}
logger.info(
{
vital: m.name,
value: m.value,
rating: m.rating ?? null,
id: m.id ?? null,
delta: m.delta ?? null,
navigationType: m.navigationType ?? null,
path: m.path ?? null,
},
'web-vital',
);
return new NextResponse(null, { status: 204 });
}

View File

@@ -48,7 +48,9 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
credentials: 'include',
});
if (!res.ok) {
const err = await res.json().catch(() => ({ 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();

View File

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

View File

@@ -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) });
* ...
* <form onSubmit={form.handleSubmit(onSubmit)}>
* <FormDevtool control={form.control} />
* {fields}
* </form>
*/
export function FormDevtool<T extends FieldValues>({ control }: { control: Control<T> }) {
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 <DevTool control={control as any} placement="bottom-right" />;
}

View File

@@ -56,7 +56,7 @@ const SELF_SOURCE: Record<NotesEntityType, NoteSource | null> = {
residential_interests: null,
};
const AGGREGATABLE: ReadonlySet<NotesEntityType> = new Set([
const AGGREGATABLE: ReadonlySet<NotesEntityType> = new Set<NotesEntityType>([
'clients',
'yachts',
'companies',

View File

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

View File

@@ -73,7 +73,14 @@ export async function apiFetch<T = unknown>(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.

View File

@@ -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<PipelineStage> = new Set([
export const HIGH_STAKES_STAGES: ReadonlySet<PipelineStage> = new Set<PipelineStage>([
'deposit_10pct',
'contract_sent',
'contract_signed',

View File

@@ -30,8 +30,8 @@ export async function refreshRates(): Promise<void> {
throw new CodedError('INTERNAL', {
internalMessage: `Frankfurter API error: ${res.status}`,
});
const data = await res.json();
const rates = data.rates as Record<string, number>;
const data = (await res.json()) as { rates: Record<string, number> };
const rates = data.rates;
for (const [currency, rate] of Object.entries(rates)) {
await db

View File

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

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
{children}

4
src/types/ts-reset.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -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([]);
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([]);
});
});

View File

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

View File

@@ -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[];
}

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

@@ -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']);

View File

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

View File

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

View File

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