Files
pn-new-crm/src/lib/services/next-in-line-notify.service.ts
Matt 4b9743a594 audit: 33-agent comprehensive audit + critical fixes
Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:35 +02:00

91 lines
3.2 KiB
TypeScript

/**
* "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';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
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 stageLabel =
STAGE_LABELS[i.pipelineStage as PipelineStage] ?? i.pipelineStage.replace(/_/g, ' ');
return `${i.clientName ?? '(unknown)'}${stageLabel}`;
});
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}`,
});
}
}