From 286248977b0db4df68b3b6b90615b63caee98499 Mon Sep 17 00:00:00 2001 From: Omitogun Ayobami Date: Fri, 31 Jul 2026 15:00:08 +0100 Subject: [PATCH] perf: fetch commitment history in parallel in RecentActivityFeed Replace the sequential for-loop in loadEvents with Promise.allSettled(commitments.map(...)), preserving the existing per-commitment try/catch-and-log behavior for failures. This drops feed render time from N x latency to max(latency) for N active commitments. Closes #1396 --- src/components/Skeleton.tsx | 275 ++++++++++++++++++ .../dashboard/RecentActivityFeed.test.tsx | 218 ++++++++++++++ .../dashboard/RecentActivityFeed.tsx | 259 +++++++++++++++++ src/lib/apiClient.ts | 10 + src/lib/client/apiClient.ts | 231 +++++++++++++++ src/utils/errorHelpers.ts | 195 +++++++++++++ 6 files changed, 1188 insertions(+) create mode 100644 src/components/Skeleton.tsx create mode 100644 src/components/dashboard/RecentActivityFeed.test.tsx create mode 100644 src/components/dashboard/RecentActivityFeed.tsx create mode 100644 src/lib/apiClient.ts create mode 100644 src/lib/client/apiClient.ts create mode 100644 src/utils/errorHelpers.ts diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx new file mode 100644 index 000000000..61b463e55 --- /dev/null +++ b/src/components/Skeleton.tsx @@ -0,0 +1,275 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +interface SkeletonProps { + className?: string; + width?: string | number; + height?: string | number; + rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; + shimmer?: boolean; +} + +/** + * Base skeleton component with reduced motion support + * + * Accessibility considerations: + * - Uses `prefers-reduced-motion` media query to disable animations + * - Provides static loading state for users with motion sensitivity + * - Includes aria-label for screen readers + */ +export function Skeleton({ + className, + width, + height, + rounded = 'md', + shimmer = true, +}: SkeletonProps) { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + // Check for reduced motion preference + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + setPrefersReducedMotion(mediaQuery.matches); + + const handleChange = (e: MediaQueryListEvent) => { + setPrefersReducedMotion(e.matches); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const borderRadius = { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + xl: 'rounded-xl', + full: 'rounded-full', + }[rounded]; + + const style: React.CSSProperties = {}; + if (width) style.width = typeof width === 'number' ? `${width}px` : width; + if (height) style.height = typeof height === 'number' ? `${height}px` : height; + + return ( +
+ {/* Base background */} +
+ + {/* Shimmer effect with reduced motion support */} + {shimmer && !prefersReducedMotion && ( +
+ )} + + {/* Static loading indicator for reduced motion */} + {shimmer && prefersReducedMotion && ( +
+ )} +
+ ); +} + +/** + * Skeleton for commitment cards in the commitments list + */ +export function CommitmentCardSkeleton() { + return ( +
+
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+
+ ); +} + +/** + * Skeleton for marketplace cards + */ +export function MarketplaceCardSkeleton() { + return ( +
+
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+ ); +} + +/** + * Skeleton for health metrics charts + */ +export function HealthChartSkeleton() { + return ( +
+
+ +
+ + + +
+
+ +
+
+ + +
+ + {/* Chart area */} +
+ {/* Y-axis */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ + {/* X-axis */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ + {/* Chart lines */} +
+
+ {/* Grid lines */} + {[...Array(4)].map((_, i) => ( +
+ ))} + + {/* Simulated chart line */} +
+ + + +
+
+
+
+ +
+ + +
+
+
+ ); +} + +/** + * Skeleton for commitment stats + */ +export function CommitmentStatsSkeleton() { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+ + + +
+ ))} +
+ ); +} + +/** + * Skeleton for filters section + */ +export function FiltersSkeleton() { + return ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ); +} diff --git a/src/components/dashboard/RecentActivityFeed.test.tsx b/src/components/dashboard/RecentActivityFeed.test.tsx new file mode 100644 index 000000000..9f613a0f6 --- /dev/null +++ b/src/components/dashboard/RecentActivityFeed.test.tsx @@ -0,0 +1,218 @@ +/** + * @vitest-environment happy-dom + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RecentActivityFeed } from './RecentActivityFeed'; +import { apiGet } from '@/lib/apiClient'; +import { Commitment } from '@/lib/types/domain'; + +vi.mock('@/lib/apiClient', () => ({ + apiGet: vi.fn(), +})); + +const mockCommitments: Commitment[] = [ + { + id: 'CMT-ABC123', + type: 'Safe', + status: 'Active', + asset: 'XLM', + amount: '50,000', + createdDate: 'Jan 10, 2026', + expiryDate: 'Feb 9, 2026', + }, + { + id: 'CMT-XYZ789', + type: 'Balanced', + status: 'Active', + asset: 'USDC', + amount: '100,000', + createdDate: 'Dec 15, 2025', + expiryDate: 'Feb 13, 2026', + }, +]; + +const mockedApiGet = vi.mocked(apiGet); + +describe('RecentActivityFeed', () => { + beforeEach(() => { + mockedApiGet.mockReset(); + }); + + it('renders loading state initially', () => { + mockedApiGet.mockImplementation(() => new Promise(() => {})); + + render(); + + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + + it('renders empty state when no commitments', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('No Recent Activity')).toBeInTheDocument(); + }); + }); + + it('renders mixed event types correctly', async () => { + mockedApiGet.mockImplementation((url: string) => { + if (url.includes('CMT-ABC123')) { + return Promise.resolve({ + success: true, + data: { + events: [ + { + eventId: 'created:CMT-ABC123', + kind: 'created', + occurredAt: new Date(Date.now() - 86400000).toISOString(), + payload: { asset: 'XLM', amount: '50,000' }, + }, + { + eventId: 'attestation:ATTR-001', + kind: 'attestation', + occurredAt: new Date(Date.now() - 3600000).toISOString(), + payload: { attestationId: 'ATTR-001', attestationType: 'health_check' }, + }, + ], + }, + }); + } + if (url.includes('CMT-XYZ789')) { + return Promise.resolve({ + success: true, + data: { + events: [ + { + eventId: 'settlement:CMT-XYZ789', + kind: 'settlement', + occurredAt: new Date(Date.now() - 7200000).toISOString(), + payload: { settlementAmount: '105,000' }, + }, + ], + }, + }); + } + return Promise.resolve({ success: true, data: { events: [] } }); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + expect(screen.getByText('Commitment Created')).toBeInTheDocument(); + expect(screen.getByText('Attestation Recorded')).toBeInTheDocument(); + expect(screen.getByText('Settlement Complete')).toBeInTheDocument(); + }); + }); + + it('caps feed length and shows view all', async () => { + const manyEvents = Array.from({ length: 10 }, (_, i) => ({ + eventId: `event-${i}`, + kind: 'attestation' as const, + occurredAt: new Date(Date.now() - i * 3600000).toISOString(), + payload: { attestationId: `ATTR-${i}`, attestationType: 'health_check' }, + })); + + mockedApiGet.mockResolvedValue({ + success: true, + data: { events: manyEvents }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('View All Activity')).toBeInTheDocument(); + }); + }); + + it('does not show view all when events <= maxItems', async () => { + const fewEvents = [ + { + eventId: 'event-1', + kind: 'created' as const, + occurredAt: new Date().toISOString(), + payload: { asset: 'XLM', amount: '50,000' }, + }, + ]; + + mockedApiGet.mockResolvedValue({ + success: true, + data: { events: fewEvents }, + }); + + render(); + + await waitFor(() => { + expect(screen.queryByText('View All Activity')).not.toBeInTheDocument(); + }); + }); + + it('issues all per-commitment history requests concurrently', async () => { + const pendingResolvers: Array<(value: unknown) => void> = []; + mockedApiGet.mockImplementation( + () => + new Promise((resolve) => { + pendingResolvers.push(resolve); + }), + ); + + render(); + + // Every request must already be in flight before any of them resolves. + // A sequential loop would only issue the next request after the previous + // one settled, so both calls being issued here proves parallel issuance. + await waitFor(() => { + expect(mockedApiGet).toHaveBeenCalledTimes(mockCommitments.length); + }); + + expect(mockedApiGet).toHaveBeenCalledWith('/api/commitments/CMT-ABC123/history'); + expect(mockedApiGet).toHaveBeenCalledWith('/api/commitments/CMT-XYZ789/history'); + + // Resolve all pending requests so loading finishes and the feed renders. + pendingResolvers.forEach((resolve) => resolve({ success: true, data: { events: [] } })); + + await waitFor(() => { + expect(screen.getByText('No Recent Activity')).toBeInTheDocument(); + }); + }); + + it('still renders events from other commitments when one request fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + mockedApiGet.mockImplementation((url: string) => { + if (url.includes('CMT-ABC123')) { + return Promise.reject(new Error('network down')); + } + return Promise.resolve({ + success: true, + data: { + events: [ + { + eventId: 'settlement:CMT-XYZ789', + kind: 'settlement', + occurredAt: new Date().toISOString(), + payload: { settlementAmount: '105,000' }, + }, + ], + }, + }); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Settlement Complete')).toBeInTheDocument(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to load history for CMT-ABC123', + expect.any(Error), + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); +}); diff --git a/src/components/dashboard/RecentActivityFeed.tsx b/src/components/dashboard/RecentActivityFeed.tsx new file mode 100644 index 000000000..c9d133a36 --- /dev/null +++ b/src/components/dashboard/RecentActivityFeed.tsx @@ -0,0 +1,259 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { apiGet } from '@/lib/apiClient'; +import { Commitment, HistoryEvent, HistoryEventKind } from '@/lib/types/domain'; +import { Skeleton } from '@/components/Skeleton'; + +type FeedEvent = HistoryEvent & { + commitmentId: string; +}; + +interface RecentActivityFeedProps { + commitments: Commitment[]; + maxItems?: number; +} + +function formatRelativeTime(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + + if (diffSeconds < 60) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`; + if (diffMonths < 12) return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`; + const diffYears = Math.floor(diffMonths / 12); + return `${diffYears} year${diffYears === 1 ? '' : 's'} ago`; +} + +function getEventIcon(kind: HistoryEventKind) { + switch (kind) { + case 'created': + return ( + + + + + ); + case 'attestation': + return ( + + + + + ); + case 'early_exit': + return ( + + + + + ); + case 'settlement': + return ( + + + + + ); + default: + return null; + } +} + +function getEventTitle(event: FeedEvent): string { + switch (event.kind) { + case 'created': + return `Commitment Created`; + case 'attestation': + return `Attestation Recorded`; + case 'early_exit': + return `Early Exit`; + case 'settlement': + return `Settlement Complete`; + default: + return 'Event'; + } +} + +function getEventDescription(event: FeedEvent): string { + switch (event.kind) { + case 'created': + return `${event.payload.amount} ${event.payload.asset}`; + case 'attestation': + return event.payload.attestationType || 'Health check'; + case 'early_exit': + return event.payload.exitedBy + ? `Exited by ${event.payload.exitedBy.slice(0, 8)}...` + : 'Early exit executed'; + case 'settlement': + return event.payload.settlementAmount + ? `Settled: ${event.payload.settlementAmount}` + : 'Commitment settled'; + default: + return ''; + } +} + +export function RecentActivityFeed({ commitments, maxItems = 5 }: RecentActivityFeedProps) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function loadEvents() { + if (commitments.length === 0) { + setLoading(false); + return; + } + + // Fetch every commitment's history in parallel. `Promise.allSettled` + // preserves the previous per-commitment try/catch-and-log behavior: a + // failed request for one commitment never blocks the others from + // rendering, so total time is ~max(latency) instead of N × latency. + const results = await Promise.allSettled( + commitments.map(async (commitment) => { + try { + const response = await apiGet<{ success: boolean; data: { events: HistoryEvent[] } }>( + `/api/commitments/${commitment.id}/history`, + ); + + if (response.success && response.data?.events) { + return response.data.events.map((event) => ({ + ...event, + commitmentId: commitment.id, + })); + } + return []; + } catch (err) { + console.error(`Failed to load history for ${commitment.id}`, err); + return []; + } + }), + ); + + const allEvents: FeedEvent[] = results.flatMap((result) => + result.status === 'fulfilled' ? result.value : [], + ); + + allEvents.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime()); + + setEvents(allEvents); + setLoading(false); + } + + loadEvents(); + }, [commitments]); + + if (loading) { + return ( +
+
+ +
+
+ {[...Array(3)].map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+ ); + } + + const displayEvents = events.slice(0, maxItems); + const hasMore = events.length > maxItems; + + if (displayEvents.length === 0) { + return ( +
+

No Recent Activity

+

Activity from your commitments will appear here.

+
+ ); + } + + return ( +
+
+

Recent Activity

+ + {events.length} events + +
+ +
    + {displayEvents.map((event) => ( +
  • +
    + +
    + + {getEventTitle(event)} + +

    + {getEventDescription(event)} · Commitment {event.commitmentId.substring(0, 8)} +

    +
    + +
    +
  • + ))} +
+ + {hasMore && ( +
+ + View All Activity + + + + +
+ )} +
+ ); +} diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts new file mode 100644 index 000000000..89ee269aa --- /dev/null +++ b/src/lib/apiClient.ts @@ -0,0 +1,10 @@ +export { + ApiClientError, + ApiError, + apiRequest, + apiGet, + apiPost, + apiPut, + apiDelete, +} from './client/apiClient'; +export type { UiApiError } from '@/utils/errorHelpers'; diff --git a/src/lib/client/apiClient.ts b/src/lib/client/apiClient.ts new file mode 100644 index 000000000..e9f662222 --- /dev/null +++ b/src/lib/client/apiClient.ts @@ -0,0 +1,231 @@ +import { z } from 'zod'; +import { ErrorBodySchema, OkBodySchema } from '@/lib/schemas/apiContracts'; +import { normalizeApiError, type UiApiError } from '@/utils/errorHelpers'; +import type { FailResponse, OkResponse } from '@/lib/backend/apiResponse'; + +export class ApiClientError extends Error { + public readonly code: string; + public readonly status?: number; + public readonly details?: unknown; + public readonly retryAfterSeconds?: number; + public readonly correlationId?: string; + public readonly friendlyMessage: string; + + constructor(error: UiApiError) { + super(error.message); + this.name = 'ApiClientError'; + this.code = error.code; + if (error.status !== undefined) { + this.status = error.status; + } + if (error.details !== undefined) { + this.details = error.details; + } + if (error.retryAfterSeconds !== undefined) { + this.retryAfterSeconds = error.retryAfterSeconds; + } + if (error.correlationId !== undefined) { + this.correlationId = error.correlationId; + } + this.friendlyMessage = error.message; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ApiClientError); + } + } +} + +export class ApiError extends ApiClientError {} + +function isErrorEnvelope(value: unknown): value is FailResponse { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial & Record; + const hasFailure = candidate.success === false || candidate.ok === false; + const hasErrorShape = + typeof candidate.error?.code === 'string' && typeof candidate.error?.message === 'string'; + return hasFailure && hasErrorShape; +} + +function isSuccessEnvelope(value: unknown): value is OkResponse { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial> & Record; + const hasSuccess = candidate.success === true || candidate.ok === true; + return hasSuccess && ('data' in candidate || 'result' in candidate); +} + +export function parseApiResponse(payload: unknown): T { + if (isErrorEnvelope(payload)) { + const parsed = ErrorBodySchema.safeParse(payload); + if (parsed.success) { + throw new ApiError( + normalizeApiError( + { + code: parsed.data.error.code, + message: parsed.data.error.message, + details: parsed.data.error.details, + }, + 500, + ), + ); + } + } + + if (isSuccessEnvelope(payload)) { + const record = payload as unknown as Record; + if ('data' in record) { + return record.data as T; + } + + const parsed = OkBodySchema(z.any()).safeParse(payload); + if (parsed.success) { + return parsed.data.data as T; + } + } + + return payload as T; +} + +export async function apiRequest( + input: string | URL | Request, + init?: RequestInit, + options?: { timeoutMs?: number }, +): Promise { + const timeoutMs = options?.timeoutMs ?? 5000; + const controller = new AbortController(); + const externalSignal = init?.signal; + + const timeoutId = globalThis.setTimeout(() => controller.abort(), timeoutMs); + + const abortPromise = new Promise((_, reject) => { + const onAbort = () => { + controller.abort(); + reject(new DOMException('Aborted', 'AbortError')); + }; + + if (externalSignal) { + if (externalSignal.aborted) { + onAbort(); + return; + } + externalSignal.addEventListener('abort', onAbort, { once: true }); + } + + controller.signal.addEventListener('abort', onAbort, { once: true }); + }); + + try { + const response = await Promise.race([ + fetch(input, { ...init, signal: controller.signal }), + abortPromise, + ]); + clearTimeout(timeoutId); + + let payload: unknown; + let isJson = true; + try { + payload = await response.json(); + } catch { + isJson = false; + payload = null; + } + + if (!response.ok) { + if (isJson && isErrorEnvelope(payload)) { + throw new ApiError( + normalizeApiError( + { + code: payload.error.code, + message: payload.error.message, + details: payload.error.details, + retryAfterSeconds: payload.error.retryAfterSeconds, + correlationId: payload.error.correlationId, + }, + response.status, + ), + ); + } + + const contentType = response.headers.get('content-type') || ''; + const statusText = `${response.status} ${response.statusText || 'Error'}`; + + if (!isJson) { + throw new ApiError( + normalizeApiError( + new Error( + `Request failed with status ${statusText} (non-JSON response, content-type: ${contentType})`, + ), + response.status, + ), + ); + } + + throw new ApiError( + normalizeApiError(new Error(`Request failed with status ${statusText}`), response.status), + ); + } + + return parseApiResponse(payload); + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof ApiError) { + throw error; + } + + if (error instanceof DOMException && error.name === 'AbortError') { + throw new ApiError({ + code: 'TIMEOUT', + message: 'The request was cancelled.', + status: 0, + }); + } + + throw new ApiError(normalizeApiError(error)); + } +} + +export function apiGet( + url: string, + initOrTimeout?: RequestInit | number, + timeoutMs = 5000, +): Promise { + if (typeof initOrTimeout === 'number') { + return apiRequest(url, { method: 'GET' }, { timeoutMs: initOrTimeout }); + } + + return apiRequest(url, { method: 'GET', ...(initOrTimeout ?? {}) }, { timeoutMs }); +} + +export function apiPost(url: string, body: unknown, timeoutMs = 5000): Promise { + return apiRequest( + url, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + { timeoutMs }, + ); +} + +export function apiPut(url: string, body: unknown, timeoutMs = 5000): Promise { + return apiRequest( + url, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + { timeoutMs }, + ); +} + +export function apiDelete(url: string, timeoutMs = 5000): Promise { + return apiRequest(url, { method: 'DELETE' }, { timeoutMs }); +} diff --git a/src/utils/errorHelpers.ts b/src/utils/errorHelpers.ts new file mode 100644 index 000000000..eed971a98 --- /dev/null +++ b/src/utils/errorHelpers.ts @@ -0,0 +1,195 @@ +import { ERROR_CODE_REGISTRY } from '../lib/backend/errorCodes'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ApiErrorResponse { + success: false; + error: { + code: number; + type: string; + message: string; + retryAfter?: number; + details?: string; + }; +} + +export interface UiApiError { + code: string; + message: string; + status?: number; + details?: unknown; + retryAfterSeconds?: number; + correlationId?: string; +} + +// ─── Error Factories ────────────────────────────────────────────────────────── + +/** + * Creates an ApiErrorResponse for 429 Rate Limit Exceeded. + * Default message and status code are sourced from ERROR_CODE_REGISTRY.TOO_MANY_REQUESTS. + */ +export function rateLimitError(retryAfter = 60, details?: string): ApiErrorResponse { + const registryEntry = ERROR_CODE_REGISTRY.TOO_MANY_REQUESTS!; + return { + success: false, + error: { + code: registryEntry.statusCode, + type: 'RATE_LIMIT_EXCEEDED', + message: registryEntry.meaning, + retryAfter, + ...(details && process.env.NODE_ENV === 'development' ? { details } : {}), + }, + }; +} + +/** + * Creates an ApiErrorResponse for 500 Internal Server Error. + * Default message and status code are sourced from ERROR_CODE_REGISTRY.INTERNAL_ERROR. + */ +export function internalServerError(details?: string): ApiErrorResponse { + const registryEntry = ERROR_CODE_REGISTRY.INTERNAL_ERROR!; + return { + success: false, + error: { + code: registryEntry.statusCode, + type: 'INTERNAL_SERVER_ERROR', + message: registryEntry.meaning, + ...(details && process.env.NODE_ENV === 'development' ? { details } : {}), + }, + }; +} + +/** + * Creates an ApiErrorResponse for 502 Bad Gateway. + * Default message and status code are sourced from ERROR_CODE_REGISTRY.BAD_GATEWAY. + */ +export function badGatewayError(details?: string): ApiErrorResponse { + const registryEntry = ERROR_CODE_REGISTRY.BAD_GATEWAY!; + return { + success: false, + error: { + code: registryEntry.statusCode, + type: 'BAD_GATEWAY', + message: registryEntry.meaning, + ...(details && process.env.NODE_ENV === 'development' ? { details } : {}), + }, + }; +} + +/** + * Creates an ApiErrorResponse for 503 Service Unavailable. + * Default message and status code are sourced from ERROR_CODE_REGISTRY.SERVICE_UNAVAILABLE. + */ +export function serviceUnavailableError(retryAfter = 30, details?: string): ApiErrorResponse { + const registryEntry = ERROR_CODE_REGISTRY.SERVICE_UNAVAILABLE!; + return { + success: false, + error: { + code: registryEntry.statusCode, + type: 'SERVICE_UNAVAILABLE', + message: registryEntry.meaning, + retryAfter, + ...(details && process.env.NODE_ENV === 'development' ? { details } : {}), + }, + }; +} + +/** + * Creates an ApiErrorResponse for 504 Gateway Timeout. + * Default message and status code are sourced from ERROR_CODE_REGISTRY.GATEWAY_TIMEOUT. + */ +export function gatewayTimeoutError(details?: string): ApiErrorResponse { + const registryEntry = ERROR_CODE_REGISTRY.GATEWAY_TIMEOUT!; + return { + success: false, + error: { + code: registryEntry.statusCode, + type: 'GATEWAY_TIMEOUT', + message: registryEntry.meaning, + ...(details && process.env.NODE_ENV === 'development' ? { details } : {}), + }, + }; +} + +// ─── Generic 5xx Resolver ───────────────────────────────────────────────────── + +export function resolveServerError(statusCode: number, details?: string): ApiErrorResponse { + switch (statusCode) { + case 502: + return badGatewayError(details); + case 503: + return serviceUnavailableError(30, details); + case 504: + return gatewayTimeoutError(details); + default: + return internalServerError(details); + } +} + +// ─── HTTP Headers Helper ────────────────────────────────────────────────────── + +export function getErrorHeaders(error: ApiErrorResponse): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (error.error.retryAfter !== undefined) { + headers['Retry-After'] = String(error.error.retryAfter); + } + + return headers; +} + +export function normalizeApiError(error: unknown, status?: number): UiApiError { + const maybeErrorLike = error as Partial & { code?: string; message?: string }; + const codeFromError = typeof maybeErrorLike?.code === 'string' ? maybeErrorLike.code : undefined; + const messageFromError = + typeof maybeErrorLike?.message === 'string' ? maybeErrorLike.message : undefined; + const inboundMessage = + messageFromError || + (error instanceof Error ? error.message : undefined) || + 'Something went wrong.'; + const lower = inboundMessage.toLowerCase(); + + const code = + codeFromError?.toUpperCase() || + (lower.includes('fetch') || lower.includes('network') ? 'NETWORK_ERROR' : undefined) || + (lower.includes('timeout') || lower.includes('aborted') ? 'TIMEOUT' : undefined) || + (lower.includes('not found') ? 'NOT_FOUND' : undefined) || + (lower.includes('unauthorized') ? 'UNAUTHORIZED' : undefined) || + (lower.includes('forbidden') ? 'FORBIDDEN' : undefined) || + (lower.includes('rate limit') ? 'RATE_LIMIT_EXCEEDED' : undefined) || + 'REQUEST_FAILED'; + + const message = + code === 'NETWORK_ERROR' + ? 'We could not reach the server. Please try again.' + : code === 'TIMEOUT' + ? 'The request took too long. Please try again.' + : code === 'NOT_FOUND' + ? 'The requested resource was not found.' + : code === 'UNAUTHORIZED' + ? 'You are not authorized to perform that action.' + : code === 'FORBIDDEN' + ? 'You do not have permission to do that.' + : code === 'RATE_LIMIT_EXCEEDED' + ? 'Too many requests. Please wait before trying again.' + : inboundMessage; + + // Only attach optional fields when they are actually defined, to satisfy + // `exactOptionalPropertyTypes` (no explicit `undefined` on optional props). + const result: UiApiError = { code, message }; + if (status !== undefined) { + result.status = status; + } + if (maybeErrorLike?.details !== undefined) { + result.details = maybeErrorLike.details; + } + if (maybeErrorLike?.retryAfterSeconds !== undefined) { + result.retryAfterSeconds = maybeErrorLike.retryAfterSeconds; + } + if (maybeErrorLike?.correlationId !== undefined) { + result.correlationId = maybeErrorLike.correlationId; + } + return result; +}