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:
@@ -11,8 +11,15 @@ import { setClientTags } from '@/lib/services/clients.service';
|
|||||||
import {
|
import {
|
||||||
getClientArchiveDossier,
|
getClientArchiveDossier,
|
||||||
HIGH_STAKES_STAGES,
|
HIGH_STAKES_STAGES,
|
||||||
|
type ClientArchiveDossier,
|
||||||
} from '@/lib/services/client-archive-dossier.service';
|
} 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 { errorResponse } from '@/lib/errors';
|
||||||
import type { PipelineStage } from '@/lib/constants';
|
import type { PipelineStage } from '@/lib/constants';
|
||||||
|
|
||||||
@@ -65,6 +72,15 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
|
|
||||||
const reasonsByClientId = body.action === 'archive' ? (body.reasonsByClientId ?? {}) : {};
|
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) => {
|
const { results, summary } = await runBulk(body.ids, async (id) => {
|
||||||
if (body.action === 'archive') {
|
if (body.action === 'archive') {
|
||||||
// Bulk archive uses the smart-archive backend with sensible
|
// 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',
|
(d) => d.status === 'completed' || d.status === 'signed',
|
||||||
);
|
);
|
||||||
const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)';
|
const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)';
|
||||||
await archiveClientWithDecisions({
|
const result = await archiveClientWithDecisions({
|
||||||
dossier,
|
dossier,
|
||||||
decisions: {
|
decisions: {
|
||||||
reason,
|
reason,
|
||||||
@@ -117,6 +133,7 @@ export const POST = withAuth(async (req, ctx) => {
|
|||||||
},
|
},
|
||||||
meta,
|
meta,
|
||||||
});
|
});
|
||||||
|
archiveSideEffects.push({ dossier, result });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const client = await db.query.clients.findFirst({
|
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);
|
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 } });
|
return NextResponse.json({ data: { results, summary } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -178,8 +178,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
title={isArchived ? 'Restore client' : 'Archive client'}
|
title={isArchived ? 'Restore client' : 'Archive client'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||||
'hover:bg-foreground/5 hover:text-foreground',
|
'hover:bg-foreground/5',
|
||||||
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
|
isArchived ? 'hover:text-emerald-600' : 'hover:text-destructive',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
|
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
|
||||||
|
|||||||
@@ -74,12 +74,28 @@ async function main(): Promise<void> {
|
|||||||
const { notificationsWorker } = await import('@/lib/queue/workers/notifications');
|
const { notificationsWorker } = await import('@/lib/queue/workers/notifications');
|
||||||
const { importWorker } = await import('@/lib/queue/workers/import');
|
const { importWorker } = await import('@/lib/queue/workers/import');
|
||||||
const { exportWorker } = await import('@/lib/queue/workers/export');
|
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();
|
await registerRecurringJobs();
|
||||||
logger.info('BullMQ recurring jobs registered (dev mode)');
|
logger.info('BullMQ recurring jobs registered (dev mode)');
|
||||||
|
|
||||||
// Keep a reference so workers aren't GC'd
|
// 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, () => {
|
httpServer.listen(env.PORT, () => {
|
||||||
|
|||||||
@@ -15,9 +15,25 @@ import { documentsWorker } from '@/lib/queue/workers/documents';
|
|||||||
import { notificationsWorker } from '@/lib/queue/workers/notifications';
|
import { notificationsWorker } from '@/lib/queue/workers/notifications';
|
||||||
import { importWorker } from '@/lib/queue/workers/import';
|
import { importWorker } from '@/lib/queue/workers/import';
|
||||||
import { exportWorker } from '@/lib/queue/workers/export';
|
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
|
// 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> {
|
async function main(): Promise<void> {
|
||||||
logger.info({ workerCount: workers.length }, 'BullMQ workers started');
|
logger.info({ workerCount: workers.length }, 'BullMQ workers started');
|
||||||
|
|||||||
Reference in New Issue
Block a user