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, HIGH_STAKES_STAGES, } from '@/lib/services/client-archive-dossier.service'; import { archiveClientWithDecisions } from '@/lib/services/client-archive.service'; import { errorResponse } from '@/lib/errors'; import type { PipelineStage } from '@/lib/constants'; 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 ?? {}) : {}; 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); 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)'; await archiveClientWithDecisions({ dossier, decisions: { reason, acknowledgedSignedDocuments: hasSignedDocs, berthDecisions: dossier.berths.map((b) => ({ berthId: b.berthId, interestId: dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber) ?.interestId ?? dossier.interests[0]?.interestId ?? '', action: b.status === 'sold' ? 'retain' : 'release', })), yachtDecisions: dossier.yachts.map((y) => ({ yachtId: y.yachtId, action: 'retain' })), reservationDecisions: dossier.reservations.map((r) => ({ reservationId: r.reservationId, action: 'cancel', })), invoiceDecisions: dossier.invoices.map((i) => ({ invoiceId: i.invoiceId, action: 'leave', })), documentDecisions: dossier.documents.map((d) => ({ documentId: d.documentId, action: 'leave', })), }, meta, }); 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); }); return NextResponse.json({ data: { results, summary } }); }); // Suppress unused-import warning when the helper isn't referenced after // future refactors strip the local archive call. void HIGH_STAKES_STAGES; void ({} as PipelineStage);