diff --git a/src/app/api/portal/auth/activate/route.ts b/src/app/api/portal/auth/activate/route.ts index 6fe0eac..33c5a84 100644 --- a/src/app/api/portal/auth/activate/route.ts +++ b/src/app/api/portal/auth/activate/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { errorResponse } from '@/lib/errors'; -import { activateAccount } from '@/lib/services/portal-auth.service'; import { enforcePublicRateLimit } from '@/lib/api/route-helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; +import { activateAccount } from '@/lib/services/portal-auth.service'; const bodySchema = z.object({ token: z.string().min(1), @@ -15,22 +15,19 @@ export async function POST(req: NextRequest): Promise { const limited = await enforcePublicRateLimit(req, 'portalToken'); if (limited) return limited; - let body: unknown; try { - body = await req.json(); - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); - } + let body: unknown; + try { + body = await req.json(); + } catch { + throw new ValidationError('Invalid request body'); + } - const parsed = bodySchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid input' }, - { status: 400 }, - ); - } + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input'); + } - try { await activateAccount(parsed.data.token, parsed.data.password); return NextResponse.json({ success: true }); } catch (err) { diff --git a/src/app/api/portal/auth/forgot-password/route.ts b/src/app/api/portal/auth/forgot-password/route.ts index 3180940..ed40408 100644 --- a/src/app/api/portal/auth/forgot-password/route.ts +++ b/src/app/api/portal/auth/forgot-password/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; +import { enforcePublicRateLimit } from '@/lib/api/route-helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { requestPasswordReset } from '@/lib/services/portal-auth.service'; -import { enforcePublicRateLimit } from '@/lib/api/route-helpers'; const bodySchema = z.object({ email: z.string().email() }); @@ -14,24 +15,26 @@ export async function POST(req: NextRequest): Promise { const limited = await enforcePublicRateLimit(req, 'portalForgot'); if (limited) return limited; - let body: unknown; try { - body = await req.json(); - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); - } + let body: unknown; + try { + body = await req.json(); + } catch { + throw new ValidationError('Invalid request body'); + } - const parsed = bodySchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: 'Invalid email address' }, { status: 400 }); - } + const parsed = bodySchema.safeParse(body); + if (!parsed.success) throw new ValidationError('Invalid email address'); - // Always return 200 to prevent account-enumeration. Errors are logged - // server-side, never surfaced to the client. - try { - await requestPasswordReset(parsed.data.email); - } catch (err) { - logger.error({ err }, 'Portal forgot-password failed (swallowed)'); + // Always return 200 to prevent account-enumeration. Errors are logged + // server-side, never surfaced to the client. + try { + await requestPasswordReset(parsed.data.email); + } catch (err) { + logger.error({ err }, 'Portal forgot-password failed (swallowed)'); + } + return NextResponse.json({ success: true }); + } catch (error) { + return errorResponse(error); } - return NextResponse.json({ success: true }); } diff --git a/src/app/api/portal/auth/reset-password/route.ts b/src/app/api/portal/auth/reset-password/route.ts index cd1363f..bd555f7 100644 --- a/src/app/api/portal/auth/reset-password/route.ts +++ b/src/app/api/portal/auth/reset-password/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { errorResponse } from '@/lib/errors'; -import { resetPassword } from '@/lib/services/portal-auth.service'; import { enforcePublicRateLimit } from '@/lib/api/route-helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; +import { resetPassword } from '@/lib/services/portal-auth.service'; const bodySchema = z.object({ token: z.string().min(1), @@ -15,22 +15,19 @@ export async function POST(req: NextRequest): Promise { const limited = await enforcePublicRateLimit(req, 'portalToken'); if (limited) return limited; - let body: unknown; try { - body = await req.json(); - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); - } + let body: unknown; + try { + body = await req.json(); + } catch { + throw new ValidationError('Invalid request body'); + } - const parsed = bodySchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid input' }, - { status: 400 }, - ); - } + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input'); + } - try { await resetPassword(parsed.data.token, parsed.data.password); return NextResponse.json({ success: true }); } catch (err) { diff --git a/src/app/api/portal/auth/sign-in/route.ts b/src/app/api/portal/auth/sign-in/route.ts index 7d1b4cd..56850f8 100644 --- a/src/app/api/portal/auth/sign-in/route.ts +++ b/src/app/api/portal/auth/sign-in/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { errorResponse } from '@/lib/errors'; +import { enforcePublicRateLimit } from '@/lib/api/route-helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { PORTAL_COOKIE } from '@/lib/portal/auth'; import { signIn } from '@/lib/services/portal-auth.service'; -import { enforcePublicRateLimit } from '@/lib/api/route-helpers'; const bodySchema = z.object({ email: z.string().email(), @@ -18,12 +18,12 @@ export async function POST(req: NextRequest): Promise { try { body = await req.json(); } catch { - return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 }); + return errorResponse(new ValidationError('Email format is invalid')); } const parsed = bodySchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 }); + return errorResponse(new ValidationError('Email format is invalid')); } // Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so diff --git a/src/app/api/portal/dashboard/route.ts b/src/app/api/portal/dashboard/route.ts index e4b551d..80317ef 100644 --- a/src/app/api/portal/dashboard/route.ts +++ b/src/app/api/portal/dashboard/route.ts @@ -1,20 +1,18 @@ import { NextResponse } from 'next/server'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; import { withPortalAuth } from '@/lib/portal/helpers'; import { getPortalDashboard } from '@/lib/services/portal.service'; -import { logger } from '@/lib/logger'; export const GET = withPortalAuth(async (_req, session) => { try { const dashboard = await getPortalDashboard(session.clientId, session.portId); - - if (!dashboard) { - return NextResponse.json({ error: 'Client not found' }, { status: 404 }); - } + if (!dashboard) throw new NotFoundError('client'); return NextResponse.json({ data: dashboard }); } catch (error) { - logger.error({ error }, 'Portal dashboard fetch failed'); - return NextResponse.json({ error: 'Failed to load dashboard' }, { status: 500 }); + logger.error({ err: error }, 'Portal dashboard fetch failed'); + return errorResponse(error); } }); diff --git a/src/app/api/portal/documents/[documentId]/download/route.ts b/src/app/api/portal/documents/[documentId]/download/route.ts index d00310e..f34c07b 100644 --- a/src/app/api/portal/documents/[documentId]/download/route.ts +++ b/src/app/api/portal/documents/[documentId]/download/route.ts @@ -1,26 +1,21 @@ import { NextResponse } from 'next/server'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; import { withPortalAuth } from '@/lib/portal/helpers'; import { getDocumentDownloadUrl } from '@/lib/services/portal.service'; -import { logger } from '@/lib/logger'; export const GET = withPortalAuth(async (_req, session, params) => { try { const documentId = params.documentId; - - if (!documentId) { - return NextResponse.json({ error: 'Document ID required' }, { status: 400 }); - } + if (!documentId) throw new ValidationError('documentId is required'); const url = await getDocumentDownloadUrl(session.clientId, documentId, session.portId); - - if (!url) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }); - } + if (!url) throw new NotFoundError('document'); return NextResponse.json({ url }); } catch (error) { - logger.error({ error }, 'Portal document download URL fetch failed'); - return NextResponse.json({ error: 'Failed to generate download URL' }, { status: 500 }); + logger.error({ err: error }, 'Portal document download URL fetch failed'); + return errorResponse(error); } }); diff --git a/src/app/api/portal/documents/route.ts b/src/app/api/portal/documents/route.ts index 29f057a..4fe1244 100644 --- a/src/app/api/portal/documents/route.ts +++ b/src/app/api/portal/documents/route.ts @@ -1,15 +1,16 @@ import { NextResponse } from 'next/server'; +import { errorResponse } from '@/lib/errors'; +import { logger } from '@/lib/logger'; import { withPortalAuth } from '@/lib/portal/helpers'; import { getClientDocuments } from '@/lib/services/portal.service'; -import { logger } from '@/lib/logger'; export const GET = withPortalAuth(async (_req, session) => { try { const data = await getClientDocuments(session.clientId, session.portId); return NextResponse.json({ data }); } catch (error) { - logger.error({ error }, 'Portal documents fetch failed'); - return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 }); + logger.error({ err: error }, 'Portal documents fetch failed'); + return errorResponse(error); } }); diff --git a/src/app/api/portal/interests/route.ts b/src/app/api/portal/interests/route.ts index be8c3bd..bf91317 100644 --- a/src/app/api/portal/interests/route.ts +++ b/src/app/api/portal/interests/route.ts @@ -1,15 +1,16 @@ import { NextResponse } from 'next/server'; +import { errorResponse } from '@/lib/errors'; +import { logger } from '@/lib/logger'; import { withPortalAuth } from '@/lib/portal/helpers'; import { getClientInterests } from '@/lib/services/portal.service'; -import { logger } from '@/lib/logger'; export const GET = withPortalAuth(async (_req, session) => { try { const data = await getClientInterests(session.clientId, session.portId); return NextResponse.json({ data }); } catch (error) { - logger.error({ error }, 'Portal interests fetch failed'); - return NextResponse.json({ error: 'Failed to load interests' }, { status: 500 }); + logger.error({ err: error }, 'Portal interests fetch failed'); + return errorResponse(error); } }); diff --git a/src/app/api/portal/invoices/route.ts b/src/app/api/portal/invoices/route.ts index 4d86d64..c002a01 100644 --- a/src/app/api/portal/invoices/route.ts +++ b/src/app/api/portal/invoices/route.ts @@ -1,15 +1,16 @@ import { NextResponse } from 'next/server'; +import { errorResponse } from '@/lib/errors'; +import { logger } from '@/lib/logger'; import { withPortalAuth } from '@/lib/portal/helpers'; import { getClientInvoices } from '@/lib/services/portal.service'; -import { logger } from '@/lib/logger'; export const GET = withPortalAuth(async (_req, session) => { try { const data = await getClientInvoices(session.clientId, session.portId); return NextResponse.json({ data }); } catch (error) { - logger.error({ error }, 'Portal invoices fetch failed'); - return NextResponse.json({ error: 'Failed to load invoices' }, { status: 500 }); + logger.error({ err: error }, 'Portal invoices fetch failed'); + return errorResponse(error); } }); diff --git a/src/app/api/public/berths/[mooringNumber]/route.ts b/src/app/api/public/berths/[mooringNumber]/route.ts index 1b6b1f2..6dc5672 100644 --- a/src/app/api/public/berths/[mooringNumber]/route.ts +++ b/src/app/api/public/berths/[mooringNumber]/route.ts @@ -5,6 +5,7 @@ import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { berths, berthMapData } from '@/lib/db/schema/berths'; import { interestBerths, interests } from '@/lib/db/schema/interests'; +import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { toPublicBerth } from '@/lib/services/public-berths'; @@ -32,77 +33,81 @@ export async function GET( request: Request, ctx: { params: Promise<{ mooringNumber: string }> }, ): Promise { - const { mooringNumber } = await ctx.params; - const url = new URL(request.url); - const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG; - if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) { - return NextResponse.json( - { error: 'port is not part of the public berths feed', portSlug: requestedSlug }, - { status: 404, headers: { 'cache-control': 'no-store' } }, - ); + try { + const { mooringNumber } = await ctx.params; + const url = new URL(request.url); + const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG; + if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) { + return NextResponse.json( + { error: 'port is not part of the public berths feed', portSlug: requestedSlug }, + { status: 404, headers: { 'cache-control': 'no-store' } }, + ); + } + const portSlug = requestedSlug; + + // Reject obviously malformed mooring numbers up front so cache poisoning + // / random-URL probing returns 400 rather than 404 (saves a DB hit). + if (!MOORING_PATTERN.test(mooringNumber)) { + return NextResponse.json( + { error: 'invalid mooring number', mooringNumber }, + { status: 400, headers: { 'cache-control': 'no-store' } }, + ); + } + + const [port] = await db + .select({ id: ports.id }) + .from(ports) + .where(eq(ports.slug, portSlug)) + .limit(1); + if (!port) { + return NextResponse.json( + { error: 'port not found', portSlug }, + { status: 404, headers: { 'cache-control': 'no-store' } }, + ); + } + + const [berth] = await db + .select() + .from(berths) + .where(and(eq(berths.portId, port.id), eq(berths.mooringNumber, mooringNumber))) + .limit(1); + + if (!berth) { + return NextResponse.json( + { error: 'berth not found', mooringNumber }, + { status: 404, headers: { 'cache-control': 'no-store' } }, + ); + } + + const [mapData, specificInterestRows] = await Promise.all([ + db.select().from(berthMapData).where(eq(berthMapData.berthId, berth.id)).limit(1), + db + .select({ berthId: interestBerths.berthId }) + .from(interestBerths) + .innerJoin(interests, eq(interests.id, interestBerths.interestId)) + .where( + and( + eq(interestBerths.berthId, berth.id), + eq(interestBerths.isSpecificInterest, true), + isNull(interests.archivedAt), + // Closed deals (won/lost/cancelled) don't promote to "Under + // Offer" - won flows through berths.status='sold' handled in + // derivePublicStatus; lost/cancelled means back on the market. + isNull(interests.outcome), + ), + ) + .limit(1), + ]); + + const out = toPublicBerth(berth, mapData[0] ?? null, specificInterestRows.length > 0); + + if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') { + logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range'); + throw new Error('Public berth status out of range'); + } + + return new Response(JSON.stringify(out), { headers: RESPONSE_HEADERS, status: 200 }); + } catch (err) { + return errorResponse(err); } - const portSlug = requestedSlug; - - // Reject obviously malformed mooring numbers up front so cache poisoning - // / random-URL probing returns 400 rather than 404 (saves a DB hit). - if (!MOORING_PATTERN.test(mooringNumber)) { - return NextResponse.json( - { error: 'invalid mooring number', mooringNumber }, - { status: 400, headers: { 'cache-control': 'no-store' } }, - ); - } - - const [port] = await db - .select({ id: ports.id }) - .from(ports) - .where(eq(ports.slug, portSlug)) - .limit(1); - if (!port) { - return NextResponse.json( - { error: 'port not found', portSlug }, - { status: 404, headers: { 'cache-control': 'no-store' } }, - ); - } - - const [berth] = await db - .select() - .from(berths) - .where(and(eq(berths.portId, port.id), eq(berths.mooringNumber, mooringNumber))) - .limit(1); - - if (!berth) { - return NextResponse.json( - { error: 'berth not found', mooringNumber }, - { status: 404, headers: { 'cache-control': 'no-store' } }, - ); - } - - const [mapData, specificInterestRows] = await Promise.all([ - db.select().from(berthMapData).where(eq(berthMapData.berthId, berth.id)).limit(1), - db - .select({ berthId: interestBerths.berthId }) - .from(interestBerths) - .innerJoin(interests, eq(interests.id, interestBerths.interestId)) - .where( - and( - eq(interestBerths.berthId, berth.id), - eq(interestBerths.isSpecificInterest, true), - isNull(interests.archivedAt), - // Closed deals (won/lost/cancelled) don't promote to "Under - // Offer" - won flows through berths.status='sold' handled in - // derivePublicStatus; lost/cancelled means back on the market. - isNull(interests.outcome), - ), - ) - .limit(1), - ]); - - const out = toPublicBerth(berth, mapData[0] ?? null, specificInterestRows.length > 0); - - if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') { - logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range'); - return NextResponse.json({ error: 'internal' }, { status: 500 }); - } - - return new Response(JSON.stringify(out), { headers: RESPONSE_HEADERS, status: 200 }); } diff --git a/src/app/api/public/berths/route.ts b/src/app/api/public/berths/route.ts index 8c8f27c..53e9b2c 100644 --- a/src/app/api/public/berths/route.ts +++ b/src/app/api/public/berths/route.ts @@ -5,6 +5,7 @@ import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { berths, berthMapData } from '@/lib/db/schema/berths'; import { interestBerths, interests } from '@/lib/db/schema/interests'; +import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { toPublicBerth, type PublicBerth } from '@/lib/services/public-berths'; @@ -48,98 +49,99 @@ interface ListResponse { } export async function GET(request: Request): Promise { - const url = new URL(request.url); - const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG; - if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) { - return NextResponse.json( - { error: 'port is not part of the public berths feed', portSlug: requestedSlug }, - { status: 404, headers: { 'cache-control': 'no-store' } }, - ); - } - const portSlug = requestedSlug; - - const [port] = await db - .select({ id: ports.id }) - .from(ports) - .where(eq(ports.slug, portSlug)) - .limit(1); - if (!port) { - return NextResponse.json( - { error: 'port not found', portSlug }, - { status: 404, headers: { 'cache-control': 'no-store' } }, - ); - } - - // 1. Active berths for the port (archived would be an explicit field - // once we add one - today we don't have an archived_at on berths, - // so we surface every row except those marked status='sold' on - // request? No: §4.5 says "filters out berths archived in CRM". - // The current schema has no archived flag for berths, so this is - // a no-op today; future archive flag plugs in here. - const berthRows = await db.select().from(berths).where(eq(berths.portId, port.id)); - - if (berthRows.length === 0) { - return jsonResponse({ list: [], pageInfo: emptyPageInfo() }); - } - - const berthIds = berthRows.map((b) => b.id); - - // 2. Bulk-fetch map_data + the "has specific-interest link" flag. - const [mapRows, specificInterestRows] = await Promise.all([ - db.select().from(berthMapData).where(inArray(berthMapData.berthId, berthIds)), - db - .selectDistinct({ berthId: interestBerths.berthId }) - .from(interestBerths) - .innerJoin(interests, eq(interests.id, interestBerths.interestId)) - .where( - and( - inArray(interestBerths.berthId, berthIds), - eq(interestBerths.isSpecificInterest, true), - isNull(interests.archivedAt), - // Don't promote a berth to "Under Offer" when the only specific- - // interest link is a closed deal. `won` flips happen via - // berths.status='sold' (handled in derivePublicStatus). Lost/ - // cancelled outcomes mean the berth is back on the market. - isNull(interests.outcome), - ), - ), - ]); - - const mapByBerth = new Map(mapRows.map((m) => [m.berthId, m])); - const specificInterestSet = new Set(specificInterestRows.map((r) => r.berthId)); - - const list = berthRows.map((b) => - toPublicBerth(b, mapByBerth.get(b.id) ?? null, specificInterestSet.has(b.id)), - ); - - // Validate the response enum before returning - any unknown status - // value would hit a 500 (per §14.8) rather than silently shipping - // invalid data downstream. - for (const row of list) { - if (row.Status !== 'Available' && row.Status !== 'Under Offer' && row.Status !== 'Sold') { - // Log just the identifying fields - never the full berth row, which - // includes price + amenity columns that don't belong in error logs. - logger.error( - { berthId: row.Id, mooringNumber: row['Mooring Number'], status: row.Status }, - 'Public berth status out of range', - ); + try { + const url = new URL(request.url); + const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG; + if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) { return NextResponse.json( - { error: 'internal', detail: 'berth status enum drift' }, - { status: 500 }, + { error: 'port is not part of the public berths feed', portSlug: requestedSlug }, + { status: 404, headers: { 'cache-control': 'no-store' } }, ); } - } + const portSlug = requestedSlug; - return jsonResponse({ - list, - pageInfo: { - totalRows: list.length, - page: 1, - pageSize: list.length, - isFirstPage: true, - isLastPage: true, - }, - }); + const [port] = await db + .select({ id: ports.id }) + .from(ports) + .where(eq(ports.slug, portSlug)) + .limit(1); + if (!port) { + return NextResponse.json( + { error: 'port not found', portSlug }, + { status: 404, headers: { 'cache-control': 'no-store' } }, + ); + } + + // 1. Active berths for the port (archived would be an explicit field + // once we add one - today we don't have an archived_at on berths, + // so we surface every row except those marked status='sold' on + // request? No: §4.5 says "filters out berths archived in CRM". + // The current schema has no archived flag for berths, so this is + // a no-op today; future archive flag plugs in here. + const berthRows = await db.select().from(berths).where(eq(berths.portId, port.id)); + + if (berthRows.length === 0) { + return jsonResponse({ list: [], pageInfo: emptyPageInfo() }); + } + + const berthIds = berthRows.map((b) => b.id); + + // 2. Bulk-fetch map_data + the "has specific-interest link" flag. + const [mapRows, specificInterestRows] = await Promise.all([ + db.select().from(berthMapData).where(inArray(berthMapData.berthId, berthIds)), + db + .selectDistinct({ berthId: interestBerths.berthId }) + .from(interestBerths) + .innerJoin(interests, eq(interests.id, interestBerths.interestId)) + .where( + and( + inArray(interestBerths.berthId, berthIds), + eq(interestBerths.isSpecificInterest, true), + isNull(interests.archivedAt), + // Don't promote a berth to "Under Offer" when the only specific- + // interest link is a closed deal. `won` flips happen via + // berths.status='sold' (handled in derivePublicStatus). Lost/ + // cancelled outcomes mean the berth is back on the market. + isNull(interests.outcome), + ), + ), + ]); + + const mapByBerth = new Map(mapRows.map((m) => [m.berthId, m])); + const specificInterestSet = new Set(specificInterestRows.map((r) => r.berthId)); + + const list = berthRows.map((b) => + toPublicBerth(b, mapByBerth.get(b.id) ?? null, specificInterestSet.has(b.id)), + ); + + // Validate the response enum before returning - any unknown status + // value would hit a 500 (per §14.8) rather than silently shipping + // invalid data downstream. + for (const row of list) { + if (row.Status !== 'Available' && row.Status !== 'Under Offer' && row.Status !== 'Sold') { + // Log just the identifying fields - never the full berth row, which + // includes price + amenity columns that don't belong in error logs. + logger.error( + { berthId: row.Id, mooringNumber: row['Mooring Number'], status: row.Status }, + 'Public berth status out of range', + ); + throw new Error('Public berth status out of range'); + } + } + + return jsonResponse({ + list, + pageInfo: { + totalRows: list.length, + page: 1, + pageSize: list.length, + isFirstPage: true, + isLastPage: true, + }, + }); + } catch (err) { + return errorResponse(err); + } } function jsonResponse(body: ListResponse): Response { diff --git a/src/app/api/public/interests/route.ts b/src/app/api/public/interests/route.ts index a054a5c..213abe2 100644 --- a/src/app/api/public/interests/route.ts +++ b/src/app/api/public/interests/route.ts @@ -11,7 +11,7 @@ import { ports } from '@/lib/db/schema/ports'; import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts'; import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { createAuditLog } from '@/lib/audit'; -import { errorResponse, RateLimitError } from '@/lib/errors'; +import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; import { publicInterestSchema } from '@/lib/validators/interests'; import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service'; @@ -49,9 +49,7 @@ export async function POST(req: NextRequest) { // Resolve portId from query param or header (public endpoints need explicit port) const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id'); - if (!portId) { - return NextResponse.json({ error: 'Port context required' }, { status: 400 }); - } + if (!portId) throw new ValidationError('Port context required'); // Server-side phone normalization for older website builds that post raw // international/national strings. Newer builds may pre-fill phoneE164/Country. diff --git a/src/app/api/public/website-inquiries/route.ts b/src/app/api/public/website-inquiries/route.ts index fc9bd79..83e06e0 100644 --- a/src/app/api/public/website-inquiries/route.ts +++ b/src/app/api/public/website-inquiries/route.ts @@ -7,6 +7,7 @@ import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; import { env } from '@/lib/env'; +import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; @@ -173,5 +174,5 @@ export async function POST(req: NextRequest) { { submissionId: parsed.submission_id }, 'website-inquiry conflict but row not found on lookup', ); - return NextResponse.json({ error: 'Insert failed' }, { status: 500 }); + return errorResponse(new Error('website-inquiry conflict but row not found on lookup')); } diff --git a/src/app/api/storage/[token]/route.ts b/src/app/api/storage/[token]/route.ts index 70f9e8d..6231b0b 100644 --- a/src/app/api/storage/[token]/route.ts +++ b/src/app/api/storage/[token]/route.ts @@ -21,6 +21,7 @@ import { Readable } from 'node:stream'; import { NextRequest, NextResponse } from 'next/server'; import { MAX_FILE_SIZE } from '@/lib/constants/file-validation'; +import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { redis } from '@/lib/redis'; import { FilesystemBackend, getStorageBackend } from '@/lib/storage'; @@ -229,7 +230,7 @@ export async function PUT( }); } catch (err) { logger.error({ err, key: payload.k }, 'Storage proxy upload write failed'); - return NextResponse.json({ error: 'Upload write failed' }, { status: 500 }); + return errorResponse(err); } return NextResponse.json({ ok: true, key: payload.k, sizeBytes: buffer.length }, { status: 200 }); diff --git a/src/app/api/v1/admin/alerts/run-engine/route.ts b/src/app/api/v1/admin/alerts/run-engine/route.ts index 169e90c..10fec2f 100644 --- a/src/app/api/v1/admin/alerts/run-engine/route.ts +++ b/src/app/api/v1/admin/alerts/run-engine/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { runAlertEngineForPorts } from '@/lib/services/alert-engine'; @@ -14,9 +14,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine'; */ export const POST = withAuth(async (_req, ctx) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Super admin only' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'admin.alerts.run-engine'); const summary = await runAlertEngineForPorts([ctx.portId]); return NextResponse.json({ data: summary }); } catch (error) { diff --git a/src/app/api/v1/admin/connections/route.ts b/src/app/api/v1/admin/connections/route.ts index 4467381..a2234f0 100644 --- a/src/app/api/v1/admin/connections/route.ts +++ b/src/app/api/v1/admin/connections/route.ts @@ -1,14 +1,12 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { getActiveConnections } from '@/lib/services/system-monitoring.service'; export const GET = withAuth(async (_req, ctx) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'admin.connections.read'); const connections = await getActiveConnections(); return NextResponse.json({ data: connections }); diff --git a/src/app/api/v1/admin/duplicates/handlers.ts b/src/app/api/v1/admin/duplicates/handlers.ts index 59182be..49182f5 100644 --- a/src/app/api/v1/admin/duplicates/handlers.ts +++ b/src/app/api/v1/admin/duplicates/handlers.ts @@ -4,7 +4,7 @@ import { and, eq, inArray } from 'drizzle-orm'; import type { AuthContext } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { clients, clientMergeCandidates } from '@/lib/db/schema/clients'; -import { errorResponse, NotFoundError } from '@/lib/errors'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; import { listPendingMergeCandidates, mergeClients, @@ -81,7 +81,7 @@ export async function confirmMergeHandler( fieldChoices?: MergeFieldChoices; }; if (!body.winnerId) { - return NextResponse.json({ error: 'winnerId required' }, { status: 400 }); + throw new ValidationError('winnerId is required'); } const [candidate] = await db @@ -103,10 +103,7 @@ export async function confirmMergeHandler( ? candidate.clientAId : null; if (!loserId) { - return NextResponse.json( - { error: 'winnerId must match one of the candidate clients' }, - { status: 400 }, - ); + throw new ValidationError('winnerId must match one of the candidate clients'); } const result = await mergeClients({ diff --git a/src/app/api/v1/admin/errors/route.ts b/src/app/api/v1/admin/errors/route.ts index ff8fba8..ec1f829 100644 --- a/src/app/api/v1/admin/errors/route.ts +++ b/src/app/api/v1/admin/errors/route.ts @@ -1,14 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { getRecentErrors } from '@/lib/services/system-monitoring.service'; export const GET = withAuth(async (req: NextRequest, ctx) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'admin.errors.list'); const url = new URL(req.url); const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10))); diff --git a/src/app/api/v1/admin/health/route.ts b/src/app/api/v1/admin/health/route.ts index 61f3493..eecc890 100644 --- a/src/app/api/v1/admin/health/route.ts +++ b/src/app/api/v1/admin/health/route.ts @@ -1,14 +1,12 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { healthCheck } from '@/lib/services/system-monitoring.service'; export const GET = withAuth(async (_req, ctx) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'admin.health.read'); const status = await healthCheck(); return NextResponse.json({ data: status }); diff --git a/src/app/api/v1/admin/invitations/[id]/resend/route.ts b/src/app/api/v1/admin/invitations/[id]/resend/route.ts index da39a76..ba47fb4 100644 --- a/src/app/api/v1/admin/invitations/[id]/resend/route.ts +++ b/src/app/api/v1/admin/invitations/[id]/resend/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; -import { withAuth, withPermission } from '@/lib/api/helpers'; -import { errorResponse, ForbiddenError } from '@/lib/errors'; +import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; import { resendCrmInvite } from '@/lib/services/crm-invite.service'; // Resend mints a fresh token + new email on a global invite row; @@ -10,9 +10,7 @@ import { resendCrmInvite } from '@/lib/services/crm-invite.service'; export const POST = withAuth( withPermission('admin', 'manage_users', async (_req, ctx, params) => { try { - if (!ctx.isSuperAdmin) { - throw new ForbiddenError('Resending CRM invites requires super-admin'); - } + requireSuperAdmin(ctx, 'admin.invitations.resend'); const id = params.id ?? ''; const result = await resendCrmInvite(id, { userId: ctx.userId, diff --git a/src/app/api/v1/admin/invitations/[id]/route.ts b/src/app/api/v1/admin/invitations/[id]/route.ts index 05f838e..dee4d6e 100644 --- a/src/app/api/v1/admin/invitations/[id]/route.ts +++ b/src/app/api/v1/admin/invitations/[id]/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; -import { withAuth, withPermission } from '@/lib/api/helpers'; -import { errorResponse, ForbiddenError } from '@/lib/errors'; +import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; import { revokeCrmInvite } from '@/lib/services/crm-invite.service'; // Invites are a global resource (no portId column). Revoking a foreign @@ -10,9 +10,7 @@ import { revokeCrmInvite } from '@/lib/services/crm-invite.service'; export const DELETE = withAuth( withPermission('admin', 'manage_users', async (_req, ctx, params) => { try { - if (!ctx.isSuperAdmin) { - throw new ForbiddenError('Revoking CRM invites requires super-admin'); - } + requireSuperAdmin(ctx, 'admin.invitations.revoke'); const id = params.id ?? ''; await revokeCrmInvite(id, { userId: ctx.userId, diff --git a/src/app/api/v1/admin/invitations/route.ts b/src/app/api/v1/admin/invitations/route.ts index ad2b624..9eff32a 100644 --- a/src/app/api/v1/admin/invitations/route.ts +++ b/src/app/api/v1/admin/invitations/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; -import { withAuth, withPermission } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse, ForbiddenError } from '@/lib/errors'; import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service'; @@ -14,9 +14,7 @@ export const GET = withAuth( // port. Listing it cross-tenant would let a port-A director // enumerate pending invitee emails, names, and isSuperAdmin flags // for every other tenant. Restrict the listing to super-admins. - if (!ctx.isSuperAdmin) { - throw new ForbiddenError('Listing CRM invites requires super-admin'); - } + requireSuperAdmin(ctx, 'admin.invitations.list'); const data = await listCrmInvites(); return NextResponse.json({ data }); } catch (error) { diff --git a/src/app/api/v1/admin/ocr-settings/route.ts b/src/app/api/v1/admin/ocr-settings/route.ts index 01e40d1..8ceaf52 100644 --- a/src/app/api/v1/admin/ocr-settings/route.ts +++ b/src/app/api/v1/admin/ocr-settings/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; -import { withAuth, withPermission } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service'; const saveSchema = z.object({ @@ -27,8 +27,8 @@ export const GET = withAuth( try { const url = new URL(req.url); const scope = url.searchParams.get('scope') ?? 'port'; - if (scope === 'global' && !ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Super admin only' }, { status: 403 }); + if (scope === 'global') { + requireSuperAdmin(ctx, 'admin.ocr-settings.read.global'); } const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId); return NextResponse.json({ data: config, models: OCR_MODELS }); @@ -42,15 +42,12 @@ export const PUT = withAuth( withPermission('admin', 'manage_settings', async (req, ctx) => { try { const body = await parseBody(req, saveSchema); - if (body.scope === 'global' && !ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Super admin only' }, { status: 403 }); + if (body.scope === 'global') { + requireSuperAdmin(ctx, 'admin.ocr-settings.write.global'); } const validModels = OCR_MODELS[body.provider]; if (!validModels.includes(body.model)) { - return NextResponse.json( - { error: `Invalid model for provider ${body.provider}` }, - { status: 400 }, - ); + throw new ValidationError(`Invalid model for provider ${body.provider}`); } await saveOcrConfig( body.scope === 'global' ? null : ctx.portId, diff --git a/src/app/api/v1/admin/ocr-settings/test/route.ts b/src/app/api/v1/admin/ocr-settings/test/route.ts index 03bf3da..3defed6 100644 --- a/src/app/api/v1/admin/ocr-settings/test/route.ts +++ b/src/app/api/v1/admin/ocr-settings/test/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { OCR_MODELS } from '@/lib/services/ocr-config.service'; import { testProvider } from '@/lib/services/ocr-providers'; @@ -20,7 +20,7 @@ export const POST = withAuth( try { const body = await parseBody(req, schema); if (!OCR_MODELS[body.provider].includes(body.model)) { - return NextResponse.json({ error: 'Invalid model' }, { status: 400 }); + throw new ValidationError('Invalid model'); } const result = await testProvider(body.provider, body.apiKey, body.model); return NextResponse.json(result); diff --git a/src/app/api/v1/admin/ports/route.ts b/src/app/api/v1/admin/ports/route.ts index 45448da..e92a862 100644 --- a/src/app/api/v1/admin/ports/route.ts +++ b/src/app/api/v1/admin/ports/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from 'next/server'; -import { withAuth, withPermission } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { listPorts, createPort } from '@/lib/services/ports.service'; import { createPortSchema } from '@/lib/validators/ports'; -import { errorResponse, ForbiddenError } from '@/lib/errors'; +import { errorResponse } from '@/lib/errors'; // Listing every tenant and creating new tenants are super-admin operations: // a port director must not be able to enumerate other ports (target @@ -13,9 +13,7 @@ import { errorResponse, ForbiddenError } from '@/lib/errors'; export const GET = withAuth( withPermission('admin', 'manage_settings', async (_req, ctx) => { try { - if (!ctx.isSuperAdmin) { - throw new ForbiddenError('Listing all ports requires super-admin'); - } + requireSuperAdmin(ctx, 'admin.ports.list'); const data = await listPorts(); return NextResponse.json({ data }); } catch (error) { @@ -27,9 +25,7 @@ export const GET = withAuth( export const POST = withAuth( withPermission('admin', 'manage_settings', async (req, ctx) => { try { - if (!ctx.isSuperAdmin) { - throw new ForbiddenError('Creating ports requires super-admin'); - } + requireSuperAdmin(ctx, 'admin.ports.create'); const body = await parseBody(req, createPortSchema); const data = await createPort(body, { userId: ctx.userId, diff --git a/src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts b/src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts index baf2d73..d27e487 100644 --- a/src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts +++ b/src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts @@ -1,24 +1,22 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; -import { errorResponse } from '@/lib/errors'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { retryJob } from '@/lib/services/system-monitoring.service'; import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue'; export const POST = withAuth(async (_req, ctx, params) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'admin.queues.job.retry'); const queueName = params['queueName']; const jobId = params['jobId']; if (!queueName || !jobId) { - return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }); + throw new ValidationError('queueName and jobId are required'); } const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[]; if (!validQueues.includes(queueName as QueueName)) { - return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 }); + throw new ValidationError('Invalid queue name'); } await retryJob(queueName as QueueName, jobId, ctx.userId); diff --git a/src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts b/src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts index 481fc6e..5afcecd 100644 --- a/src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts +++ b/src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts @@ -1,24 +1,22 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; -import { errorResponse } from '@/lib/errors'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { deleteJob } from '@/lib/services/system-monitoring.service'; import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue'; export const DELETE = withAuth(async (_req, ctx, params) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'admin.queues.job.delete'); const queueName = params['queueName']; const jobId = params['jobId']; if (!queueName || !jobId) { - return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }); + throw new ValidationError('queueName and jobId are required'); } const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[]; if (!validQueues.includes(queueName as QueueName)) { - return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 }); + throw new ValidationError('Invalid queue name'); } await deleteJob(queueName as QueueName, jobId, ctx.userId); diff --git a/src/app/api/v1/admin/queues/[queueName]/route.ts b/src/app/api/v1/admin/queues/[queueName]/route.ts index 7941094..6c17950 100644 --- a/src/app/api/v1/admin/queues/[queueName]/route.ts +++ b/src/app/api/v1/admin/queues/[queueName]/route.ts @@ -1,20 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; -import { errorResponse } from '@/lib/errors'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { getQueueJobs } from '@/lib/services/system-monitoring.service'; import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue'; export const GET = withAuth(async (req: NextRequest, ctx, params) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'admin.queues.jobs.list'); const { queueName } = params; const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[]; if (!validQueues.includes(queueName as QueueName)) { - return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 }); + throw new ValidationError('Invalid queue name'); } const url = new URL(req.url); diff --git a/src/app/api/v1/admin/queues/route.ts b/src/app/api/v1/admin/queues/route.ts index 5d1956a..d420098 100644 --- a/src/app/api/v1/admin/queues/route.ts +++ b/src/app/api/v1/admin/queues/route.ts @@ -1,14 +1,12 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { getQueueDashboard } from '@/lib/services/system-monitoring.service'; export const GET = withAuth(async (_req, ctx) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'admin.queues.list'); const queues = await getQueueDashboard(); return NextResponse.json({ data: queues }); diff --git a/src/app/api/v1/admin/storage/migrate/route.ts b/src/app/api/v1/admin/storage/migrate/route.ts index 4b00fa6..a315aed 100644 --- a/src/app/api/v1/admin/storage/migrate/route.ts +++ b/src/app/api/v1/admin/storage/migrate/route.ts @@ -10,9 +10,9 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; -import { withAuth } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; -import { errorResponse, ForbiddenError } from '@/lib/errors'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { runMigration } from '@/lib/storage/migrate'; const schema = z.object({ @@ -25,12 +25,10 @@ export const runtime = 'nodejs'; export const POST = withAuth(async (req, ctx) => { try { - if (!ctx.isSuperAdmin) { - throw new ForbiddenError('Super admin only'); - } + requireSuperAdmin(ctx, 'admin.storage.migrate'); const body = await parseBody(req, schema); if (body.from === body.to) { - return NextResponse.json({ error: 'from and to must differ' }, { status: 400 }); + throw new ValidationError('from and to must differ'); } const result = await runMigration({ ...body, userId: ctx.userId }); return NextResponse.json({ data: result }); diff --git a/src/app/api/v1/admin/storage/route.ts b/src/app/api/v1/admin/storage/route.ts index 5413d95..d9da177 100644 --- a/src/app/api/v1/admin/storage/route.ts +++ b/src/app/api/v1/admin/storage/route.ts @@ -7,8 +7,8 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; -import { errorResponse, ForbiddenError } from '@/lib/errors'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; import { TABLES_WITH_STORAGE_KEYS } from '@/lib/storage/migrate'; import { getStorageBackend } from '@/lib/storage'; import { S3Backend } from '@/lib/storage/s3'; @@ -19,9 +19,7 @@ export const runtime = 'nodejs'; export const GET = withAuth(async (_req, ctx) => { try { - if (!ctx.isSuperAdmin) { - throw new ForbiddenError('Super admin only'); - } + requireSuperAdmin(ctx, 'admin.storage.read'); const backend = await getStorageBackend(); // Aggregate row count + total bytes across every storage-bearing table. @@ -54,9 +52,7 @@ export const GET = withAuth(async (_req, ctx) => { export const POST = withAuth(async (_req, ctx) => { try { - if (!ctx.isSuperAdmin) { - throw new ForbiddenError('Super admin only'); - } + requireSuperAdmin(ctx, 'admin.storage.test'); const backend = await getStorageBackend(); if (!(backend instanceof S3Backend)) { return NextResponse.json( diff --git a/src/app/api/v1/ai/email-draft/[jobId]/route.ts b/src/app/api/v1/ai/email-draft/[jobId]/route.ts index c958363..a55e958 100644 --- a/src/app/api/v1/ai/email-draft/[jobId]/route.ts +++ b/src/app/api/v1/ai/email-draft/[jobId]/route.ts @@ -2,14 +2,12 @@ import { NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/helpers'; import { getEmailDraftResult } from '@/lib/services/email-draft.service'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ValidationError } from '@/lib/errors'; export const GET = withAuth(async (_req, ctx, params) => { try { const { jobId } = params; - if (!jobId) { - return NextResponse.json({ error: 'jobId is required' }, { status: 400 }); - } + if (!jobId) throw new ValidationError('jobId is required'); const result = await getEmailDraftResult(jobId, { userId: ctx.userId, diff --git a/src/app/api/v1/ai/email-draft/route.ts b/src/app/api/v1/ai/email-draft/route.ts index 485b841..2beef71 100644 --- a/src/app/api/v1/ai/email-draft/route.ts +++ b/src/app/api/v1/ai/email-draft/route.ts @@ -7,19 +7,18 @@ import { systemSettings } from '@/lib/db/schema/system'; import { requestEmailDraft } from '@/lib/services/email-draft.service'; import { parseBody } from '@/lib/api/route-helpers'; import { requestDraftSchema } from '@/lib/validators/ai'; -import { errorResponse } from '@/lib/errors'; +import { CodedError, errorResponse } from '@/lib/errors'; export const POST = withAuth(async (req, ctx) => { try { // Feature flag check const flag = await db.query.systemSettings.findFirst({ - where: and( - eq(systemSettings.key, 'ai_email_drafts'), - eq(systemSettings.portId, ctx.portId), - ), + where: and(eq(systemSettings.key, 'ai_email_drafts'), eq(systemSettings.portId, ctx.portId)), }); if (flag?.value !== true) { - return NextResponse.json({ error: 'Feature not available' }, { status: 404 }); + throw new CodedError('NOT_FOUND', { + internalMessage: 'AI email-draft feature flag disabled for this port', + }); } const body = await parseBody(req, requestDraftSchema); diff --git a/src/app/api/v1/ai/interest-score/bulk/route.ts b/src/app/api/v1/ai/interest-score/bulk/route.ts index 3877ea0..82c2412 100644 --- a/src/app/api/v1/ai/interest-score/bulk/route.ts +++ b/src/app/api/v1/ai/interest-score/bulk/route.ts @@ -5,7 +5,7 @@ import { withAuth } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; import { calculateBulkScores } from '@/lib/services/interest-scoring.service'; -import { errorResponse } from '@/lib/errors'; +import { CodedError, errorResponse } from '@/lib/errors'; export const GET = withAuth(async (_req, ctx) => { try { @@ -17,7 +17,9 @@ export const GET = withAuth(async (_req, ctx) => { ), }); if (flag?.value !== true) { - return NextResponse.json({ error: 'Feature not available' }, { status: 404 }); + throw new CodedError('NOT_FOUND', { + internalMessage: 'AI bulk interest-score feature flag disabled for this port', + }); } const scores = await calculateBulkScores(ctx.portId); diff --git a/src/app/api/v1/ai/interest-score/route.ts b/src/app/api/v1/ai/interest-score/route.ts index 6acee7f..5d4a862 100644 --- a/src/app/api/v1/ai/interest-score/route.ts +++ b/src/app/api/v1/ai/interest-score/route.ts @@ -7,7 +7,7 @@ import { systemSettings } from '@/lib/db/schema/system'; import { calculateInterestScore } from '@/lib/services/interest-scoring.service'; import { parseQuery } from '@/lib/api/route-helpers'; import { requestScoreSchema } from '@/lib/validators/ai'; -import { errorResponse } from '@/lib/errors'; +import { CodedError, errorResponse } from '@/lib/errors'; export const GET = withAuth(async (req, ctx) => { try { @@ -19,7 +19,9 @@ export const GET = withAuth(async (req, ctx) => { ), }); if (flag?.value !== true) { - return NextResponse.json({ error: 'Feature not available' }, { status: 404 }); + throw new CodedError('NOT_FOUND', { + internalMessage: 'AI interest-score feature flag disabled for this port', + }); } const { interestId } = parseQuery(req, requestScoreSchema); diff --git a/src/app/api/v1/alerts/[id]/acknowledge/route.ts b/src/app/api/v1/alerts/[id]/acknowledge/route.ts index 5da53e6..28ef846 100644 --- a/src/app/api/v1/alerts/[id]/acknowledge/route.ts +++ b/src/app/api/v1/alerts/[id]/acknowledge/route.ts @@ -1,11 +1,16 @@ import { NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { acknowledgeAlert } from '@/lib/services/alerts.service'; export const POST = withAuth(async (_req, ctx, params) => { - const id = params.id; - if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }); - await acknowledgeAlert(id, ctx.portId, ctx.userId); - return NextResponse.json({ ok: true }); + try { + const id = params.id; + if (!id) throw new ValidationError('id is required'); + await acknowledgeAlert(id, ctx.portId, ctx.userId); + return NextResponse.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } }); diff --git a/src/app/api/v1/alerts/[id]/dismiss/route.ts b/src/app/api/v1/alerts/[id]/dismiss/route.ts index 6319789..a1b14a9 100644 --- a/src/app/api/v1/alerts/[id]/dismiss/route.ts +++ b/src/app/api/v1/alerts/[id]/dismiss/route.ts @@ -1,11 +1,16 @@ import { NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { dismissAlert } from '@/lib/services/alerts.service'; export const POST = withAuth(async (_req, ctx, params) => { - const id = params.id; - if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }); - await dismissAlert(id, ctx.portId, ctx.userId); - return NextResponse.json({ ok: true }); + try { + const id = params.id; + if (!id) throw new ValidationError('id is required'); + await dismissAlert(id, ctx.portId, ctx.userId); + return NextResponse.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } }); diff --git a/src/app/api/v1/analytics/route.ts b/src/app/api/v1/analytics/route.ts index f5e2d87..eeaa01e 100644 --- a/src/app/api/v1/analytics/route.ts +++ b/src/app/api/v1/analytics/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { ALL_RANGES, getLeadSourceAttribution, @@ -23,68 +24,63 @@ const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/; export const GET = withAuth( withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => { - const url = new URL(req.url); - const metric = url.searchParams.get('metric') as MetricBase | null; - const rawRange = url.searchParams.get('range') ?? '30d'; - const fromParam = url.searchParams.get('from'); - const toParam = url.searchParams.get('to'); + try { + const url = new URL(req.url); + const metric = url.searchParams.get('metric') as MetricBase | null; + const rawRange = url.searchParams.get('range') ?? '30d'; + const fromParam = url.searchParams.get('from'); + const toParam = url.searchParams.get('to'); - if (!metric || !(metric in METRICS)) { - return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 }); - } + if (!metric || !(metric in METRICS)) { + throw new ValidationError('Invalid or missing metric'); + } - let range: DateRange; - if (rawRange === 'custom') { - if (!fromParam || !toParam) { - return NextResponse.json( - { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' }, - { status: 400 }, - ); - } - if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) { - return NextResponse.json( - { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' }, - { status: 400 }, - ); - } - if (fromParam > toParam) { - return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 }); - } - // Round-trip date check: regex passes "9999-13-99" or "2026-02-31" - // (rolls over silently when handed to `new Date`). Re-serialize and - // confirm it matches the input to catch invalid calendar values. - for (const [label, raw] of [ - ['from', fromParam], - ['to', toParam], - ] as const) { - const d = new Date(`${raw}T00:00:00.000Z`); - if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) { - return NextResponse.json( - { error: `\`${label}\` is not a valid calendar date` }, - { status: 400 }, - ); + let range: DateRange; + if (rawRange === 'custom') { + if (!fromParam || !toParam) { + throw new ValidationError('Custom range requires `from` and `to` (YYYY-MM-DD)'); } + if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) { + throw new ValidationError('`from`/`to` must be ISO date strings (YYYY-MM-DD)'); + } + if (fromParam > toParam) { + throw new ValidationError('`from` must be on or before `to`'); + } + // Round-trip date check: regex passes "9999-13-99" or "2026-02-31" + // (rolls over silently when handed to `new Date`). Re-serialize and + // confirm it matches the input to catch invalid calendar values. + for (const [label, raw] of [ + ['from', fromParam], + ['to', toParam], + ] as const) { + const d = new Date(`${raw}T00:00:00.000Z`); + if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) { + throw new ValidationError(`\`${label}\` is not a valid calendar date`); + } + } + // Backstop against the occupancy-timeline N+1 query loop. Each day + // in the range issues its own DB query, so a multi-year custom + // range would saturate the connection pool. 365 days is a generous + // ceiling for analytical queries; if a longer span is needed, the + // service should be restructured to use `generate_series` instead + // of a JS loop. + const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime(); + const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime(); + if ((toMs - fromMs) / 86_400_000 > 365) { + throw new ValidationError('Custom range cannot exceed 365 days'); + } + range = { kind: 'custom', from: fromParam, to: toParam }; + } else { + if (!ALL_RANGES.includes(rawRange as PresetDateRange)) { + throw new ValidationError('Invalid range'); + } + range = rawRange as PresetDateRange; } - // Backstop against the occupancy-timeline N+1 query loop. Each day - // in the range issues its own DB query, so a multi-year custom - // range would saturate the connection pool. 365 days is a generous - // ceiling for analytical queries; if a longer span is needed, the - // service should be restructured to use `generate_series` instead - // of a JS loop. - const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime(); - const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime(); - if ((toMs - fromMs) / 86_400_000 > 365) { - return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 }); - } - range = { kind: 'custom', from: fromParam, to: toParam }; - } else { - if (!ALL_RANGES.includes(rawRange as PresetDateRange)) { - return NextResponse.json({ error: 'Invalid range' }, { status: 400 }); - } - range = rawRange as PresetDateRange; - } - const data = await METRICS[metric](ctx.portId, range); - return NextResponse.json({ metric, range, data }); + const data = await METRICS[metric](ctx.portId, range); + return NextResponse.json({ metric, range, data }); + } catch (error) { + return errorResponse(error); + } }), ); diff --git a/src/app/api/v1/clients/[id]/portal-user/route.ts b/src/app/api/v1/clients/[id]/portal-user/route.ts index fb81350..c7b1de9 100644 --- a/src/app/api/v1/clients/[id]/portal-user/route.ts +++ b/src/app/api/v1/clients/[id]/portal-user/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, NotFoundError } from '@/lib/errors'; import { createPortalUser, resendActivation } from '@/lib/services/portal-auth.service'; import { db } from '@/lib/db'; import { eq } from 'drizzle-orm'; @@ -36,9 +36,7 @@ export const POST = withAuth( const existing = await db.query.portalUsers.findFirst({ where: eq(portalUsers.email, body.email.toLowerCase().trim()), }); - if (!existing) { - return NextResponse.json({ error: 'Portal user not found' }, { status: 404 }); - } + if (!existing) throw new NotFoundError('portal user'); await resendActivation(existing.id, ctx.portId); return NextResponse.json({ success: true }); } diff --git a/src/app/api/v1/currency/rates/refresh/route.ts b/src/app/api/v1/currency/rates/refresh/route.ts index 8ff0093..827d534 100644 --- a/src/app/api/v1/currency/rates/refresh/route.ts +++ b/src/app/api/v1/currency/rates/refresh/route.ts @@ -1,14 +1,12 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { refreshRates } from '@/lib/services/currency'; export const POST = withAuth(async (_req, ctx) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'currency.rates.refresh'); await refreshRates(); return NextResponse.json({ data: { success: true } }); diff --git a/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts b/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts index 2093666..28b7233 100644 --- a/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts +++ b/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts @@ -1,14 +1,14 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { clearDuplicate } from '@/lib/services/expense-dedup.service'; export const POST = withAuth( withPermission('expenses', 'edit', async (_req, ctx, params) => { try { const id = params.id; - if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + if (!id) throw new ValidationError('id is required'); await clearDuplicate(id, ctx.portId); return NextResponse.json({ ok: true }); } catch (error) { diff --git a/src/app/api/v1/expenses/[id]/merge/route.ts b/src/app/api/v1/expenses/[id]/merge/route.ts index 5944ba5..1e0ecd2 100644 --- a/src/app/api/v1/expenses/[id]/merge/route.ts +++ b/src/app/api/v1/expenses/[id]/merge/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { mergeDuplicate } from '@/lib/services/expense-dedup.service'; const mergeSchema = z.object({ @@ -15,9 +15,7 @@ export const POST = withAuth( withPermission('expenses', 'edit', async (req, ctx, params) => { try { const sourceId = params.id; - if (!sourceId) { - return NextResponse.json({ error: 'Missing id' }, { status: 400 }); - } + if (!sourceId) throw new ValidationError('id is required'); const body = await parseBody(req, mergeSchema); await mergeDuplicate(sourceId, body.targetId, ctx.portId); return NextResponse.json({ ok: true }); diff --git a/src/app/api/v1/expenses/export/parent-company/route.ts b/src/app/api/v1/expenses/export/parent-company/route.ts index b283073..37ba2da 100644 --- a/src/app/api/v1/expenses/export/parent-company/route.ts +++ b/src/app/api/v1/expenses/export/parent-company/route.ts @@ -1,15 +1,13 @@ import { NextResponse } from 'next/server'; -import { withAuth } from '@/lib/api/helpers'; +import { requireSuperAdmin, withAuth } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { exportParentCompany } from '@/lib/services/expense-export'; import { listExpensesSchema } from '@/lib/validators/expenses'; export const POST = withAuth(async (req, ctx) => { try { - if (!ctx.isSuperAdmin) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); - } + requireSuperAdmin(ctx, 'expenses.export.parent-company'); const body = await req.json().catch(() => ({})); const query = listExpensesSchema.parse(body); diff --git a/src/app/api/v1/expenses/scan-receipt/route.ts b/src/app/api/v1/expenses/scan-receipt/route.ts index 58c61cd..dcd8692 100644 --- a/src/app/api/v1/expenses/scan-receipt/route.ts +++ b/src/app/api/v1/expenses/scan-receipt/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service'; import { @@ -29,9 +29,7 @@ export const POST = withAuth( try { const formData = await req.formData(); const file = formData.get('file') as File | null; - if (!file) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }); - } + if (!file) throw new ValidationError('A file is required'); const buffer = Buffer.from(await file.arrayBuffer()); const mimeType = file.type || 'image/jpeg'; diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts index ad08d1b..23a15d1 100644 --- a/src/app/api/v1/me/route.ts +++ b/src/app/api/v1/me/route.ts @@ -5,7 +5,7 @@ import { withAuth, type AuthContext } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { userProfiles } from '@/lib/db/schema'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, NotFoundError } from '@/lib/errors'; import { z } from 'zod'; const updateProfileSchema = z.object({ @@ -42,9 +42,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => { const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, ctx.userId), }); - if (!profile) { - return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); - } + if (!profile) throw new NotFoundError('profile'); const updates: Record = { updatedAt: new Date() }; if (body.displayName !== undefined) updates.displayName = body.displayName; diff --git a/src/app/api/v1/reminders/route.ts b/src/app/api/v1/reminders/route.ts index ae2abf2..8c7f200 100644 --- a/src/app/api/v1/reminders/route.ts +++ b/src/app/api/v1/reminders/route.ts @@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody, parseQuery } from '@/lib/api/route-helpers'; import { listReminders, createReminder } from '@/lib/services/reminders.service'; import { reminderListQuerySchema, createReminderSchema } from '@/lib/validators/reminders'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ForbiddenError } from '@/lib/errors'; export const GET = withAuth( withPermission('reminders', 'view_own', async (req, ctx) => { @@ -25,14 +25,8 @@ export const POST = withAuth( // Check assign_others permission if assigning to someone else if (body.assignedTo && body.assignedTo !== ctx.userId) { - if (!ctx.isSuperAdmin) { - const perms = ctx.permissions?.reminders; - if (!perms?.assign_others) { - return NextResponse.json( - { error: 'Cannot assign reminders to other users' }, - { status: 403 }, - ); - } + if (!ctx.isSuperAdmin && !ctx.permissions?.reminders?.assign_others) { + throw new ForbiddenError('Cannot assign reminders to other users'); } } diff --git a/src/app/api/v1/saved-views/[id]/handlers.ts b/src/app/api/v1/saved-views/[id]/handlers.ts index 9a974b4..2731813 100644 --- a/src/app/api/v1/saved-views/[id]/handlers.ts +++ b/src/app/api/v1/saved-views/[id]/handlers.ts @@ -5,33 +5,24 @@ import type { AuthContext } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { savedViews } from '@/lib/db/schema'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ForbiddenError, NotFoundError } from '@/lib/errors'; import { savedViewsService } from '@/lib/services/saved-views.service'; import { updateSavedViewSchema } from '@/lib/validators/saved-views'; /** * Resolves the view and enforces ownership before mutating. * - * Returns a 404 when the view does not exist (or lives in a different port) - * and a 403 when it belongs to a different user. The 404-before-403 split - * matches the rest of the API and avoids leaking the existence of another - * user's saved view via timing or status code. + * Throws NotFoundError when the view does not exist (or lives in a different + * port) and ForbiddenError when it belongs to a different user. The 404- + * before-403 split matches the rest of the API and avoids leaking the + * existence of another user's saved view via timing or status code. */ -async function assertViewOwner( - id: string, - portId: string, - userId: string, -): Promise { +async function assertViewOwner(id: string, portId: string, userId: string): Promise { const view = await db.query.savedViews.findFirst({ where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)), }); - if (!view) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - if (view.userId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - return null; + if (!view) throw new NotFoundError('saved view'); + if (view.userId !== userId) throw new ForbiddenError('That saved view belongs to someone else.'); } export async function patchHandler( @@ -41,8 +32,7 @@ export async function patchHandler( ): Promise { try { const id = params.id ?? ''; - const denied = await assertViewOwner(id, ctx.portId, ctx.userId); - if (denied) return denied; + await assertViewOwner(id, ctx.portId, ctx.userId); const body = await parseBody(req as never, updateSavedViewSchema); const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body); return NextResponse.json({ data: view }); @@ -58,8 +48,7 @@ export async function deleteHandler( ): Promise { try { const id = params.id ?? ''; - const denied = await assertViewOwner(id, ctx.portId, ctx.userId); - if (denied) return denied; + await assertViewOwner(id, ctx.portId, ctx.userId); await savedViewsService.delete(ctx.portId, ctx.userId, id); return NextResponse.json({ data: null }, { status: 200 }); } catch (error) { diff --git a/src/app/api/v1/settings/feature-flag/route.ts b/src/app/api/v1/settings/feature-flag/route.ts index 34a10ea..a0fef61 100644 --- a/src/app/api/v1/settings/feature-flag/route.ts +++ b/src/app/api/v1/settings/feature-flag/route.ts @@ -4,14 +4,12 @@ import { and, eq } from 'drizzle-orm'; import { withAuth } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, ValidationError } from '@/lib/errors'; export const GET = withAuth(async (req, ctx) => { try { const key = req.nextUrl.searchParams.get('key'); - if (!key) { - return NextResponse.json({ error: 'key query parameter is required' }, { status: 400 }); - } + if (!key) throw new ValidationError('key query parameter is required'); const setting = await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)), diff --git a/src/app/api/v1/users/me/preferences/route.ts b/src/app/api/v1/users/me/preferences/route.ts index 6de3ff5..8adaa02 100644 --- a/src/app/api/v1/users/me/preferences/route.ts +++ b/src/app/api/v1/users/me/preferences/route.ts @@ -5,7 +5,7 @@ import { withAuth } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { userProfiles, type UserPreferences } from '@/lib/db/schema/users'; -import { errorResponse } from '@/lib/errors'; +import { errorResponse, NotFoundError } from '@/lib/errors'; import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences'; export const GET = withAuth(async (_req, ctx) => { @@ -26,9 +26,7 @@ export const PATCH = withAuth(async (req, ctx) => { const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, ctx.userId), }); - if (!profile) { - return NextResponse.json({ error: 'Profile not found' }, { status: 404 }); - } + if (!profile) throw new NotFoundError('profile'); const next: UserPreferences = { ...(profile.preferences ?? {}), diff --git a/src/app/api/v1/website-analytics/route.ts b/src/app/api/v1/website-analytics/route.ts index debe066..cdef0af 100644 --- a/src/app/api/v1/website-analytics/route.ts +++ b/src/app/api/v1/website-analytics/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range'; +import { CodedError, errorResponse, ValidationError } from '@/lib/errors'; import { getActiveVisitors, getMetric, @@ -68,46 +69,48 @@ function parseRange(req: NextRequest): DateRange | { error: string } { export const GET = withAuth( withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => { - const url = new URL(req.url); - const metric = url.searchParams.get('metric'); - if (!metric) { - return NextResponse.json({ error: 'Missing metric' }, { status: 400 }); - } - - const rangeOrError = parseRange(req); - if (typeof rangeOrError === 'object' && 'error' in rangeOrError) { - return NextResponse.json({ error: rangeOrError.error }, { status: 400 }); - } - const range = rangeOrError as DateRange; - try { - let data: unknown; + const url = new URL(req.url); + const metric = url.searchParams.get('metric'); + if (!metric) throw new ValidationError('Missing metric'); - if (metric === 'stats') { - data = await getStats(ctx.portId, range); - } else if (metric === 'pageviews') { - data = await getPageviewsSeries(ctx.portId, range); - } else if (metric === 'active') { - data = await getActiveVisitors(ctx.portId); - } else if (TOP_METRIC_RX.test(metric)) { - const type = metric.replace(/^top-/, '') as UmamiMetricType; - const limit = Number(url.searchParams.get('limit') ?? 10); - data = await getMetric(ctx.portId, range, type, limit); - } else { - return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 }); + const rangeOrError = parseRange(req); + if (typeof rangeOrError === 'object' && 'error' in rangeOrError) { + throw new ValidationError(rangeOrError.error); + } + const range = rangeOrError as DateRange; + + let data: unknown; + try { + if (metric === 'stats') { + data = await getStats(ctx.portId, range); + } else if (metric === 'pageviews') { + data = await getPageviewsSeries(ctx.portId, range); + } else if (metric === 'active') { + data = await getActiveVisitors(ctx.portId); + } else if (TOP_METRIC_RX.test(metric)) { + const type = metric.replace(/^top-/, '') as UmamiMetricType; + const limit = Number(url.searchParams.get('limit') ?? 10); + data = await getMetric(ctx.portId, range, type, limit); + } else { + throw new ValidationError(`Unknown metric: ${metric}`); + } + } catch (err) { + if (err instanceof ValidationError) throw err; + // Upstream Umami failure - re-throw as a typed code so the user gets + // a friendly message and the request id is captured to error_events. + const internalMessage = err instanceof Error ? err.message : 'Unknown error'; + throw new CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage }); } // `data === null` from the service means Umami isn't configured for // this port - surface that explicitly so the UI can render a // "configure your credentials" empty state instead of a chart. - if (data === null) { - return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 }); - } + if (data === null) throw new CodedError('UMAMI_NOT_CONFIGURED'); return NextResponse.json({ metric, range, data }); } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return NextResponse.json({ error: message, metric, range }, { status: 502 }); + return errorResponse(err); } }), ); diff --git a/src/components/admin/custom-fields/custom-field-form.tsx b/src/components/admin/custom-fields/custom-field-form.tsx index 22f40d4..2736975 100644 --- a/src/components/admin/custom-fields/custom-field-form.tsx +++ b/src/components/admin/custom-fields/custom-field-form.tsx @@ -1,4 +1,5 @@ 'use client'; +import { formatErrorBanner } from '@/lib/api/toast-error'; import { useState, useEffect } from 'react'; import { Plus, X } from 'lucide-react'; @@ -149,7 +150,7 @@ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: Custom onSuccess(); onOpenChange(false); } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Something went wrong'; + const message = formatErrorBanner(err); setError(message); } finally { setLoading(false); @@ -308,7 +309,7 @@ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: Custom /> - {error &&

{error}

} + {error &&

{error}

} + ) : (
@@ -126,21 +128,20 @@ export function WebhookForm({ open, onOpenChange, webhook, onSuccess }: WebhookF
- +
- {error &&

{error}

} + {error &&

{error}

} - diff --git a/src/lib/api/toast-error.ts b/src/lib/api/toast-error.ts index 4f75844..bb8720d 100644 --- a/src/lib/api/toast-error.ts +++ b/src/lib/api/toast-error.ts @@ -4,6 +4,28 @@ 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: *