import { NextResponse } from 'next/server'; import { z } from 'zod'; import { eq, and } from 'drizzle-orm'; import { withAuth } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { runBulk } from '@/lib/api/bulk-helpers'; import { db } from '@/lib/db'; import { clients, clientTags } from '@/lib/db/schema/clients'; import { setClientTags } from '@/lib/services/clients.service'; import { getClientArchiveDossier, type ClientArchiveDossier, } from '@/lib/services/client-archive-dossier.service'; import { archiveClientWithDecisions, type ArchiveResult, } from '@/lib/services/client-archive.service'; import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service'; import { getQueue } from '@/lib/queue'; import { logger } from '@/lib/logger'; import { errorResponse } from '@/lib/errors'; const bulkSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('archive'), ids: z.array(z.string().min(1)).min(1).max(100), /** When provided, lifts the high-stakes block on listed clients * individually. The bulk-archive wizard collects these from the * operator one client at a time. Reasons must be ≥5 characters. */ reasonsByClientId: z.record(z.string(), z.string().min(5).max(2000)).optional(), }), z.object({ action: z.literal('add_tag'), ids: z.array(z.string().min(1)).min(1).max(100), tagId: z.string().min(1), }), z.object({ action: z.literal('remove_tag'), ids: z.array(z.string().min(1)).min(1).max(100), tagId: z.string().min(1), }), ]); const PERMISSION_BY_ACTION = { archive: 'delete' as const, add_tag: 'edit' as const, 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?.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 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; }> = []; 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 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); }); // 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', ), ); } } } return NextResponse.json({ data: { results, summary } }); });