2026-05-06 14:58:34 +02:00
|
|
|
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';
|
2026-05-06 18:32:30 +02:00
|
|
|
import { setClientTags } from '@/lib/services/clients.service';
|
|
|
|
|
import {
|
|
|
|
|
getClientArchiveDossier,
|
|
|
|
|
HIGH_STAKES_STAGES,
|
fix(audit): CRITICAL — wire 5 missing workers + bulk-archive side-effects + restore-button hover
C1: src/worker.ts and src/server.ts only imported 5 of 10 BullMQ
workers. ai/bulk/maintenance/reports/webhooks were never started, so
in production: webhooks never delivered, no maintenance crons (DB
backups, session cleanup, retention sweeps, alerts, analytics refresh,
calendar sync), no scheduled reports, no AI features, no async bulk.
All 10 are now imported and held against GC.
R2-C1: Bulk archive's runBulk callback discarded the return value
from archiveClientWithDecisions, so Documenso envelopes marked for
void in the wizard were never queued and next-in-line notifications
never fired. Now we collect the per-archive (dossier, result) pairs
and replay the same post-commit fan-out the single-client route uses.
R2-C2: Archived-client header's Restore icon was hovering destructive-
red because an unconditional hover:text-foreground was overriding the
later conditional. Restore now hovers emerald; archive still hovers
red.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:03:47 +02:00
|
|
|
type ClientArchiveDossier,
|
2026-05-06 18:32:30 +02:00
|
|
|
} from '@/lib/services/client-archive-dossier.service';
|
fix(audit): CRITICAL — wire 5 missing workers + bulk-archive side-effects + restore-button hover
C1: src/worker.ts and src/server.ts only imported 5 of 10 BullMQ
workers. ai/bulk/maintenance/reports/webhooks were never started, so
in production: webhooks never delivered, no maintenance crons (DB
backups, session cleanup, retention sweeps, alerts, analytics refresh,
calendar sync), no scheduled reports, no AI features, no async bulk.
All 10 are now imported and held against GC.
R2-C1: Bulk archive's runBulk callback discarded the return value
from archiveClientWithDecisions, so Documenso envelopes marked for
void in the wizard were never queued and next-in-line notifications
never fired. Now we collect the per-archive (dossier, result) pairs
and replay the same post-commit fan-out the single-client route uses.
R2-C2: Archived-client header's Restore icon was hovering destructive-
red because an unconditional hover:text-foreground was overriding the
later conditional. Restore now hovers emerald; archive still hovers
red.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:03:47 +02:00
|
|
|
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';
|
2026-05-06 14:58:34 +02:00
|
|
|
import { errorResponse } from '@/lib/errors';
|
2026-05-06 18:32:30 +02:00
|
|
|
import type { PipelineStage } from '@/lib/constants';
|
2026-05-06 14:58:34 +02:00
|
|
|
|
|
|
|
|
const bulkSchema = z.discriminatedUnion('action', [
|
|
|
|
|
z.object({
|
|
|
|
|
action: z.literal('archive'),
|
|
|
|
|
ids: z.array(z.string().min(1)).min(1).max(100),
|
2026-05-06 19:29:17 +02:00
|
|
|
/** 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(),
|
2026-05-06 14:58:34 +02:00
|
|
|
}),
|
|
|
|
|
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<typeof bulkSchema>;
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-06 19:29:17 +02:00
|
|
|
const reasonsByClientId = body.action === 'archive' ? (body.reasonsByClientId ?? {}) : {};
|
|
|
|
|
|
fix(audit): CRITICAL — wire 5 missing workers + bulk-archive side-effects + restore-button hover
C1: src/worker.ts and src/server.ts only imported 5 of 10 BullMQ
workers. ai/bulk/maintenance/reports/webhooks were never started, so
in production: webhooks never delivered, no maintenance crons (DB
backups, session cleanup, retention sweeps, alerts, analytics refresh,
calendar sync), no scheduled reports, no AI features, no async bulk.
All 10 are now imported and held against GC.
R2-C1: Bulk archive's runBulk callback discarded the return value
from archiveClientWithDecisions, so Documenso envelopes marked for
void in the wizard were never queued and next-in-line notifications
never fired. Now we collect the per-archive (dossier, result) pairs
and replay the same post-commit fan-out the single-client route uses.
R2-C2: Archived-client header's Restore icon was hovering destructive-
red because an unconditional hover:text-foreground was overriding the
later conditional. Restore now hovers emerald; archive still hovers
red.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:03:47 +02:00
|
|
|
// 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;
|
|
|
|
|
}> = [];
|
|
|
|
|
|
2026-05-06 14:58:34 +02:00
|
|
|
const { results, summary } = await runBulk(body.ids, async (id) => {
|
|
|
|
|
if (body.action === 'archive') {
|
2026-05-06 18:32:30 +02:00
|
|
|
// 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,
|
2026-05-06 19:29:17 +02:00
|
|
|
// 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.
|
2026-05-06 18:32:30 +02:00
|
|
|
const dossier = await getClientArchiveDossier(id, ctx.portId);
|
2026-05-06 19:29:17 +02:00
|
|
|
const perClientReason = reasonsByClientId[id];
|
|
|
|
|
if (dossier.stakeLevel === 'high' && !perClientReason) {
|
2026-05-06 18:32:30 +02:00
|
|
|
throw new Error(
|
2026-05-06 19:29:17 +02:00
|
|
|
`Client at ${dossier.highStakesStage} requires a per-client reason; supply one in reasonsByClientId.`,
|
2026-05-06 18:32:30 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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',
|
|
|
|
|
);
|
2026-05-06 19:29:17 +02:00
|
|
|
const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)';
|
fix(audit): CRITICAL — wire 5 missing workers + bulk-archive side-effects + restore-button hover
C1: src/worker.ts and src/server.ts only imported 5 of 10 BullMQ
workers. ai/bulk/maintenance/reports/webhooks were never started, so
in production: webhooks never delivered, no maintenance crons (DB
backups, session cleanup, retention sweeps, alerts, analytics refresh,
calendar sync), no scheduled reports, no AI features, no async bulk.
All 10 are now imported and held against GC.
R2-C1: Bulk archive's runBulk callback discarded the return value
from archiveClientWithDecisions, so Documenso envelopes marked for
void in the wizard were never queued and next-in-line notifications
never fired. Now we collect the per-archive (dossier, result) pairs
and replay the same post-commit fan-out the single-client route uses.
R2-C2: Archived-client header's Restore icon was hovering destructive-
red because an unconditional hover:text-foreground was overriding the
later conditional. Restore now hovers emerald; archive still hovers
red.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:03:47 +02:00
|
|
|
const result = await archiveClientWithDecisions({
|
2026-05-06 18:32:30 +02:00
|
|
|
dossier,
|
|
|
|
|
decisions: {
|
2026-05-06 19:29:17 +02:00
|
|
|
reason,
|
2026-05-06 18:32:30 +02:00
|
|
|
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,
|
|
|
|
|
});
|
fix(audit): CRITICAL — wire 5 missing workers + bulk-archive side-effects + restore-button hover
C1: src/worker.ts and src/server.ts only imported 5 of 10 BullMQ
workers. ai/bulk/maintenance/reports/webhooks were never started, so
in production: webhooks never delivered, no maintenance crons (DB
backups, session cleanup, retention sweeps, alerts, analytics refresh,
calendar sync), no scheduled reports, no AI features, no async bulk.
All 10 are now imported and held against GC.
R2-C1: Bulk archive's runBulk callback discarded the return value
from archiveClientWithDecisions, so Documenso envelopes marked for
void in the wizard were never queued and next-in-line notifications
never fired. Now we collect the per-archive (dossier, result) pairs
and replay the same post-commit fan-out the single-client route uses.
R2-C2: Archived-client header's Restore icon was hovering destructive-
red because an unconditional hover:text-foreground was overriding the
later conditional. Restore now hovers emerald; archive still hovers
red.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:03:47 +02:00
|
|
|
archiveSideEffects.push({ dossier, result });
|
2026-05-06 14:58:34 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
fix(audit): CRITICAL — wire 5 missing workers + bulk-archive side-effects + restore-button hover
C1: src/worker.ts and src/server.ts only imported 5 of 10 BullMQ
workers. ai/bulk/maintenance/reports/webhooks were never started, so
in production: webhooks never delivered, no maintenance crons (DB
backups, session cleanup, retention sweeps, alerts, analytics refresh,
calendar sync), no scheduled reports, no AI features, no async bulk.
All 10 are now imported and held against GC.
R2-C1: Bulk archive's runBulk callback discarded the return value
from archiveClientWithDecisions, so Documenso envelopes marked for
void in the wizard were never queued and next-in-line notifications
never fired. Now we collect the per-archive (dossier, result) pairs
and replay the same post-commit fan-out the single-client route uses.
R2-C2: Archived-client header's Restore icon was hovering destructive-
red because an unconditional hover:text-foreground was overriding the
later conditional. Restore now hovers emerald; archive still hovers
red.
1175/1175 vitest passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:03:47 +02:00
|
|
|
// 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',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 14:58:34 +02:00
|
|
|
return NextResponse.json({ data: { results, summary } });
|
|
|
|
|
});
|
2026-05-06 18:32:30 +02:00
|
|
|
|
|
|
|
|
// 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);
|