diff --git a/src/app/api/v1/berths/bulk/route.ts b/src/app/api/v1/berths/bulk/route.ts index 4a414ebb..20e1d032 100644 --- a/src/app/api/v1/berths/bulk/route.ts +++ b/src/app/api/v1/berths/bulk/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { and, eq } from 'drizzle-orm'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withRateLimit } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { berths, berthTags } from '@/lib/db/schema/berths'; @@ -71,78 +71,80 @@ const PERMISSION_BY_ACTION: Record< archive: { resource: 'berths', action: 'edit' }, }; -export const POST = withAuth(async (req, ctx) => { - let body: z.infer; - try { - body = await parseBody(req, bulkSchema); - } catch (error) { - return errorResponse(error); - } - - const perm = PERMISSION_BY_ACTION[body.action]; - const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action]; - if (!allowed) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - const meta = { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }; - - const results: RowResult[] = []; - - for (const id of body.ids) { +export const POST = withAuth( + withRateLimit('bulk', async (req, ctx) => { + let body: z.infer; try { - if (body.action === 'change_status') { - // Status mutations go through the dedicated path so the under- - // offer / sold transitions can auto-create the primary - // interest_berths link + emit the rules-engine evaluation. - await updateBerthStatus( - id, - ctx.portId, - { status: body.status, reason: 'Bulk status change' }, - meta, - ); - } else if (body.action === 'change_tenure_type') { - await updateBerth(id, ctx.portId, { tenureType: body.tenureType }, meta); - } else if (body.action === 'archive') { - await archiveBerth(id, ctx.portId, { reason: body.reason ?? '' }, meta); - } else if (body.action === 'add_tag' || body.action === 'remove_tag') { - const berth = await db.query.berths.findFirst({ - where: and(eq(berths.id, id), eq(berths.portId, ctx.portId)), - }); - if (!berth) { - results.push({ id, ok: false, error: 'Not found' }); - continue; - } - // Compose the new tag set, then re-write atomically. - const currentTags = await db - .select({ tagId: berthTags.tagId }) - .from(berthTags) - .where(eq(berthTags.berthId, id)); - const currentIds = new Set(currentTags.map((t) => t.tagId)); - if (body.action === 'add_tag') currentIds.add(body.tagId); - else currentIds.delete(body.tagId); - await setBerthTags(id, ctx.portId, Array.from(currentIds), meta); - } - results.push({ id, ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - results.push({ id, ok: false, error: message }); + body = await parseBody(req, bulkSchema); + } catch (error) { + return errorResponse(error); } - } - const okCount = results.filter((r) => r.ok).length; - return NextResponse.json({ - data: { - action: body.action, - total: results.length, - ok: okCount, - failed: results.length - okCount, - results, - }, - }); -}); + const perm = PERMISSION_BY_ACTION[body.action]; + const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action]; + if (!allowed) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; + + const results: RowResult[] = []; + + for (const id of body.ids) { + try { + if (body.action === 'change_status') { + // Status mutations go through the dedicated path so the under- + // offer / sold transitions can auto-create the primary + // interest_berths link + emit the rules-engine evaluation. + await updateBerthStatus( + id, + ctx.portId, + { status: body.status, reason: 'Bulk status change' }, + meta, + ); + } else if (body.action === 'change_tenure_type') { + await updateBerth(id, ctx.portId, { tenureType: body.tenureType }, meta); + } else if (body.action === 'archive') { + await archiveBerth(id, ctx.portId, { reason: body.reason ?? '' }, meta); + } else if (body.action === 'add_tag' || body.action === 'remove_tag') { + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, id), eq(berths.portId, ctx.portId)), + }); + if (!berth) { + results.push({ id, ok: false, error: 'Not found' }); + continue; + } + // Compose the new tag set, then re-write atomically. + const currentTags = await db + .select({ tagId: berthTags.tagId }) + .from(berthTags) + .where(eq(berthTags.berthId, id)); + const currentIds = new Set(currentTags.map((t) => t.tagId)); + if (body.action === 'add_tag') currentIds.add(body.tagId); + else currentIds.delete(body.tagId); + await setBerthTags(id, ctx.portId, Array.from(currentIds), meta); + } + results.push({ id, ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + results.push({ id, ok: false, error: message }); + } + } + + const okCount = results.filter((r) => r.ok).length; + return NextResponse.json({ + data: { + action: body.action, + total: results.length, + ok: okCount, + failed: results.length - okCount, + results, + }, + }); + }), +); diff --git a/src/app/api/v1/clients/bulk/route.ts b/src/app/api/v1/clients/bulk/route.ts index 4344aead..0092f761 100644 --- a/src/app/api/v1/clients/bulk/route.ts +++ b/src/app/api/v1/clients/bulk/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { eq, and } from 'drizzle-orm'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withRateLimit } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { runBulk } from '@/lib/api/bulk-helpers'; import { db } from '@/lib/db'; @@ -48,174 +48,176 @@ const PERMISSION_BY_ACTION = { remove_tag: 'edit' as const, }; -export const POST = withAuth(async (req, ctx) => { - let body: z.infer; - try { - body = await parseBody(req, bulkSchema); - } catch (error) { - return errorResponse(error); - } +export const POST = withAuth( + withRateLimit('bulk', async (req, ctx) => { + let body: z.infer; + try { + body = await parseBody(req, bulkSchema); + } catch (error) { + return errorResponse(error); + } - const allowed = ctx.isSuperAdmin - ? true - : !!ctx.permissions?.clients?.[PERMISSION_BY_ACTION[body.action]]; - if (!allowed) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const allowed = ctx.isSuperAdmin + ? true + : !!ctx.permissions?.clients?.[PERMISSION_BY_ACTION[body.action]]; + if (!allowed) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - const meta = { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }; + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; - const reasonsByClientId = body.action === 'archive' ? (body.reasonsByClientId ?? {}) : {}; + const reasonsByClientId = body.action === 'archive' ? (body.reasonsByClientId ?? {}) : {}; - // Collect per-archive side-effects so we can fan out Documenso voids - // + next-in-line notifications AFTER the bulk loop completes (mirrors - // the single-client route's post-commit behaviour). Without this the - // bulk path silently dropped both side-effect streams (audit R2-C1). - const archiveSideEffects: Array<{ - dossier: ClientArchiveDossier; - result: ArchiveResult; - }> = []; + // Collect per-archive side-effects so we can fan out Documenso voids + // + next-in-line notifications AFTER the bulk loop completes (mirrors + // the single-client route's post-commit behaviour). Without this the + // bulk path silently dropped both side-effect streams (audit R2-C1). + const archiveSideEffects: Array<{ + dossier: ClientArchiveDossier; + result: ArchiveResult; + }> = []; - const { results, summary } = await runBulk(body.ids, async (id) => { - if (body.action === 'archive') { - // Bulk archive uses the smart-archive backend with sensible - // low-stakes defaults: release available/under-offer berths, - // retain sold ones, cancel active reservations, leave invoices, - // leave Documenso envelopes pending. High-stakes clients require - // a per-client reason supplied via reasonsByClientId; the bulk- - // archive wizard captures these one at a time before submitting. - const dossier = await getClientArchiveDossier(id, ctx.portId); - // Idempotent: if a previous request already archived this client - // (e.g. a network retry / double-click), treat it as success - // rather than letting `archiveClientWithDecisions` throw a - // ConflictError that runBulk will surface as a per-row failure. - if (dossier.client.archivedAt) { + const { results, summary } = await runBulk(body.ids, async (id) => { + if (body.action === 'archive') { + // Bulk archive uses the smart-archive backend with sensible + // low-stakes defaults: release available/under-offer berths, + // retain sold ones, cancel active reservations, leave invoices, + // leave Documenso envelopes pending. High-stakes clients require + // a per-client reason supplied via reasonsByClientId; the bulk- + // archive wizard captures these one at a time before submitting. + const dossier = await getClientArchiveDossier(id, ctx.portId); + // Idempotent: if a previous request already archived this client + // (e.g. a network retry / double-click), treat it as success + // rather than letting `archiveClientWithDecisions` throw a + // ConflictError that runBulk will surface as a per-row failure. + if (dossier.client.archivedAt) { + return; + } + const perClientReason = reasonsByClientId[id]; + if (dossier.stakeLevel === 'high' && !perClientReason) { + throw new Error( + `Client at ${dossier.highStakesStage} requires a per-client reason; supply one in reasonsByClientId.`, + ); + } + if (dossier.blockers.length > 0) { + throw new Error(`Cannot archive: ${dossier.blockers[0]}`); + } + const hasSignedDocs = dossier.documents.some( + (d) => d.status === 'completed' || d.status === 'signed', + ); + const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)'; + // Pick the berth's first linked interest from the dossier + // (authoritative interest_berths join). Berths with no linked + // interest for this client are dropped - emitting an empty + // interestId causes the delete to silently match zero rows + // (audit R2-H3). + const berthDecisions = dossier.berths + .map((b) => { + const interestId = b.linkedInterestIds[0]; + if (!interestId) return null; + return { + berthId: b.berthId, + interestId, + action: b.status === 'sold' ? ('retain' as const) : ('release' as const), + }; + }) + .filter( + (x): x is { berthId: string; interestId: string; action: 'retain' | 'release' } => + x !== null, + ); + + const result = await archiveClientWithDecisions({ + dossier, + decisions: { + reason, + acknowledgedSignedDocuments: hasSignedDocs, + berthDecisions, + yachtDecisions: dossier.yachts.map((y) => ({ yachtId: y.yachtId, action: 'retain' })), + tenancyDecisions: dossier.tenancies.map((r) => ({ + tenancyId: r.tenancyId, + action: 'cancel', + })), + invoiceDecisions: dossier.invoices.map((i) => ({ + invoiceId: i.invoiceId, + action: 'leave', + })), + documentDecisions: dossier.documents.map((d) => ({ + documentId: d.documentId, + action: 'leave', + })), + }, + meta, + }); + archiveSideEffects.push({ dossier, result }); return; } - const perClientReason = reasonsByClientId[id]; - if (dossier.stakeLevel === 'high' && !perClientReason) { - throw new Error( - `Client at ${dossier.highStakesStage} requires a per-client reason; supply one in reasonsByClientId.`, - ); - } - if (dossier.blockers.length > 0) { - throw new Error(`Cannot archive: ${dossier.blockers[0]}`); - } - const hasSignedDocs = dossier.documents.some( - (d) => d.status === 'completed' || d.status === 'signed', - ); - const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)'; - // Pick the berth's first linked interest from the dossier - // (authoritative interest_berths join). Berths with no linked - // interest for this client are dropped - emitting an empty - // interestId causes the delete to silently match zero rows - // (audit R2-H3). - const berthDecisions = dossier.berths - .map((b) => { - const interestId = b.linkedInterestIds[0]; - if (!interestId) return null; - return { - berthId: b.berthId, - interestId, - action: b.status === 'sold' ? ('retain' as const) : ('release' as const), - }; - }) - .filter( - (x): x is { berthId: string; interestId: string; action: 'retain' | 'release' } => - x !== null, - ); - - const result = await archiveClientWithDecisions({ - dossier, - decisions: { - reason, - acknowledgedSignedDocuments: hasSignedDocs, - berthDecisions, - yachtDecisions: dossier.yachts.map((y) => ({ yachtId: y.yachtId, action: 'retain' })), - tenancyDecisions: dossier.tenancies.map((r) => ({ - tenancyId: r.tenancyId, - action: 'cancel', - })), - invoiceDecisions: dossier.invoices.map((i) => ({ - invoiceId: i.invoiceId, - action: 'leave', - })), - documentDecisions: dossier.documents.map((d) => ({ - documentId: d.documentId, - action: 'leave', - })), - }, - meta, + const client = await db.query.clients.findFirst({ + where: and(eq(clients.id, id), eq(clients.portId, ctx.portId)), }); - archiveSideEffects.push({ dossier, result }); - return; - } - const client = await db.query.clients.findFirst({ - where: and(eq(clients.id, id), eq(clients.portId, ctx.portId)), + if (!client) throw new Error('Client not found'); + const existing = await db + .select({ tagId: clientTags.tagId }) + .from(clientTags) + .where(eq(clientTags.clientId, id)); + const current = new Set(existing.map((t) => t.tagId)); + if (body.action === 'add_tag') current.add(body.tagId); + else current.delete(body.tagId); + await setClientTags(id, ctx.portId, Array.from(current), meta); }); - if (!client) throw new Error('Client not found'); - const existing = await db - .select({ tagId: clientTags.tagId }) - .from(clientTags) - .where(eq(clientTags.clientId, id)); - const current = new Set(existing.map((t) => t.tagId)); - if (body.action === 'add_tag') current.add(body.tagId); - else current.delete(body.tagId); - await setClientTags(id, ctx.portId, Array.from(current), meta); - }); - // Post-commit side-effects, identical pattern to the single-client - // route at /api/v1/clients/[id]/archive. Documenso voids → BullMQ - // documents queue; next-in-line notifications fire-and-forget per - // released berth. - if (archiveSideEffects.length > 0) { - const queue = getQueue('documents'); - for (const { dossier, result } of archiveSideEffects) { - for (const c of result.externalCleanups) { - if (c.kind === 'documenso_void') { - await queue - .add('documenso-void', { - documentId: c.documentId, - documensoId: c.documensoId, - portId: ctx.portId, - }) - .catch((err) => - logger.error( - { err, documentId: c.documentId, clientId: result.clientId }, - 'Bulk archive: failed to enqueue Documenso void', - ), - ); + // Post-commit side-effects, identical pattern to the single-client + // route at /api/v1/clients/[id]/archive. Documenso voids → BullMQ + // documents queue; next-in-line notifications fire-and-forget per + // released berth. + if (archiveSideEffects.length > 0) { + const queue = getQueue('documents'); + for (const { dossier, result } of archiveSideEffects) { + for (const c of result.externalCleanups) { + if (c.kind === 'documenso_void') { + await queue + .add('documenso-void', { + documentId: c.documentId, + documensoId: c.documensoId, + portId: ctx.portId, + }) + .catch((err) => + logger.error( + { err, documentId: c.documentId, clientId: result.clientId }, + 'Bulk archive: failed to enqueue Documenso void', + ), + ); + } + } + + for (const released of result.releasedBerths) { + if (released.nextInLineInterestIds.length === 0) continue; + const otherInterests = + dossier.berths + .find((b) => b.berthId === released.berthId) + ?.otherInterests.map((o) => ({ + interestId: o.interestId, + clientName: o.clientName, + pipelineStage: o.pipelineStage, + })) ?? []; + void notifyNextInLine({ + portId: ctx.portId, + berthId: released.berthId, + mooringNumber: released.mooringNumber, + archivedClientName: dossier.client.fullName, + nextInLineInterests: otherInterests, + }).catch((err) => + logger.error( + { err, berthId: released.berthId, clientId: result.clientId }, + 'Bulk archive: failed to fire next-in-line notification', + ), + ); } } - - for (const released of result.releasedBerths) { - if (released.nextInLineInterestIds.length === 0) continue; - const otherInterests = - dossier.berths - .find((b) => b.berthId === released.berthId) - ?.otherInterests.map((o) => ({ - interestId: o.interestId, - clientName: o.clientName, - pipelineStage: o.pipelineStage, - })) ?? []; - void notifyNextInLine({ - portId: ctx.portId, - berthId: released.berthId, - mooringNumber: released.mooringNumber, - archivedClientName: dossier.client.fullName, - nextInLineInterests: otherInterests, - }).catch((err) => - logger.error( - { err, berthId: released.berthId, clientId: result.clientId }, - 'Bulk archive: failed to fire next-in-line notification', - ), - ); - } } - } - return NextResponse.json({ data: { results, summary } }); -}); + return NextResponse.json({ data: { results, summary } }); + }), +); diff --git a/src/app/api/v1/companies/bulk/route.ts b/src/app/api/v1/companies/bulk/route.ts index e6e8c8aa..b19024bf 100644 --- a/src/app/api/v1/companies/bulk/route.ts +++ b/src/app/api/v1/companies/bulk/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { eq, and } from 'drizzle-orm'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withRateLimit } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { runBulk } from '@/lib/api/bulk-helpers'; import { db } from '@/lib/db'; @@ -33,44 +33,46 @@ const PERMISSION_BY_ACTION = { remove_tag: 'edit' as const, }; -export const POST = withAuth(async (req, ctx) => { - let body: z.infer; - try { - body = await parseBody(req, bulkSchema); - } catch (error) { - return errorResponse(error); - } - - const allowed = ctx.isSuperAdmin - ? true - : !!ctx.permissions?.companies?.[PERMISSION_BY_ACTION[body.action]]; - if (!allowed) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - - const meta = { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }; - - const { results, summary } = await runBulk(body.ids, async (id) => { - if (body.action === 'archive') { - await archiveCompany(id, ctx.portId, meta); - return; +export const POST = withAuth( + withRateLimit('bulk', async (req, ctx) => { + let body: z.infer; + try { + body = await parseBody(req, bulkSchema); + } catch (error) { + return errorResponse(error); } - const company = await db.query.companies.findFirst({ - where: and(eq(companies.id, id), eq(companies.portId, ctx.portId)), - }); - if (!company) throw new Error('Company not found'); - const existing = await db - .select({ tagId: companyTags.tagId }) - .from(companyTags) - .where(eq(companyTags.companyId, id)); - const current = new Set(existing.map((t) => t.tagId)); - if (body.action === 'add_tag') current.add(body.tagId); - else current.delete(body.tagId); - await setCompanyTags(id, ctx.portId, Array.from(current), meta); - }); - return NextResponse.json({ data: { results, summary } }); -}); + const allowed = ctx.isSuperAdmin + ? true + : !!ctx.permissions?.companies?.[PERMISSION_BY_ACTION[body.action]]; + if (!allowed) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; + + const { results, summary } = await runBulk(body.ids, async (id) => { + if (body.action === 'archive') { + await archiveCompany(id, ctx.portId, meta); + return; + } + const company = await db.query.companies.findFirst({ + where: and(eq(companies.id, id), eq(companies.portId, ctx.portId)), + }); + if (!company) throw new Error('Company not found'); + const existing = await db + .select({ tagId: companyTags.tagId }) + .from(companyTags) + .where(eq(companyTags.companyId, id)); + const current = new Set(existing.map((t) => t.tagId)); + if (body.action === 'add_tag') current.add(body.tagId); + else current.delete(body.tagId); + await setCompanyTags(id, ctx.portId, Array.from(current), meta); + }); + + return NextResponse.json({ data: { results, summary } }); + }), +); diff --git a/src/app/api/v1/interests/bulk/route.ts b/src/app/api/v1/interests/bulk/route.ts index ae6b7caf..7535ead3 100644 --- a/src/app/api/v1/interests/bulk/route.ts +++ b/src/app/api/v1/interests/bulk/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { eq, and } from 'drizzle-orm'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withRateLimit } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; @@ -62,69 +62,71 @@ const PERMISSION_BY_ACTION: Record< archive: { resource: 'interests', action: 'delete' }, }; -export const POST = withAuth(async (req, ctx) => { - let body: z.infer; - try { - body = await parseBody(req, bulkSchema); - } catch (error) { - return errorResponse(error); - } - - // Per-action permission check (mirrors the per-row endpoints). - const perm = PERMISSION_BY_ACTION[body.action]; - const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action]; - if (!allowed) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - const meta = { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }; - - const results: RowResult[] = []; - - for (const id of body.ids) { +export const POST = withAuth( + withRateLimit('bulk', async (req, ctx) => { + let body: z.infer; try { - if (body.action === 'change_stage') { - await changeInterestStage(id, ctx.portId, { pipelineStage: body.pipelineStage }, meta); - } else if (body.action === 'archive') { - await archiveInterest(id, ctx.portId, meta); - } else if (body.action === 'add_tag' || body.action === 'remove_tag') { - // Tenant gate: load the existing interest tag set, mutate, save. - const interest = await db.query.interests.findFirst({ - where: and(eq(interests.id, id), eq(interests.portId, ctx.portId)), - }); - if (!interest) { - results.push({ id, ok: false, error: 'Interest not found' }); - continue; - } - const existingTags = await db - .select({ tagId: interestTags.tagId }) - .from(interestTags) - .where(eq(interestTags.interestId, id)); - const current = new Set(existingTags.map((t) => t.tagId)); - if (body.action === 'add_tag') current.add(body.tagId); - else current.delete(body.tagId); - await setInterestTags(id, ctx.portId, Array.from(current), meta); - } - results.push({ id, ok: true }); - } catch (err) { - results.push({ - id, - ok: false, - error: err instanceof Error ? err.message : 'unknown error', - }); + body = await parseBody(req, bulkSchema); + } catch (error) { + return errorResponse(error); } - } - const summary = { - total: results.length, - succeeded: results.filter((r) => r.ok).length, - failed: results.filter((r) => !r.ok).length, - }; + // Per-action permission check (mirrors the per-row endpoints). + const perm = PERMISSION_BY_ACTION[body.action]; + const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action]; + if (!allowed) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } - return NextResponse.json({ data: { results, summary } }); -}); + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; + + const results: RowResult[] = []; + + for (const id of body.ids) { + try { + if (body.action === 'change_stage') { + await changeInterestStage(id, ctx.portId, { pipelineStage: body.pipelineStage }, meta); + } else if (body.action === 'archive') { + await archiveInterest(id, ctx.portId, meta); + } else if (body.action === 'add_tag' || body.action === 'remove_tag') { + // Tenant gate: load the existing interest tag set, mutate, save. + const interest = await db.query.interests.findFirst({ + where: and(eq(interests.id, id), eq(interests.portId, ctx.portId)), + }); + if (!interest) { + results.push({ id, ok: false, error: 'Interest not found' }); + continue; + } + const existingTags = await db + .select({ tagId: interestTags.tagId }) + .from(interestTags) + .where(eq(interestTags.interestId, id)); + const current = new Set(existingTags.map((t) => t.tagId)); + if (body.action === 'add_tag') current.add(body.tagId); + else current.delete(body.tagId); + await setInterestTags(id, ctx.portId, Array.from(current), meta); + } + results.push({ id, ok: true }); + } catch (err) { + results.push({ + id, + ok: false, + error: err instanceof Error ? err.message : 'unknown error', + }); + } + } + + const summary = { + total: results.length, + succeeded: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + }; + + return NextResponse.json({ data: { results, summary } }); + }), +); diff --git a/src/app/api/v1/reports/export-pdf/route.ts b/src/app/api/v1/reports/export-pdf/route.ts index d0926344..70acbc81 100644 --- a/src/app/api/v1/reports/export-pdf/route.ts +++ b/src/app/api/v1/reports/export-pdf/route.ts @@ -24,40 +24,77 @@ import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report'; * option) so we don't have to keep adding routes per report kind. */ +// M15: this route renders a fully client-supplied payload synchronously +// via `renderToBuffer` on the request thread, gated only by +// `reports.view_dashboard`. Without hard bounds an authed user can POST +// a huge payload and OOM/stall the Node process. The async worker path +// caps rows at REPORT_ROW_CAP (1000); mirror that here, and additionally +// cap the section count, per-section column count, and the total cell +// budget across all sections so a fan-out of many small sections can't +// dodge the per-section row cap. +const REPORT_ROW_CAP = 1_000; +const MAX_SECTIONS = 50; +const MAX_COLUMNS = 50; +const MAX_KPIS = 100; +/** Upper bound on total rendered table cells (rows × columns, summed + * across every section). Sized so the worst case stays well within the + * per-section caps but bounds the aggregate render cost. */ +const MAX_TOTAL_CELLS = 200_000; + // Minimal shape validation — full ReportPayload is structurally typed // in TS; here we just check it has the basic envelope. -const payloadSchema = z.object({ - title: z.string().min(1), - description: z.string().optional(), - filenameSlug: z.string().min(1), - range: z.object({ - from: z.string().datetime(), - to: z.string().datetime(), - }), - kpis: z.array( - z.object({ - label: z.string(), - value: z.union([z.string(), z.number()]), - hint: z.string().optional(), +const payloadSchema = z + .object({ + title: z.string().min(1), + description: z.string().optional(), + filenameSlug: z.string().min(1), + range: z.object({ + from: z.string().datetime(), + to: z.string().datetime(), }), - ), - sections: z.array( - z.object({ - title: z.string(), - columns: z.array( + kpis: z + .array( z.object({ - key: z.string(), label: z.string(), - align: z.enum(['left', 'right', 'center']).optional(), + value: z.union([z.string(), z.number()]), + hint: z.string().optional(), }), - ), - rows: z.array(z.record(z.string(), z.unknown())), - }), - ), - /** Optional filename override (without extension) — the client - * passes the slug derived from the custom title. */ - filenameOverride: z.string().optional(), -}); + ) + .max(MAX_KPIS), + sections: z + .array( + z.object({ + title: z.string(), + columns: z + .array( + z.object({ + key: z.string(), + label: z.string(), + align: z.enum(['left', 'right', 'center']).optional(), + }), + ) + .max(MAX_COLUMNS), + rows: z.array(z.record(z.string(), z.unknown())).max(REPORT_ROW_CAP), + }), + ) + .max(MAX_SECTIONS), + /** Optional filename override (without extension) — the client + * passes the slug derived from the custom title. */ + filenameOverride: z.string().optional(), + }) + // Total-cell budget: the per-section `.max()` caps bound each section, + // but a payload could still fan out MAX_SECTIONS × REPORT_ROW_CAP × + // MAX_COLUMNS cells. Reject any payload whose summed cell count exceeds + // the aggregate budget before it reaches the synchronous renderer. + .refine( + (p) => + p.sections.reduce((total, s) => total + s.rows.length * Math.max(1, s.columns.length), 0) <= + MAX_TOTAL_CELLS, + { + message: `Report payload exceeds the maximum of ${MAX_TOTAL_CELLS} total cells`, + path: ['sections'], + }, + ); export const POST = withAuth( withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => { diff --git a/src/app/api/v1/residential/interests/bulk/route.ts b/src/app/api/v1/residential/interests/bulk/route.ts index bc0bfa44..5acd1bd7 100644 --- a/src/app/api/v1/residential/interests/bulk/route.ts +++ b/src/app/api/v1/residential/interests/bulk/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withRateLimit } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; import { assertResidentialModuleEnabled } from '@/lib/services/residential-module.service'; @@ -46,62 +46,64 @@ const PERMISSION_BY_ACTION: Record< archive: { resource: 'residential_interests', action: 'delete' }, }; -export const POST = withAuth(async (req, ctx) => { - let body: z.infer; - try { - await assertResidentialModuleEnabled(ctx.portId); - body = await parseBody(req, bulkSchema); - } catch (error) { - return errorResponse(error); - } - - const perm = PERMISSION_BY_ACTION[body.action]; - const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action]; - if (!allowed) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - const meta = { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }; - - const results: RowResult[] = []; - - for (const id of body.ids) { +export const POST = withAuth( + withRateLimit('bulk', async (req, ctx) => { + let body: z.infer; try { - if (body.action === 'change_stage') { - await updateResidentialInterest( - id, - ctx.portId, - { pipelineStage: body.pipelineStage }, - meta, - ); - } else if (body.action === 'archive') { - await archiveResidentialInterest(id, ctx.portId, meta); - } - results.push({ id, ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - results.push({ id, ok: false, error: message }); + await assertResidentialModuleEnabled(ctx.portId); + body = await parseBody(req, bulkSchema); + } catch (error) { + return errorResponse(error); } - } - const okCount = results.filter((r) => r.ok).length; - return NextResponse.json({ - data: { - action: body.action, - total: results.length, - ok: okCount, - failed: results.length - okCount, - results, - summary: { + const perm = PERMISSION_BY_ACTION[body.action]; + const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action]; + if (!allowed) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; + + const results: RowResult[] = []; + + for (const id of body.ids) { + try { + if (body.action === 'change_stage') { + await updateResidentialInterest( + id, + ctx.portId, + { pipelineStage: body.pipelineStage }, + meta, + ); + } else if (body.action === 'archive') { + await archiveResidentialInterest(id, ctx.portId, meta); + } + results.push({ id, ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + results.push({ id, ok: false, error: message }); + } + } + + const okCount = results.filter((r) => r.ok).length; + return NextResponse.json({ + data: { + action: body.action, total: results.length, - succeeded: okCount, + ok: okCount, failed: results.length - okCount, + results, + summary: { + total: results.length, + succeeded: okCount, + failed: results.length - okCount, + }, }, - }, - }); -}); + }); + }), +); diff --git a/src/app/api/v1/yachts/bulk/route.ts b/src/app/api/v1/yachts/bulk/route.ts index 6d50a2ba..0c9a0169 100644 --- a/src/app/api/v1/yachts/bulk/route.ts +++ b/src/app/api/v1/yachts/bulk/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { eq, and } from 'drizzle-orm'; -import { withAuth } from '@/lib/api/helpers'; +import { withAuth, withRateLimit } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { runBulk } from '@/lib/api/bulk-helpers'; import { db } from '@/lib/db'; @@ -33,44 +33,46 @@ const PERMISSION_BY_ACTION = { remove_tag: 'edit' as const, }; -export const POST = withAuth(async (req, ctx) => { - let body: z.infer; - try { - body = await parseBody(req, bulkSchema); - } catch (error) { - return errorResponse(error); - } - - const allowed = ctx.isSuperAdmin - ? true - : !!ctx.permissions?.yachts?.[PERMISSION_BY_ACTION[body.action]]; - if (!allowed) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - - const meta = { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }; - - const { results, summary } = await runBulk(body.ids, async (id) => { - if (body.action === 'archive') { - await archiveYacht(id, ctx.portId, meta); - return; +export const POST = withAuth( + withRateLimit('bulk', async (req, ctx) => { + let body: z.infer; + try { + body = await parseBody(req, bulkSchema); + } catch (error) { + return errorResponse(error); } - const yacht = await db.query.yachts.findFirst({ - where: and(eq(yachts.id, id), eq(yachts.portId, ctx.portId)), - }); - if (!yacht) throw new Error('Yacht not found'); - const existing = await db - .select({ tagId: yachtTags.tagId }) - .from(yachtTags) - .where(eq(yachtTags.yachtId, id)); - const current = new Set(existing.map((t) => t.tagId)); - if (body.action === 'add_tag') current.add(body.tagId); - else current.delete(body.tagId); - await setYachtTags(id, ctx.portId, Array.from(current), meta); - }); - return NextResponse.json({ data: { results, summary } }); -}); + const allowed = ctx.isSuperAdmin + ? true + : !!ctx.permissions?.yachts?.[PERMISSION_BY_ACTION[body.action]]; + if (!allowed) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; + + const { results, summary } = await runBulk(body.ids, async (id) => { + if (body.action === 'archive') { + await archiveYacht(id, ctx.portId, meta); + return; + } + const yacht = await db.query.yachts.findFirst({ + where: and(eq(yachts.id, id), eq(yachts.portId, ctx.portId)), + }); + if (!yacht) throw new Error('Yacht not found'); + const existing = await db + .select({ tagId: yachtTags.tagId }) + .from(yachtTags) + .where(eq(yachtTags.yachtId, id)); + const current = new Set(existing.map((t) => t.tagId)); + if (body.action === 'add_tag') current.add(body.tagId); + else current.delete(body.tagId); + await setYachtTags(id, ctx.portId, Array.from(current), meta); + }); + + return NextResponse.json({ data: { results, summary } }); + }), +); diff --git a/src/lib/api/helpers.ts b/src/lib/api/helpers.ts index 5dc27fa3..9a90b040 100644 --- a/src/lib/api/helpers.ts +++ b/src/lib/api/helpers.ts @@ -112,6 +112,15 @@ export function deepMerge( export function withAuth>( handler: RouteHandler, ): (req: NextRequest, routeContext: { params: Promise }) => Promise { + // M14: apply the broad per-user `api` limiter (120/min) as a default + // backstop for EVERY authenticated v1 request. Tighter named limiters + // (`ai`, `bulk`, `ocr`, …) still compose ON TOP via `withRateLimit` + // inside the handler chain - they use distinct Redis key prefixes, so + // a request that trips a named limiter is counted in its own bucket + // AND this `api` bucket independently (no double-counting within a + // single bucket). `checkRateLimit` fails OPEN on a Redis outage + // (see rate-limit.ts), so this can never lock the API out. + const rateLimited = withRateLimit('api', handler as RouteHandler) as RouteHandler; return async (req, routeContext) => { // Mint or accept a request id BEFORE entering the ALS frame so every // log line + the response header reference the same value. Clients @@ -269,7 +278,10 @@ export function withAuth>( }; const params = await routeContext.params; - return tag(await handler(req, ctx, params)); + // Call through the `api`-limited wrapper (M14). On a 429 it + // short-circuits before the inner handler; otherwise it + // delegates straight to the original handler. + return tag(await rateLimited(req, ctx, params)); } catch (error) { return tag(errorResponse(error)); }