From ae68e384ca56d7c43e3b8653cea5f756bfe0fbc1 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 10 May 2026 12:38:43 +0200 Subject: [PATCH] docs(claude-md): document folders model + soft-rescue delete semantics Documents the new document_folders self-FK tree, the sibling-name uniqueness invariant, and the soft-rescue delete behaviour so future sessions don't try to wire CASCADE. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index b7a94f36..199a988a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,7 @@ src/ - **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified. - **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1//[id]/tags` endpoint backed by a `setTags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place. - **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape. +- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents carry a nullable `folder_id` (null = root). Sibling-name uniqueness enforced via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain. All folder ops gated on `documents.manage_folders` (read access piggybacks on `documents.view`). - **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware. - **Multi-berth interest model:** `interest_berths` is the source of truth for which berths an interest is linked to; `interests.berth_id` does not exist (dropped in migration 0029). Three role flags: `is_primary` (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), `is_specific_interest` (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), `is_in_eoi_bundle` (covered by the interest's EOI signature). Read/write through `src/lib/services/interest-berths.service.ts` helpers (`getPrimaryBerth`, `getPrimaryBerthsForInterests`, `upsertInterestBerth`, `setPrimaryBerth`, `removeInterestBerth`); never query `interest_berths` from outside that service. - **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.