Files
pn-new-crm/src/lib/api/toast-error.ts
Matt Ciaccio d3a6a9beef fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners
Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:

* 50 route files swept (61 sites): manual NextResponse.json({error,
  status: 4xx|5xx}) early-returns replaced by typed throws +
  errorResponse(err) at the catch.
  - Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
    helper from src/lib/api/helpers.ts so denials hit the audit log.
  - Path-param + body validation 400s become ValidationError throws.
  - 404s become NotFoundError or CodedError('NOT_FOUND') for AI
    feature-flag paths.
  - 11 manual 5xx returns now re-throw so error_events captures the
    request-id (the admin error inspector becomes usable from real
    incidents).
  - website-analytics 200-with-error anti-pattern flipped to 409 +
    UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
  - 11 sites intentionally preserved: storage/[token] anti-enumeration
    token-failure paths, webhook-secret 401, "Unknown port" 400 in
    public intake.

* 7 admin forms (roles, users, ports, webhooks, custom-fields,
  document-templates, tags) gain a formatErrorBanner() helper from
  src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
  banner — the rep can copy the request id when reporting a failed
  save.  Banners get whitespace-pre-line so newlines render.

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:36:59 +02:00

72 lines
2.6 KiB
TypeScript

'use client';
import { toast } from 'sonner';
import { ApiError } from '@/lib/api/client';
/**
* Build a multi-line string suitable for an inline form banner — the
* primary message followed by `Error code:` / `Reference ID:` lines when
* the error is an ApiError. Use from admin forms that want to keep
* their inline error UX instead of switching to a toast.
*
* Example output:
* "Couldn't save the role.
* Error code: ROLES_DUPLICATE_NAME
* Reference ID: 1ab2c3d4-…"
*/
export function formatErrorBanner(err: unknown, fallback = 'Something went wrong.'): string {
if (err instanceof ApiError) {
const parts = [err.message || fallback];
if (err.code) parts.push(`Error code: ${err.code}`);
if (err.requestId) parts.push(`Reference ID: ${err.requestId}`);
return parts.join('\n');
}
if (err instanceof Error) return err.message || fallback;
return fallback;
}
/**
* Render an API error as a toast in the consistent platform format:
*
* ┌─────────────────────────────────────────────┐
* │ {plain-text message} │
* │ │
* │ Error code: EXPENSES_RECEIPT_REQUIRED │
* │ Reference ID: ab12-cd34-… [Copy] │
* └─────────────────────────────────────────────┘
*
* Use this anywhere a `useMutation({ onError })` would otherwise just
* call `toast.error(err.message)`. Falls back gracefully when the error
* isn't an ApiError (network errors, programmer errors, etc.).
*/
export function toastError(err: unknown, fallback = 'Something went wrong.'): void {
if (err instanceof ApiError) {
const lines: string[] = [];
if (err.code) lines.push(`Error code: ${err.code}`);
if (err.requestId) lines.push(`Reference ID: ${err.requestId}`);
toast.error(err.message, {
description: lines.length > 0 ? lines.join('\n') : undefined,
// Long enough to read the message + grab the reference id.
duration: 8_000,
action: err.requestId
? {
label: 'Copy ID',
onClick: () => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(err.requestId!);
toast.success('Reference ID copied');
}
},
}
: undefined,
});
return;
}
if (err instanceof Error) {
toast.error(err.message || fallback);
return;
}
toast.error(fallback);
}