feat(client-archive): async Documenso voids + next-in-line sales notifications

Post-archive side-effects now run with backpressure:
- Documenso envelope voids enqueue to BullMQ documents queue with retry/DLQ
- Released berths fan out a "next in line" notification to port users with
  interests.change_stage; informational only, no auto stage transitions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 19:12:55 +02:00
parent 8c02f88cbd
commit 1ae5d88af4
3 changed files with 159 additions and 5 deletions

View File

@@ -8,6 +8,9 @@ import {
type ArchiveDecisions,
} from '@/lib/services/client-archive.service';
import { getClientArchiveDossier } from '@/lib/services/client-archive-dossier.service';
import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service';
import { getQueue } from '@/lib/queue';
import { logger } from '@/lib/logger';
import { errorResponse, NotFoundError } from '@/lib/errors';
const decisionsSchema = z.object({
@@ -80,11 +83,54 @@ export const POST = withAuth(
},
});
// External cleanups (Documenso void) + next-in-line notifications
// are queued post-commit. v1 fires them best-effort inline; future
// iteration: enqueue to BullMQ for retry/dead-letter (see
// bulletproof-webhooks design).
// TODO(bulletproof-webhooks): move to queue.
// ─── Post-commit side-effects ────────────────────────────────────
// 1. Documenso envelope voids → queued for retry on the documents
// queue. Permanently-failed jobs land in BullMQ's failed jobs
// list (the DLQ in admin/monitoring).
if (result.externalCleanups.length > 0) {
const queue = getQueue('documents');
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 }, 'Failed to enqueue Documenso void'),
);
}
}
}
// 2. Next-in-line notifications → fire-and-forget per released
// berth so the sales team knows who else expressed interest.
// No automatic stage transitions on the next interests; sales
// rep decides what to do.
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 },
'Failed to fire next-in-line notification',
),
);
}
return NextResponse.json({ data: result });
} catch (error) {