feat(tenancies-p2): rename berth_reservations → berth_tenancies (schema + perms + UI)

73-file atomic rename per docs/tenancies-design.md:

- Migration 0085: rename table + indexes + FK constraints; rename
  documents.reservation_id → tenancy_id; migrate jsonb permission maps
  (reservations resource → tenancies; collapse create+activate → manage);
  rewrite historical audit_logs.entity_type='berth_reservation' →
  'berth_tenancy'. FK renames wrapped in DO blocks so dev DBs that pre-date
  the FK additions don't abort.
- Schema: berthReservations → berthTenancies; BerthReservation type →
  BerthTenancy; indexes idx_br_* / idx_brr_* → idx_bt_*.
- RolePermissions: resource { view, create, activate, cancel } collapses to
  { view, manage, cancel }; all 8 default seed bundles + role-form + matrix
  updated.
- Service: berth-reservations.service.ts → berth-tenancies.service.ts;
  endReservation → endTenancy; listReservations → listTenancies.
- API: /api/v1/berth-reservations → /api/v1/tenancies (+ nested [id]);
  /api/v1/berths/[id]/reservations → /api/v1/berths/[id]/tenancies.
- Validators: reservations.ts → tenancies.ts; RESERVATION_STATUSES →
  TENANCY_STATUSES; endReservationSchema → endTenancySchema.
- Routes: /{portSlug}/berth-reservations → /{portSlug}/tenancies;
  /portal/my-reservations → /portal/my-tenancies.
- Components: src/components/reservations/* → src/components/tenancies/*;
  BerthReservationsTab → BerthTenanciesTab; ClientReservationsTab →
  ClientTenanciesTab; ReservationList → TenancyList.
- Socket events: berth_reservation:* → berth_tenancy:*; payload
  reservationId → tenancyId.
- Webhook events: berth_reservation.* → berth_tenancy.*.
- Portal: getPortalUserReservations → getPortalUserTenancies;
  PortalReservation → PortalTenancy; PortalDashboard.counts.activeReservations
  → activeTenancies; PortalNav label "Reservations" → "Tenancies".
- Dossier: DossierReservation → DossierTenancy; reservationDecisions →
  tenancyDecisions across smart-archive-dialog + bulk-archive routes.
- Documents schema: documents.reservationId → documents.tenancyId
  (TS + DB column + index + FK constraint).
- Activity feed label berth_reservation → berth_tenancy (matched against
  migrated historical audit rows).

KEPT (separate concepts):
- Reservation Agreement document type (the contract sent to clients).
- "Reservation" pipeline stage name.
- {{reservation.*}} merge tokens in template authoring.
- interest.reservationStatus / reservationDocStatus / dateReservationSent
  fields (track agreement signing on the deal).
- reservation-agreement-context.ts service (builds merge context for the
  Reservation Agreement doc; only its DB imports were renamed).

Verified: tsc clean, 1480/1480 vitest passing, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:09:35 +02:00
parent 4f350d1fbd
commit ccc775dc66
77 changed files with 818 additions and 742 deletions

View File

@@ -93,7 +93,7 @@ test.describe('destructive: client smart-archive + smart-restore', () => {
acknowledgedSignedDocuments: false,
berthDecisions: [],
yachtDecisions: [],
reservationDecisions: [],
tenancyDecisions: [],
invoiceDecisions: [],
documentDecisions: [],
},

View File

@@ -31,7 +31,7 @@ export async function teardown() {
-- Cascade-delete dependent rows. Order respects FK chains.
, del_audit AS (DELETE FROM audit_logs WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_bml AS (DELETE FROM berth_maintenance_log WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_resv AS (DELETE FROM berth_reservations WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_resv AS (DELETE FROM berth_tenancies WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_caddr AS (DELETE FROM client_addresses WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_cmlog AS (DELETE FROM client_merge_log WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
, del_crel AS (DELETE FROM client_relationships WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)

View File

@@ -22,7 +22,7 @@ import {
type NewCompany,
type Company,
} from '@/lib/db/schema/companies';
import { berthReservations, type BerthReservation } from '@/lib/db/schema/reservations';
import { berthTenancies, type BerthTenancy } from '@/lib/db/schema/tenancies';
// ─── Port ────────────────────────────────────────────────────────────────────
@@ -157,7 +157,7 @@ export async function makeMembership(args: {
// ─── Berth Reservation ───────────────────────────────────────────────────────
export async function makeReservation(args: {
export async function makeTenancy(args: {
berthId: string;
portId: string;
clientId: string;
@@ -169,9 +169,9 @@ export async function makeReservation(args: {
interestId?: string;
createdBy?: string;
notes?: string;
}): Promise<BerthReservation> {
}): Promise<BerthTenancy> {
const [row] = await db
.insert(berthReservations)
.insert(berthTenancies)
.values({
berthId: args.berthId,
portId: args.portId,
@@ -363,7 +363,7 @@ export function makeFullPermissions(): RolePermissions {
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
companies: { view: true, create: true, edit: true, delete: true },
memberships: { view: true, manage: true },
reservations: { view: true, create: true, activate: true, cancel: true },
tenancies: { view: true, manage: true, cancel: true },
admin: {
manage_users: true,
view_audit_log: true,
@@ -451,7 +451,7 @@ export function makeViewerPermissions(): RolePermissions {
yachts: { view: true, create: false, edit: false, delete: false, transfer: false },
companies: { view: true, create: false, edit: false, delete: false },
memberships: { view: true, manage: false },
reservations: { view: true, create: false, activate: false, cancel: false },
tenancies: { view: true, manage: false, cancel: false },
admin: {
manage_users: false,
view_audit_log: false,
@@ -539,7 +539,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
yachts: { view: true, create: true, edit: true, delete: false, transfer: false },
companies: { view: true, create: true, edit: false, delete: false },
memberships: { view: true, manage: false },
reservations: { view: true, create: true, activate: true, cancel: false },
tenancies: { view: true, manage: true, cancel: false },
admin: {
manage_users: false,
view_audit_log: false,
@@ -627,7 +627,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
yachts: { view: true, create: true, edit: true, delete: false, transfer: true },
companies: { view: true, create: true, edit: true, delete: false },
memberships: { view: true, manage: true },
reservations: { view: true, create: true, activate: true, cancel: true },
tenancies: { view: true, manage: true, cancel: true },
admin: {
manage_users: false,
view_audit_log: true,

View File

@@ -16,7 +16,7 @@ vi.mock('@/lib/socket/server', () => ({
import { db } from '@/lib/db';
import { alerts } from '@/lib/db/schema/insights';
import { interests } from '@/lib/db/schema/interests';
import { berthReservations } from '@/lib/db/schema/reservations';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { documents } from '@/lib/db/schema/documents';
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
import { makePort, makeClient, makeBerth, makeYacht } from '../helpers/factories';
@@ -49,7 +49,7 @@ describe('alert engine', () => {
});
const fourDaysAgo = new Date(Date.now() - 4 * 86_400_000);
const [resv] = await db
.insert(berthReservations)
.insert(berthTenancies)
.values({
portId: port.id,
berthId: berth.id,
@@ -82,7 +82,7 @@ describe('alert engine', () => {
ownerId: client.id,
});
const stale = new Date(Date.now() - 10 * 86_400_000);
await db.insert(berthReservations).values({
await db.insert(berthTenancies).values({
portId: port.id,
berthId: berth.id,
clientId: client.id,
@@ -112,7 +112,7 @@ describe('alert engine', () => {
});
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000);
const [resv] = await db
.insert(berthReservations)
.insert(berthTenancies)
.values({
portId: port.id,
berthId: berth.id,
@@ -132,7 +132,7 @@ describe('alert engine', () => {
// Add an agreement document - condition no longer fires.
await db.insert(documents).values({
portId: port.id,
reservationId: resv!.id,
tenancyId: resv!.id,
documentType: 'reservation_agreement',
title: 'Reservation Agreement',
status: 'sent',

View File

@@ -1,15 +1,15 @@
/**
* Port-scoped global reservations list - locks in feat(marina): the new
* `GET /api/v1/berth-reservations` endpoint that powers the
* `[portSlug]/berth-reservations` page. The route is thin (parseQuery
* listReservations); the test guarantees port scoping at the handler
* Port-scoped global tenancies list - locks in feat(marina): the
* `GET /api/v1/tenancies` endpoint that powers the
* `[portSlug]/tenancies` page. The route is thin (parseQuery
* listTenancies); the test guarantees port scoping at the handler
* boundary so a future refactor of the service can't accidentally leak
* cross-port rows.
*/
import { describe, it, expect } from 'vitest';
import { listHandler } from '@/app/api/v1/berth-reservations/handlers';
import { createHandler as createReservationHandler } from '@/app/api/v1/berths/[id]/reservations/handlers';
import { listHandler } from '@/app/api/v1/tenancies/handlers';
import { createHandler as createTenancyHandler } from '@/app/api/v1/berths/[id]/tenancies/handlers';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
import {
makeBerth,
@@ -19,13 +19,13 @@ import {
makeYacht,
} from '../../helpers/factories';
async function seedReservation(portId: string) {
async function seedTenancy(portId: string) {
const berth = await makeBerth({ portId });
const client = await makeClient({ portId });
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
const ctx = makeMockCtx({ portId, permissions: makeFullPermissions() });
const res = await createReservationHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
const res = await createTenancyHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
@@ -38,17 +38,14 @@ async function seedReservation(portId: string) {
return ((await res.json()) as any).data as { id: string; berthId: string };
}
describe('GET /api/v1/berth-reservations', () => {
it('returns all reservations for the requesting port', async () => {
describe('GET /api/v1/tenancies', () => {
it('returns all tenancies for the requesting port', async () => {
const port = await makePort();
const r1 = await seedReservation(port.id);
const r2 = await seedReservation(port.id);
const r1 = await seedTenancy(port.id);
const r2 = await seedTenancy(port.id);
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const res = await listHandler(
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
ctx,
);
const res = await listHandler(makeMockRequest('GET', 'http://localhost/api/v1/tenancies'), ctx);
expect(res.status).toBe(200);
const body = (await res.json()) as any;
@@ -57,33 +54,30 @@ describe('GET /api/v1/berth-reservations', () => {
expect(body.pagination).toMatchObject({ page: 1, total: 2 });
});
it('does not leak reservations from a different port', async () => {
it('does not leak tenancies from a different port', async () => {
const portA = await makePort();
const portB = await makePort();
const reservationInB = await seedReservation(portB.id);
const tenancyInB = await seedTenancy(portB.id);
// Caller is operating in portA; portB's reservation must not appear.
// Caller is operating in portA; portB's tenancy must not appear.
const ctx = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
const res = await listHandler(
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
ctx,
);
const res = await listHandler(makeMockRequest('GET', 'http://localhost/api/v1/tenancies'), ctx);
expect(res.status).toBe(200);
const body = (await res.json()) as any;
const ids = (body.data as Array<{ id: string }>).map((r) => r.id);
expect(ids).not.toContain(reservationInB.id);
expect(ids).not.toContain(tenancyInB.id);
});
it('honors pagination via query params', async () => {
const port = await makePort();
await seedReservation(port.id);
await seedReservation(port.id);
await seedReservation(port.id);
await seedTenancy(port.id);
await seedTenancy(port.id);
await seedTenancy(port.id);
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const res = await listHandler(
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations?page=1&limit=2'),
makeMockRequest('GET', 'http://localhost/api/v1/tenancies?page=1&limit=2'),
ctx,
);
expect(res.status).toBe(200);

View File

@@ -4,14 +4,14 @@ import { eq } from 'drizzle-orm';
import {
createHandler as createReservationHandler,
listHandler as listReservationsHandler,
} from '@/app/api/v1/berths/[id]/reservations/handlers';
} from '@/app/api/v1/berths/[id]/tenancies/handlers';
import {
getHandler as getReservationHandler,
patchHandler as patchReservationHandler,
deleteHandler as deleteReservationHandler,
} from '@/app/api/v1/berth-reservations/[id]/handlers';
} from '@/app/api/v1/tenancies/[id]/handlers';
import { db } from '@/lib/db';
import { berthReservations } from '@/lib/db/schema/reservations';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
import {
makeBerth,
@@ -22,9 +22,9 @@ import {
makeYacht,
} from '../../helpers/factories';
// ─── POST /api/v1/berths/[id]/reservations ───────────────────────────────────
// ─── POST /api/v1/berths/[id]/tenancies ───────────────────────────────────
describe('POST /api/v1/berths/[id]/reservations', () => {
describe('POST /api/v1/berths/[id]/tenancies', () => {
it('creates pending reservation (201)', async () => {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
@@ -36,7 +36,7 @@ describe('POST /api/v1/berths/[id]/reservations', () => {
});
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
@@ -64,7 +64,7 @@ describe('POST /api/v1/berths/[id]/reservations', () => {
});
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: {
clientId: otherClient.id,
yachtId: yacht.id,
@@ -88,17 +88,13 @@ describe('POST /api/v1/berths/[id]/reservations', () => {
// Caller is scoped to portB but the URL berth lives in portA.
const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'POST',
`http://localhost/api/v1/berths/${berthA.id}/reservations`,
{
body: {
clientId: client.id,
yachtId: yacht.id,
startDate: new Date().toISOString(),
},
const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
startDate: new Date().toISOString(),
},
);
});
const res = await createReservationHandler(req, ctxB, { id: berthA.id });
expect(res.status).toBe(404);
});
@@ -115,18 +111,14 @@ describe('POST /api/v1/berths/[id]/reservations', () => {
});
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'POST',
`http://localhost/api/v1/berths/${urlBerth.id}/reservations`,
{
body: {
berthId: bodyBerth.id,
clientId: client.id,
yachtId: yacht.id,
startDate: new Date().toISOString(),
},
const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${urlBerth.id}/tenancies`, {
body: {
berthId: bodyBerth.id,
clientId: client.id,
yachtId: yacht.id,
startDate: new Date().toISOString(),
},
);
});
const res = await createReservationHandler(req, ctx, { id: urlBerth.id });
expect(res.status).toBe(201);
const body = (await res.json()) as any;
@@ -135,9 +127,9 @@ describe('POST /api/v1/berths/[id]/reservations', () => {
});
});
// ─── GET /api/v1/berths/[id]/reservations ────────────────────────────────────
// ─── GET /api/v1/berths/[id]/tenancies ────────────────────────────────────
describe('GET /api/v1/berths/[id]/reservations', () => {
describe('GET /api/v1/berths/[id]/tenancies', () => {
it('returns reservations filtered by that berth', async () => {
const port = await makePort();
const berthA = await makeBerth({ portId: port.id });
@@ -152,7 +144,7 @@ describe('GET /api/v1/berths/[id]/reservations', () => {
// Create a reservation for berthA.
await createReservationHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/reservations`, {
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
@@ -165,7 +157,7 @@ describe('GET /api/v1/berths/[id]/reservations', () => {
// Create a reservation for berthB.
await createReservationHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthB.id}/reservations`, {
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthB.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
@@ -177,7 +169,7 @@ describe('GET /api/v1/berths/[id]/reservations', () => {
);
const res = await listReservationsHandler(
makeMockRequest('GET', `http://localhost/api/v1/berths/${berthA.id}/reservations`),
makeMockRequest('GET', `http://localhost/api/v1/berths/${berthA.id}/tenancies`),
ctx,
{ id: berthA.id },
);
@@ -188,9 +180,9 @@ describe('GET /api/v1/berths/[id]/reservations', () => {
});
});
// ─── GET /api/v1/berth-reservations/[id] ─────────────────────────────────────
// ─── GET /api/v1/tenancies/[id] ─────────────────────────────────────
describe('GET /api/v1/berth-reservations/[id]', () => {
describe('GET /api/v1/tenancies/[id]', () => {
it('returns the reservation', async () => {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
@@ -203,7 +195,7 @@ describe('GET /api/v1/berth-reservations/[id]', () => {
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const createRes = await createReservationHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
@@ -216,7 +208,7 @@ describe('GET /api/v1/berth-reservations/[id]', () => {
const reservation = ((await createRes.json()) as any).data;
const res = await getReservationHandler(
makeMockRequest('GET', `http://localhost/api/v1/berth-reservations/${reservation.id}`),
makeMockRequest('GET', `http://localhost/api/v1/tenancies/${reservation.id}`),
ctx,
{ id: reservation.id },
);
@@ -238,7 +230,7 @@ describe('GET /api/v1/berth-reservations/[id]', () => {
const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
const createRes = await createReservationHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
@@ -252,7 +244,7 @@ describe('GET /api/v1/berth-reservations/[id]', () => {
const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
const res = await getReservationHandler(
makeMockRequest('GET', `http://localhost/api/v1/berth-reservations/${reservation.id}`),
makeMockRequest('GET', `http://localhost/api/v1/tenancies/${reservation.id}`),
ctxB,
{ id: reservation.id },
);
@@ -260,9 +252,9 @@ describe('GET /api/v1/berth-reservations/[id]', () => {
});
});
// ─── PATCH /api/v1/berth-reservations/[id] ───────────────────────────────────
// ─── PATCH /api/v1/tenancies/[id] ───────────────────────────────────
describe('PATCH /api/v1/berth-reservations/[id]', () => {
describe('PATCH /api/v1/tenancies/[id]', () => {
async function seedReservation() {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
@@ -275,7 +267,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const createRes = await createReservationHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
@@ -292,7 +284,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
it('activate: pending → active (200)', async () => {
const { ctx, reservation } = await seedReservation();
const res = await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' },
}),
ctx,
@@ -308,7 +300,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
// First activate.
await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' },
}),
ctx,
@@ -318,7 +310,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
// Then end.
const endDate = new Date('2027-01-01T00:00:00.000Z');
const res = await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: {
action: 'end',
endDate: endDate.toISOString(),
@@ -337,7 +329,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
it('cancel: pending → cancelled (200)', async () => {
const { ctx, reservation } = await seedReservation();
const res = await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'cancel', reason: 'client changed mind' },
}),
ctx,
@@ -353,7 +345,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
// pending → active.
await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' },
}),
ctx,
@@ -361,7 +353,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
);
// active → ended.
await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: {
action: 'end',
endDate: new Date().toISOString(),
@@ -373,7 +365,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
// ended → activate should fail.
const res = await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' },
}),
ctx,
@@ -385,7 +377,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
it('returns 400 on invalid body shape (action missing)', async () => {
const { ctx, reservation } = await seedReservation();
const res = await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { notes: 'noop' },
}),
ctx,
@@ -394,18 +386,18 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
expect(res.status).toBe(400);
});
it('returns 403 when caller lacks reservations.activate for activate action', async () => {
it('returns 403 when caller lacks tenancies.manage for activate action', async () => {
const { port, reservation } = await seedReservation();
// Viewer-like permissions: no activate.
// Viewer-like permissions: no manage.
const ctx = makeMockCtx({
portId: port.id,
permissions: {
...makeSalesAgentPermissions(),
reservations: { view: true, create: true, activate: false, cancel: true },
tenancies: { view: true, manage: false, cancel: true },
},
});
const res = await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' },
}),
ctx,
@@ -414,15 +406,15 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
expect(res.status).toBe(403);
});
it('returns 403 when caller lacks reservations.cancel for cancel action', async () => {
it('returns 403 when caller lacks tenancies.cancel for cancel action', async () => {
const { port, reservation } = await seedReservation();
// Sales agent - has activate but NOT cancel.
// Sales agent - has manage but NOT cancel.
const ctx = makeMockCtx({
portId: port.id,
permissions: makeSalesAgentPermissions(),
});
const res = await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'cancel', reason: 'test' },
}),
ctx,
@@ -432,7 +424,7 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
// But activate succeeds with the same permissions set.
const activateRes = await patchReservationHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, {
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
body: { action: 'activate' },
}),
ctx,
@@ -442,9 +434,9 @@ describe('PATCH /api/v1/berth-reservations/[id]', () => {
});
});
// ─── DELETE /api/v1/berth-reservations/[id] ──────────────────────────────────
// ─── DELETE /api/v1/tenancies/[id] ──────────────────────────────────
describe('DELETE /api/v1/berth-reservations/[id]', () => {
describe('DELETE /api/v1/tenancies/[id]', () => {
it('cancels the reservation (204)', async () => {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
@@ -457,7 +449,7 @@ describe('DELETE /api/v1/berth-reservations/[id]', () => {
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const createRes = await createReservationHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
body: {
clientId: client.id,
yachtId: yacht.id,
@@ -470,7 +462,7 @@ describe('DELETE /api/v1/berth-reservations/[id]', () => {
const reservation = ((await createRes.json()) as any).data;
const delRes = await deleteReservationHandler(
makeMockRequest('DELETE', `http://localhost/api/v1/berth-reservations/${reservation.id}`),
makeMockRequest('DELETE', `http://localhost/api/v1/tenancies/${reservation.id}`),
ctx,
{ id: reservation.id },
);
@@ -478,8 +470,8 @@ describe('DELETE /api/v1/berth-reservations/[id]', () => {
const [row] = await db
.select()
.from(berthReservations)
.where(eq(berthReservations.id, reservation.id));
.from(berthTenancies)
.where(eq(berthTenancies.id, reservation.id));
expect(row?.status).toBe('cancelled');
});
});

View File

@@ -6,7 +6,7 @@ import {
makeBerth,
makeYacht,
makeMembership,
makeReservation,
makeTenancy,
makeOwnershipTransfer,
} from '../helpers/factories';
import { db } from '@/lib/db';
@@ -23,7 +23,7 @@ describe('factory helpers smoke', () => {
expect(m.endDate).toBeNull();
});
it('makeReservation inserts a row in any status', async () => {
it('makeTenancy inserts a row in any status', async () => {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
@@ -32,7 +32,7 @@ describe('factory helpers smoke', () => {
ownerType: 'client',
ownerId: client.id,
});
const r = await makeReservation({
const r = await makeTenancy({
berthId: berth.id,
portId: port.id,
clientId: client.id,

View File

@@ -252,9 +252,9 @@ describe('deepMerge - permission override merging', () => {
});
});
// ─── new resources (yachts, companies, memberships, reservations) ────────────
// ─── new resources (yachts, companies, memberships, tenancies) ──────────────
describe('new resources (yachts, companies, memberships, reservations)', () => {
describe('new resources (yachts, companies, memberships, tenancies)', () => {
it('super_admin bypasses all new resource permissions', async () => {
const ctx = makeCtx({ isSuperAdmin: true, permissions: null });
const handler = vi.fn(okHandler());
@@ -295,15 +295,15 @@ describe('new resources (yachts, companies, memberships, reservations)', () => {
expect(manageRes.status).toBe(200);
});
it('sales_agent can reservations.activate but not reservations.cancel', async () => {
it('sales_agent can tenancies.manage but not tenancies.cancel', async () => {
const ctx = makeCtx({ permissions: makeSalesAgentPermissions() });
const activateRes = await withPermission('reservations', 'activate', vi.fn(okHandler()))(
const manageRes = await withPermission('tenancies', 'manage', vi.fn(okHandler()))(
makeRequest(),
ctx,
{},
);
expect(activateRes.status).toBe(200);
const cancelRes = await withPermission('reservations', 'cancel', vi.fn(okHandler()))(
expect(manageRes.status).toBe(200);
const cancelRes = await withPermission('tenancies', 'cancel', vi.fn(okHandler()))(
makeRequest(),
ctx,
{},

View File

@@ -12,7 +12,7 @@ import { describe, it, expect, beforeAll } from 'vitest';
import { db } from '@/lib/db';
import { yachtOwnershipHistory } from '@/lib/db/schema/yachts';
import { berthReservations } from '@/lib/db/schema/reservations';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { companies } from '@/lib/db/schema/companies';
import { makeBerth, makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories';
@@ -86,7 +86,7 @@ describe('schema constraints', () => {
});
const berth = await makeBerth({ portId: port.id });
await db.insert(berthReservations).values({
await db.insert(berthTenancies).values({
berthId: berth.id,
portId: port.id,
clientId: clientA.id,
@@ -97,7 +97,7 @@ describe('schema constraints', () => {
});
await expect(
db.insert(berthReservations).values({
db.insert(berthTenancies).values({
berthId: berth.id,
portId: port.id,
clientId: clientB.id,
@@ -126,7 +126,7 @@ describe('schema constraints', () => {
// Two ended reservations on same berth - both should succeed
// (partial index only constrains status='active').
await expect(
db.insert(berthReservations).values([
db.insert(berthTenancies).values([
{
berthId: berth.id,
portId: port.id,

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { createPending, activate, endReservation } from '@/lib/services/berth-reservations.service';
import { createPending, activate, endTenancy } from '@/lib/services/berth-tenancies.service';
import { makeBerth, makeClient, makePort, makeYacht, makeAuditMeta } from '../helpers/factories';
import { ConflictError } from '@/lib/errors';
describe('reservation exclusivity', () => {
describe('tenancy exclusivity', () => {
it('two concurrent activates on same berth: one succeeds, one throws ConflictError', async () => {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
@@ -52,7 +52,7 @@ describe('reservation exclusivity', () => {
expect((failures[0] as PromiseRejectedResult).reason).toBeInstanceOf(ConflictError);
});
it('activating a second reservation after first is ended succeeds', async () => {
it('activating a second tenancy after first is ended succeeds', async () => {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
const clientA = await makeClient({ portId: port.id });
@@ -74,12 +74,7 @@ describe('reservation exclusivity', () => {
makeAuditMeta({ portId: port.id }),
);
await activate(resA.id, port.id, {}, makeAuditMeta({ portId: port.id }));
await endReservation(
resA.id,
port.id,
{ endDate: new Date() },
makeAuditMeta({ portId: port.id }),
);
await endTenancy(resA.id, port.id, { endDate: new Date() }, makeAuditMeta({ portId: port.id }));
const resB = await createPending(
port.id,

View File

@@ -3,10 +3,10 @@ import { eq } from 'drizzle-orm';
import {
createPending,
activate,
endReservation,
endTenancy,
cancel,
listReservations,
} from '@/lib/services/berth-reservations.service';
listTenancies,
} from '@/lib/services/berth-tenancies.service';
import {
makePort,
makeClient,
@@ -20,7 +20,7 @@ import { companyMemberships } from '@/lib/db/schema/companies';
// ─── createPending ───────────────────────────────────────────────────────────
describe('berth-reservations.service - createPending', () => {
describe('berth-tenancies.service - createPending', () => {
it('creates pending reservation for client-owned yacht', async () => {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
@@ -203,7 +203,7 @@ describe('berth-reservations.service - createPending', () => {
// ─── Lifecycle transitions ───────────────────────────────────────────────────
describe('berth-reservations.service - lifecycle transitions', () => {
describe('berth-tenancies.service - lifecycle transitions', () => {
async function setup() {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
@@ -238,10 +238,10 @@ describe('berth-reservations.service - lifecycle transitions', () => {
expect(activated.status).toBe('active');
});
it('active → ended (endReservation)', async () => {
it('active → ended (endTenancy)', async () => {
const { port, reservation } = await setup();
await activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id }));
const ended = await endReservation(
const ended = await endTenancy(
reservation.id,
port.id,
{ endDate: new Date() },
@@ -277,7 +277,7 @@ describe('berth-reservations.service - lifecycle transitions', () => {
it('rejects ended → active (invalid transition)', async () => {
const { port, reservation } = await setup();
await activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id }));
await endReservation(
await endTenancy(
reservation.id,
port.id,
{ endDate: new Date() },
@@ -304,7 +304,7 @@ describe('berth-reservations.service - lifecycle transitions', () => {
it('rejects cancel from ended state', async () => {
const { port, reservation } = await setup();
await activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id }));
await endReservation(
await endTenancy(
reservation.id,
port.id,
{ endDate: new Date() },
@@ -315,10 +315,10 @@ describe('berth-reservations.service - lifecycle transitions', () => {
).rejects.toThrow(/invalid transition/i);
});
it('rejects endReservation on a pending reservation', async () => {
it('rejects endTenancy on a pending reservation', async () => {
const { port, reservation } = await setup();
await expect(
endReservation(
endTenancy(
reservation.id,
port.id,
{ endDate: new Date() },
@@ -328,9 +328,9 @@ describe('berth-reservations.service - lifecycle transitions', () => {
});
});
// ─── listReservations ────────────────────────────────────────────────────────
// ─── listTenancies ────────────────────────────────────────────────────────
describe('berth-reservations.service - listReservations', () => {
describe('berth-tenancies.service - listTenancies', () => {
async function makeReservation(portId: string, opts?: { berthId?: string }) {
const berth = opts?.berthId ? { id: opts.berthId } : await makeBerth({ portId });
const client = await makeClient({ portId });
@@ -354,7 +354,7 @@ describe('berth-reservations.service - listReservations', () => {
const resA = await makeReservation(portA.id);
await makeReservation(portB.id);
const result = await listReservations(portA.id, {
const result = await listTenancies(portA.id, {
page: 1,
limit: 50,
order: 'desc',
@@ -371,7 +371,7 @@ describe('berth-reservations.service - listReservations', () => {
const resActive = await makeReservation(port.id);
await activate(resActive.id, port.id, {}, makeAuditMeta({ portId: port.id }));
const activeList = await listReservations(port.id, {
const activeList = await listTenancies(port.id, {
page: 1,
limit: 50,
order: 'desc',
@@ -403,7 +403,7 @@ describe('berth-reservations.service - listReservations', () => {
makeAuditMeta({ portId: port.id }),
);
const result = await listReservations(port.id, {
const result = await listTenancies(port.id, {
page: 1,
limit: 50,
order: 'desc',
@@ -418,7 +418,7 @@ describe('berth-reservations.service - listReservations', () => {
// ─── Self-check: DB state is as expected after cancel ────────────────────────
describe('berth-reservations.service - DB state', () => {
describe('berth-tenancies.service - DB state', () => {
it('cancel persists status=cancelled in the database', async () => {
const port = await makePort();
const berth = await makeBerth({ portId: port.id });
@@ -431,8 +431,8 @@ describe('berth-reservations.service - DB state', () => {
);
await cancel(res.id, port.id, {}, makeAuditMeta({ portId: port.id }));
const { berthReservations } = await import('@/lib/db/schema');
const [row] = await db.select().from(berthReservations).where(eq(berthReservations.id, res.id));
const { berthTenancies } = await import('@/lib/db/schema');
const [row] = await db.select().from(berthTenancies).where(eq(berthTenancies.id, res.id));
expect(row!.status).toBe('cancelled');
});
});

View File

@@ -238,8 +238,8 @@ describe('portal.service - getPortalUserMemberships', () => {
});
});
describe('portal.service - getPortalUserReservations', () => {
let getPortalUserReservations: (clientId: string, portId: string) => Promise<Array<any>>;
describe('portal.service - getPortalUserTenancies', () => {
let getPortalUserTenancies: (clientId: string, portId: string) => Promise<Array<any>>;
let makeClient: typeof import('../../helpers/factories').makeClient;
@@ -249,20 +249,20 @@ describe('portal.service - getPortalUserReservations', () => {
let makeBerth: typeof import('../../helpers/factories').makeBerth;
let makeReservation: typeof import('../../helpers/factories').makeReservation;
let makeTenancy: typeof import('../../helpers/factories').makeTenancy;
beforeAll(async () => {
const portalMod = await import('@/lib/services/portal.service');
getPortalUserReservations = portalMod.getPortalUserReservations;
getPortalUserTenancies = portalMod.getPortalUserTenancies;
const factoriesMod = await import('../../helpers/factories');
makeClient = factoriesMod.makeClient;
makePort = factoriesMod.makePort;
makeYacht = factoriesMod.makeYacht;
makeBerth = factoriesMod.makeBerth;
makeReservation = factoriesMod.makeReservation;
makeTenancy = factoriesMod.makeTenancy;
});
it('returns active + pending reservations', async () => {
it('returns active + pending tenancies', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({
@@ -272,14 +272,14 @@ describe('portal.service - getPortalUserReservations', () => {
});
const berth1 = await makeBerth({ portId: port.id });
const berth2 = await makeBerth({ portId: port.id });
await makeReservation({
await makeTenancy({
berthId: berth1.id,
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
status: 'active',
});
await makeReservation({
await makeTenancy({
berthId: berth2.id,
portId: port.id,
clientId: client.id,
@@ -287,7 +287,7 @@ describe('portal.service - getPortalUserReservations', () => {
status: 'pending',
});
const result = await getPortalUserReservations(client.id, port.id);
const result = await getPortalUserTenancies(client.id, port.id);
expect(result).toHaveLength(2);
const statuses = result.map((r) => r.status).sort();
expect(statuses).toEqual(['active', 'pending']);
@@ -303,14 +303,14 @@ describe('portal.service - getPortalUserReservations', () => {
});
const berth1 = await makeBerth({ portId: port.id });
const berth2 = await makeBerth({ portId: port.id });
await makeReservation({
await makeTenancy({
berthId: berth1.id,
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
status: 'ended',
});
await makeReservation({
await makeTenancy({
berthId: berth2.id,
portId: port.id,
clientId: client.id,
@@ -318,7 +318,7 @@ describe('portal.service - getPortalUserReservations', () => {
status: 'cancelled',
});
const result = await getPortalUserReservations(client.id, port.id);
const result = await getPortalUserTenancies(client.id, port.id);
expect(result).toHaveLength(0);
});
@@ -332,7 +332,7 @@ describe('portal.service - getPortalUserReservations', () => {
ownerId: client.id,
});
const berthB = await makeBerth({ portId: portB.id });
await makeReservation({
await makeTenancy({
berthId: berthB.id,
portId: portB.id,
clientId: client.id,
@@ -340,7 +340,7 @@ describe('portal.service - getPortalUserReservations', () => {
status: 'active',
});
const resultA = await getPortalUserReservations(client.id, portA.id);
const resultA = await getPortalUserTenancies(client.id, portA.id);
expect(resultA).toHaveLength(0);
});
@@ -357,7 +357,7 @@ describe('portal.service - getPortalUserReservations', () => {
portId: port.id,
overrides: { mooringNumber: 'M-42' },
});
await makeReservation({
await makeTenancy({
berthId: berth.id,
portId: port.id,
clientId: client.id,
@@ -365,7 +365,7 @@ describe('portal.service - getPortalUserReservations', () => {
status: 'active',
});
const result = await getPortalUserReservations(client.id, port.id);
const result = await getPortalUserTenancies(client.id, port.id);
expect(result).toHaveLength(1);
expect(result[0]!.yachtName).toBe('Test Vessel');
expect(result[0]!.berthMooringNumber).toBe('M-42');

View File

@@ -8,7 +8,7 @@ import { createFieldSchema, updateFieldSchema } from '@/lib/validators/custom-fi
import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts';
import { createCompanySchema } from '@/lib/validators/companies';
import { addMembershipSchema } from '@/lib/validators/company-memberships';
import { createPendingSchema } from '@/lib/validators/reservations';
import { createPendingSchema } from '@/lib/validators/tenancies';
// ─── Client schemas ───────────────────────────────────────────────────────────

View File

@@ -103,13 +103,13 @@ describe('new resource events (yachts, companies, memberships, reservations)', (
);
});
it('includes all berth reservation lifecycle events', () => {
it('includes all berth tenancy lifecycle events', () => {
const events = new Set<string>(WEBHOOK_EVENTS);
[
'berth_reservation.created',
'berth_reservation.activated',
'berth_reservation.ended',
'berth_reservation.cancelled',
'berth_tenancy.created',
'berth_tenancy.activated',
'berth_tenancy.ended',
'berth_tenancy.cancelled',
].forEach((e) => {
expect(events.has(e)).toBe(true);
});
@@ -121,9 +121,7 @@ describe('new resource events (yachts, companies, memberships, reservations)', (
);
});
it('berth_reservation:activated maps to berth_reservation.activated', () => {
expect(INTERNAL_TO_WEBHOOK_MAP['berth_reservation:activated']).toBe(
'berth_reservation.activated',
);
it('berth_tenancy:activated maps to berth_tenancy.activated', () => {
expect(INTERNAL_TO_WEBHOOK_MAP['berth_tenancy:activated']).toBe('berth_tenancy.activated');
});
});