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:
118
tests/integration/berth-maintenance-log.test.ts
Normal file
118
tests/integration/berth-maintenance-log.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Integration test: berth maintenance-log service (add / get / update / delete).
|
||||
*
|
||||
* The schema + add/get already existed; this covers the new update + delete
|
||||
* paths and the tenant guard (an entry can only be reached through its own
|
||||
* berth + port). Runs against the real test DB.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
let svc: typeof import('@/lib/services/berths.service');
|
||||
let makePort: typeof import('../helpers/factories').makePort;
|
||||
let makeBerth: typeof import('../helpers/factories').makeBerth;
|
||||
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
|
||||
|
||||
beforeAll(async () => {
|
||||
svc = await import('@/lib/services/berths.service');
|
||||
const f = await import('../helpers/factories');
|
||||
makePort = f.makePort;
|
||||
makeBerth = f.makeBerth;
|
||||
makeAuditMeta = f.makeAuditMeta;
|
||||
});
|
||||
|
||||
describe('berth maintenance log', () => {
|
||||
async function setup() {
|
||||
const port = await makePort();
|
||||
const berth = await makeBerth({ portId: port.id });
|
||||
const meta = makeAuditMeta({ portId: port.id });
|
||||
return { port, berth, meta };
|
||||
}
|
||||
|
||||
it('adds, lists (newest first), updates, and deletes an entry', async () => {
|
||||
const { port, berth, meta } = await setup();
|
||||
|
||||
const created = await svc.addMaintenanceLog(
|
||||
berth.id,
|
||||
port.id,
|
||||
{
|
||||
category: 'repair',
|
||||
description: 'Replaced cleat bolt',
|
||||
cost: 120.5,
|
||||
costCurrency: 'EUR',
|
||||
responsibleParty: 'Dockside Ltd',
|
||||
performedDate: '2026-05-10',
|
||||
},
|
||||
meta,
|
||||
);
|
||||
expect(created.category).toBe('repair');
|
||||
expect(created.cost).toBe('120.5');
|
||||
|
||||
// A second, more recent entry to assert ordering.
|
||||
await svc.addMaintenanceLog(
|
||||
berth.id,
|
||||
port.id,
|
||||
{ category: 'inspection', description: 'Annual check', performedDate: '2026-05-20' },
|
||||
meta,
|
||||
);
|
||||
|
||||
const list = await svc.getMaintenanceLogs(berth.id, port.id);
|
||||
expect(list).toHaveLength(2);
|
||||
// Newest performedDate first.
|
||||
expect(list[0]!.performedDate).toBe('2026-05-20');
|
||||
expect(list[1]!.performedDate).toBe('2026-05-10');
|
||||
|
||||
const updated = await svc.updateMaintenanceLog(
|
||||
berth.id,
|
||||
created.id,
|
||||
port.id,
|
||||
{ description: 'Replaced cleat bolt + washer', cost: null },
|
||||
meta,
|
||||
);
|
||||
expect(updated.description).toBe('Replaced cleat bolt + washer');
|
||||
// cost cleared to null
|
||||
expect(updated.cost).toBeNull();
|
||||
|
||||
await svc.deleteMaintenanceLog(berth.id, created.id, port.id, meta);
|
||||
const afterDelete = await db
|
||||
.select()
|
||||
.from(berthMaintenanceLog)
|
||||
.where(eq(berthMaintenanceLog.id, created.id));
|
||||
expect(afterDelete).toHaveLength(0);
|
||||
|
||||
const remaining = await svc.getMaintenanceLogs(berth.id, port.id);
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.category).toBe('inspection');
|
||||
});
|
||||
|
||||
it('refuses to update or delete an entry through a different port (tenant guard)', async () => {
|
||||
const { port, berth, meta } = await setup();
|
||||
const otherPort = await makePort();
|
||||
|
||||
const entry = await svc.addMaintenanceLog(
|
||||
berth.id,
|
||||
port.id,
|
||||
{ category: 'routine', description: 'Pressure wash', performedDate: '2026-05-15' },
|
||||
meta,
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.updateMaintenanceLog(berth.id, entry.id, otherPort.id, { description: 'hijack' }, meta),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(
|
||||
svc.deleteMaintenanceLog(berth.id, entry.id, otherPort.id, meta),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Untouched.
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(berthMaintenanceLog)
|
||||
.where(eq(berthMaintenanceLog.id, entry.id));
|
||||
expect(row!.description).toBe('Pressure wash');
|
||||
});
|
||||
});
|
||||
112
tests/integration/berth-waitlist-notify.test.ts
Normal file
112
tests/integration/berth-waitlist-notify.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Integration test: waiting-list next-in-line notification.
|
||||
*
|
||||
* `notifyWaitlistNextInLine` surfaces the #1 client on a berth's waiting list
|
||||
* to staff who hold `berths.manage_waiting_list` when the berth frees up.
|
||||
* `createNotification` is mocked so we assert the fan-out decision (who, what
|
||||
* payload) without exercising the full notification insert / preferences /
|
||||
* queue path (covered by notification-lifecycle.test.ts).
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/services/notifications.service', () => ({
|
||||
createNotification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { user, roles, userPortRoles, type RolePermissions } from '@/lib/db/schema/users';
|
||||
import { berthWaitingList } from '@/lib/db/schema/berths';
|
||||
import { createNotification } from '@/lib/services/notifications.service';
|
||||
|
||||
let notifyWaitlistNextInLine: typeof import('@/lib/services/next-in-line-notify.service').notifyWaitlistNextInLine;
|
||||
let makePort: typeof import('../helpers/factories').makePort;
|
||||
let makeBerth: typeof import('../helpers/factories').makeBerth;
|
||||
let makeClient: typeof import('../helpers/factories').makeClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
notifyWaitlistNextInLine = (await import('@/lib/services/next-in-line-notify.service'))
|
||||
.notifyWaitlistNextInLine;
|
||||
const f = await import('../helpers/factories');
|
||||
makePort = f.makePort;
|
||||
makeBerth = f.makeBerth;
|
||||
makeClient = f.makeClient;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(createNotification).mockClear();
|
||||
});
|
||||
|
||||
async function seedManager(portId: string, perms: RolePermissions): Promise<string> {
|
||||
const suffix = crypto.randomUUID().slice(0, 8);
|
||||
const [u] = await db
|
||||
.insert(user)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: `WL User ${suffix}`,
|
||||
email: `wl-${suffix}@test.local`,
|
||||
emailVerified: true,
|
||||
})
|
||||
.returning();
|
||||
const [role] = await db
|
||||
.insert(roles)
|
||||
.values({ name: `WL Role ${suffix}`, permissions: perms })
|
||||
.returning();
|
||||
await db.insert(userPortRoles).values({ userId: u!.id, portId, roleId: role!.id });
|
||||
return u!.id;
|
||||
}
|
||||
|
||||
describe('notifyWaitlistNextInLine', () => {
|
||||
it('notifies managers about the #1 client when a berth frees up', async () => {
|
||||
const port = await makePort();
|
||||
const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'WL1' } });
|
||||
const first = await makeClient({ portId: port.id, overrides: { fullName: 'Ada First' } });
|
||||
const second = await makeClient({ portId: port.id, overrides: { fullName: 'Bob Second' } });
|
||||
|
||||
await db.insert(berthWaitingList).values([
|
||||
{ berthId: berth.id, clientId: first.id, position: 1, priority: 'high' },
|
||||
{ berthId: berth.id, clientId: second.id, position: 2, priority: 'normal' },
|
||||
]);
|
||||
|
||||
await seedManager(port.id, { berths: { manage_waiting_list: true } } as RolePermissions);
|
||||
|
||||
await notifyWaitlistNextInLine({
|
||||
portId: port.id,
|
||||
berthId: berth.id,
|
||||
mooringNumber: 'WL1',
|
||||
});
|
||||
|
||||
expect(createNotification).toHaveBeenCalledTimes(1);
|
||||
const payload = vi.mocked(createNotification).mock.calls[0]![0];
|
||||
expect(payload.type).toBe('berth_waiting_list_update');
|
||||
expect(payload.title).toContain('WL1');
|
||||
// #1 by position (Ada), with the high-priority note.
|
||||
expect(payload.description).toContain('Ada First');
|
||||
expect(payload.description).toContain('high priority');
|
||||
expect(payload.dedupeKey).toBe(`berth-waitlist-available:${berth.id}`);
|
||||
expect(payload.entityId).toBe(berth.id);
|
||||
});
|
||||
|
||||
it('does not notify users who lack manage_waiting_list', async () => {
|
||||
const port = await makePort();
|
||||
const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'WL2' } });
|
||||
const c = await makeClient({ portId: port.id, overrides: { fullName: 'Carol Only' } });
|
||||
await db.insert(berthWaitingList).values({ berthId: berth.id, clientId: c.id, position: 1 });
|
||||
|
||||
await seedManager(port.id, { berths: { manage_waiting_list: false } } as RolePermissions);
|
||||
|
||||
await notifyWaitlistNextInLine({ portId: port.id, berthId: berth.id, mooringNumber: 'WL2' });
|
||||
|
||||
expect(createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no-ops when the waiting list is empty', async () => {
|
||||
const port = await makePort();
|
||||
const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'WL3' } });
|
||||
await seedManager(port.id, { berths: { manage_waiting_list: true } } as RolePermissions);
|
||||
|
||||
await notifyWaitlistNextInLine({ portId: port.id, berthId: berth.id, mooringNumber: 'WL3' });
|
||||
|
||||
expect(createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user