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,
|
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';
|
|
|
|
|
|
|
|
|
|
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 22:11:00 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
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)';
|
2026-05-06 22:11:00 +02:00
|
|
|
// Pick the berth's first linked interest from the dossier
|
|
|
|
|
// (authoritative interest_berths join). Berths with no linked
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
// interest for this client are dropped - emitting an empty
|
2026-05-06 22:11:00 +02:00
|
|
|
// 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,
|
|
|
|
|
);
|
|
|
|
|
|
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,
|
2026-05-06 22:11:00 +02:00
|
|
|
berthDecisions,
|
2026-05-06 18:32:30 +02:00
|
|
|
yachtDecisions: dossier.yachts.map((y) => ({ yachtId: y.yachtId, action: 'retain' })),
|
2026-05-25 15:09:35 +02:00
|
|
|
tenancyDecisions: dossier.tenancies.map((r) => ({
|
|
|
|
|
tenancyId: r.tenancyId,
|
2026-05-06 18:32:30 +02:00
|
|
|
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 } });
|
|
|
|
|
});
|