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:
2026-05-25 16:03:14 +02:00
parent e9ef5831aa
commit dd25ccfb53
19 changed files with 71 additions and 75 deletions

View File

@@ -62,7 +62,7 @@ export default function DataImportPage() {
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
<li>Per-port import history with rollback.</li>
<li>Templates for clients, yachts, companies, berths, reservations, expenses.</li>
<li>Templates for clients, yachts, companies, berths, tenancies, expenses.</li>
</ul>
<p className="text-xs text-muted-foreground pt-2">
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial

View File

@@ -67,7 +67,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
yachts: new Set(['view', 'create', 'edit', 'delete', 'transfer']),
companies: new Set(['view', 'create', 'edit', 'delete']),
memberships: new Set(['view', 'manage']),
reservations: new Set(['view', 'create', 'activate', 'cancel']),
tenancies: new Set(['view', 'manage', 'cancel']),
admin: new Set([
'manage_users',
'view_audit_log',

View File

@@ -189,7 +189,7 @@ const GROUPS: AdminGroup[] = [
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
description: 'Tenant-defined fields for clients, yachts, and tenancies.',
icon: SlidersHorizontal,
},
{
@@ -261,7 +261,7 @@ const GROUPS: AdminGroup[] = [
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
description: 'CSV-driven imports for clients, yachts, and tenancies.',
icon: FileUp,
},
{

View File

@@ -170,7 +170,7 @@ function BulkArchiveWizardBody({ open, onOpenChange, clientIds, onSuccess }: Pro
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel
reservations, leave invoices/signing requests alone. Yachts stay on the archived
tenancies, leave invoices/signing requests alone. Yachts stay on the archived
client. To customise per-client, archive that client individually instead.
</div>
</div>

View File

@@ -128,8 +128,8 @@ function BulkHardDeleteDialogBody({ onOpenChange, clientIds, onDeleted }: Props)
</p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 text-xs">
For each client we delete: client record + addresses, contacts, notes, tags, portal
user, GDPR records, all interests, all reservations. Signed documents, email threads,
files and reminders are detached but kept.
user, GDPR records, all interests, all tenancies. Signed documents, email threads, files
and reminders are detached but kept.
</div>
</div>
)}

View File

@@ -114,6 +114,7 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
'yacht:ownership_transferred': [['clients', clientId]],
'company_membership:added': [['clients', clientId]],
'company_membership:ended': [['clients', clientId]],
'berth_tenancy:created': [['clients', clientId]],
'berth_tenancy:activated': [['clients', clientId]],
'berth_tenancy:ended': [['clients', clientId]],
'berth_tenancy:cancelled': [['clients', clientId]],

View File

@@ -120,7 +120,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
<DialogTitle>Personal data export</DialogTitle>
<DialogDescription>
Bundles every record we hold about this client (profile, contacts, addresses, yachts,
companies, interests, reservations, invoices, documents, audit log) into a ZIP with JSON
companies, interests, tenancies, invoices, documents, audit log) into a ZIP with JSON
and HTML copies. Used to satisfy GDPR Article 15 access requests.
</DialogDescription>
</DialogHeader>

View File

@@ -114,7 +114,7 @@ function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }:
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">
<li>Client record + addresses, contacts, notes, tags</li>
<li>Portal user account + GDPR consent records</li>
<li>All pipeline interests + reservations for this client</li>
<li>All pipeline interests + tenancies for this client</li>
</ul>
<p className="font-medium mt-2">What is preserved</p>
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">

View File

@@ -100,9 +100,9 @@ export function WonStatusPanel({ interestId, outcome }: WonStatusPanelProps) {
Won - wrap-up checklist
</CardTitle>
<p className="text-xs text-emerald-800/80">
Upload anything that didn&apos;t flow through the system automatically. Reservations,
deposit invoicing, and client billing are handled outside the CRM - this checklist is for
the paperwork that lives on the deal itself.
Upload anything that didn&apos;t flow through the system automatically. Tenancies, deposit
invoicing, and client billing are handled outside the CRM - this checklist is for the
paperwork that lives on the deal itself.
</p>
</CardHeader>
<CardContent className="space-y-2">

View File

@@ -65,7 +65,7 @@ async function reservationNoAgreement(portId: string): Promise<AlertCandidate[]>
title: `Reservation needs an agreement`,
body: `Active reservation for ${r.yachtName} (${r.clientName}) has no signed agreement yet.`,
link: `/[port]/tenancies/${r.id}`,
entityType: 'reservation',
entityType: 'berth_tenancy',
entityId: r.id,
}));
}

View File

@@ -80,7 +80,7 @@ export interface MergeResult {
notes: number;
tags: number;
relationships: number;
reservations: number;
tenancies: number;
};
}
@@ -152,7 +152,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
.select({ id: interests.id })
.from(interests)
.where(eq(interests.clientId, opts.loserId));
const loserReservations = await tx
const loserTenancies = await tx
.select({ id: berthTenancies.id })
.from(berthTenancies)
.where(eq(berthTenancies.clientId, opts.loserId));
@@ -172,7 +172,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
notes: loserNotes,
tags: loserTags,
interests: loserInterests.map((r) => r.id),
reservations: loserReservations.map((r) => r.id),
tenancies: loserTenancies.map((r) => r.id),
relationshipsAsA: loserRelationshipsAsA,
relationshipsAsB: loserRelationshipsAsB,
fieldChoices: opts.fieldChoices ?? {},
@@ -213,7 +213,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
.returning({ id: interests.id })
).length;
const movedReservations = (
const movedTenancies = (
await tx
.update(berthTenancies)
.set({ clientId: opts.winnerId, updatedAt: new Date() })
@@ -357,7 +357,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
loserId: opts.loserId,
loserName: loserRow.fullName,
movedInterests,
movedReservations,
movedTenancies,
movedContacts,
movedAddresses,
},
@@ -372,7 +372,7 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
notes: movedNotes,
tags: movedTags,
relationships: movedRelationships,
reservations: movedReservations,
tenancies: movedTenancies,
},
};
});

View File

@@ -57,7 +57,7 @@ export interface GdprBundle {
}>;
interests: Record<string, unknown>[];
contactLog: Record<string, unknown>[];
reservations: Record<string, unknown>[];
tenancies: Record<string, unknown>[];
invoices: Record<string, unknown>[];
documents: Record<string, unknown>[];
files: Record<string, unknown>[];
@@ -95,7 +95,7 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
ownedYachts,
membershipRows,
interestRows,
reservationRows,
tenancyRows,
invoiceRows,
documentRows,
fileRows,
@@ -268,7 +268,7 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
})),
interests: interestRows.map(toJsonRow),
contactLog: contactLogRows.map(toJsonRow),
reservations: reservationRows.map(toJsonRow),
tenancies: tenancyRows.map(toJsonRow),
invoices: invoiceRows.map(toJsonRow),
documents: documentRows.map(toJsonRow),
files: fileRows.map(toJsonRow),
@@ -361,7 +361,7 @@ export function renderBundleHtml(bundle: GdprBundle): string {
),
tableSection('Interests', bundle.interests),
tableSection('Contact log', bundle.contactLog),
tableSection('Reservations', bundle.reservations),
tableSection('Tenancies', bundle.tenancies),
tableSection('Invoices', bundle.invoices),
tableSection('Documents', bundle.documents),
tableSection('Files', bundle.files),