Files
pn-new-crm/src/lib/api/client.ts

176 lines
6.2 KiB
TypeScript
Raw Normal View History

'use client';
import { useUIStore } from '@/stores/ui-store';
export interface ApiFetchOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
}
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
/** In-memory cache: slug -> id, populated lazily by `resolvePortIdFromSlug`.
* Avoids re-fetching `/api/v1/admin/ports` on every request when the Zustand
* store hasn't hydrated yet (fresh browser context, e2e tests, hard reload). */
const slugToIdCache = new Map<string, string>();
/** Dedupe in-flight admin/ports lookups so a stampede of parallel apiFetch
* calls (typical on dashboard mount) collapses into a single network round-
* trip instead of N. */
let inFlightPortsLookup: Promise<Array<{ id: string; slug: string }> | null> | null = null;
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
const cached = slugToIdCache.get(slug);
if (cached) return cached;
if (!inFlightPortsLookup) {
inFlightPortsLookup = (async () => {
try {
fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep Knocks out 10 of the 13 known issues from yesterday's Playwright audit. A4 — Client form silently rejected submit when a contact row had an empty value. The F19 filter ran in mutationFn after zod's handleSubmit had already short-circuited on min(1). Now wraps the onSubmit to prune empty rows BEFORE handleSubmit/zod sees them. A16 — File upload to documents hub root 400'd because FormData.get returns null for absent fields and zod's .optional() rejects null. Route handler now coerces null/empty → undefined before parse. A17 — Added /api/v1/me/ports endpoint that any authenticated user can hit; client.ts now uses it as the bootstrap port-slug→port-id resolver. Eliminates the wasteful 400s sales-reps and viewers were firing on every page load against the super-admin-gated /admin/ports. A1 — Filter permission_denied actions from the dashboard activity feed. Still in the audit log; just not noise on the dashboard. A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor helpers in lib/constants. Activity-feed maps legacy 9-stage enum values (deposit_10pct, contract_sent, etc.) to their 7-stage labels on the way out, so historical audit rows read as "Deposit Paid" not "Deposit 10Pct". A19 — Same-stage write now returns 204 No Content. Service returns a STAGE_NOOP sentinel; the route handler translates it. A9 — Catch-up wizard now derives stage from berth status (under_offer → EOI, sold → contract) with a stageOverride state for explicit user picks. Avoids the set-state-in-effect rule violation. A20 — OwnerPicker shows a "Client / Company" hint chip on the trigger when no value is set, so users know the trigger opens a two-tab picker instead of just a client list. A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'` to NULL so the column lives at strictly 3 states. A6 — file-preview-dialog gets a screen-reader DialogDescription so the Radix "Missing aria-describedby" warning stops firing on every preview. A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist (Next returns 404); /api/v1/admin/audit exists and 403s. A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate pass — both are dev-only cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00
// A17: use /me/ports — works for every authenticated user.
// The prior code hit /admin/ports which is super-admin-gated, so
// sales-reps/viewers fired a wasteful 400 on every page load.
const res = await fetch('/api/v1/me/ports', { credentials: 'include' });
if (!res.ok) return null;
const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> };
return body.data ?? null;
} catch {
return null;
}
})().finally(() => {
inFlightPortsLookup = null;
});
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
}
const ports = await inFlightPortsLookup;
const port = ports?.find((p) => p.slug === slug);
if (!port) return null;
slugToIdCache.set(slug, port.id);
return port.id;
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
}
/**
fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch **mobile-pwa-auditor H4 — mobile shell uses min-h-screen** `min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE viewport height (URL bar collapsed). On first paint the page renders ~75–100px taller than visible, and reps see a blank strip past the bottom tab bar until the URL bar collapses on first scroll. Swap `min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner layout already does this correctly. **multi-port-auditor C1 — port-switcher race / cross-port bleed** `apiFetch` previously preferred Zustand for the X-Port-Id header and only consulted the URL slug as a fallback. Zustand lags by one render behind `PortProvider`'s reconcile effect; clicking from /port-A to /port-B fired the first round of queries with X-Port-Id = port-A while the page chrome rendered port-B → silent cross-port data bleed in the UI. Make the URL slug authoritative: read it first via `window.location.pathname` + `resolvePortIdFromSlug`, fall back to Zustand only on global routes (/dashboard) without a port slug. **multi-port-auditor C3 — defaultPortId silently stripped** `withAuth` reads `preferences.defaultPortId` as the X-Port-Id fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS allow-list silently dropped the key on every write. The fallback was therefore dead — super-admins always landed alphabetically-first. Add `defaultPortId: z.string().uuid().optional()` to the strict schema and include it in ALLOWED_PREF_KEYS so super-admins can persist their last-picked port. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:08:09 +02:00
* Client-side fetch wrapper that attaches the `X-Port-Id` header to
* every request.
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
*
fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch **mobile-pwa-auditor H4 — mobile shell uses min-h-screen** `min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE viewport height (URL bar collapsed). On first paint the page renders ~75–100px taller than visible, and reps see a blank strip past the bottom tab bar until the URL bar collapses on first scroll. Swap `min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner layout already does this correctly. **multi-port-auditor C1 — port-switcher race / cross-port bleed** `apiFetch` previously preferred Zustand for the X-Port-Id header and only consulted the URL slug as a fallback. Zustand lags by one render behind `PortProvider`'s reconcile effect; clicking from /port-A to /port-B fired the first round of queries with X-Port-Id = port-A while the page chrome rendered port-B → silent cross-port data bleed in the UI. Make the URL slug authoritative: read it first via `window.location.pathname` + `resolvePortIdFromSlug`, fall back to Zustand only on global routes (/dashboard) without a port slug. **multi-port-auditor C3 — defaultPortId silently stripped** `withAuth` reads `preferences.defaultPortId` as the X-Port-Id fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS allow-list silently dropped the key on every write. The fallback was therefore dead — super-admins always landed alphabetically-first. Add `defaultPortId: z.string().uuid().optional()` to the strict schema and include it in ALLOWED_PREF_KEYS so super-admins can persist their last-picked port. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:08:09 +02:00
* multi-port-auditor C1: the URL slug is authoritative Zustand
* is a cache that lags by one render after `PortProvider`'s reconcile
* effect commits. The previous Zustand-first lookup caused first-load
* queries on a freshly-navigated port to fire with the PRIOR port's
* id and render cross-port data inside the new shell. Now we resolve
* the slug from `window.location.pathname` first and fall back to
* Zustand only when the URL doesn't carry a port slug (e.g. /dashboard
* / non-portSlug routes).
*/
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions = {}): Promise<T> {
fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch **mobile-pwa-auditor H4 — mobile shell uses min-h-screen** `min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE viewport height (URL bar collapsed). On first paint the page renders ~75–100px taller than visible, and reps see a blank strip past the bottom tab bar until the URL bar collapses on first scroll. Swap `min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner layout already does this correctly. **multi-port-auditor C1 — port-switcher race / cross-port bleed** `apiFetch` previously preferred Zustand for the X-Port-Id header and only consulted the URL slug as a fallback. Zustand lags by one render behind `PortProvider`'s reconcile effect; clicking from /port-A to /port-B fired the first round of queries with X-Port-Id = port-A while the page chrome rendered port-B → silent cross-port data bleed in the UI. Make the URL slug authoritative: read it first via `window.location.pathname` + `resolvePortIdFromSlug`, fall back to Zustand only on global routes (/dashboard) without a port slug. **multi-port-auditor C3 — defaultPortId silently stripped** `withAuth` reads `preferences.defaultPortId` as the X-Port-Id fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS allow-list silently dropped the key on every write. The fallback was therefore dead — super-admins always landed alphabetically-first. Add `defaultPortId: z.string().uuid().optional()` to the strict schema and include it in ALLOWED_PREF_KEYS so super-admins can persist their last-picked port. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:08:09 +02:00
let portId: string | null = null;
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch **mobile-pwa-auditor H4 — mobile shell uses min-h-screen** `min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE viewport height (URL bar collapsed). On first paint the page renders ~75–100px taller than visible, and reps see a blank strip past the bottom tab bar until the URL bar collapses on first scroll. Swap `min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner layout already does this correctly. **multi-port-auditor C1 — port-switcher race / cross-port bleed** `apiFetch` previously preferred Zustand for the X-Port-Id header and only consulted the URL slug as a fallback. Zustand lags by one render behind `PortProvider`'s reconcile effect; clicking from /port-A to /port-B fired the first round of queries with X-Port-Id = port-A while the page chrome rendered port-B → silent cross-port data bleed in the UI. Make the URL slug authoritative: read it first via `window.location.pathname` + `resolvePortIdFromSlug`, fall back to Zustand only on global routes (/dashboard) without a port slug. **multi-port-auditor C3 — defaultPortId silently stripped** `withAuth` reads `preferences.defaultPortId` as the X-Port-Id fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS allow-list silently dropped the key on every write. The fallback was therefore dead — super-admins always landed alphabetically-first. Add `defaultPortId: z.string().uuid().optional()` to the strict schema and include it in ALLOWED_PREF_KEYS so super-admins can persist their last-picked port. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:08:09 +02:00
if (typeof window !== 'undefined') {
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
const slug = window.location.pathname.split('/').filter(Boolean)[0];
fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch **mobile-pwa-auditor H4 — mobile shell uses min-h-screen** `min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE viewport height (URL bar collapsed). On first paint the page renders ~75–100px taller than visible, and reps see a blank strip past the bottom tab bar until the URL bar collapses on first scroll. Swap `min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner layout already does this correctly. **multi-port-auditor C1 — port-switcher race / cross-port bleed** `apiFetch` previously preferred Zustand for the X-Port-Id header and only consulted the URL slug as a fallback. Zustand lags by one render behind `PortProvider`'s reconcile effect; clicking from /port-A to /port-B fired the first round of queries with X-Port-Id = port-A while the page chrome rendered port-B → silent cross-port data bleed in the UI. Make the URL slug authoritative: read it first via `window.location.pathname` + `resolvePortIdFromSlug`, fall back to Zustand only on global routes (/dashboard) without a port slug. **multi-port-auditor C3 — defaultPortId silently stripped** `withAuth` reads `preferences.defaultPortId` as the X-Port-Id fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS allow-list silently dropped the key on every write. The fallback was therefore dead — super-admins always landed alphabetically-first. Add `defaultPortId: z.string().uuid().optional()` to the strict schema and include it in ALLOWED_PREF_KEYS so super-admins can persist their last-picked port. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:08:09 +02:00
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api' && slug !== 'dashboard') {
feat(platform): residential module + admin UI + reliability fixes Residential platform - New schema: residentialClients, residentialInterests (separate from marina/yacht clients) with migration 0010 - Service layer with CRUD + audit + sockets + per-port portal toggle - v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries) - List + detail pages with inline editing for clients and interests - Per-user residentialAccess toggle on userPortRoles (migration 0011) - Permission keys: residential_clients, residential_interests - Sidebar nav + role form integration - Smoke spec covering page loads, UI create flow, public endpoint Admin & shared UI - Admin → Forms (form templates CRUD) with validators + service - Notification preferences page (in-app + email per type) - Email composition + accounts list + threads view - Branded auth shell shared across CRM + portal auth surfaces - Inline editing extended to yacht/company/interest detail pages - InlineTagEditor + per-entity tags endpoints (yachts, companies) - Notes service polymorphic across clients/interests/yachts/companies - Client list columns: yachtCount + companyCount badges - Reservation file-download via presigned URL (replaces stale <a href>) Route handler refactor - Extracted yachts/companies/berths reservation handlers to sibling handlers.ts files (Next.js 15 route.ts only allows specific exports) Reliability fixes - apiFetch double-stringify bug fixed across 13 components (apiFetch already JSON.stringifies its body; passing a stringified body produced double-encoded JSON which failed zod validation) - SocketProvider gated behind useSyncExternalStore-based mount check to avoid useSession() SSR crashes under React 19 + Next 15 - apiFetch falls back to URL-pathname → port-id resolution when the Zustand store hasn't hydrated yet (fresh contexts, e2e tests) - CRM invite flow (schema, service, route, email, dev script) - Dashboard route → [portSlug]/dashboard/page.tsx + redirect - Document the dev-server restart-after-migration gotcha in CLAUDE.md Tests - 5-case residential smoke spec - Integration test updates for new service signatures Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
portId = await resolvePortIdFromSlug(slug);
}
}
fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch **mobile-pwa-auditor H4 — mobile shell uses min-h-screen** `min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE viewport height (URL bar collapsed). On first paint the page renders ~75–100px taller than visible, and reps see a blank strip past the bottom tab bar until the URL bar collapses on first scroll. Swap `min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner layout already does this correctly. **multi-port-auditor C1 — port-switcher race / cross-port bleed** `apiFetch` previously preferred Zustand for the X-Port-Id header and only consulted the URL slug as a fallback. Zustand lags by one render behind `PortProvider`'s reconcile effect; clicking from /port-A to /port-B fired the first round of queries with X-Port-Id = port-A while the page chrome rendered port-B → silent cross-port data bleed in the UI. Make the URL slug authoritative: read it first via `window.location.pathname` + `resolvePortIdFromSlug`, fall back to Zustand only on global routes (/dashboard) without a port slug. **multi-port-auditor C3 — defaultPortId silently stripped** `withAuth` reads `preferences.defaultPortId` as the X-Port-Id fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS allow-list silently dropped the key on every write. The fallback was therefore dead — super-admins always landed alphabetically-first. Add `defaultPortId: z.string().uuid().optional()` to the strict schema and include it in ALLOWED_PREF_KEYS so super-admins can persist their last-picked port. Tests 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:08:09 +02:00
// Fall back to the Zustand cache when the URL didn't yield a port —
// e.g. global routes (/dashboard) where the rep hasn't picked a port
// yet but a previous session set one.
if (!portId) {
portId = useUIStore.getState().currentPortId;
}
const headers = new Headers(opts.headers);
if (portId) {
headers.set('X-Port-Id', portId);
}
if (opts.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const res = await fetch(url, {
...opts,
headers,
credentials: 'include',
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (!res.ok) {
fix(audit-wave-11): dossier sweep — error-ux + webhook + storage + search + maintainability Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the tractable Critical/High items from each: error-ux-auditor (5 items) - C2: 17 toast.error(err.message) sites swept to toastError(err, …) so every user-visible failure carries a copy-paste Reference ID - C3: apiFetch synthesizes a client-side correlation id when a 5xx comes back with a non-JSON body (reverse-proxy HTML pages); message becomes "The server is unreachable. Please try again." with code UPSTREAM_UNREACHABLE - C4: checkRateLimit fails OPEN when Redis is unavailable so an outage no longer 500s login + portal sign-in; logged at warn so monitoring catches it - H2: StorageTimeoutError (name='TimeoutError') replaces the plain Error throw in s3.ts withTimeout — error-classifier hints fire now - H5: errorResponse() adopted across /api/storage/[token], /api/public/website-inquiries, and the Documenso webhook body (drops the "Invalid secret" reconnaissance string) outbound-webhook-auditor (5 items) - C1: signature is now HMAC(secret, `${ts}.${body}`) with the timestamp surfaced as X-Webhook-Timestamp so receivers can reject replays outside a freshness window - C3: dead-letter with reason missing_signing_secret when secret is null (defence-in-depth against DB tampering / future migration mistakes) - H2: webhooks queue bumped to maxAttempts=8 with 30 s base exponential backoff so a 30 s receiver blip during a deploy no longer dead-letters every in-flight event; per-queue backoffDelayMs added to QUEUE_CONFIGS - M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192 - M2: dispatch-time https:// assertion before fetch, so a bad DB edit can't slip plaintext through storage-pathing-auditor (2 items) - H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…` with portSlug threaded into backend.presignUpload — engages the filesystem-proxy port-binding `p` token verifier - H2: presignDownloadUrl auto-derives portSlug from the key's first segment when callers don't pass it, so all 8 download sites engage the `p`-token guard without per-site plumbing search-auditor (1 item) - H3: removed dead void wantEmail; void wantPhone; pair plus the unused looksLikeEmail helper — the bucket-reorder it was scaffolded for was never wired maintainability-auditor (1 item) - M2: swept seven abandoned `void <symbol>` markers and their dead imports across clients/bulk, interests/bulk, admin/email-templates, admin/website-submissions, alert-rules, and notes.service Deferred to future work (substantial refactors, schema migrations, or multi-file UI work): - error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage, ErrorBanner component, /api/ready route, worker DLQ admin surface) - maintainability C1-C4 (documents/search/notes service splits, interest-tabs split — multi-hour refactors) - currency C1-H5 (mixed-currency dashboard aggregation, FX history table, rounding policy) — wait for second non-USD port - outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy) - storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type binding) Tests: 1315/1315 vitest ✅ ; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:27:32 +02:00
// error-ux-auditor C3: reverse-proxy 502/504 pages deliver HTML, not
// JSON. The previous code silently degraded to
// `{error: res.statusText}` which surfaced "Bad Gateway" with no
// requestId and no copy-pasteable correlation handle. Detect the
// proxy-error shape (5xx + JSON parse fail) and synthesize a
// client-side correlation id so the toast still has *something* the
// user can quote to support.
const error = (await res.json().catch(() => null)) as {
error?: string;
message?: string;
code?: string;
details?: unknown;
requestId?: string;
retryAfter?: number;
fix(audit-wave-11): dossier sweep — error-ux + webhook + storage + search + maintainability Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the tractable Critical/High items from each: error-ux-auditor (5 items) - C2: 17 toast.error(err.message) sites swept to toastError(err, …) so every user-visible failure carries a copy-paste Reference ID - C3: apiFetch synthesizes a client-side correlation id when a 5xx comes back with a non-JSON body (reverse-proxy HTML pages); message becomes "The server is unreachable. Please try again." with code UPSTREAM_UNREACHABLE - C4: checkRateLimit fails OPEN when Redis is unavailable so an outage no longer 500s login + portal sign-in; logged at warn so monitoring catches it - H2: StorageTimeoutError (name='TimeoutError') replaces the plain Error throw in s3.ts withTimeout — error-classifier hints fire now - H5: errorResponse() adopted across /api/storage/[token], /api/public/website-inquiries, and the Documenso webhook body (drops the "Invalid secret" reconnaissance string) outbound-webhook-auditor (5 items) - C1: signature is now HMAC(secret, `${ts}.${body}`) with the timestamp surfaced as X-Webhook-Timestamp so receivers can reject replays outside a freshness window - C3: dead-letter with reason missing_signing_secret when secret is null (defence-in-depth against DB tampering / future migration mistakes) - H2: webhooks queue bumped to maxAttempts=8 with 30 s base exponential backoff so a 30 s receiver blip during a deploy no longer dead-letters every in-flight event; per-queue backoffDelayMs added to QUEUE_CONFIGS - M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192 - M2: dispatch-time https:// assertion before fetch, so a bad DB edit can't slip plaintext through storage-pathing-auditor (2 items) - H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…` with portSlug threaded into backend.presignUpload — engages the filesystem-proxy port-binding `p` token verifier - H2: presignDownloadUrl auto-derives portSlug from the key's first segment when callers don't pass it, so all 8 download sites engage the `p`-token guard without per-site plumbing search-auditor (1 item) - H3: removed dead void wantEmail; void wantPhone; pair plus the unused looksLikeEmail helper — the bucket-reorder it was scaffolded for was never wired maintainability-auditor (1 item) - M2: swept seven abandoned `void <symbol>` markers and their dead imports across clients/bulk, interests/bulk, admin/email-templates, admin/website-submissions, alert-rules, and notes.service Deferred to future work (substantial refactors, schema migrations, or multi-file UI work): - error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage, ErrorBanner component, /api/ready route, worker DLQ admin surface) - maintainability C1-C4 (documents/search/notes service splits, interest-tabs split — multi-hour refactors) - currency C1-H5 (mixed-currency dashboard aggregation, FX history table, rounding policy) — wait for second non-USD port - outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy) - storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type binding) Tests: 1315/1315 vitest ✅ ; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:27:32 +02:00
} | null;
const upstreamRequestId = res.headers.get('x-request-id');
if (error === null) {
const isProxyFailure = res.status >= 500;
// Short, copy-pasteable client-side handle so support can grep
// the front-end logs even when the proxy never reached our app.
const synthId =
upstreamRequestId ??
`client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
throw new ApiError({
message: isProxyFailure
? 'The server is unreachable. Please try again.'
: res.statusText || 'Request failed',
status: res.status,
code: isProxyFailure ? 'UPSTREAM_UNREACHABLE' : null,
details: null,
requestId: synthId,
retryAfter: null,
});
}
const requestId = error.requestId ?? upstreamRequestId ?? null;
feat(errors): platform-wide request ids + error codes + admin inspector End-to-end error-handling overhaul. A user hitting any failure now sees a plain-text message + stable error code + reference id. A super admin can paste the id into /admin/errors/<id> for the full request shape, sanitized body, error stack, and a heuristic likely-cause hint. REQUEST CONTEXT (AsyncLocalStorage) - src/lib/request-context.ts mints a per-request frame carrying requestId + portId + userId + method + path + start timestamp. - withAuth wraps every authenticated handler in runWithRequestContext and accepts an upstream X-Request-Id header (validated shape) or generates a fresh UUID. The id ALWAYS leaves on the X-Request-Id response header, including early-return 401/403/4xx paths. - Pino logger reads from the same context via mixin — every log line emitted during the request automatically carries the ids with no per-call threading. ERROR CODE REGISTRY - src/lib/error-codes.ts defines stable DOMAIN_REASON codes with HTTP status + plain-text user-facing message (no jargon, written for the rep on the phone with a customer). - New CodedError class wraps a registered code + optional internalMessage (admin-only — never sent to client). - Existing AppError subclasses got plain-text default rewrites so legacy throw sites improve immediately without migration. - High-impact services migrated to specific codes: expenses (RECEIPT_REQUIRED, INVOICE_LINKED), interest-berths (CROSS_PORT_LINK_REJECTED), berth-pdf (PDF_MAGIC_BYTE / PDF_EMPTY / PDF_TOO_LARGE / VERSION_ALREADY_CURRENT), recommender (INTEREST_PORT_MISMATCH). ERROR ENVELOPE - errorResponse always sets X-Request-Id header + requestId field. - 5xx responses include a "Quote error ID …" friendly line. - 4xx kept clean (validation, permission, not-found don't pollute the inspector — they're already in audit log). PERSISTENCE (error_events table, migration 0040) - One row per 5xx, keyed on requestId, with method/path/status/error name+message/stack head (4KB cap)/sanitized body excerpt (1KB cap; password/token/secret/etc keys redacted)/duration/IP/UA/metadata. - captureErrorEvent extracts Postgres SQLSTATE/severity/cause.code so the classifier can recognize FK / unique / NOT NULL / schema- drift violations. - Failure to persist is logged-not-thrown. LIKELY-CULPRIT CLASSIFIER (src/lib/error-classifier.ts) - 4-pass heuristic (first match wins): 1. Postgres SQLSTATE → human reason (23503 FK, 23505 unique, 42703 schema drift, 53300 connection limit, …) 2. Error class name (AbortError, TimeoutError, FetchError, ZodError) 3. Stack-path patterns (/lib/storage/, /lib/email/, documenso, openai|claude, /queue/workers/) 4. Free-text message keywords (econnrefused, rate limit, timeout, unauthorized|invalid api key) - Returns { label, hint, subsystem } for the inspector badge. CLIENT SIDE - apiFetch throws structured ApiError with message + code + requestId + details + retryAfter. - toastError() helper renders the standard 3-line toast: plain message / Error code: X / Reference ID: Y [Copy ID]. ADMIN INSPECTOR - /<port>/admin/errors lists captured 5xx with status badge + path + likely-culprit badge + truncated message + reference id. Filter by status code; auto-refresh via TanStack Query. - /<port>/admin/errors/<requestId> deep-dive: request shape, full error name+message+stack, sanitized body excerpt, raw metadata, registered-code lookup (so admin can compare to what user saw), likely-culprit hint with subsystem tag. - /<port>/admin/errors/codes is the in-app code reference page — every registered code grouped by domain prefix, searchable, with HTTP status + user message inline. Linked from inspector header so admins can flip to it while triaging. - Permission: admin.view_audit_log. Super admins see all ports; regular admins port-scoped. - system-monitoring dashboard now surfaces error_events alongside permission_denied audit + queue failed jobs (RecentError gains source: 'request' variant). DOCS - docs/error-handling.md walks through coded errors, plain-text message guidelines, client toasting, admin inspector usage, persistence rules, classifier internals, pruning, and the legacy → CodedError migration path. MIGRATION SAFETY - Audit confirmed all 41 migrations (0000-0040) apply cleanly in journal order against an empty DB. 0040 references ports(id) which exists from 0000. 0035/0038 don't deadlock under sequential psql -f. Removed redundant idx_ds_sent_by from 0038 (created in 0037). Tests: 1168/1168 vitest passing. tsc clean. - security-error-responses tests updated for plain-text messages + new optional response keys (code/requestId/message). - berth-pdf-versions tests assert stable error codes via toMatchObject({ code }) rather than message regex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:12:59 +02:00
throw new ApiError({
message: error.error ?? error.message ?? 'Request failed',
status: res.status,
feat(errors): platform-wide request ids + error codes + admin inspector End-to-end error-handling overhaul. A user hitting any failure now sees a plain-text message + stable error code + reference id. A super admin can paste the id into /admin/errors/<id> for the full request shape, sanitized body, error stack, and a heuristic likely-cause hint. REQUEST CONTEXT (AsyncLocalStorage) - src/lib/request-context.ts mints a per-request frame carrying requestId + portId + userId + method + path + start timestamp. - withAuth wraps every authenticated handler in runWithRequestContext and accepts an upstream X-Request-Id header (validated shape) or generates a fresh UUID. The id ALWAYS leaves on the X-Request-Id response header, including early-return 401/403/4xx paths. - Pino logger reads from the same context via mixin — every log line emitted during the request automatically carries the ids with no per-call threading. ERROR CODE REGISTRY - src/lib/error-codes.ts defines stable DOMAIN_REASON codes with HTTP status + plain-text user-facing message (no jargon, written for the rep on the phone with a customer). - New CodedError class wraps a registered code + optional internalMessage (admin-only — never sent to client). - Existing AppError subclasses got plain-text default rewrites so legacy throw sites improve immediately without migration. - High-impact services migrated to specific codes: expenses (RECEIPT_REQUIRED, INVOICE_LINKED), interest-berths (CROSS_PORT_LINK_REJECTED), berth-pdf (PDF_MAGIC_BYTE / PDF_EMPTY / PDF_TOO_LARGE / VERSION_ALREADY_CURRENT), recommender (INTEREST_PORT_MISMATCH). ERROR ENVELOPE - errorResponse always sets X-Request-Id header + requestId field. - 5xx responses include a "Quote error ID …" friendly line. - 4xx kept clean (validation, permission, not-found don't pollute the inspector — they're already in audit log). PERSISTENCE (error_events table, migration 0040) - One row per 5xx, keyed on requestId, with method/path/status/error name+message/stack head (4KB cap)/sanitized body excerpt (1KB cap; password/token/secret/etc keys redacted)/duration/IP/UA/metadata. - captureErrorEvent extracts Postgres SQLSTATE/severity/cause.code so the classifier can recognize FK / unique / NOT NULL / schema- drift violations. - Failure to persist is logged-not-thrown. LIKELY-CULPRIT CLASSIFIER (src/lib/error-classifier.ts) - 4-pass heuristic (first match wins): 1. Postgres SQLSTATE → human reason (23503 FK, 23505 unique, 42703 schema drift, 53300 connection limit, …) 2. Error class name (AbortError, TimeoutError, FetchError, ZodError) 3. Stack-path patterns (/lib/storage/, /lib/email/, documenso, openai|claude, /queue/workers/) 4. Free-text message keywords (econnrefused, rate limit, timeout, unauthorized|invalid api key) - Returns { label, hint, subsystem } for the inspector badge. CLIENT SIDE - apiFetch throws structured ApiError with message + code + requestId + details + retryAfter. - toastError() helper renders the standard 3-line toast: plain message / Error code: X / Reference ID: Y [Copy ID]. ADMIN INSPECTOR - /<port>/admin/errors lists captured 5xx with status badge + path + likely-culprit badge + truncated message + reference id. Filter by status code; auto-refresh via TanStack Query. - /<port>/admin/errors/<requestId> deep-dive: request shape, full error name+message+stack, sanitized body excerpt, raw metadata, registered-code lookup (so admin can compare to what user saw), likely-culprit hint with subsystem tag. - /<port>/admin/errors/codes is the in-app code reference page — every registered code grouped by domain prefix, searchable, with HTTP status + user message inline. Linked from inspector header so admins can flip to it while triaging. - Permission: admin.view_audit_log. Super admins see all ports; regular admins port-scoped. - system-monitoring dashboard now surfaces error_events alongside permission_denied audit + queue failed jobs (RecentError gains source: 'request' variant). DOCS - docs/error-handling.md walks through coded errors, plain-text message guidelines, client toasting, admin inspector usage, persistence rules, classifier internals, pruning, and the legacy → CodedError migration path. MIGRATION SAFETY - Audit confirmed all 41 migrations (0000-0040) apply cleanly in journal order against an empty DB. 0040 references ports(id) which exists from 0000. 0035/0038 don't deadlock under sequential psql -f. Removed redundant idx_ds_sent_by from 0038 (created in 0037). Tests: 1168/1168 vitest passing. tsc clean. - security-error-responses tests updated for plain-text messages + new optional response keys (code/requestId/message). - berth-pdf-versions tests assert stable error codes via toMatchObject({ code }) rather than message regex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:12:59 +02:00
code: error.code ?? null,
details: error.details ?? null,
requestId,
retryAfter: typeof error.retryAfter === 'number' ? error.retryAfter : null,
});
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
feat(errors): platform-wide request ids + error codes + admin inspector End-to-end error-handling overhaul. A user hitting any failure now sees a plain-text message + stable error code + reference id. A super admin can paste the id into /admin/errors/<id> for the full request shape, sanitized body, error stack, and a heuristic likely-cause hint. REQUEST CONTEXT (AsyncLocalStorage) - src/lib/request-context.ts mints a per-request frame carrying requestId + portId + userId + method + path + start timestamp. - withAuth wraps every authenticated handler in runWithRequestContext and accepts an upstream X-Request-Id header (validated shape) or generates a fresh UUID. The id ALWAYS leaves on the X-Request-Id response header, including early-return 401/403/4xx paths. - Pino logger reads from the same context via mixin — every log line emitted during the request automatically carries the ids with no per-call threading. ERROR CODE REGISTRY - src/lib/error-codes.ts defines stable DOMAIN_REASON codes with HTTP status + plain-text user-facing message (no jargon, written for the rep on the phone with a customer). - New CodedError class wraps a registered code + optional internalMessage (admin-only — never sent to client). - Existing AppError subclasses got plain-text default rewrites so legacy throw sites improve immediately without migration. - High-impact services migrated to specific codes: expenses (RECEIPT_REQUIRED, INVOICE_LINKED), interest-berths (CROSS_PORT_LINK_REJECTED), berth-pdf (PDF_MAGIC_BYTE / PDF_EMPTY / PDF_TOO_LARGE / VERSION_ALREADY_CURRENT), recommender (INTEREST_PORT_MISMATCH). ERROR ENVELOPE - errorResponse always sets X-Request-Id header + requestId field. - 5xx responses include a "Quote error ID …" friendly line. - 4xx kept clean (validation, permission, not-found don't pollute the inspector — they're already in audit log). PERSISTENCE (error_events table, migration 0040) - One row per 5xx, keyed on requestId, with method/path/status/error name+message/stack head (4KB cap)/sanitized body excerpt (1KB cap; password/token/secret/etc keys redacted)/duration/IP/UA/metadata. - captureErrorEvent extracts Postgres SQLSTATE/severity/cause.code so the classifier can recognize FK / unique / NOT NULL / schema- drift violations. - Failure to persist is logged-not-thrown. LIKELY-CULPRIT CLASSIFIER (src/lib/error-classifier.ts) - 4-pass heuristic (first match wins): 1. Postgres SQLSTATE → human reason (23503 FK, 23505 unique, 42703 schema drift, 53300 connection limit, …) 2. Error class name (AbortError, TimeoutError, FetchError, ZodError) 3. Stack-path patterns (/lib/storage/, /lib/email/, documenso, openai|claude, /queue/workers/) 4. Free-text message keywords (econnrefused, rate limit, timeout, unauthorized|invalid api key) - Returns { label, hint, subsystem } for the inspector badge. CLIENT SIDE - apiFetch throws structured ApiError with message + code + requestId + details + retryAfter. - toastError() helper renders the standard 3-line toast: plain message / Error code: X / Reference ID: Y [Copy ID]. ADMIN INSPECTOR - /<port>/admin/errors lists captured 5xx with status badge + path + likely-culprit badge + truncated message + reference id. Filter by status code; auto-refresh via TanStack Query. - /<port>/admin/errors/<requestId> deep-dive: request shape, full error name+message+stack, sanitized body excerpt, raw metadata, registered-code lookup (so admin can compare to what user saw), likely-culprit hint with subsystem tag. - /<port>/admin/errors/codes is the in-app code reference page — every registered code grouped by domain prefix, searchable, with HTTP status + user message inline. Linked from inspector header so admins can flip to it while triaging. - Permission: admin.view_audit_log. Super admins see all ports; regular admins port-scoped. - system-monitoring dashboard now surfaces error_events alongside permission_denied audit + queue failed jobs (RecentError gains source: 'request' variant). DOCS - docs/error-handling.md walks through coded errors, plain-text message guidelines, client toasting, admin inspector usage, persistence rules, classifier internals, pruning, and the legacy → CodedError migration path. MIGRATION SAFETY - Audit confirmed all 41 migrations (0000-0040) apply cleanly in journal order against an empty DB. 0040 references ports(id) which exists from 0000. 0035/0038 don't deadlock under sequential psql -f. Removed redundant idx_ds_sent_by from 0038 (created in 0037). Tests: 1168/1168 vitest passing. tsc clean. - security-error-responses tests updated for plain-text messages + new optional response keys (code/requestId/message). - berth-pdf-versions tests assert stable error codes via toMatchObject({ code }) rather than message regex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:12:59 +02:00
/**
* Structured client-side error thrown by `apiFetch`. Carries the stable
* fields a toast / error boundary needs to render a useful message:
*
* - `message`: plain-text, ready to show to the user
* - `code`: stable error code from `src/lib/error-codes.ts`
* - `requestId`: paste this to support to find the row in
* `/admin/errors/<requestId>`
*
* Mutations should use the `toastError(err)` helper rather than reading
* these fields directly that keeps the toast format consistent.
*/
export class ApiError extends Error {
status: number;
code: string | null;
details: unknown;
requestId: string | null;
retryAfter: number | null;
constructor(args: {
message: string;
status: number;
code: string | null;
details: unknown;
requestId: string | null;
retryAfter: number | null;
}) {
super(args.message);
this.name = 'ApiError';
this.status = args.status;
this.code = args.code;
this.details = args.details;
this.requestId = args.requestId;
this.retryAfter = args.retryAfter;
}
}