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:
88
src/lib/services/next-in-line-notify.service.ts
Normal file
88
src/lib/services/next-in-line-notify.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* "Next in line" notification fan-out.
|
||||
*
|
||||
* After a smart-archive releases a berth back to available, the sales
|
||||
* team should be told who else expressed interest in that berth so they
|
||||
* can follow up. This is informational only — no automatic stage
|
||||
* transitions on the next interests.
|
||||
*
|
||||
* Recipients = port users whose role grants `interests.change_stage`
|
||||
* (the canonical "this person handles the pipeline" permission).
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { userPortRoles, roles, type RolePermissions } from '@/lib/db/schema/users';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { createNotification } from '@/lib/services/notifications.service';
|
||||
|
||||
export interface BerthReleaseNotificationInput {
|
||||
portId: string;
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
archivedClientName: string;
|
||||
/** ids of the next-in-line interests on this berth (with the metadata
|
||||
* needed for the notification body — comes from the dossier). */
|
||||
nextInLineInterests: Array<{
|
||||
interestId: string;
|
||||
clientName: string | null;
|
||||
pipelineStage: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function notifyNextInLine(input: BerthReleaseNotificationInput): Promise<void> {
|
||||
// 1. Resolve recipients: every port user whose role permits interests.change_stage.
|
||||
const portRoleRows = await db
|
||||
.select({
|
||||
userId: userPortRoles.userId,
|
||||
permissions: roles.permissions,
|
||||
})
|
||||
.from(userPortRoles)
|
||||
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
|
||||
.where(eq(userPortRoles.portId, input.portId));
|
||||
|
||||
const salesUserIds = new Set<string>();
|
||||
for (const r of portRoleRows) {
|
||||
const perms = r.permissions as RolePermissions | null;
|
||||
if (perms?.interests?.change_stage) salesUserIds.add(r.userId);
|
||||
}
|
||||
|
||||
if (salesUserIds.size === 0) {
|
||||
logger.debug(
|
||||
{ portId: input.portId, berthId: input.berthId },
|
||||
'No sales recipients for next-in-line notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Build a single description listing the next interests so the
|
||||
// rep can act on it without opening the berth detail page first.
|
||||
const previewLines = input.nextInLineInterests.slice(0, 5).map((i) => {
|
||||
const days = i.pipelineStage.replace(/_/g, ' ');
|
||||
return `${i.clientName ?? '(unknown)'} — ${days}`;
|
||||
});
|
||||
const more =
|
||||
input.nextInLineInterests.length > 5
|
||||
? `\n…and ${input.nextInLineInterests.length - 5} more`
|
||||
: '';
|
||||
const description = input.nextInLineInterests.length
|
||||
? `${previewLines.join('\n')}${more}`
|
||||
: 'No prior interests recorded — this berth is fully available again.';
|
||||
|
||||
// 3. Fire-and-forget per recipient. dedupeKey collapses duplicate
|
||||
// fires within the cooldown window if multiple events queue up.
|
||||
for (const userId of salesUserIds) {
|
||||
void createNotification({
|
||||
portId: input.portId,
|
||||
userId,
|
||||
type: 'berth_released',
|
||||
title: `Berth ${input.mooringNumber} released — ${input.archivedClientName} archived`,
|
||||
description,
|
||||
link: `/berths/${input.berthId}`,
|
||||
entityType: 'berth',
|
||||
entityId: input.berthId,
|
||||
dedupeKey: `berth-released:${input.berthId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user