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:
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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
465
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
54
src/app/api/v1/internal/vitals/route.ts
Normal file
54
src/app/api/v1/internal/vitals/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
32
src/components/shared/form-devtool.tsx
Normal file
32
src/components/shared/form-devtool.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
56
src/components/shared/web-vitals-reporter.tsx
Normal file
56
src/components/shared/web-vitals-reporter.tsx
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
4
src/types/ts-reset.d.ts
vendored
Normal 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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user