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:
@@ -23,6 +23,7 @@ import type {
|
||||
BulkUpdateBerthPricesInput,
|
||||
ListBerthsQuery,
|
||||
AddMaintenanceLogInput,
|
||||
UpdateMaintenanceLogInput,
|
||||
UpdateWaitingListInput,
|
||||
} from '@/lib/validators/berths';
|
||||
|
||||
@@ -615,6 +616,22 @@ export async function updateBerthStatus(
|
||||
await setPrimaryBerth(data.interestId, id);
|
||||
}
|
||||
|
||||
// Next-in-line: when a berth frees up (any status -> available), surface
|
||||
// the #1 client on its waiting list to the staff who manage the queue.
|
||||
// Fire-and-forget via dynamic import (keeps the notifications stack out of
|
||||
// this module's static graph); dedupeKey collapses rapid re-fires.
|
||||
if (existing.status !== 'available' && data.status === 'available') {
|
||||
void import('@/lib/services/next-in-line-notify.service')
|
||||
.then(({ notifyWaitlistNextInLine }) =>
|
||||
notifyWaitlistNextInLine({
|
||||
portId,
|
||||
berthId: id,
|
||||
mooringNumber: existing.mooringNumber,
|
||||
}),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
@@ -860,9 +877,9 @@ export async function addMaintenanceLog(
|
||||
portId,
|
||||
category: data.category,
|
||||
description: data.description,
|
||||
cost: data.cost !== undefined ? String(data.cost) : undefined,
|
||||
cost: data.cost == null ? undefined : String(data.cost),
|
||||
costCurrency: data.costCurrency,
|
||||
responsibleParty: data.responsibleParty,
|
||||
responsibleParty: data.responsibleParty ?? undefined,
|
||||
performedDate: data.performedDate,
|
||||
photoFileIds: data.photoFileIds,
|
||||
createdBy: meta.userId,
|
||||
@@ -902,7 +919,102 @@ export async function getMaintenanceLogs(id: string, portId: string) {
|
||||
.select()
|
||||
.from(berthMaintenanceLog)
|
||||
.where(and(eq(berthMaintenanceLog.berthId, id), eq(berthMaintenanceLog.portId, portId)))
|
||||
.orderBy(berthMaintenanceLog.performedDate);
|
||||
.orderBy(desc(berthMaintenanceLog.performedDate));
|
||||
}
|
||||
|
||||
// ─── Update Maintenance Log ───────────────────────────────────────────────────
|
||||
|
||||
export async function updateMaintenanceLog(
|
||||
berthId: string,
|
||||
logId: string,
|
||||
portId: string,
|
||||
data: UpdateMaintenanceLogInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
// Tenant guard: the entry must belong to a berth in this port. port_id is
|
||||
// on the row itself, so a single (id, berth_id, port_id) match is the
|
||||
// defense-in-depth scope — no foreign-tenant row can be reached.
|
||||
const existing = await db.query.berthMaintenanceLog.findFirst({
|
||||
where: and(
|
||||
eq(berthMaintenanceLog.id, logId),
|
||||
eq(berthMaintenanceLog.berthId, berthId),
|
||||
eq(berthMaintenanceLog.portId, portId),
|
||||
),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Maintenance log entry');
|
||||
|
||||
const patch: Partial<typeof berthMaintenanceLog.$inferInsert> = { updatedAt: new Date() };
|
||||
if (data.category !== undefined) patch.category = data.category;
|
||||
if (data.description !== undefined) patch.description = data.description;
|
||||
if (data.cost !== undefined) patch.cost = data.cost === null ? null : String(data.cost);
|
||||
if (data.costCurrency !== undefined) patch.costCurrency = data.costCurrency;
|
||||
if (data.responsibleParty !== undefined) patch.responsibleParty = data.responsibleParty;
|
||||
if (data.performedDate !== undefined) patch.performedDate = data.performedDate;
|
||||
if (data.photoFileIds !== undefined) patch.photoFileIds = data.photoFileIds;
|
||||
|
||||
const rows = await db
|
||||
.update(berthMaintenanceLog)
|
||||
.set(patch)
|
||||
.where(
|
||||
and(
|
||||
eq(berthMaintenanceLog.id, logId),
|
||||
eq(berthMaintenanceLog.berthId, berthId),
|
||||
eq(berthMaintenanceLog.portId, portId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const log = rows[0]!;
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'berth_maintenance_log',
|
||||
entityId: log.id,
|
||||
metadata: { berthId, category: log.category },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth:maintenanceUpdated', { berthId, logEntry: log });
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
// ─── Delete Maintenance Log ───────────────────────────────────────────────────
|
||||
|
||||
export async function deleteMaintenanceLog(
|
||||
berthId: string,
|
||||
logId: string,
|
||||
portId: string,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const rows = await db
|
||||
.delete(berthMaintenanceLog)
|
||||
.where(
|
||||
and(
|
||||
eq(berthMaintenanceLog.id, logId),
|
||||
eq(berthMaintenanceLog.berthId, berthId),
|
||||
eq(berthMaintenanceLog.portId, portId),
|
||||
),
|
||||
)
|
||||
.returning({ id: berthMaintenanceLog.id });
|
||||
if (rows.length === 0) throw new NotFoundError('Maintenance log entry');
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'berth_maintenance_log',
|
||||
entityId: logId,
|
||||
metadata: { berthId },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth:maintenanceRemoved', { berthId, logId });
|
||||
|
||||
return { id: logId };
|
||||
}
|
||||
|
||||
// ─── Get Waiting List ─────────────────────────────────────────────────────────
|
||||
@@ -913,9 +1025,23 @@ export async function getWaitingList(id: string, portId: string) {
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Berth');
|
||||
|
||||
// Left-join the client so the UI can render names (the queue stores only
|
||||
// clientId). A left join keeps a row even if the client was hard-deleted.
|
||||
return db
|
||||
.select()
|
||||
.select({
|
||||
id: berthWaitingList.id,
|
||||
berthId: berthWaitingList.berthId,
|
||||
clientId: berthWaitingList.clientId,
|
||||
clientName: clients.fullName,
|
||||
yachtId: berthWaitingList.yachtId,
|
||||
position: berthWaitingList.position,
|
||||
priority: berthWaitingList.priority,
|
||||
notifyPref: berthWaitingList.notifyPref,
|
||||
notes: berthWaitingList.notes,
|
||||
createdAt: berthWaitingList.createdAt,
|
||||
})
|
||||
.from(berthWaitingList)
|
||||
.leftJoin(clients, eq(clients.id, berthWaitingList.clientId))
|
||||
.where(eq(berthWaitingList.berthId, id))
|
||||
.orderBy(berthWaitingList.position);
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ServerToClientEvents {
|
||||
entry: unknown;
|
||||
}) => void;
|
||||
'berth:maintenanceAdded': (payload: { berthId: string; logEntry: unknown }) => void;
|
||||
'berth:maintenanceUpdated': (payload: { berthId: string; logEntry: unknown }) => void;
|
||||
'berth:maintenanceRemoved': (payload: { berthId: string; logId: string }) => void;
|
||||
|
||||
// Client events
|
||||
'client:created': (payload: { clientId: string; clientName: string; source: string }) => void;
|
||||
|
||||
@@ -158,15 +158,35 @@ export type BulkUpdateBerthPricesInput = z.infer<typeof bulkUpdateBerthPricesSch
|
||||
export const addMaintenanceLogSchema = z.object({
|
||||
category: z.enum(['routine', 'repair', 'inspection', 'upgrade']),
|
||||
description: z.string().min(1),
|
||||
cost: z.coerce.number().optional(),
|
||||
// `null` accepted (and treated as "no value") so the shared add/edit dialog
|
||||
// can send a single body shape — an empty optional field maps to null.
|
||||
cost: z.coerce.number().nullable().optional(),
|
||||
costCurrency: z.string().optional(),
|
||||
responsibleParty: z.string().optional(),
|
||||
responsibleParty: z.string().nullable().optional(),
|
||||
performedDate: z.string().min(1, 'Performed date is required'),
|
||||
photoFileIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type AddMaintenanceLogInput = z.infer<typeof addMaintenanceLogSchema>;
|
||||
|
||||
// ─── Update Maintenance Log ───────────────────────────────────────────────────
|
||||
|
||||
// Partial of the add schema — every field optional so the edit dialog can
|
||||
// PATCH just the touched columns. `cost` and `responsibleParty` accept `null`
|
||||
// so the rep can clear a previously-recorded value (an empty edit field maps
|
||||
// to null in the UI, not to 0 / '').
|
||||
export const updateMaintenanceLogSchema = z.object({
|
||||
category: z.enum(['routine', 'repair', 'inspection', 'upgrade']).optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
cost: z.coerce.number().nullable().optional(),
|
||||
costCurrency: z.string().optional(),
|
||||
responsibleParty: z.string().nullable().optional(),
|
||||
performedDate: z.string().min(1).optional(),
|
||||
photoFileIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type UpdateMaintenanceLogInput = z.infer<typeof updateMaintenanceLogSchema>;
|
||||
|
||||
// ─── Update Waiting List ──────────────────────────────────────────────────────
|
||||
|
||||
export const updateWaitingListSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user