Both berth-detail surfaces were stubbed/hidden behind a comment in berth-tabs.tsx. Their backing schema already existed; this wires the UI and fills the service gaps. Maintenance Log (was ~60% built: schema/migration/add+get service/route): - new edit + delete: updateMaintenanceLog / deleteMaintenanceLog service (port-scoped tenant guard), PATCH/DELETE at maintenance/[logId], plus updateMaintenanceLogSchema. add schema now accepts null for cost / responsibleParty so the shared add+edit dialog sends one body shape. - BerthMaintenanceTab: list (newest first) + add/edit dialog + delete confirm, realtime invalidation. New berth:maintenanceUpdated/Removed socket events. Waiting List (un-hide the orphaned manager + next-in-line notify): - getWaitingList now left-joins the client so the queue renders names, not raw ids. - WaitingListManager rewritten: ClientPicker instead of free-text id, client names, manage_waiting_list gating on add/reorder/remove, and a "Next in line" marker on position 1. - notifyWaitlistNextInLine: when a berth transitions to available, surface the #1 client to staff who hold berths.manage_waiting_list (mirrors the interest-based notifyNextInLine; dedupeKey-suppressed). Hooked into updateBerthStatus on any -> available transition. Tests: maintenance add/get/update/delete + cross-port guard; waitlist notify recipient-resolution / payload / empty + no-permission no-ops. Verified end-to-end in the browser (create/render/delete for both). Also adds scripts/dev-reset-admin-pw.ts (reset a synthetic user's password via the better-auth hasher after a dev reseed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
5.6 KiB
TypeScript
158 lines
5.6 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 { 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<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}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
// #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<string>();
|
|
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}`,
|
|
});
|
|
}
|
|
}
|