/** * "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 { asc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { userPortRoles, roles, type RolePermissions } from '@/lib/db/schema/users'; import { berthWaitingList } from '@/lib/db/schema/berths'; import { clients } from '@/lib/db/schema/clients'; 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 { // 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(); 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}`, }); } } /** * Waiting-list next-in-line. When a berth transitions to `available`, surface * the #1 client on that berth's waiting list to the staff who manage the * queue so they can reach out / open a deal. Informational only — no * automatic linking or stage changes. * * Recipients = port users whose role grants `berths.manage_waiting_list`. * No-ops when the waiting list is empty or no one holds the permission. */ export async function notifyWaitlistNextInLine(input: { portId: string; berthId: string; mooringNumber: string; }): Promise { // #1 on the queue (lowest position). Left-join the client for the name. const [next] = await db .select({ clientName: clients.fullName, priority: berthWaitingList.priority, }) .from(berthWaitingList) .leftJoin(clients, eq(clients.id, berthWaitingList.clientId)) .where(eq(berthWaitingList.berthId, input.berthId)) .orderBy(asc(berthWaitingList.position)) .limit(1); if (!next) return; // empty waiting list - nothing to surface const recipientRows = 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 recipientIds = new Set(); for (const r of recipientRows) { const perms = r.permissions as RolePermissions | null; if (perms?.berths?.manage_waiting_list) recipientIds.add(r.userId); } if (recipientIds.size === 0) { logger.debug( { portId: input.portId, berthId: input.berthId }, 'No waiting-list managers to notify of berth availability', ); return; } const clientLabel = next.clientName ?? '(unknown client)'; const priorityNote = next.priority === 'high' ? ' (high priority)' : ''; for (const userId of recipientIds) { void createNotification({ portId: input.portId, userId, type: 'berth_waiting_list_update', title: `Berth ${input.mooringNumber} is now available`, description: `${clientLabel} is next in line${priorityNote}. Open the berth to action the waiting list.`, link: `/berths/${input.berthId}`, entityType: 'berth', entityId: input.berthId, dedupeKey: `berth-waitlist-available:${input.berthId}`, }); } }