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>
This commit is contained in:
Matt Ciaccio
2026-05-06 22:03:47 +02:00
parent 9890d065f8
commit c5b41ca4b5
4 changed files with 105 additions and 6 deletions

View File

@@ -11,8 +11,15 @@ import { setClientTags } from '@/lib/services/clients.service';
import {
getClientArchiveDossier,
HIGH_STAKES_STAGES,
type ClientArchiveDossier,
} from '@/lib/services/client-archive-dossier.service';
import { archiveClientWithDecisions } from '@/lib/services/client-archive.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';
import type { PipelineStage } from '@/lib/constants';
@@ -65,6 +72,15 @@ export const POST = withAuth(async (req, ctx) => {
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
@@ -87,7 +103,7 @@ export const POST = withAuth(async (req, ctx) => {
(d) => d.status === 'completed' || d.status === 'signed',
);
const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)';
await archiveClientWithDecisions({
const result = await archiveClientWithDecisions({
dossier,
decisions: {
reason,
@@ -117,6 +133,7 @@ export const POST = withAuth(async (req, ctx) => {
},
meta,
});
archiveSideEffects.push({ dossier, result });
return;
}
const client = await db.query.clients.findFirst({
@@ -133,6 +150,56 @@ export const POST = withAuth(async (req, ctx) => {
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 } });
});

View File

@@ -178,8 +178,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
title={isArchived ? 'Restore client' : 'Archive client'}
className={cn(
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
'hover:bg-foreground/5',
isArchived ? 'hover:text-emerald-600' : 'hover:text-destructive',
)}
>
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}

View File

@@ -74,12 +74,28 @@ async function main(): Promise<void> {
const { notificationsWorker } = await import('@/lib/queue/workers/notifications');
const { importWorker } = await import('@/lib/queue/workers/import');
const { exportWorker } = await import('@/lib/queue/workers/export');
const { aiWorker } = await import('@/lib/queue/workers/ai');
const { bulkWorker } = await import('@/lib/queue/workers/bulk');
const { maintenanceWorker } = await import('@/lib/queue/workers/maintenance');
const { reportsWorker } = await import('@/lib/queue/workers/reports');
const { webhooksWorker } = await import('@/lib/queue/workers/webhooks');
await registerRecurringJobs();
logger.info('BullMQ recurring jobs registered (dev mode)');
// Keep a reference so workers aren't GC'd
void [emailWorker, documentsWorker, notificationsWorker, importWorker, exportWorker];
void [
emailWorker,
documentsWorker,
notificationsWorker,
importWorker,
exportWorker,
aiWorker,
bulkWorker,
maintenanceWorker,
reportsWorker,
webhooksWorker,
];
}
httpServer.listen(env.PORT, () => {

View File

@@ -15,9 +15,25 @@ import { documentsWorker } from '@/lib/queue/workers/documents';
import { notificationsWorker } from '@/lib/queue/workers/notifications';
import { importWorker } from '@/lib/queue/workers/import';
import { exportWorker } from '@/lib/queue/workers/export';
import { aiWorker } from '@/lib/queue/workers/ai';
import { bulkWorker } from '@/lib/queue/workers/bulk';
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
import { reportsWorker } from '@/lib/queue/workers/reports';
import { webhooksWorker } from '@/lib/queue/workers/webhooks';
// Keep references so workers aren't GC'd
const workers = [emailWorker, documentsWorker, notificationsWorker, importWorker, exportWorker];
const workers = [
emailWorker,
documentsWorker,
notificationsWorker,
importWorker,
exportWorker,
aiWorker,
bulkWorker,
maintenanceWorker,
reportsWorker,
webhooksWorker,
];
async function main(): Promise<void> {
logger.info({ workerCount: workers.length }, 'BullMQ workers started');