feat(berths): ship Waiting List + Maintenance Log tabs

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>
This commit is contained in:
2026-06-01 21:55:04 +02:00
parent d98aa5cc8a
commit 8be7a6e29d
11 changed files with 1046 additions and 103 deletions

View File

@@ -10,10 +10,12 @@
* (the canonical "this person handles the pipeline" permission).
*/
import { eq } from 'drizzle-orm';
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';
@@ -88,3 +90,68 @@ export async function notifyNextInLine(input: BerthReleaseNotificationInput): Pr
});
}
}
/**
* 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}`,
});
}
}