fix(tenancies-audit): resolve findings from 7-agent system-wide rename audit
MUST-FIX:
- src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:70 — the
PUT allowlist still gated `reservations: {view,create,activate,cancel}`.
Stale: would reject valid `tenancies.{view,manage,cancel}` writes and
silently accept ghost `reservations.*` writes that never land. Replaced.
- src/lib/services/alert-rules.ts:68 — `reservation.no_agreement` alert
emitted `entityType: 'reservation'`. Every other tenancy-related
audit/socket/dashboard label is `'berth_tenancy'`. Inconsistent dedupe
+ activity-feed label miss.
- tests/e2e/exhaustive/08-portal.spec.ts:6 — hardcoded /portal/my-reservations
navigates to a 404 every run.
- tests/e2e/exhaustive/03-reservations.spec.ts — entire spec renamed to
03-tenancies.spec.ts; tab + button locators updated to match renamed UI.
SHOULD-FIX (consistency):
- src/components/clients/client-detail.tsx — useRealtimeInvalidation only
caught 3 of the 4 berth_tenancy:* events; added the `:created` listener.
- src/lib/services/client-merge.service.ts — MergeResult.movedRows.reservations
+ snapshot.reservations + local loserReservations / movedReservations
renamed to tenancies / loserTenancies / movedTenancies. No external
consumers grep-confirmed.
- src/lib/services/gdpr-bundle-builder.ts — GdprBundle.reservations field
renamed to .tenancies; user-facing HTML section "Reservations" → "Tenancies";
local reservationRows → tenancyRows.
- 6 UI copy strings: gdpr-export-button, bulk-archive-wizard,
bulk-hard-delete-dialog, hard-delete-dialog, admin-sections-browser ×2,
admin/import/page, won-status-panel — all "reservations" prose updated
to "tenancies" (occupancy-record sense).
- tests/integration/api/tenancies.test.ts — handler import aliases
`createReservationHandler` etc renamed to `createTenancyHandler` etc.
- tests/unit/services/berth-tenancies.test.ts — local helper makeReservation
→ makeTenancyLocal (avoids shadow of the renamed factory).
- scripts/audit-permissions.ts — stale allowlist entry for
/berth-reservations/[id]/route.ts removed (path no longer exists).
- docs/runbooks/permission-audit.md — stale row for same path removed.
- docs/tenancies-design.md — fixed factual error
("tenancies.service.ts" → "berth-tenancies.service.ts").
Verified: tsc clean, 1493/1493 vitest.
Dev-server note: the running `next dev` process started before P2 and
shows Turbopack cached compile errors against the renamed schema files.
Source is correct (./tenancies); restart `next dev` to clear the cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,12 @@ import { test, expect } from '@playwright/test';
|
||||
import { clickEverythingOnPage } from '../../helpers/click-everything';
|
||||
import { login, navigateTo, PORT_SLUG } from '../smoke/helpers';
|
||||
|
||||
test.describe('exhaustive: berth reservations', () => {
|
||||
test.describe('exhaustive: berth tenancies', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
|
||||
test('berths list - reservation-related affordances are clickable', async ({ page }) => {
|
||||
test('berths list - tenancy-related affordances are clickable', async ({ page }) => {
|
||||
await navigateTo(page, '/berths');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
test('berth detail - reservations tab opens and is interactive', async ({ page }) => {
|
||||
test('berth detail - tenancies tab opens and is interactive', async ({ page }) => {
|
||||
await navigateTo(page, '/berths');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -29,9 +29,9 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
await page.waitForURL(new RegExp(`/${PORT_SLUG}/berths/[^/]+`), { timeout: 10_000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const reservationsTab = page.getByRole('tab', { name: /reservations/i }).first();
|
||||
if (await reservationsTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await reservationsTab.click();
|
||||
const tenanciesTab = page.getByRole('tab', { name: /tenancies/i }).first();
|
||||
if (await tenanciesTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await tenanciesTab.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
expect(result.errors, JSON.stringify(result.errors, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
test('reserve dialog opens and closes', async ({ page }) => {
|
||||
test('create-tenancy dialog opens and closes', async ({ page }) => {
|
||||
await navigateTo(page, '/berths');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const firstRow = page.locator('tbody tr a, tbody tr button').first();
|
||||
@@ -50,9 +50,9 @@ test.describe('exhaustive: berth reservations', () => {
|
||||
await firstRow.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const reserveBtn = page.getByRole('button', { name: /reserve|new reservation/i }).first();
|
||||
if (await reserveBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await reserveBtn.click();
|
||||
const createBtn = page.getByRole('button', { name: /create tenancy|reserve/i }).first();
|
||||
if (await createBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await createBtn.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
const cancel = dialog.getByRole('button', { name: /cancel|close/i }).first();
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test';
|
||||
import { clickEverythingOnPage } from '../../helpers/click-everything';
|
||||
import { login } from '../smoke/helpers';
|
||||
|
||||
const PORTAL_PAGES = ['/portal/yachts', '/portal/memberships', '/portal/my-reservations'];
|
||||
const PORTAL_PAGES = ['/portal/yachts', '/portal/memberships', '/portal/my-tenancies'];
|
||||
|
||||
test.describe('exhaustive: client portal', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
createHandler as createReservationHandler,
|
||||
listHandler as listReservationsHandler,
|
||||
createHandler as createTenancyHandler,
|
||||
listHandler as listTenanciesHandler,
|
||||
} from '@/app/api/v1/berths/[id]/tenancies/handlers';
|
||||
import {
|
||||
getHandler as getReservationHandler,
|
||||
patchHandler as patchReservationHandler,
|
||||
deleteHandler as deleteReservationHandler,
|
||||
getHandler as getTenancyHandler,
|
||||
patchHandler as patchTenancyHandler,
|
||||
deleteHandler as deleteTenancyHandler,
|
||||
} from '@/app/api/v1/tenancies/[id]/handlers';
|
||||
import { db } from '@/lib/db';
|
||||
import { berthTenancies } from '@/lib/db/schema/tenancies';
|
||||
@@ -53,7 +53,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const res = await createReservationHandler(req, ctx, { id: berth.id });
|
||||
const res = await createTenancyHandler(req, ctx, { id: berth.id });
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.data.berthId).toBe(berth.id);
|
||||
@@ -81,7 +81,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const res = await createReservationHandler(req, ctx, { id: berth.id });
|
||||
const res = await createTenancyHandler(req, ctx, { id: berth.id });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const res = await createReservationHandler(req, ctxB, { id: berthA.id });
|
||||
const res = await createTenancyHandler(req, ctxB, { id: berthA.id });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('POST /api/v1/berths/[id]/tenancies', () => {
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const res = await createReservationHandler(req, ctx, { id: urlBerth.id });
|
||||
const res = await createTenancyHandler(req, ctx, { id: urlBerth.id });
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.data.berthId).toBe(urlBerth.id);
|
||||
@@ -153,7 +153,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
// Create a reservation for berthA.
|
||||
await createReservationHandler(
|
||||
await createTenancyHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/tenancies`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -166,7 +166,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
|
||||
);
|
||||
|
||||
// Create a reservation for berthB.
|
||||
await createReservationHandler(
|
||||
await createTenancyHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berthB.id}/tenancies`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -178,7 +178,7 @@ describe('GET /api/v1/berths/[id]/tenancies', () => {
|
||||
{ id: berthB.id },
|
||||
);
|
||||
|
||||
const res = await listReservationsHandler(
|
||||
const res = await listTenanciesHandler(
|
||||
makeMockRequest('GET', `http://localhost/api/v1/berths/${berthA.id}/tenancies`),
|
||||
ctx,
|
||||
{ id: berthA.id },
|
||||
@@ -204,7 +204,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
||||
});
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
const createRes = await createReservationHandler(
|
||||
const createRes = await createTenancyHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -217,7 +217,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
||||
);
|
||||
const reservation = ((await createRes.json()) as any).data;
|
||||
|
||||
const res = await getReservationHandler(
|
||||
const res = await getTenancyHandler(
|
||||
makeMockRequest('GET', `http://localhost/api/v1/tenancies/${reservation.id}`),
|
||||
ctx,
|
||||
{ id: reservation.id },
|
||||
@@ -239,7 +239,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
||||
});
|
||||
const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
|
||||
|
||||
const createRes = await createReservationHandler(
|
||||
const createRes = await createTenancyHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -253,7 +253,7 @@ describe('GET /api/v1/tenancies/[id]', () => {
|
||||
const reservation = ((await createRes.json()) as any).data;
|
||||
|
||||
const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
|
||||
const res = await getReservationHandler(
|
||||
const res = await getTenancyHandler(
|
||||
makeMockRequest('GET', `http://localhost/api/v1/tenancies/${reservation.id}`),
|
||||
ctxB,
|
||||
{ id: reservation.id },
|
||||
@@ -276,7 +276,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
});
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
const createRes = await createReservationHandler(
|
||||
const createRes = await createTenancyHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -293,7 +293,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
|
||||
it('activate: pending → active (200)', async () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -309,7 +309,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
|
||||
// First activate.
|
||||
await patchReservationHandler(
|
||||
await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -319,7 +319,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
|
||||
// Then end.
|
||||
const endDate = new Date('2027-01-01T00:00:00.000Z');
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: {
|
||||
action: 'end',
|
||||
@@ -338,7 +338,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
|
||||
it('cancel: pending → cancelled (200)', async () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'cancel', reason: 'client changed mind' },
|
||||
}),
|
||||
@@ -354,7 +354,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
|
||||
// pending → active.
|
||||
await patchReservationHandler(
|
||||
await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -362,7 +362,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
{ id: reservation.id },
|
||||
);
|
||||
// active → ended.
|
||||
await patchReservationHandler(
|
||||
await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: {
|
||||
action: 'end',
|
||||
@@ -374,7 +374,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
);
|
||||
|
||||
// ended → activate should fail.
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -386,7 +386,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
|
||||
it('returns 400 on invalid body shape (action missing)', async () => {
|
||||
const { ctx, reservation } = await seedReservation();
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { notes: 'noop' },
|
||||
}),
|
||||
@@ -406,7 +406,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
tenancies: { view: true, manage: false, cancel: true },
|
||||
},
|
||||
});
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -423,7 +423,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
portId: port.id,
|
||||
permissions: makeSalesAgentPermissions(),
|
||||
});
|
||||
const res = await patchReservationHandler(
|
||||
const res = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'cancel', reason: 'test' },
|
||||
}),
|
||||
@@ -433,7 +433,7 @@ describe('PATCH /api/v1/tenancies/[id]', () => {
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// But activate succeeds with the same permissions set.
|
||||
const activateRes = await patchReservationHandler(
|
||||
const activateRes = await patchTenancyHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/tenancies/${reservation.id}`, {
|
||||
body: { action: 'activate' },
|
||||
}),
|
||||
@@ -458,7 +458,7 @@ describe('DELETE /api/v1/tenancies/[id]', () => {
|
||||
});
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
const createRes = await createReservationHandler(
|
||||
const createRes = await createTenancyHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/tenancies`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
@@ -471,7 +471,7 @@ describe('DELETE /api/v1/tenancies/[id]', () => {
|
||||
);
|
||||
const reservation = ((await createRes.json()) as any).data;
|
||||
|
||||
const delRes = await deleteReservationHandler(
|
||||
const delRes = await deleteTenancyHandler(
|
||||
makeMockRequest('DELETE', `http://localhost/api/v1/tenancies/${reservation.id}`),
|
||||
ctx,
|
||||
{ id: reservation.id },
|
||||
|
||||
@@ -331,7 +331,7 @@ describe('berth-tenancies.service - lifecycle transitions', () => {
|
||||
// ─── listTenancies ────────────────────────────────────────────────────────
|
||||
|
||||
describe('berth-tenancies.service - listTenancies', () => {
|
||||
async function makeReservation(portId: string, opts?: { berthId?: string }) {
|
||||
async function makeTenancyLocal(portId: string, opts?: { berthId?: string }) {
|
||||
const berth = opts?.berthId ? { id: opts.berthId } : await makeBerth({ portId });
|
||||
const client = await makeClient({ portId });
|
||||
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
|
||||
@@ -351,8 +351,8 @@ describe('berth-tenancies.service - listTenancies', () => {
|
||||
it('is tenant-scoped', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const resA = await makeReservation(portA.id);
|
||||
await makeReservation(portB.id);
|
||||
const resA = await makeTenancyLocal(portA.id);
|
||||
await makeTenancyLocal(portB.id);
|
||||
|
||||
const result = await listTenancies(portA.id, {
|
||||
page: 1,
|
||||
@@ -367,8 +367,8 @@ describe('berth-tenancies.service - listTenancies', () => {
|
||||
|
||||
it('filters by status', async () => {
|
||||
const port = await makePort();
|
||||
const resPending = await makeReservation(port.id);
|
||||
const resActive = await makeReservation(port.id);
|
||||
const resPending = await makeTenancyLocal(port.id);
|
||||
const resActive = await makeTenancyLocal(port.id);
|
||||
await activate(resActive.id, port.id, {}, makeAuditMeta({ portId: port.id }));
|
||||
|
||||
const activeList = await listTenancies(port.id, {
|
||||
|
||||
Reference in New Issue
Block a user