From 04ddd596620f65949ae52900cc0bf9171f5978be Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 18:02:45 +0200 Subject: [PATCH] chore(repo): untrack internal docs + CLAUDE.md (keep local-only) Per Matt: internal planning/audit/deployment docs + CLAUDE.md don't belong in the shared Gitea repo. git rm --cached (files kept in the working tree) + gitignored docs/ and CLAUDE.md. Tests kept. No history rewrite - what was exposed is infra topology (IP/SSH), not credentials (actual secrets were always in gitignored private/). Fresh repo-appropriate docs to follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + CLAUDE.md | 240 - docs/AUDIT-2026-05-12.md | 7522 ----------------- docs/AUDIT-CATALOG.md | 733 -- docs/AUDIT-FINDINGS-2026-05-15.md | 335 - docs/AUDIT-FIX-WAVE-2026-05-18.md | 266 - docs/AUDIT-FOLLOWUPS.md | 716 -- docs/AUDIT-PARKED-QUESTIONS.md | 212 - docs/AUDIT-PROGRESS-2026-05-15.md | 83 - docs/AUDIT-TRIAGE.md | 153 - docs/BACKLOG.md | 451 - docs/MANUAL-TESTING-BACKLOG-2026-05-15.md | 1622 ---- docs/MASTER-PLAN-2026-05-18.md | 1928 ----- docs/POST-AUDIT-FIX-PLAN.md | 305 - docs/POST-AUDIT-SPEC-2026-05-18.md | 251 - docs/PRE-DEPLOY-PLAN.md | 243 - docs/admin-ia-proposal.md | 415 - docs/admin-ux-backlog.md | 196 - docs/audit-2026-05-15.md | 117 - docs/audit-comprehensive-2026-05-05.md | 1126 --- docs/audit-comprehensive-2026-05-06.md | 753 -- docs/audit-final-deferred.md | 278 - .../01a-legacy-master-grep.md | 22 - .../01b-legacy-rendering-surfaces.md | 28 - .../01c-legacy-adjacent-enums.md | 26 - .../02-multitenancy-schema.md | 105 - docs/audit-findings-tmp/03-routes-auth.md | 68 - docs/audit-findings-tmp/04-audit-log.md | 92 - docs/audit-findings-tmp/05-documents-files.md | 52 - docs/audit-findings-tmp/06-security.md | 30 - .../07-email-integrations.md | 112 - docs/audit-findings-tmp/08-perf-behavioral.md | 55 - docs/audit-findings-tmp/09-ux-forms.md | 159 - docs/audit-frontend-2026-05-06.md | 223 - docs/audit-missing-features-2026-05-06.md | 405 - docs/audit-permissions-2026-05-06.md | 266 - docs/audit-reliability-2026-05-06.md | 220 - docs/audits/2026-06-02/findings-master.md | 697 -- docs/berth-feature-handoff-prompt.md | 147 - docs/berth-recommender-and-pdf-plan.md | 1086 --- docs/deal-pulse-trigger-audit.md | 134 - docs/deployment-plan.md | 378 - docs/documenso-build-plan.md | 722 -- docs/documenso-integration-audit.md | 252 - docs/email-refactor-deferred.md | 49 - docs/eoi-documenso-field-mapping.md | 81 - docs/error-handling.md | 188 - docs/features-list.md | 234 - docs/launch-readiness.md | 718 -- docs/marketing-site-followups.md | 37 - docs/new-system-feature-summary.md | 338 - docs/operations/outbound-comms-safety.md | 123 - docs/reports-content-spec.md | 524 -- docs/reports-page-design.md | 278 - docs/runbooks/backup-and-restore.md | 199 - docs/runbooks/email-deliverability.md | 186 - docs/runbooks/permission-audit.md | 55 - .../audits/2026-05-11-prod-readiness-audit.md | 489 -- .../audits/2026-05-18-full-codebase-audit.md | 335 - .../audits/2026-05-21-remaining-plan.md | 242 - docs/superpowers/audits/active-uat.md | 667 -- docs/superpowers/audits/alpha-uat-master.md | 1119 --- .../plans/2026-04-14-inquiry-notifications.md | 1119 --- .../plans/2026-04-23-data-model-refactor.md | 2678 ------ .../plans/2026-04-29-mobile-foundation.md | 1918 ----- .../plans/2026-05-09-documents-folders.md | 3151 ------- ...-documents-hub-split-and-client-folders.md | 4501 ---------- .../2026-05-18-audit-cleanup-completion.md | 1235 --- .../plans/2026-06-02-reports-polish.md | 1147 --- ...2026-04-14-inquiry-notifications-design.md | 201 - .../2026-04-23-data-model-refactor-design.md | 663 -- ...026-04-28-country-phone-timezone-design.md | 171 - .../specs/2026-04-28-documents-hub-design.md | 775 -- ...26-04-28-phase-b-insights-alerts-design.md | 435 - .../2026-04-29-gws-inbox-triage-design.md | 376 - .../2026-04-29-mobile-optimization-design.md | 189 - .../2026-05-03-dedup-and-migration-design.md | 564 -- ...nts-hub-split-and-client-folders-design.md | 375 - .../2026-05-12-pdf-stack-overhaul-design.md | 491 -- ...026-05-15-env-to-admin-migration-design.md | 391 - .../specs/2026-06-01-bulk-import-design.md | 168 - ...2026-06-01-legacy-data-migration-design.md | 212 - .../specs/2026-06-02-reports-polish-design.md | 154 - docs/tenancies-design.md | 302 - docs/umami-api-capabilities.md | 189 - docs/website-analytics-flesh-out-plan.md | 428 - docs/website-cutover-runbook.md | 124 - docs/website-refactor.md | 160 - 88 files changed, 4 insertions(+), 51203 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 docs/AUDIT-2026-05-12.md delete mode 100644 docs/AUDIT-CATALOG.md delete mode 100644 docs/AUDIT-FINDINGS-2026-05-15.md delete mode 100644 docs/AUDIT-FIX-WAVE-2026-05-18.md delete mode 100644 docs/AUDIT-FOLLOWUPS.md delete mode 100644 docs/AUDIT-PARKED-QUESTIONS.md delete mode 100644 docs/AUDIT-PROGRESS-2026-05-15.md delete mode 100644 docs/AUDIT-TRIAGE.md delete mode 100644 docs/BACKLOG.md delete mode 100644 docs/MANUAL-TESTING-BACKLOG-2026-05-15.md delete mode 100644 docs/MASTER-PLAN-2026-05-18.md delete mode 100644 docs/POST-AUDIT-FIX-PLAN.md delete mode 100644 docs/POST-AUDIT-SPEC-2026-05-18.md delete mode 100644 docs/PRE-DEPLOY-PLAN.md delete mode 100644 docs/admin-ia-proposal.md delete mode 100644 docs/admin-ux-backlog.md delete mode 100644 docs/audit-2026-05-15.md delete mode 100644 docs/audit-comprehensive-2026-05-05.md delete mode 100644 docs/audit-comprehensive-2026-05-06.md delete mode 100644 docs/audit-final-deferred.md delete mode 100644 docs/audit-findings-tmp/01a-legacy-master-grep.md delete mode 100644 docs/audit-findings-tmp/01b-legacy-rendering-surfaces.md delete mode 100644 docs/audit-findings-tmp/01c-legacy-adjacent-enums.md delete mode 100644 docs/audit-findings-tmp/02-multitenancy-schema.md delete mode 100644 docs/audit-findings-tmp/03-routes-auth.md delete mode 100644 docs/audit-findings-tmp/04-audit-log.md delete mode 100644 docs/audit-findings-tmp/05-documents-files.md delete mode 100644 docs/audit-findings-tmp/06-security.md delete mode 100644 docs/audit-findings-tmp/07-email-integrations.md delete mode 100644 docs/audit-findings-tmp/08-perf-behavioral.md delete mode 100644 docs/audit-findings-tmp/09-ux-forms.md delete mode 100644 docs/audit-frontend-2026-05-06.md delete mode 100644 docs/audit-missing-features-2026-05-06.md delete mode 100644 docs/audit-permissions-2026-05-06.md delete mode 100644 docs/audit-reliability-2026-05-06.md delete mode 100644 docs/audits/2026-06-02/findings-master.md delete mode 100644 docs/berth-feature-handoff-prompt.md delete mode 100644 docs/berth-recommender-and-pdf-plan.md delete mode 100644 docs/deal-pulse-trigger-audit.md delete mode 100644 docs/deployment-plan.md delete mode 100644 docs/documenso-build-plan.md delete mode 100644 docs/documenso-integration-audit.md delete mode 100644 docs/email-refactor-deferred.md delete mode 100644 docs/eoi-documenso-field-mapping.md delete mode 100644 docs/error-handling.md delete mode 100644 docs/features-list.md delete mode 100644 docs/launch-readiness.md delete mode 100644 docs/marketing-site-followups.md delete mode 100644 docs/new-system-feature-summary.md delete mode 100644 docs/operations/outbound-comms-safety.md delete mode 100644 docs/reports-content-spec.md delete mode 100644 docs/reports-page-design.md delete mode 100644 docs/runbooks/backup-and-restore.md delete mode 100644 docs/runbooks/email-deliverability.md delete mode 100644 docs/runbooks/permission-audit.md delete mode 100644 docs/superpowers/audits/2026-05-11-prod-readiness-audit.md delete mode 100644 docs/superpowers/audits/2026-05-18-full-codebase-audit.md delete mode 100644 docs/superpowers/audits/2026-05-21-remaining-plan.md delete mode 100644 docs/superpowers/audits/active-uat.md delete mode 100644 docs/superpowers/audits/alpha-uat-master.md delete mode 100644 docs/superpowers/plans/2026-04-14-inquiry-notifications.md delete mode 100644 docs/superpowers/plans/2026-04-23-data-model-refactor.md delete mode 100644 docs/superpowers/plans/2026-04-29-mobile-foundation.md delete mode 100644 docs/superpowers/plans/2026-05-09-documents-folders.md delete mode 100644 docs/superpowers/plans/2026-05-10-documents-hub-split-and-client-folders.md delete mode 100644 docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md delete mode 100644 docs/superpowers/plans/2026-06-02-reports-polish.md delete mode 100644 docs/superpowers/specs/2026-04-14-inquiry-notifications-design.md delete mode 100644 docs/superpowers/specs/2026-04-23-data-model-refactor-design.md delete mode 100644 docs/superpowers/specs/2026-04-28-country-phone-timezone-design.md delete mode 100644 docs/superpowers/specs/2026-04-28-documents-hub-design.md delete mode 100644 docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md delete mode 100644 docs/superpowers/specs/2026-04-29-gws-inbox-triage-design.md delete mode 100644 docs/superpowers/specs/2026-04-29-mobile-optimization-design.md delete mode 100644 docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md delete mode 100644 docs/superpowers/specs/2026-05-10-documents-hub-split-and-client-folders-design.md delete mode 100644 docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md delete mode 100644 docs/superpowers/specs/2026-05-15-env-to-admin-migration-design.md delete mode 100644 docs/superpowers/specs/2026-06-01-bulk-import-design.md delete mode 100644 docs/superpowers/specs/2026-06-01-legacy-data-migration-design.md delete mode 100644 docs/superpowers/specs/2026-06-02-reports-polish-design.md delete mode 100644 docs/tenancies-design.md delete mode 100644 docs/umami-api-capabilities.md delete mode 100644 docs/website-analytics-flesh-out-plan.md delete mode 100644 docs/website-cutover-runbook.md delete mode 100644 docs/website-refactor.md diff --git a/.gitignore b/.gitignore index a4d3de60..d03cce16 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ docker-compose.override.yml # Scratch / audit artefacts tmp/ + +# Internal docs + Claude instructions: kept local-only, not in the shared repo +docs/ +/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f957e925..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,240 +0,0 @@ -# Port Nimara CRM - -Multi-tenant CRM for marina/port management. Next.js 15 App Router (standalone), React 19, TypeScript strict (`noUncheckedIndexedAccess`, no `any`), Drizzle ORM on PostgreSQL. - -## Quick reference - -```bash -pnpm dev # Dev server -pnpm build # Production build -pnpm lint / format # ESLint / Prettier -pnpm db:generate # Generate Drizzle migrations -pnpm db:push # Push schema to DB -pnpm db:studio # Drizzle Studio GUI -pnpm db:seed # Seed (tsx src/lib/db/seed.ts) - -# Tests -pnpm exec vitest run # Unit + integration (~3s) -pnpm exec playwright test --project=smoke # Click-through smoke (~10min) -pnpm exec playwright test --project=exhaustive # Full UI exhaustive -pnpm exec playwright test --project=destructive # Archive/delete flows -pnpm exec playwright test --project=realapi # Real Documenso/IMAP (opt-in) -pnpm exec playwright test --project=visual # Pixel-diff baselines -pnpm exec playwright test --project=visual --update-snapshots # Regenerate baselines - -# Dev helpers -pnpm tsx scripts/dev-trigger-portal-invite.ts # Send a portal activation email -pnpm tsx scripts/dev-imap-probe.ts # Dump recent IMAP inbox messages - -# Cloudflare quick-tunnel (for Documenso webhook testing) -launchctl load ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # start -launchctl unload ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist # stop -./scripts/tunnel-url.sh --copy # print + copy webhook URL - -# Schema migration (pnpm db:migrate is broken — apply via psql) -PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm -f src/lib/db/migrations/0075_*.sql -``` - -## Working in this repo — skills, MCPs, agents - -Reach for these before grinding through tasks manually: - -- **Skills** (invoke with `Skill` tool): - - `superpowers:brainstorming` before any feature/component work — explores intent + design first - - `superpowers:test-driven-development` for any feature or bugfix - - `superpowers:systematic-debugging` for any bug / test failure / unexpected behavior - - `superpowers:verification-before-completion` before claiming "done" or committing - - `superpowers:writing-plans` / `executing-plans` for multi-step specs - - `superpowers:dispatching-parallel-agents` when 2+ tasks are independent - - `frontend-design:frontend-design` for new UI work (avoids generic AI aesthetics) - - `code-review:code-review` and `security-review` before merging -- **MCPs**: - - **Context7** (`mcp__plugin_context7_context7__*`) — pull current docs for Next 15, Drizzle, better-auth, BullMQ, Tailwind, Radix etc. Prefer over web search; our training data lags. - - **Playwright** (`mcp__plugin_playwright_playwright__*`) — verify UI changes in a real browser before reporting "done". Default viewport — do NOT call `browser_resize`. - - **Serena** (`mcp__plugin_serena_serena__*`) — symbol-level navigation (`find_symbol`, `find_referencing_symbols`, `replace_symbol_body`). Much faster than grep for "where is this called". - - **Postman** (`mcp__claude_ai_Postman__*`) — when designing or auditing API surfaces. -- **Agents** (via `Agent` tool, `subagent_type=`): - - `Explore` for any codebase search that would take > 3 queries - - `feature-dev:code-explorer` / `code-architect` / `code-reviewer` for new feature work -- **Doctrine**: skills override default behavior except user instructions in this file. If a CLAUDE.md rule conflicts with a skill, this file wins. -- **Pre-launch tracker**: `docs/launch-readiness.md` is the master pre-launch tracker for the beta phase. Append every launch-blocking initiative or sub-task there with status tags (`OPEN | IN PROGRESS | SHIPPED in | BLOCKED | DEFERRED`). Read it at the start of any non-trivial task. -- **Manual UAT — currently active doc**: `docs/superpowers/audits/active-uat.md` is the **live** findings doc. Every UAT finding the user surfaces in chat lands here regardless of which session captures it. Persists across sessions until the user explicitly says to wrap the round and archive — at which point rename to `YYYY-MM-DD-uat.md` and start a fresh `active-uat.md`. Buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged). Tag every entry with status: `OPEN | IN PROGRESS | SHIPPED in | QUEUED | BLOCKED`. Don't ask the format each time. - -## Tech stack (non-obvious choices) - -- **Auth:** better-auth — session cookie `pn-crm.session_token` -- **Queue:** BullMQ + Redis (ioredis) -- **Storage:** pluggable via `getStorageBackend()` — MinIO/S3 default; never import the S3 SDK directly -- **Realtime:** Socket.IO with Redis adapter -- **UI:** Radix UI + shadcn/ui (`src/components/ui/`) + Lucide + CVA + tailwind-merge -- **Forms:** react-hook-form + zod resolvers -- **State:** Zustand (`src/stores/`) + TanStack React Query -- **PDF:** pdfme (templates) + pdf-lib (AcroForm fill) -- **Email:** nodemailer + imapflow + mailparser - -## Project structure - -``` -src/ - app/ - (auth)/ # Login/auth pages - (dashboard)/ # Main app — route: /[portSlug]/... - (portal)/ # Client portal - api/ # API routes (route.ts + sibling handlers.ts) - components/ - ui/ # shadcn/ui base components - layout/ # Shell, sidebar, header - [domain]/ # clients, yachts, companies, reservations, berths, … - shared/ # Cross-domain (BrandedAuthShell, InlineEditableField, …) - hooks/ # use-auth, use-permissions, use-socket, … - lib/ - api/ # Route helpers (parseBody, errorResponse, withAuth, …) - auth/ # better-auth config - db/schema/ # Drizzle schema — one file per domain, re-exported from index.ts - db/migrations/ # Generated Drizzle migrations (apply via psql in dev) - env.ts # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses) - services/ # Business logic - storage/ # Pluggable storage backend - templates/ # Email/document merge fields, berth-range formatter - validators/ # Zod schemas for API input - middleware.ts # Auth middleware (cookie check, redirects) - stores/ # Zustand -``` - -## Conventions & gotchas - -### API shape - -- **Envelope:** `{ data: }` for any returned content (read OR write). Mutations returning nothing emit `204 No Content`. Don't use `{ success: true }` (legacy; normalized away 2026-05-07). Public portal-auth endpoints keep `{ success: true }` so the frontend can chain. -- **Lists:** `{ data: , total?, hasMore? }` — see `/api/v1/clients`. -- **Errors:** always via `errorResponse(error)` from `@/lib/errors` (request-id propagation + audit-tier mapping). -- **Body parsing:** always `parseBody(req, schema)` from `@/lib/api/route-helpers`. Raw `req.json() + schema.parse()` produces a generic 500 instead of the field-level 400 the frontend's `toastError` hook expects. -- **Route handlers:** `route.ts` files can only export `GET|POST|…`. Service-tested handlers live in sibling `handlers.ts` (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by `route.ts` with `withAuth(withPermission(...))`. Integration tests import from `handlers.ts` directly to bypass middleware. - -### Data model - -- **Polymorphic ownership:** Yachts and invoice billing-entities use `_type` + `_id` pairs (`'client' | 'company'`). Resolve via `src/lib/services/yachts.service.ts` / `eoi-context.ts` — never read the columns ad hoc. -- **Multi-berth interest model:** `interest_berths` is the source of truth — `interests.berth_id` does not exist (dropped in 0029). Three flags: `is_primary` (≤1 per interest, partial unique index — "the berth for this deal"), `is_specific_interest` (true → public map shows "Under Offer"), `is_in_eoi_bundle` (covered by EOI signature). Read/write only via `src/lib/services/interest-berths.service.ts` helpers. -- **Notes (polymorphic):** `notes.service.ts` dispatches across `clientNotes`/`interestNotes`/`yachtNotes`/`companyNotes` via an `entityType` discriminator. `` works for all four. `companyNotes` lacks `updatedAt` — service substitutes `createdAt` for shape uniformity. -- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, EOI-rendered in this exact form. Regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit. -- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled. - -### Schema migrations during dev - -After `db:push` or applying a migration via `psql` against a running dev server, **restart `next dev`**. Drizzle/postgres.js prepared statements cache stale column lists; symptom is `42703 column X does not exist` 500s on migrated tables. - -### Documenso - -- **Webhooks:** plaintext secret in `X-Documenso-Secret` (no HMAC) — timing-safe equality via `verifyDocumensoSecret`. Event names arrive uppercase-enum (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED` …); the receiver also normalizes lowercase-dotted for forward-compat. `handleDocumentCompleted` is **idempotent** (early-return when `status='completed' && signedFileId`) so 5xx retries don't double-write. Switch handles SIGNED|COMPLETED|REJECTED|DECLINED|OPENED|EXPIRED + v2 aliases RECIPIENT_VIEWED/SIGNED. Detail: `docs/documenso-integration-audit.md`. -- **v1 vs v2 routing:** `getPortDocumensoConfig(portId)` resolves per-port `apiVersion`. `documenso-client.ts` exports version-aware wrappers (`getDocument`, `createDocument`, `sendDocument`, `sendReminder`, `downloadSignedPdf`, `voidDocument`, `placeFields`). v2 → `/api/v2/envelope/*` (multipart create, `distribute` returns per-recipient signingUrl, `redistribute` for reminders, `field/create-many` for bulk placement). v1 → `/api/v1/documents/*`. **Template flow stays v1** (`/api/v1/templates/{id}/generate-document` with name-keyed `formValues`) — v2 instances accept via backcompat. v2-only settings honoured: `documenso_signing_order` (PARALLEL/SEQUENTIAL) + `documenso_redirect_url`. -- **Response normalization:** 2.x uses `documentId` / `recipientId`; v1.13 uses `id`. `normalizeDocument()` surfaces the legacy `id` form to downstream consumers. -- **`DOCUMENSO_API_URL`:** bare host only — never include `/api/v1`. Client appends versioned paths based on `DOCUMENSO_API_VERSION`. Double-pathing returns 404 with no useful diagnostic. - -### EOI generation - -- Two pathways share `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway uses `documenso-payload.ts` → template-generate endpoint; in-app pathway fills `assets/eoi-template.pdf` via `src/lib/pdf/fill-eoi-form.ts`. Routed through `generateAndSign(...)` in `document-templates.ts` with a `pathway` parameter. -- **Merge fields:** Catalog in `src/lib/templates/merge-fields.ts`; `createTemplateSchema` uses `VALID_MERGE_TOKENS` as an allow-list, rejecting unknown tokens at template creation. -- **Berth range formatter:** Multi-berth EOIs render the in-bundle berth set as a compact range ("A1-A3, B5-B7") via `formatBerthRange()` (`src/lib/templates/berth-range.ts`). Output populates the existing `Berth Number` Documenso field (single-berth = primary mooring verbatim; multi-berth = range). CRM UI always shows berths as chips. `{{eoi.berthRange}}` token available for template body copy. -- Detail: `docs/eoi-documenso-field-mapping.md`, `assets/README.md`. - -### UI patterns - -- **Sheet vs Drawer:** `` (`src/components/ui/sheet.tsx`, Radix dialog) is the canonical side-panel for both desktop and mobile (`w-3/4 sm:max-w-sm`). Vaul `` (`src/components/shared/drawer.tsx`) is mobile-bottom-sheet only — currently just `MoreSheet`. Need a side panel? Use Sheet. Don't add Vaul without a mobile-bottom-sheet justification. -- **Inline editing:** Detail pages use `` for text/select/textarea and `` for tag chips. Each entity exposes `PUT /api/v1//[id]/tags` backed by a `setTags` service helper (single-transaction wipe-and-rewrite). No separate "Edit" modals — overview tab is editable in place. -- **Email + auth surfaces:** Branded HTML in `src/lib/email/templates/`; portal-auth uses `portal-auth.ts`. All templates: table-based, max-width 600, logo + blurred overhead background (`s3.portnimara.com`). CRM `/login`, `/reset-password`, `/set-password` and portal `/portal/login`, `/portal/activate`, `/portal/reset-password` all wrap content in `` for visual continuity. - -### Document folders - -- Per-port nestable tree (`document_folders.parent_id` self-FK; null parent = root). Documents and files carry nullable `folder_id`. Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id,'__root__'), LOWER(name))`. Folder delete is **soft rescue** (`deleteFolderSoftRescue`) — re-parents children up, drops folder; never CASCADE. `moveFolder` walks ancestor chain to prevent cycles. -- Three system roots (`Clients/`, `Companies/`, `Yachts/`) auto-created via `ensureSystemRoots`. Entity subfolders are lazy via `ensureEntityFolder` — race-safe via partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL`. System rows mutated only by entity rename/archive/hard-delete (auto-sync via service helpers); `assertNotSystemManaged` rejects direct API mutation. -- **Auto-deposit on signing completion:** `handleDocumentCompleted` resolves owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.clientId`), ensures the entity folder, and sets `files.folder_id` + entity FK. Falls back to root when unresolvable. -- **Aggregated projection:** `listFilesAggregatedByEntity` / `listInflightWorkflowsAggregatedByEntity` walk symmetric reach (Client ↔ Company via `company_memberships` active rows, ↔ Yacht via `yachts.current_owner_type/id`), group by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT), cap 20 per group. **Defense-in-depth `port_id` at every join.** **File-FK snapshot is source of truth** — historical files stay filed even if relationships change. -- Permission gating: `documents.view` reads; `documents.manage_folders` for create/rename/move/delete (system folders immutable via API). -- Deploy: migration `0051_documents_hub_split.sql` + `pnpm db:backfill:doc-folders` (idempotent via per-port advisory lock). - -### Berths - -- **Public API:** `/api/public/berths` (list) + `/api/public/berths/[mooringNumber]` (single) feed the marketing site. Output mirrors legacy NocoDB shape verbatim. Status precedence: `"Sold"` > `"Under Offer"` (status OR active `is_specific_interest=true` link with open outcome) > `"Available"`. Cache `s-maxage=300, stale-while-revalidate=60`. -- **Public health:** `/api/public/health` dual-mode — anonymous gets `{status, timestamp}` (never 503); requests with timing-safe `X-Intake-Secret` matching `WEBSITE_INTAKE_SECRET` get full `{checks: {db, redis}}` + 503 on failure. The website uses the authenticated form on startup so it refuses to start when pointed at the wrong env. -- **Recommender:** Pure SQL (no AI). `src/lib/services/berth-recommender.service.ts`. Tier ladder A/B/C/D from `interest_berths` aggregates. Heat scoring fires only for tier B; weights tuned via `system_settings` (`heat_weight_*`, `recommender_*`, `fallthrough_*`, `tier_ladder_hide_late_stage`). Multi-port isolation enforced at entry point AND in the SQL aggregates CTE. -- **Rules engine:** `src/lib/services/berth-rules-engine.ts`. Seven triggers, all wired: `eoi_sent`, `eoi_signed`, `deposit_received`, `contract_signed`, `interest_archived`, `interest_completed`, `berth_unlinked`. Callers fire `evaluateRule(...)` via dynamic import (circular-dep avoidance). Defaults vary; admins tune via `berth_rules` setting. Pairs with `advanceStageIfBehind` to keep pipeline stage in sync. -- **Per-berth PDFs:** Versioned via `berth_pdf_versions`; `berths.current_pdf_version_id` is current. Storage key is UUID per upload (no collisions on concurrent uploads); `pg_advisory_xact_lock` per berth_id serializes version-number allocation. 3-tier parse: AcroForm → OCR (Tesseract.js) → optional AI on low confidence. Magic-byte (`%PDF-`) check on BOTH in-server and presigned-PUT paths. Mooring mismatch → service-level `ConflictError` unless `confirmMooringMismatch: true`. -- **Brochures:** Per-port, `is_default` enforced by partial unique index `(port_id) WHERE is_default=true AND archived_at IS NULL`. Same upload flow as berth PDFs. -- **NocoDB re-import:** `pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara`. Idempotent (skips rows where `updated_at > last_imported_at` unless `--force`); add `--update-snapshot` to rewrite the seed JSON. Helpers in `src/lib/services/berth-import.ts` are unit-tested. -- Plan-of-record: `docs/berth-recommender-and-pdf-plan.md`. - -### Storage - -- All file I/O through `getStorageBackend()` (`src/lib/storage/`). Interface: `put`, `get`, `head`, `delete`, `listByPrefix`, `presignUpload`, `presignDownload`. Selected via `system_settings.storage_backend` (`'s3' | 'filesystem'`). Switching backends = settings change + `pnpm tsx scripts/migrate-storage.ts` (round-trips every blob in `files`, `berth_pdf_versions`, `brochure_versions`, `gdpr_exports`, verifies SHA-256). -- MinIO calls wrapped in 30s `withTimeout` to prevent TCP-blackhole stalls. **Filesystem backend is single-node only** — refuses to start when `MULTI_NODE_DEPLOYMENT=true`. - -### Send-from accounts (sales send-outs) - -- Configurable via `system_settings`; defaults to `sales@portnimara.com` (human) + `noreply@portnimara.com` (automation). SMTP/IMAP passwords AES-256-GCM at rest; API returns only `*PassIsSet` markers. -- Audit → `document_sends` (separate from `audit_logs` for volume + binary refs). Body markdown rendered via `renderEmailBody()` (escape-then-allowlist; XSS-tested). Rate limit 50 sends/user/hour. Files > `email_attach_threshold_mb` ship as 24h signed-URL link (filename HTML-escaped against injection). The threshold banner in the compose UI is informational and shows whenever the preview API returns the per-port threshold — it does NOT depend on IMAP. Separately, bounce monitoring (`imap-bounce-poller.ts`) needs IMAP creds and no-ops cleanly when they're unset. - -### Pre-commit - -Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx`. **Blocks all `.env*` files** (including `.env.example`) — pass them via a separate workflow if needed. - -## Environment - -Copy `.env.example` to `.env`. See `src/lib/env.ts` for the full Zod schema. `SKIP_ENV_VALIDATION=1` bypasses validation (Docker build). - -Dev/test-only env (not in `.env.example`): - -- `EMAIL_REDIRECT_TO=
` — reroutes every outbound email to this address, prefixes subject `[redirected from ]`. Dev safety net; **must be unset in production**. -- `IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` / `IMAP_PASS` — used by `tests/e2e/realapi/portal-imap-activation.spec.ts`; the spec skips when any are missing. - -## Testing - -Six Playwright projects (`playwright.config.ts`): - -- `setup` — global setup (seeds users, port, berths, system settings) -- `smoke` — fast click-through, run on every change (~10 min, 125 specs) -- `exhaustive` — deeper UI coverage -- `destructive` — archive/delete/cancel paths against throwaway entities -- `realapi` — opt-in real Documenso send-side + IMAP round-trip. Needs `DOCUMENSO_API_*`, `SMTP_*`, `IMAP_*` env + cloudflared tunnel running for the local webhook receiver -- `visual` — pixel-diff baselines (`tests/e2e/visual/snapshots.spec.ts-snapshots/`); regenerate with `--update-snapshots` - -Vitest covers unit + integration with mocked externals (`tests/unit/`, `tests/integration/`). - -## Docker - -- `Dockerfile` — production multi-stage (deps → build → runner) -- `Dockerfile.dev` — dev with bind-mounted source -- `Dockerfile.worker` — BullMQ worker process -- `docker-compose.yml` / `.dev.yml` / `.prod.yml` - -## Architecture docs - -Numbered specs (`01-CONSOLIDATED-SYSTEM-SPEC.md` … `15-DESIGN-TOKENS.md`) in repo root carry the detailed architecture decisions, schema docs, API catalog, and sequence. - -### Beta-phase tracker (read this first) - -We are in pre-launch beta. **`docs/launch-readiness.md` is the canonical -home for every outstanding initiative we need to ship before -production cutover.** Read it at the start of any non-trivial task to -see what's in flight, what's blocked, and what's been deferred. Append -new launch-blocking items there (status tags: `OPEN | IN PROGRESS | -SHIPPED in | BLOCKED | DEFERRED`) — do NOT create a new -parallel audit doc. Companion files: - -- `docs/launch-readiness.md` — the master pre-launch tracker (5+ - initiatives: reports overhaul, marketing pipeline cutover, invoicing - audit, codebase + security audit, website integration, e2e testing, - data migration) -- `docs/reports-content-spec.md` — working spec for the reports - initiative (per-category KPIs / charts / tables); referenced by - `launch-readiness.md` Initiative 1 -- `docs/superpowers/audits/active-uat.md` — live UAT findings the user - surfaces in chat; persists across sessions until explicit wrap -- `docs/BACKLOG.md` — long-tail backlog index (post-launch and - general) - -### Domain reference docs - -- `docs/berth-recommender-and-pdf-plan.md` — berths + PDF + send-outs bundle -- `docs/eoi-documenso-field-mapping.md` — canonical EoiContext ↔ Documenso/AcroForm mapping -- `docs/documenso-integration-audit.md` — full Documenso v1/v2 quirks reference -- `assets/README.md` — in-app EOI source PDF requirements diff --git a/docs/AUDIT-2026-05-12.md b/docs/AUDIT-2026-05-12.md deleted file mode 100644 index 6bb9cc45..00000000 --- a/docs/AUDIT-2026-05-12.md +++ /dev/null @@ -1,7522 +0,0 @@ -# Port Nimara CRM — Comprehensive Platform Audit - -**Generated:** 2026-05-12 (session run) -**Branch:** `feat/documents-folders` -**Method:** 19 parallel audit agents on Claude Opus 4.7, read-only static analysis. Each agent owned a single domain and wrote a CRITICAL/HIGH/MEDIUM-grouped report. This document consolidates the reports and overlays the fixes already shipped during the session. - ---- - -## How to read this document - -1. **Executive summary** lists every CRITICAL finding (must address before production), per domain. -2. **Already fixed in this session** is a manifest of the changes I shipped while the audit was running. Don't re-fix these. -3. **Cross-cutting priority queue** is the top ~15 highest-impact findings across the entire codebase, ordered. Tackle these first. -4. **Per-domain reports** below contain the full text of every agent's report verbatim — useful when you sit down to actually fix a specific area. -5. **Methodology + agent roster** appendix at the bottom lists who looked at what. - -Severity is the auditor's judgment, not mine — I have not re-graded findings. Treat anything tagged CRITICAL as a real block on shipping. - ---- - -## Executive summary - -### CRITICAL findings (must address) - -| # | Domain | File | Issue | Status | -| --- | ------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -| 1 | Security | `src/app/api/v1/admin/users/[id]/permission-overrides/route.ts` | Admins could grant themselves every permission leaf via self-target | **FIXED this session** | -| 2 | Security | `src/app/api/auth/resolve-identifier/route.ts` | Username enumeration via hit/miss response shape + no rate limit | **FIXED this session** | -| 3 | Services | `src/lib/services/users.service.ts` (admin email-change) | `account.accountId` not updated → user can't sign in with either old or new email after admin rotation; sessions also not revoked | **FIXED this session** | -| 4 | Observability | `src/lib/services/search-nav-catalog.ts` | 10 NAV_CATALOG entries pointed at routes that don't exist (`/admin/audit-log`, `/admin/error-events`, `/user-settings`, 7×`/settings/`) | **FIXED this session** | -| 5 | Auth flow | `src/middleware.ts` | Token-gated email confirm/cancel routes blocked by session 401 | **FIXED this session** | -| 6 | Email | `src/lib/env.ts` + `src/lib/email/index.ts` | `EMAIL_REDIRECT_TO` has no `NODE_ENV=production` guard — a stray prod env value silently funnels every email to one inbox | Open | -| 7 | Email | every template | URL interpolations into `href="…"` and link text are unescaped — a `"` in any URL breaks out, no scheme rejection | Open | -| 8 | Data model | `src/lib/db/migrations/0052_audit_critical_fixes.sql` | `CREATE INDEX CONCURRENTLY` silently never runs because there's no real `db:migrate` runner — six composite indexes missing in prod | Open | -| 9 | Data model | `db:push` flow | Two structural constraints (berths.current_pdf_version_id circular FK, system_settings NULLS NOT DISTINCT) not in `db:push`; fresh-deploy diverges from prod | Open | -| 10 | Services | `documents.service.ts: handleDocumentCompleted` | Orphan-blob window — failure between `storage.put` and `documents.update` leaves the blob and marks status='completed' with no `signedFileId` | Open | -| 11 | GDPR | `src/lib/services/gdpr-bundle-builder.ts` | Article-15 export missing portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions | Open | -| 12 | GDPR | `src/lib/services/client-hard-delete.service.ts` | "Right to be forgotten" doesn't actually erase — verbatim PII survives in email_messages.body_html, files, document_sends.recipient_email forever | Open | -| 13 | GDPR | `src/app/api/auth/resolve-identifier/route.ts` (post-fix) | Still echoes the real canonical email on a successful username hit (rate-limited but enumerable) | Partial — see Open follow-ups | -| 14 | GDPR | `audit_logs.metadata` field | Not covered by `maskSensitiveFields`; raw PII (emails, IPs, names) accumulates unbounded with no retention cron | Open | -| 15 | Observability | `src/app/api/webhooks/documenso/route.ts` | Webhook handler bypasses the platform-error pipeline entirely — admin/errors silent on Documenso webhook crashes | Open | -| 16 | UI/UX | 16 sites use native `window.confirm()` | Bypasses `ConfirmationDialog` / `AlertDialog` for destructive flows (cancel signing, delete files, archive interest/company/yacht…) | Open | -| 17 | Documenso | `documenso-client.ts` v1↔v2 routing | (Pending full report) | In progress | -| 18 | Concurrency | (see report) | Various race windows on multi-rep edits + partial-unique-index inserts | Open | - -### HIGH-priority queue - -Listed after CRITICALs in the priority queue section below. - ---- - -## Already fixed in this session - -These changes are on the `feat/documents-folders` branch (post-commit `660553c` and onward). Do not re-fix. - -### Security - -- **Self-target privilege escalation block** — `src/app/api/v1/admin/users/[id]/permission-overrides/route.ts` now refuses `PUT` when `targetUserId === ctx.userId`. Additionally, the body now sanitises against a canonical `ALLOWED_RESOURCE_ACTIONS` allow-list mirroring `RolePermissions`, so unknown resource/action keys are stripped before write. Cross-tenant pollution check added (refuses overrides for users without a `user_port_roles` row in the caller's port). -- **Username enumeration kill** — `src/app/api/auth/resolve-identifier/route.ts` now (a) shares the `auth` 5-per-15-min rate-limit bucket keyed by client IP, (b) returns a synthetic `@auth.invalid` email on miss so hit and miss are indistinguishable in shape. (Note: GDPR auditor flagged the hit-path still echoes a real canonical email — still an information leak that's worth a deeper redesign; see Open follow-ups.) -- **Email-change account/session rotation** — `src/lib/services/users.service.ts` now also updates `account.accountId` for the `credential` provider (Better Auth's actual login key) AND revokes every active `session` row when an admin rotates a user's email. Previously the user could not sign in with either old or new email after rotation. -- **Middleware unblocks token-gated email routes** — `src/middleware.ts` adds `/api/v1/me/email/confirm/` and `/api/v1/me/email/cancel/` to `PUBLIC_PATHS` so the confirm/cancel links work in a fresh browser without an existing session. - -### Search + navigation - -- **NAV_CATALOG dead-link sweep** — `src/lib/services/search-nav-catalog.ts` corrected 10 entries that pointed to non-existent routes. `/admin/audit-log` → `/admin/audit`, `/admin/error-events` → `/admin/errors`, `/user-settings` → `/settings/profile`, and the 7 phantom `/settings/` entries redirected to their real `/admin/` homes. -- **Topbar global search extended** — every admin sub-card now indexed in `NAV_CATALOG` with curated `keywords` (client portal, ai scoring, pipeline weights, recommender heat weights, etc.). Results sort to the bottom of the cmd-K dropdown, beneath entity hits. -- **Admin sections page search** — `src/components/admin/admin-sections-browser.tsx` `AdminSection` gained a `keywords?: string[]` field, populated for System Settings (mirrors `KNOWN_SETTINGS`), AI configuration, OCR, Users, and Website analytics. `filteredMatches` haystack now includes those keywords. - -### User management - -- **Disable / enable button** — third Power/PowerOff action button on the desktop user list + matching dropdown item on the mobile card. Backed by `userProfiles.isActive` (already enforced by `withAuth` → 403 on disabled accounts). -- **UserForm tabs + permissions matrix** — UserForm now wraps Profile & role + Permissions in tabs. New `UserPermissionMatrix` component renders the full `RolePermissions` shape with three-state per-leaf toggle (Inherit / Grant / Deny). The matrix is `role="radiogroup"` + `aria-checked` per option, and shows an amber callout explaining that overrides save on their own button. Dirty-state tracked via originalOverrides comparison. -- **First/last name + admin email change** — UserForm collects first + last name (canonical) alongside displayName. Email change behind an AlertDialog confirmation; on confirm sends an automated notice to the prior address (new template `src/lib/email/templates/admin-email-change.ts`). -- **Phone formatting** — UserForm swaps the bare tel input for the shared `PhoneInput` (country combobox + AsYouType + E.164 storage). - -### Optional username sign-in - -- Migration `0054_user_profiles_username.sql` adds `username` column (2..30 chars, regex `^[a-z0-9._-]{2,30}$`, partial unique index on `LOWER(username)`). -- Login page now accepts email OR username via `/api/auth/resolve-identifier`. -- Self-service username card on `src/components/settings/user-settings.tsx`. -- `/api/v1/me` PATCH now accepts username with allow-list + reserved-name check + uniqueness check before write. - -### Per-user permission overrides - -- Migration `0055_user_permission_overrides.sql` adds the table. -- Effective-permissions resolver in `src/lib/api/helpers.ts` now layers user overrides on top of role + port-role overrides + residential toggle. -- `GET / PUT /api/v1/admin/users/[id]/permission-overrides` endpoints. - -### Role + enum normalization - -- `formatRole()` + `ROLE_LABELS` in `src/lib/constants.ts` — replaces the ad-hoc `humanizeRole` in `sidebar.tsx` and `prettifyRoleName` in `role-list.tsx`. user-list, user-card, role-list, user-form now render "Sales Agent" instead of "sales_agent". -- `formatOutcome()` + `OUTCOME_LABELS` for interest outcomes. Updated `client-columns.tsx`, `realtime-toasts.tsx`, `interest-detail-header.tsx`, `command-search.tsx`. -- Pipeline stage normalization extended to: `next-in-line-notify.service.ts`, `command-search.tsx` (interest + residential interest bucket), `yacht-tabs.tsx`, `interest-picker.tsx`, `ai.ts` worker email body, `pipeline-report.ts` + `revenue-report.ts` PDF generators. - -### Auto-memory - -- Saved feedback memory: "Be thorough — audit everything that ends in a user-facing notification". (Memory subsystem is /Users/matt/.claude/projects/...) - ---- - -## Cross-cutting priority queue - -Tackle in this order. C-prefix = CRITICAL still open; H-prefix = HIGH. - -1. **[C] Wire a real `db:migrate` runner** — without it, `0052_audit_critical_fixes.sql` silently never creates 6 composite indexes (data-model C1). Recommended: a tsx script that reads migrations in order, splits on `--> statement-breakpoint`, runs `CREATE INDEX CONCURRENTLY` outside a tx, and tracks state in a `__drizzle_migrations` table. Same script gives you `db:migrate:status` for prod readiness. -2. **[C] Add `EMAIL_REDIRECT_TO` prod guard** — `src/lib/env.ts` should refine to reject when `NODE_ENV === 'production'`, and `src/lib/email/index.ts` should `logger.warn` at boot when set (not debug). 5 minutes of work, prevents an extremely-bad-day class of incident. -3. **[C] Fix orphan-blob window in `handleDocumentCompleted`** — `src/lib/services/documents.service.ts:1100-1253`. Wrap the storage.put + files.insert + documents.update sequence in a transaction or a saga with a compensating delete. The current catch-block path also incorrectly marks `status='completed'` with no `signedFileId`, hiding the failure from reps. -4. **[C] Escape URLs in email templates** — every template in `src/lib/email/templates/*` inlines `${data.link}` etc. into href/text without escaping. Move all template rendering through a shared `escapeUrl` helper and add scheme allow-listing (http(s) only). -5. **[C] Eliminate the 16 native `window.confirm()` calls** — each one is a destructive flow that bypasses `ConfirmationDialog` / `AlertDialog`. ui-ux-auditor lists the sites; high-leverage UX fix. -6. **[C] GDPR export completeness** — `gdpr-bundle-builder.ts` must include portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions. This is a regulator-finding-level gap. -7. **[C] Right-to-be-forgotten actually erase** — `client-hard-delete.service.ts` currently nullifies FKs but leaves verbatim PII in email_messages.body_html, files, document_sends.recipient_email. Add a true wipe path (or document the limitation in the legal text and gate the feature behind a "we cannot fully erase X" warning). -8. **[C] Add `user_permission_overrides.user_id` FK + onDelete='set null' on nullable client refs** — data-model H1+H2. Migration 0056. -9. **[C] Resolve-identifier hit-path still leaks email** — replace the API entirely with a server-side signIn proxy that takes `{identifier, password}` and never returns the canonical email at all. Current rate-limited hit still echoes real emails to anyone with a guessable username. -10. **[H] Re-audit `audit_logs.metadata` masking** — extend `maskSensitiveFields` to cover `audit_logs.metadata`; add a 90-day retention cron (mirroring `error_events`). -11. **[H] Webhook → error pipeline** — `documenso/route.ts` should `captureErrorEvent` on handler crash. Apply the same to every other webhook route. -12. **[H] Wire admin email-template subject editor** — 5 of 8 templates ignore `overrides.subject`; admins see "Saved" with zero effect. `email-auditor` H1+H2. -13. **[H] Wire admin signature/footer fields** — `/admin/email` writes `email_signature_html` + `email_footer_html` which the shell never reads. Either delete or wire. -14. **[H] PII redaction in audit/error pipeline** — `error_events.request_body_excerpt` sanitizer redacts password/token but not email/phone/name/dob/address. -15. **[H] Notification email worker XSS** — `workers/notifications.ts:65-71` interpolates `notif.description` and `notif.link` into HTML unescaped. Apply `escapeHtml` + URL allow-list. - ---- - -## Per-domain reports - -Each section below is the agent's report verbatim. File:line refs reference the repo as it stands at the start of the audit session — some have already been addressed (see "Already fixed in this session" above). - ---- - -## 1. Security + API + auth audit (security-auditor + early api-security run) - -Two reports — the team-spawned `security-auditor` and an earlier standalone run. Both included verbatim. - -### Report A: security-auditor (team) - -# Security / API / Auth Audit — `feat/documents-folders` branch - -Read-only audit of the `pn-crm` repo. Scope: auth wrappers, tenant scoping, -public/webhook endpoints, the just-shipped username-resolve + permission- -overrides + admin email-change flows, CSRF posture, audit-log coverage. - -No **CRITICAL** issues found — auth helpers (`withAuth` / `withPermission` / -`requireSuperAdmin`) are applied consistently across `src/app/api/v1/**`, -public endpoints all use timing-safe secret compares + per-IP rate limits, -and the Documenso webhook idempotency + per-port secret resolution is sound. -The findings below are HIGH / MEDIUM. - ---- - -## HIGH - -### H1. `resolve-identifier` leaks username→email mapping AND has no rate limit - -**File:** `src/app/api/auth/resolve-identifier/route.ts` (lines 25–58) - -The route's own docstring claims it "pairs with the global login-attempt -limiter" — but no `enforcePublicRateLimit` / `checkRateLimit` is actually -called in the handler. Unauthenticated attackers can POST `{identifier:"matt"}` -at unbounded volume; on a hit the response is `{email:"matt@letsbe.solutions"}`, -on a miss the response echoes the raw input. That makes existence -trivially decidable (response contains `@` ↔ hit), and on a hit the caller -_also_ learns the actual email address. Usernames are typically far more -guessable than emails (first names, social handles), so this becomes a one- -way `username → email` harvester usable for downstream phishing / password -spraying. **Fix:** wrap with `enforcePublicRateLimit(req, 'portalSignIn', -identifier.toLowerCase())` (or a new `loginIdentifier` bucket) AND stop -echoing the resolved email — either return `{ok:true}` and require the -caller to POST `(username,password)` together to a single sign-in endpoint -that does the lookup server-side, or return an opaque short-lived token that -Better Auth's sign-in step can redeem internally. - -### H2. Admin email-change leaves `emailVerified` true → account takeover via reset - -**File:** `src/lib/services/users.service.ts` (lines 233–262, 355–387) - -`updateUser` rotates `user.email` directly when an admin edits the address -(line 246–247) but never resets `emailVerified`. A hostile or compromised -admin can point any victim's account at an attacker-controlled mailbox, then -trigger the existing "forgot password" flow on the new address and silently -hijack the account; the existing `notifyAdminEmailChange` notice fires to -the _old_ address fire-and-forget and is documented as non-blocking -("failure to send doesn't roll back"). There is _also_ no `createAuditLog` -specifically for the email-change — the generic update audit at line 287 -buries the change inside `newValue: data` rather than emitting a dedicated -`email_change` action that monitoring can alert on. **Fix:** when -`wantsEmailChange`, set `emailVerified: false` in the Better Auth user -update, write a dedicated `severity: 'warning'` audit row with -`{oldEmail, newEmail, changedBy}`, and require the recipient to click the -existing `/api/v1/me/email/confirm/[token]` flow before the rotation -applies — i.e. mint a `user_email_changes` row rather than direct-UPDATE. - -### H3. Permission-overrides PUT accepts arbitrary keys → JSONB pollution + deep-merge surprise - -**File:** `src/app/api/v1/admin/users/[id]/permission-overrides/route.ts` -(lines 31–35, 97–141) - -`updateOverridesSchema` is `z.record(z.string(), z.record(z.string(), z.boolean()))` — no allow-list against the known `RolePermissions` resource/action keys. An admin (or a stolen admin session) can persist arbitrary keys into `user_permission_overrides.permission_overrides`. Two concrete impacts: (a) future deep-merge logic that maps unknown keys into newly added resources promotes the rogue keys silently (silent privilege creep when new permissions ship); (b) the JSONB can be bloated to harm downstream readers. **Fix:** validate against `KNOWN_PERMISSION_LEAVES` derived from `RolePermissions` (resource → action set), reject unknown keys with `ValidationError`, and bound the merged blob size as `/api/v1/me/route.ts` already does for `preferences`. The GET handler is fine — it only reads what was already persisted. - -### H4. `/api/v1/me/email/confirm|cancel/[token]` is unreachable for logged-out users (middleware 401) - -**File:** `src/app/api/v1/me/email/cancel/[token]/route.ts`, -`src/app/api/v1/me/email/confirm/[token]/route.ts`, -`src/middleware.ts` (PUBLIC_PATHS list, line 8–20) - -The handlers correctly skip `withAuth` ("the token IS the proof") but -`/api/v1/me/email/...` is not in `PUBLIC_PATHS`, so `middleware.ts` returns -a 401 JSON for any unauthenticated request — exactly the case a user -clicking the confirm link from email on a different device will hit. End -result: every confirm/cancel click from a logged-out browser fails with -"Authentication required". Also, the GET request applies an irreversible -state mutation with no CSRF guard (the origin-check in middleware only fires -for `STATE_CHANGING_METHODS`). **Fix:** move these handlers under -`/api/auth/email-change/{confirm,cancel}/[token]` so they're covered by the -`/api/auth/` PUBLIC_PATHS prefix, OR add `/api/v1/me/email/` to -`PUBLIC_PATHS`. Convert the GET mutation to a POST landing page (one-click -confirm form) so cross-site image/prefetch tags can't silently flip state. - ---- - -## MEDIUM - -### M1. Direct `Schema.parse(body)` instead of `parseBody(req, schema)` - -**Files:** `src/app/api/v1/admin/custom-fields/[fieldId]/route.ts:18-19`, -`src/app/api/v1/search/route.ts:11`, -`src/app/api/v1/files/upload/route.ts:21`, -`src/app/api/v1/companies/[id]/members/[mid]/handlers.ts:29`, -`src/app/api/public/website-inquiries/route.ts:97-98`, -`src/app/api/public/residential-inquiries/route.ts:51-52`, -`src/app/api/public/interests/route.ts:47-48`, -`src/app/api/portal/auth/{sign-in,forgot-password,reset-password,activate,change-password}/route.ts`, -`src/app/api/auth/{set-password,resolve-identifier}/route.ts`. - -CLAUDE.md explicitly requires `parseBody` so the 400 envelope + field- -errors shape stays uniform (the frontend's `toastError` hook depends on -it). Most of these are caught by an outer try/catch that routes ZodError -into `errorResponse`, which masks the issue — but the response shape -diverges (a thrown ZodError becomes a generic 500 unless `errorResponse` -maps it). Admin route `custom-fields/[fieldId]` is the worst case: a -malformed PATCH body 500s instead of 400-with-field-errors. **Fix:** swap -to `parseBody(req, schema)` in the admin/internal routes; the portal / -public auth routes intentionally use `safeParse` + manual `ValidationError` -mapping and can be left as-is. - -### M2. CSRF origin check disabled in development - -**File:** `src/middleware.ts` (line 80) - -`process.env.NODE_ENV !== 'development'` gates the origin check. If a -production deployment is ever booted with `NODE_ENV=development` -accidentally (shell export leakage, container override, "debug deploy"), -all CSRF defense-in-depth is silently off — SameSite=Lax still helps but -isn't enough for legacy browsers / extension contexts. **Fix:** key the -bypass on an explicit `DISABLE_CSRF_FOR_LAN=1` env var that's defaulted to -unset and refused in `lib/env.ts` when `NODE_ENV==='production'`. - -### M3. Permission-override audit log lacks severity escalation - -**File:** `src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:124-134` - -Changing user permission grants is exactly the action an attacker would -take after compromising an admin; the audit row should be emitted with -`severity:'warning'` (matching the `email_change_cancelled` precedent in -`src/app/api/v1/me/email/cancel/[token]/route.ts:46`) so the audit UI's -default filter surfaces it. Today it's a vanilla `action:'update'` lost in -the noise. - -### M4. `/api/public/interests` audit row stores client phone in `metadata` - -**File:** `src/app/api/public/interests/route.ts:254-271` - -The audit row's `newValue` and surrounding `metadata` capture `ip` plus -foreign keys, which is fine, but `data.phone` is held in scope and could -easily slip in during a future edit. Today the row is OK; flag as a place -to add a regression test. (Not a finding to act on, just a watch-list item -for the broader audit team.) - -### M5. Filesystem storage proxy: token leak via Referer - -**File:** `src/app/api/storage/[token]/route.ts:42-119` - -`Cache-Control: private, no-store` is set on the response, but the URL -itself (with the HMAC token in the path) leaks via the `Referer` header -when the downloaded asset is opened inside a browser tab that then -navigates to a third-party link. Single-use replay protection mitigates -reuse, but a token still-in-window is good for one stolen download. **Fix:** -either rotate to a POST-with-token-in-body form (breaks ``), -or set `Referrer-Policy: no-referrer` on the response and document that -issuers should mint with the shortest possible expiry. Lower-impact -because filesystem mode is single-tenant per the boot guard. - -### M6. `/api/v1/clients/bulk-hard-delete` lacks per-IP rate-limit - -**File:** `src/app/api/v1/clients/bulk-hard-delete/route.ts` (no `withRateLimit`) - -The sibling `bulk-hard-delete-request/route.ts` is wrapped in `withRateLimit` -but the actual delete endpoint is not. A compromised admin session could -fan out hundreds of irrevocable hard-deletes in a tight loop with no -limiter to slow it down. **Fix:** add `withRateLimit('destructiveBulk', ...)` -or similar with a 5/minute cap; the existing audit row will still be -emitted, but the limiter caps the blast radius. - ---- - -## Verified clean (no finding) - -- `withAuth` / `withPermission` / `requireSuperAdmin` applied uniformly: - every `route.ts` under `src/app/api/v1/**` was checked; the only files - without the wrappers are `me/email/{confirm,cancel}/[token]/route.ts` - (covered by H4) which intentionally use bearer-token auth. -- `withAuth` enforces port-context via `X-Port-Id` header / preferences, - never from body (helpers.ts:160–168). -- Documenso webhook: timing-safe per-port secret resolution, replay guard - via `signatureHash` unique index, per-handler `portScope` forwarded so a - documensoId reused across ports can't cross-mutate. -- Public website-intake: timing-safe `verifySecret` with length-equal - buffer pad, refusal-by-default when `WEBSITE_INTAKE_SECRET` unset, per-IP - rate-limit, unknown port slug → generic 400 (no input echo). -- Raw `sql\`...\``usage scanned across`src/lib/services`and`src/app/api`: every interpolation is via Drizzle's parameter binding -(`sql\`... ${foo} ...\``); no string concatenation gaps found. -- Storage proxy upload (PUT) does HMAC verify + single-use replay + size cap - - PDF magic-byte enforcement before disk write. - -— security-auditor (read-only audit; no source files edited) - -### Report B: api-security (standalone earlier run) - -# API + Auth + Security Audit – Port Nimara CRM - -Scope: `src/app/api/**`, `src/lib/api/helpers.ts`, `src/lib/auth/**`, `src/middleware.ts`, -plus the newly-added permission-overrides and resolve-identifier flows. - -## CRITICAL - -### 1. Privilege escalation via `PUT /api/v1/admin/users/[id]/permission-overrides` - -`src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:97-141` - -The PUT handler gates only on `withPermission('admin', 'manage_users', …)` and never -verifies that `params.id !== ctx.userId`. Any user who holds `admin.manage_users` can -target their own userId and write a `userPermissionOverrides` row that grants every -leaf (`{ admin: { manage_users: true, manage_settings: true, … }, … }`). Because -`withAuth` deep-merges `userOverride.permissionOverrides` last in the chain -(`src/lib/api/helpers.ts:227-238`), the row wins over the base role and instantly -escalates the caller to admin-of-everything on the next request. The companion -`removeUserFromPort` service in `src/lib/services/users.service.ts:319` does have a -self-target guard — the same guard is missing here. Fix: in the PUT handler, throw -`ForbiddenError` when `targetUserId === ctx.userId && !ctx.isSuperAdmin`, and require -super-admin to flip `admin.*` leaves (or any leaf that the calling user cannot already -grant). Tier-2 fix: rotate this row to require super-admin outright; admin-of-port -shouldn't be able to mint persistent overrides for peers anyway. - -### 2. `/api/auth/resolve-identifier` has no rate-limit — username enumeration - -`src/app/api/auth/resolve-identifier/route.ts:25-59` - -The endpoint is unauthenticated, sits behind `/api/auth/*` (so the middleware -origin check is skipped per `src/middleware.ts:46-49`), and does NO rate-limit / -throttling. The header comment claims it "pairs with the global login-attempt -limiter" but that limiter is only triggered when the _subsequent_ sign-in call -runs — an attacker hitting just this endpoint with a wordlist is unconstrained. -While the response shape is the same on hit and miss (`{ email: }`), -the _content_ differs: a hit returns an `@`-bearing email, a miss returns the -unchanged raw input. So with one HTTP call per candidate an attacker -deterministically learns which usernames map to real accounts; they then funnel -only the validated emails into the rate-limited sign-in flow, defeating the -per-account brute-force ceiling. Fix: wrap in `enforcePublicRateLimit(req, -'portalSignIn', normalized)` (or a new bucket like `usernameResolve` with ~10/15min -per-IP), and consider returning a constant fake-email when the username doesn't -resolve so hit/miss are indistinguishable at the response-body level too. - -## HIGH - -### 3. `permission-overrides` PUT does not validate the override shape - -`src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:31-34, 97-141` - -`updateOverridesSchema` is `z.record(z.string(), z.record(z.string(), z.boolean()))` — -any resource name and any action key is accepted. This stores garbage in -`user_permission_overrides.permission_overrides` forever, and silently typo'd -keys (`'clien_ts.view'`) won't take effect but won't 400 either. More -importantly, there is no allow-list against the `RolePermissions` shape defined -in `src/lib/db/schema/users.ts:6`, so a future code path that does -`Object.keys(permissions).forEach(…)` could be surprised by a foreign resource -appearing in the merged map. Fix: derive a Zod allow-list at module load from -the canonical `RolePermissions` shape (the same `VALID_MERGE_TOKENS` pattern the -templates code uses) and reject unknown resource/action keys with 400. - -### 4. `permission-overrides` PUT writes for users not assigned to the current port - -`src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:97-122` - -The PUT inserts/updates a `(userId, portId)` row without first verifying that -`targetUserId` actually has a `user_port_roles` row for `ctx.portId`. An admin at -port A can mint override rows for users belonging only to port B (the row is keyed -on the admin's portId, so it's a "future override that would activate if the user -ever joins this port"). Functionally inert today, but pollutes the override table -across tenants and breaks the implicit "you can only manage users in your port" -invariant the rest of the admin/users routes enforce. The GET path does the -implicit validation by failing the port-role lookup; the PUT should mirror it. -Fix: `findFirst` on `userPortRoles` with `(targetUserId, ctx.portId)` first; 404 -if missing, mirroring `updateUser` at `src/lib/services/users.service.ts:216-219`. - -### 5. Email-change confirm endpoint cannot be aborted after compromise window - -`src/app/api/v1/me/email/confirm/[token]/route.ts:42-57` - -Token-based unauthenticated swap. The flow looks otherwise correct (sha256- -hashed token, expiry, single-use via `appliedAt`, race-checked uniqueness). What's -missing: when a confirmation completes, all _other_ outstanding `userEmailChanges` -rows for the same `userId` should be cancelled, and all existing Better Auth -sessions for that user should be revoked. Today, if an attacker compromises the -account, requests an email change to attacker-owned address, and the victim -spots the cancel email but races against the attacker — once the attacker -confirms, the victim's cancel link still works on the _other_ pending row but -not on the now-applied change, and the attacker's existing CRM session -(`pn-crm.session_token`) survives the swap. Fix: in the confirm handler, after -the email UPDATE, also `db.delete(sessions).where(eq(sessions.userId, -pending.userId))` (or whatever the Better-Auth session table is called) and -mark all other open `userEmailChanges` rows for that user as cancelled. Mirror -the cancel-handler behaviour. Severity is HIGH not CRITICAL because the -attacker needs the session in the first place. - -### 6. Public `/api/auth/[...all]` audits the attempted email but doesn't bound brute-force timing - -`src/app/api/auth/[...all]/route.ts:100-146` - -Better Auth handles sign-in rate-limiting internally (it has a built-in limiter -when configured), but I see no explicit `enforcePublicRateLimit` wrapper around -this catch-all. The `loginAttempt` bucket I expected in `src/lib/rate-limit.ts` -isn't present in the listing; the closest is `portalSignIn`, which is wired only -to the _portal_ sign-in handler, not the CRM sign-in. If Better Auth's default -limiter isn't actively configured in `src/lib/auth/index.ts:55-113` (and I don't -see a `rateLimit:` block there), the CRM login endpoint is effectively -unrate-limited and the resolve-identifier finding compounds into a real -brute-force window. Fix: add an `enforcePublicRateLimit(req, 'crmSignIn', -attemptedEmail)` call inside `withAuthAudit` before forwarding to -`upstream.POST(forwardReq)` when `isSignIn`, keyed per-email; declare the bucket -in `rate-limit.ts` mirroring `portalSignIn`'s shape. - -## MEDIUM - -### 7. CRM `updateUser` cross-tenant email change has no notification when target is super-admin - -`src/lib/services/users.service.ts:236-262` - -When an admin at port A updates a user (including a super-admin who happens to -have a port-role row at port A), the email-change flow flips Better Auth's -identity instantly with only a courtesy email to the prior address. There's no -challenge / token round-trip — the admin acts unilaterally. Self-service email -change (`/api/v1/me/email`) DOES require token confirmation; admin-initiated -should at least block when the target is a super-admin or require the change to -go through the same confirm-token flow. Fix: gate `wantsEmailChange` on -`!profile.isSuperAdmin || ctx.isSuperAdmin` and/or always use the token flow -even for admin-initiated changes. - -### 8. `permission-overrides` PUT does not write audit log atomically with the DB write - -`src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:111-134` - -The `existing` row is read, then conditionally update-or-insert, but two -concurrent PUTs against the same `(userId, portId)` race: both see `existing` -as the same value, both call `update`, second writer wins silently with a -last-write audit log that's missing the intermediate state. Severity is medium -because the audit log still captures both writers' new values and there's no -correctness invariant broken — just a forensic gap. Fix: wrap the read + -update/insert in `withTransaction` with `FOR UPDATE` (or use an upsert with -`returning('old')`-equivalent semantics) and log `oldValue` from the locked row. - -### 9. Documenso webhook returns 200 on every failure including dedup, which masks crashes - -`src/app/api/webhooks/documenso/route.ts:264-268` - -The handler's outermost `try/catch` logs `err` but always returns 200. That's -the correct posture for _signature_-invalid traffic (don't leak signal), but -also masks downstream handler crashes — Documenso will never retry a 5xx -because it never sees one. The handlers are documented as idempotent -(`handleDocumentCompleted` early-returns on duplicate completion), so a retry -storm wouldn't double-write, but the missing retry signal turns one transient -DB failure into a permanently dropped event. Fix: return 500 on the catch -branch so Documenso retries; keep 200 for _secret_-invalid (line 100) and -dedup (line 123) since those are intentional no-ops. - -### 10. `withAuth` deep-merge: permission overrides only ADD permissions, never EXPLICITLY DENY - -`src/lib/api/helpers.ts:73-98, 233-238` - -`deepMerge` does a recursive shallow assignment — `userOverride.permissionOverrides` -overwrites leaves wholesale. So `{clients: {view: false}}` works as a deny. -However the override is keyed by _resource → action map_, and the override row -stores `Partial`. There's no "tri-state" (inherit/grant/deny) -expressed at the DB layer — the comment in the route says "use null at a leaf -to clear an override" but the Zod schema only accepts `z.boolean()` per leaf, -not null. So the UI cannot actually clear an override leaf via this endpoint -without removing the resource key entirely from the JSON. Worth aligning the -schema with the documented contract. Fix: accept `z.union([z.boolean(), -z.null()])` and strip null leaves server-side before writing. - -### 11. Origin check disabled in dev — but `process.env.NODE_ENV` check is per-process - -`src/middleware.ts:79-89` - -CSRF defense-in-depth is skipped when `NODE_ENV !== 'production'`. The -dev/staging boundary is correct in principle, but `staging` deployments -typically run with `NODE_ENV=production`, while CI / preview-builds may not. -Worth confirming the Dockerfile (`Dockerfile`) sets `NODE_ENV=production` on -any environment that's reachable from the internet. Note also that the -fallback at `src/middleware.ts:68-69` allows a request with neither Origin nor -Referer through — this is correct for server-side fetches but means any HTTP -client that strips both headers (curl with `-H "Origin:"`) bypasses the check. -Combined with SameSite=strict cookies the residual risk is low. - -### 12. `me/email` confirm/cancel tokens are URL-only — referer leakage risk - -`src/app/api/v1/me/email/route.ts:88-89, src/app/api/v1/me/email/confirm/[token]/route.ts:24-35` - -The confirm/cancel URLs are emailed as `${baseUrl}/api/v1/me/email/confirm/${rawToken}`. -The user clicks from their inbox; the email client opens the URL in a browser -which then renders `/settings?emailChange=confirmed` (a redirect). If -`/settings` makes any third-party request before navigating away, the Referer -header carries the full confirm URL including the token. The token is -single-use and short-lived, so the post-redirect exposure window is small, but -defensively the route should `Referrer-Policy: no-referrer` on the redirect -response. Fix: `res.headers.set('Referrer-Policy', 'no-referrer')` on the -`NextResponse.redirect(...)` call. - -## Summary - -Two CRITICAL findings: self-targetable permission-overrides escalation -(finding 1) and unlimited username harvesting at `/api/auth/resolve-identifier` -(finding 2). Both are direct consequences of the recently-added routes that -prompted this audit. The remainder are mostly hardening — the v1/\* surface -overall is well-disciplined: nearly every route under `/api/v1/**` flows -through `withAuth(withPermission(...))`, body parsing consistently uses -`parseBody` (only public/auth handlers use raw `req.json()` for documented -reasons), and the few raw `sql\`…\`` usages I sampled -(`admin/website-submissions`, `admin/document-sends`, `search/recently-viewed`) -all interpolate via the parameterized tag form rather than string concat. -Multi-tenant scoping looks consistent — services accept `ctx.portId` and the -defense-in-depth pattern is well-applied (e.g. the berth-recommender note in -CLAUDE.md). The Documenso webhook receiver has solid replay/dedup/secret -discipline. - ---- - -## 2. UI/UX consistency + accessibility audit (ui-ux-auditor) - -# UI/UX Consistency + Accessibility Audit - -Scope: Form patterns, dialog/sheet/drawer choices, mobile parity, enum leakage, empty/loading states, badge tones, a11y, plus the recently added surfaces (UserForm tabs, UserList Power toggle, UserPermissionMatrix, Login identifier field, user settings username card). - ---- - -## CRITICAL - -### C1 — `window.confirm()` / `confirm()` used for destructive flows (>=15 sites) - -Files using native browser confirm instead of `ConfirmationDialog` (which wraps `AlertDialog`): - -- `src/components/clients/contacts-editor.tsx:115` — remove contact -- `src/components/clients/client-files-tab.tsx:50` — delete file -- `src/components/yachts/yacht-list.tsx:187` — archive yacht (bulk) -- `src/components/admin/document-templates/template-version-history.tsx:54` — restore older version -- `src/components/shared/addresses-editor.tsx:77` — remove address -- `src/components/documents/document-detail.tsx:160` — cancel/void signing envelope -- `src/components/interests/interest-list.tsx:314` — archive interest -- `src/components/interests/interest-tabs.tsx:483` — outcome/archival flow -- `src/components/interests/interest-eoi-tab.tsx:299` — cancel EOI -- `src/components/interests/interest-reservation-tab.tsx:313` — cancel contract -- `src/components/interests/interest-contact-log-tab.tsx:222` — delete contact log -- `src/components/interests/interest-contract-tab.tsx:310` — cancel contract -- `src/components/interests/interest-documents-tab.tsx:80` — delete file -- `src/components/companies/company-files-tab.tsx:50` — delete file -- `src/components/companies/company-list.tsx:201` — archive company -- `src/components/documents/document-list.tsx:136` — delete document - -**Why it matters:** native confirm cannot be styled, bypasses our `` keyboard semantics, no focus trap, no destructive-action red styling, fails focus-return after dismiss; inconsistent with the rest of the app which uses `ConfirmationDialog`. Several of these are catastrophic (cancel signing envelope, hard-delete file, archive company). -**Fix:** replace each with `` matching the pattern in `user-list.tsx`. - -### C2 — UserForm "Permissions" tab silently drops unsaved overrides - -`src/components/admin/users/user-form.tsx:204-212` and `user-permission-matrix.tsx:175-191`. -The matrix has its own "Save overrides" button; the parent Sheet's "Save changes" only persists Profile-tab fields. `onSaveStateChange` is declared in the matrix props but **never passed** by `user-form.tsx` (line 206), so the parent has no idea overrides are dirty. A user who toggles Inherit/Grant/Deny then clicks "Save changes" loses everything when the Sheet closes — no warning, no toast. -**Fix:** lift `overrides` state to `user-form.tsx`, persist both endpoints inside `persist()`, or track dirty state via `onSaveStateChange` and block Sheet close with an AlertDialog. - ---- - -## HIGH - -### H1 — Raw enum render via `.replace(/_/g, ' ')` outside `constants.ts` (40+ sites) - -Examples (not exhaustive): - -- `src/components/documents/documents-hub.tsx:292`, `document-detail.tsx:204,210,386`, `entity-folder-view.tsx:63`, `hub-root-view.tsx:69`, `signing-details-dialog.tsx:123` — `status`, `eventType`, `documentType` -- `src/components/reservations/reservation-detail.tsx:230,285,339` — `tenureType`, agreement status -- `src/components/berths/berth-status-suggestion-dialog.tsx:61,65` -- `src/components/expenses/expense-detail.tsx:229,233`, `expense-card.tsx:71`, `expense-columns.tsx:121`, `expense-form-dialog.tsx:257,278`, `expense-filters.tsx:16` -- `src/components/admin/audit/audit-log-list.tsx:234-235`, `roles/role-list.tsx:223,239`, `roles/role-form.tsx:123` -- `src/components/admin/users/user-permission-matrix.tsx:101` — local `formatAction` duplicates pattern -- `src/components/dashboard/source-conversion-chart.tsx:60`, `activity-feed.tsx:34,44` -- `src/components/scan/scan-shell.tsx:227,242` -- `src/components/interests/linked-berths-list.tsx:94`, `interest-tabs.tsx:40` -- `src/app/(portal)/portal/{my-yachts,documents,interests}/page.tsx` — portal-side enum leakage -- `src/components/search/command-search.tsx:939,965` — fallback after `STAGE_LABELS` - -**Fix:** route through `stageLabel`, `formatRole`, `formatOutcome`, `formatSource` (already in `constants.ts`); add `formatDocumentStatus`, `formatTenureType`, `formatEventType`, `formatExpenseCategory`, `formatPaymentMethod`, `formatBerthStatus`, `formatPermissionAction` to `constants.ts` and replace call-sites. Removes "manage memberships" / "Eoi Signed" inconsistencies. - -### H2 — Mobile parity: 18 list components have no `cardRender` - -DataTable already supports `cardRender`; without it the mobile view falls back to a raw horizontal-scroll table (bad UX on iOS): - -- `src/components/reservations/reservation-list.tsx`, `berth-reservations-list.tsx` -- `src/components/website-analytics/top-list.tsx` -- `src/components/shared/notes-list.tsx` -- `src/components/residential/residential-clients-list.tsx`, `residential-interests-list.tsx` -- `src/components/documents/document-list.tsx` -- `src/components/interests/linked-berths-list.tsx`, `recommendation-list.tsx` -- `src/components/email/email-accounts-list.tsx`, `email-threads-list.tsx` -- `src/components/reports/reports-list.tsx` -- `src/components/admin/document-templates/template-list.tsx`, `forms/form-template-list.tsx`, `roles/role-list.tsx`, `tags/tag-list.tsx`, `ports/port-list.tsx` - -**Fix:** add cardRender mirroring desktop columns. `UserCard`/`ClientCard`/`InterestCard` are good templates. - -### H3 — User settings phone field is unbound on load - -`src/components/settings/user-settings.tsx:69-92` — `loadProfile()` reads `firstName`, `lastName`, `email`, etc., but **never reads `phone`** into state. Yet `saveProfile()` at line 143 sends `phone: phone || null`, which **clears the user's stored phone on every save**. Also `country as never` cast at line 298 is unsound — when no country is selected the PhoneInput shows a US flag even for European users. -**Fix:** add `phone` to MeResponse + `setPhone(res.data.profile?.phone ?? '')`. Store country alongside phone (the PhoneInput value is `{e164, country}` — persist the parsed country). - -### H4 — UserPermissionMatrix three-state toggle has no a11y semantics - -`user-permission-matrix.tsx:247-267` — three sibling ` - - - ))} - - - ); -} -``` - -- [ ] **Step 3: Verify it compiles** - -Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty 2>&1 | head -30` -Expected: No errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/components/admin/settings/settings-manager.tsx -git commit -m "feat: add inquiry notification settings to admin settings UI" -``` - ---- - -### Task 9: Build Verification - -**Files:** None (verification only) - -- [ ] **Step 1: Run the full TypeScript check** - -Run: `cd C:/repos/new-pn-crm && npx tsc --noEmit --pretty` -Expected: No errors. - -- [ ] **Step 2: Run the linter** - -Run: `cd C:/repos/new-pn-crm && pnpm lint` -Expected: No errors. - -- [ ] **Step 3: Run the production build** - -Run: `cd C:/repos/new-pn-crm && SKIP_ENV_VALIDATION=1 pnpm build 2>&1 | tail -20` -Expected: Build completes successfully. - -- [ ] **Step 4: Generate the Drizzle migration (if not done in Task 1)** - -Run: `cd C:/repos/new-pn-crm && pnpm db:generate` -Expected: Migration for `client_addresses` table is present. - -- [ ] **Step 5: Verify the settings UI renders** - -Start the dev server (`pnpm dev`) and navigate to `/{portSlug}/admin/settings`. Confirm: - -- The "Inquiry Settings" card appears with the contact email input -- The "Advanced Configuration" section shows the `inquiry_notification_recipients` JSON setting -- Values can be saved and persisted - -- [ ] **Step 6: Test the public endpoint with curl** - -```bash -curl -X POST http://localhost:3000/api/public/interests?portId= \ - -H "Content-Type: application/json" \ - -d '{ - "firstName": "Test", - "lastName": "User", - "email": "test@example.com", - "phone": "+1234567890", - "mooringNumber": "A3", - "preferredContactMethod": "email", - "address": { - "street": "123 Marina Way", - "city": "Anguilla", - "country": "AI" - } - }' -``` - -Expected: `201` response with interest ID. Check the database for: - -- Client record with `full_name = 'Test User'`, `preferred_contact_method = 'email'` -- `client_addresses` record with the address data and `is_primary = true` -- `interests` record with the correct `berth_id` (if berth A3 exists) -- Notification records in the `notifications` table (if users with interests.view exist) -- BullMQ jobs in the email queue (check via admin queue UI at `/{portSlug}/admin/queues`) - -- [ ] **Step 7: Test backward compatibility** - -```bash -curl -X POST http://localhost:3000/api/public/interests?portId= \ - -H "Content-Type: application/json" \ - -d '{ - "fullName": "Legacy User", - "email": "legacy@example.com", - "phone": "+1234567890" - }' -``` - -Expected: `201` response. Client created with `full_name = 'Legacy User'`. diff --git a/docs/superpowers/plans/2026-04-23-data-model-refactor.md b/docs/superpowers/plans/2026-04-23-data-model-refactor.md deleted file mode 100644 index eb91e726..00000000 --- a/docs/superpowers/plans/2026-04-23-data-model-refactor.md +++ /dev/null @@ -1,2678 +0,0 @@ -# Data-Model Refactor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor the core client/yacht/company data model into first-class entities so the system can represent real-world multi-cardinality relationships (one person with many yachts, one yacht owned by a company, one berth with one active reservation). Ships with rich dummy data for dev; real data arrives in Spec 2. Hand-off ready: dual-path EOI generation, exhaustive click-through test suite, golden-image template regression. - -**Architecture:** New tables `yachts`, `yacht_ownership_history`, `companies`, `company_memberships`, `berth_reservations` (plus notes/tags/addresses mirrors). Removes yacht/company/proxy columns from `clients`. Adds `yachtId` and `companyId` FKs to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents`. Polymorphic owner columns (`currentOwnerType` + `currentOwnerId`) validated in the service layer; partial unique indexes enforce exclusivity at the DB level. EOI generation routes through a shared `buildEoiContext()` that feeds either Documenso or in-app pdfme. - -**Tech Stack:** Drizzle ORM (Postgres), Zod (validation), better-auth, BullMQ, Redis, MinIO, Socket.IO, Next.js 15 App Router, React 19, TanStack Table/Query, react-hook-form, shadcn/ui, pdfme, Documenso API, Vitest, Playwright. - -**Spec:** `docs/superpowers/specs/2026-04-23-data-model-refactor-design.md` — refer to it for WHY decisions were made. This plan focuses on HOW to execute them. - ---- - -## Plan conventions - -- **Branch:** All work on `refactor/data-model` (long-lived feature branch). Rebase onto `main` daily; merge to `main` in one final PR after Task 15.5. -- **Commits:** After each task, commit with `feat(scope): description` or `refactor(scope): description` or `test(scope): description`. Example: `feat(yachts): add yachts schema`. -- **Tests before code (TDD):** For every service function and every route handler, write the failing test first, verify it fails, then implement. -- **Running tests:** `pnpm vitest run path/to/test.test.ts` for unit/integration; `pnpm playwright test tests/e2e/...` for E2E. -- **Schema changes:** After any `src/lib/db/schema/*.ts` change, run `pnpm db:generate` to produce a migration file, then `pnpm db:push` to apply locally. -- **Linting:** `pnpm lint` and `pnpm format` before every commit (or rely on Husky pre-commit hook). -- **Permission checks:** Every new route MUST call `requirePermission(context, '')` before service calls. Cross-tenant guards are handled inside services via `portId` scoping. - -## File structure - -New files (created during this plan): - -``` -src/lib/db/schema/ - yachts.ts — yachts + yacht_ownership_history + yacht_notes + yacht_tags - companies.ts — companies + company_memberships + company_addresses + company_notes + company_tags - reservations.ts — berth_reservations - -src/lib/validators/ - yachts.ts — zod schemas for yacht CRUD + transfer - companies.ts — zod for company CRUD - company-memberships.ts — zod for membership CRUD - reservations.ts — zod for reservation CRUD + state transitions - -src/lib/services/ - yachts.service.ts - companies.service.ts - company-memberships.service.ts - berth-reservations.service.ts - eoi-context.ts — shared EOI payload builder - -src/app/api/v1/yachts/ - route.ts — GET list, POST create - autocomplete/route.ts - [id]/route.ts — GET, PATCH, DELETE - [id]/transfer/route.ts — POST transfer - [id]/ownership-history/route.ts — GET history - -src/app/api/v1/companies/ - route.ts - autocomplete/route.ts - [id]/route.ts - [id]/members/route.ts — GET list, POST add - [id]/members/[mid]/route.ts — PATCH, DELETE - -src/app/api/v1/berths/[id]/reservations/ - route.ts — GET, POST - -src/app/api/v1/berth-reservations/[id]/route.ts — PATCH state transitions - -src/components/yachts/ - yacht-form.tsx - yacht-detail.tsx - yacht-detail-header.tsx - yacht-tabs.tsx - yacht-columns.tsx - yacht-picker.tsx - yacht-ownership-history.tsx - yacht-transfer-dialog.tsx - -src/components/companies/ - company-form.tsx - company-detail.tsx - company-detail-header.tsx - company-tabs.tsx - company-columns.tsx - company-picker.tsx - company-members-tab.tsx - company-owned-yachts-tab.tsx - add-membership-dialog.tsx - -src/components/reservations/ - reservation-form.tsx - reservation-list.tsx - berth-reserve-dialog.tsx - -src/components/shared/ - owner-picker.tsx — polymorphic client|company combobox - billing-entity-picker.tsx - -src/app/(dashboard)/[portSlug]/yachts/ - page.tsx — list - [yachtId]/page.tsx — detail - -src/app/(dashboard)/[portSlug]/companies/ - page.tsx - [companyId]/page.tsx - -tests/helpers/factories.ts — EXTEND with yacht/company/membership/reservation factories -tests/helpers/click-everything.ts — NEW utility for Tier 3.5 exhaustive suite - -tests/unit/services/yachts.test.ts -tests/unit/services/companies.test.ts -tests/unit/services/company-memberships.test.ts -tests/unit/services/berth-reservations.test.ts -tests/unit/services/eoi-context.test.ts -tests/unit/validators/yachts.test.ts -tests/unit/validators/companies.test.ts -tests/integration/schema-constraints.test.ts -tests/integration/ownership-transfer.test.ts -tests/integration/reservation-exclusivity.test.ts - -tests/e2e/scenarios/yacht-lifecycle.spec.ts -tests/e2e/scenarios/company-lifecycle.spec.ts -tests/e2e/scenarios/multi-cardinality.spec.ts -tests/e2e/scenarios/eoi-documenso-path.spec.ts -tests/e2e/scenarios/eoi-inapp-path.spec.ts -tests/e2e/scenarios/portal.spec.ts - -tests/e2e/exhaustive/yachts.spec.ts -tests/e2e/exhaustive/companies.spec.ts -tests/e2e/exhaustive/reservations.spec.ts -tests/e2e/exhaustive/client-detail-refactored.spec.ts -tests/e2e/exhaustive/eoi-generate.spec.ts -tests/e2e/exhaustive/invoice-form.spec.ts -tests/e2e/exhaustive/berths-with-reservations.spec.ts -tests/e2e/exhaustive/portal.spec.ts -tests/e2e/exhaustive/navigation.spec.ts - -tests/e2e/templates/eoi-golden-image.spec.ts -tests/e2e/fixtures/eoi-golden/*.pdf — committed reference PDFs - -src/lib/pdf/templates/eoi-standard-inapp.ts — new in-app EOI template -``` - -Modified files (major ones): - -``` -src/lib/db/schema/clients.ts — drop yacht/company/proxy columns -src/lib/db/schema/interests.ts — add yachtId -src/lib/db/schema/berths.ts — add yachtId to waiting list -src/lib/db/schema/financial.ts — add billingEntityType + billingEntityId to invoices -src/lib/db/schema/documents.ts — add yachtId + companyId to files + documents -src/lib/db/schema/relations.ts — wire all new tables -src/lib/db/schema/index.ts — re-export new tables -src/lib/db/seed.ts — rewrite for new model -src/lib/services/clients.service.ts — strip yacht/company/proxy handling -src/lib/services/interests.service.ts — accept yachtId -src/lib/services/berths.service.ts — integrate berth_reservations -src/lib/services/invoices.service.ts — billingEntityType + billingEntityId -src/lib/services/search.service.ts — extend to yachts + companies -src/lib/services/recommendations.ts — read yacht dims from yachts table -src/lib/services/document-templates.ts — update MERGE_FIELDS + resolveTemplate -src/lib/services/portal.service.ts — portal: my-yachts, my-memberships, my-reservations -src/lib/validators/clients.ts — drop yacht/company/proxy fields -src/lib/validators/interests.ts — add yachtId -src/lib/validators/invoices.ts — add billingEntityType + billingEntityId -src/components/clients/client-form.tsx -src/components/clients/client-detail.tsx -src/components/clients/client-tabs.tsx -src/components/clients/client-columns.tsx -src/components/interests/interest-form.tsx -src/components/invoices/invoice-form.tsx -src/components/berths/berth-detail-*.tsx -src/app/api/public/interests/route.ts -src/app/(portal)/portal/**/*.tsx -``` - ---- - -# PR 1 — Schema Migration - -**Goal:** Add every new table. Leave old client columns in place. Old code still works; new tables exist and are ready for the service layer in PR 2. - -**Branch off:** `main`. Merge to: `refactor/data-model`. - -### Task 1.1: Create `yachts.ts` schema file - -**Files:** - -- Create: `src/lib/db/schema/yachts.ts` - -- [ ] **Step 1: Write the schema file** - -```typescript -import { - pgTable, - text, - integer, - numeric, - timestamp, - boolean, - index, - uniqueIndex, - primaryKey, -} from 'drizzle-orm/pg-core'; -import { sql } from 'drizzle-orm'; -import { ports } from './ports'; - -export const yachts = pgTable( - 'yachts', - { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - portId: text('port_id') - .notNull() - .references(() => ports.id), - name: text('name').notNull(), - hullNumber: text('hull_number'), - registration: text('registration'), - flag: text('flag'), - yearBuilt: integer('year_built'), - builder: text('builder'), - model: text('model'), - hullMaterial: text('hull_material'), - lengthFt: numeric('length_ft'), - widthFt: numeric('width_ft'), - draftFt: numeric('draft_ft'), - lengthM: numeric('length_m'), - widthM: numeric('width_m'), - draftM: numeric('draft_m'), - currentOwnerType: text('current_owner_type').notNull(), // 'client' | 'company' - currentOwnerId: text('current_owner_id').notNull(), - status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away' - notes: text('notes'), - archivedAt: timestamp('archived_at', { withTimezone: true }), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index('idx_yachts_port').on(table.portId), - index('idx_yachts_current_owner').on( - table.portId, - table.currentOwnerType, - table.currentOwnerId, - ), - index('idx_yachts_name').on(table.portId, table.name), - index('idx_yachts_archived').on(table.portId, table.archivedAt), - ], -); - -export const yachtOwnershipHistory = pgTable( - 'yacht_ownership_history', - { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - yachtId: text('yacht_id') - .notNull() - .references(() => yachts.id, { onDelete: 'cascade' }), - ownerType: text('owner_type').notNull(), - ownerId: text('owner_id').notNull(), - startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(), - endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }), - transferReason: text('transfer_reason'), - transferNotes: text('transfer_notes'), - createdBy: text('created_by').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index('idx_yoh_yacht').on(table.yachtId), - uniqueIndex('idx_yoh_active') - .on(table.yachtId) - .where(sql`${table.endDate} IS NULL`), - ], -); - -export const yachtNotes = pgTable( - 'yacht_notes', - { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - yachtId: text('yacht_id') - .notNull() - .references(() => yachts.id, { onDelete: 'cascade' }), - authorId: text('author_id').notNull(), - content: text('content').notNull(), - mentions: text('mentions').array(), - isLocked: boolean('is_locked').notNull().default(false), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [index('idx_yn_yacht').on(table.yachtId)], -); - -export const yachtTags = pgTable( - 'yacht_tags', - { - yachtId: text('yacht_id') - .notNull() - .references(() => yachts.id, { onDelete: 'cascade' }), - tagId: text('tag_id').notNull(), - }, - (table) => [primaryKey({ columns: [table.yachtId, table.tagId] })], -); - -export type Yacht = typeof yachts.$inferSelect; -export type NewYacht = typeof yachts.$inferInsert; -export type YachtOwnershipHistoryRow = typeof yachtOwnershipHistory.$inferSelect; -export type NewYachtOwnershipHistoryRow = typeof yachtOwnershipHistory.$inferInsert; -export type YachtNote = typeof yachtNotes.$inferSelect; -export type NewYachtNote = typeof yachtNotes.$inferInsert; -``` - -- [ ] **Step 2: Re-export from schema index** - -Add to `src/lib/db/schema/index.ts`: - -```typescript -export * from './yachts'; -``` - -- [ ] **Step 3: Generate migration** - -Run: `pnpm db:generate` -Expected: new file in `src/lib/db/migrations/` containing the four table creations. - -- [ ] **Step 4: Apply migration** - -Run: `pnpm db:push` -Expected: no errors; tables created in local Postgres. - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/db/schema/yachts.ts src/lib/db/schema/index.ts src/lib/db/migrations/ -git commit -m "feat(yachts): add yachts, ownership history, notes, tags schema" -``` - -### Task 1.2: Create `companies.ts` schema file - -**Files:** - -- Create: `src/lib/db/schema/companies.ts` - -- [ ] **Step 1: Write the schema file** - -```typescript -import { - pgTable, - text, - timestamp, - boolean, - index, - uniqueIndex, - primaryKey, -} from 'drizzle-orm/pg-core'; -import { sql } from 'drizzle-orm'; -import { ports } from './ports'; -import { clients } from './clients'; - -export const companies = pgTable( - 'companies', - { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - portId: text('port_id') - .notNull() - .references(() => ports.id), - name: text('name').notNull(), - legalName: text('legal_name'), - taxId: text('tax_id'), - registrationNumber: text('registration_number'), - incorporationCountry: text('incorporation_country'), - incorporationDate: timestamp('incorporation_date', { withTimezone: true, mode: 'date' }), - status: text('status').notNull().default('active'), // 'active' | 'dissolved' - billingEmail: text('billing_email'), - notes: text('notes'), - archivedAt: timestamp('archived_at', { withTimezone: true }), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index('idx_companies_port').on(table.portId), - uniqueIndex('idx_companies_name_unique').on(table.portId, sql`lower(${table.name})`), - index('idx_companies_taxid') - .on(table.portId, table.taxId) - .where(sql`${table.taxId} IS NOT NULL`), - ], -); - -export const companyMemberships = pgTable( - 'company_memberships', - { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - companyId: text('company_id') - .notNull() - .references(() => companies.id, { onDelete: 'cascade' }), - clientId: text('client_id') - .notNull() - .references(() => clients.id, { onDelete: 'cascade' }), - role: text('role').notNull(), // director | officer | broker | representative | legal_counsel | employee | shareholder | other - roleDetail: text('role_detail'), - startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(), - endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }), - isPrimary: boolean('is_primary').notNull().default(false), - notes: text('notes'), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index('idx_cm_company').on(table.companyId), - index('idx_cm_client').on(table.clientId), - index('idx_cm_active') - .on(table.companyId, table.clientId) - .where(sql`${table.endDate} IS NULL`), - uniqueIndex('unique_cm_exact').on(table.companyId, table.clientId, table.role, table.startDate), - ], -); - -export const companyAddresses = pgTable( - 'company_addresses', - { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - companyId: text('company_id') - .notNull() - .references(() => companies.id, { onDelete: 'cascade' }), - portId: text('port_id') - .notNull() - .references(() => ports.id, { onDelete: 'cascade' }), - label: text('label').notNull().default('Primary'), - streetAddress: text('street_address'), - city: text('city'), - stateProvince: text('state_province'), - postalCode: text('postal_code'), - country: text('country'), - isPrimary: boolean('is_primary').notNull().default(true), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index('idx_compa_company').on(table.companyId), - index('idx_compa_port').on(table.portId), - uniqueIndex('idx_compa_primary') - .on(table.companyId) - .where(sql`${table.isPrimary} = true`), - ], -); - -export const companyNotes = pgTable( - 'company_notes', - { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - companyId: text('company_id') - .notNull() - .references(() => companies.id, { onDelete: 'cascade' }), - authorId: text('author_id').notNull(), - content: text('content').notNull(), - mentions: text('mentions').array(), - isLocked: boolean('is_locked').notNull().default(false), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [index('idx_compn_company').on(table.companyId)], -); - -export const companyTags = pgTable( - 'company_tags', - { - companyId: text('company_id') - .notNull() - .references(() => companies.id, { onDelete: 'cascade' }), - tagId: text('tag_id').notNull(), - }, - (table) => [primaryKey({ columns: [table.companyId, table.tagId] })], -); - -export type Company = typeof companies.$inferSelect; -export type NewCompany = typeof companies.$inferInsert; -export type CompanyMembership = typeof companyMemberships.$inferSelect; -export type NewCompanyMembership = typeof companyMemberships.$inferInsert; -export type CompanyAddress = typeof companyAddresses.$inferSelect; -export type NewCompanyAddress = typeof companyAddresses.$inferInsert; -export type CompanyNote = typeof companyNotes.$inferSelect; -export type NewCompanyNote = typeof companyNotes.$inferInsert; -``` - -- [ ] **Step 2: Re-export from schema index** - -Add to `src/lib/db/schema/index.ts`: - -```typescript -export * from './companies'; -``` - -- [ ] **Step 3: Generate + apply migration** - -```bash -pnpm db:generate -pnpm db:push -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/lib/db/schema/companies.ts src/lib/db/schema/index.ts src/lib/db/migrations/ -git commit -m "feat(companies): add companies, memberships, addresses, notes, tags schema" -``` - -### Task 1.3: Create `reservations.ts` schema file - -**Files:** - -- Create: `src/lib/db/schema/reservations.ts` - -- [ ] **Step 1: Write the schema** - -```typescript -import { pgTable, text, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core'; -import { sql } from 'drizzle-orm'; -import { ports } from './ports'; -import { berths } from './berths'; -import { clients } from './clients'; -import { yachts } from './yachts'; -import { interests } from './interests'; -import { files } from './documents'; - -export const berthReservations = pgTable( - 'berth_reservations', - { - id: text('id') - .primaryKey() - .$defaultFn(() => crypto.randomUUID()), - berthId: text('berth_id') - .notNull() - .references(() => berths.id), - portId: text('port_id') - .notNull() - .references(() => ports.id), - clientId: text('client_id') - .notNull() - .references(() => clients.id), - yachtId: text('yacht_id') - .notNull() - .references(() => yachts.id), - interestId: text('interest_id').references(() => interests.id), - status: text('status').notNull(), // 'pending' | 'active' | 'ended' | 'cancelled' - startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(), - endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }), - tenureType: text('tenure_type').notNull().default('permanent'), - contractFileId: text('contract_file_id').references(() => files.id), - notes: text('notes'), - createdBy: text('created_by').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index('idx_br_berth').on(table.berthId), - index('idx_br_client').on(table.clientId), - index('idx_br_yacht').on(table.yachtId), - index('idx_br_port').on(table.portId), - uniqueIndex('idx_br_active') - .on(table.berthId) - .where(sql`${table.status} = 'active'`), - ], -); - -export type BerthReservation = typeof berthReservations.$inferSelect; -export type NewBerthReservation = typeof berthReservations.$inferInsert; -``` - -- [ ] **Step 2: Re-export from schema index** - -- [ ] **Step 3: Generate + apply migration, commit** - -```bash -pnpm db:generate && pnpm db:push -git add src/lib/db/schema/reservations.ts src/lib/db/schema/index.ts src/lib/db/migrations/ -git commit -m "feat(reservations): add berth_reservations schema with partial unique exclusivity" -``` - -### Task 1.4: Add `yachtId` to `interests`, `berth_waiting_list` - -**Files:** - -- Modify: `src/lib/db/schema/interests.ts` -- Modify: `src/lib/db/schema/berths.ts` - -- [ ] **Step 1: Add `yachtId` to `interests` schema** - -In `src/lib/db/schema/interests.ts`, add a `yachtId` column after `berthId`: - -```typescript -yachtId: text('yacht_id'), // FK added via relation; nullable until pipeline leaves 'open' -``` - -Add an index: - -```typescript -index('idx_interests_yacht').on(table.yachtId), -``` - -- [ ] **Step 2: Add `yachtId` to `berth_waiting_list`** - -In `src/lib/db/schema/berths.ts`, find `berthWaitingList` and add: - -```typescript -yachtId: text('yacht_id'), // FK added via relation; nullable -``` - -- [ ] **Step 3: Generate + apply migration, commit** - -```bash -pnpm db:generate && pnpm db:push -git add src/lib/db/schema/interests.ts src/lib/db/schema/berths.ts src/lib/db/migrations/ -git commit -m "feat(schema): add yachtId to interests and berth_waiting_list" -``` - -### Task 1.5: Modify `invoices` — add billing entity - -**Files:** - -- Modify: `src/lib/db/schema/financial.ts` - -- [ ] **Step 1: Add billing entity columns** - -After `clientName` in the `invoices` table, add: - -```typescript -billingEntityType: text('billing_entity_type').notNull().default('client'), // 'client' | 'company' — default needed for backfill-less green-field -billingEntityId: text('billing_entity_id').notNull().default(''), -``` - -Add an index: - -```typescript -index('idx_invoices_billing_entity').on(table.portId, table.billingEntityType, table.billingEntityId), -``` - -- [ ] **Step 2: Generate + apply migration, commit** - -```bash -pnpm db:generate && pnpm db:push -git add src/lib/db/schema/financial.ts src/lib/db/migrations/ -git commit -m "feat(invoices): add billingEntityType/Id for polymorphic billing" -``` - -### Task 1.6: Modify `files` and `documents` — add yachtId/companyId FKs - -**Files:** - -- Modify: `src/lib/db/schema/documents.ts` - -- [ ] **Step 1: Add FKs to `files` table** - -```typescript -yachtId: text('yacht_id'), // FK wired in relations.ts -companyId: text('company_id'), // FK wired in relations.ts -``` - -Also add indexes. - -- [ ] **Step 2: Add same FKs to `documents` table** - -Same two columns + indexes. - -- [ ] **Step 3: Generate + apply migration, commit** - -```bash -pnpm db:generate && pnpm db:push -git add src/lib/db/schema/documents.ts src/lib/db/migrations/ -git commit -m "feat(documents): add yachtId/companyId to files and documents" -``` - -### Task 1.7: Wire new tables into `relations.ts` - -**Files:** - -- Modify: `src/lib/db/schema/relations.ts` - -- [ ] **Step 1: Import new tables** - -At the top of `relations.ts`, add imports: - -```typescript -import { yachts, yachtOwnershipHistory, yachtNotes, yachtTags } from './yachts'; -import { - companies, - companyMemberships, - companyAddresses, - companyNotes, - companyTags, -} from './companies'; -import { berthReservations } from './reservations'; -``` - -- [ ] **Step 2: Add relations for `yachts`** - -```typescript -export const yachtsRelations = relations(yachts, ({ one, many }) => ({ - port: one(ports, { fields: [yachts.portId], references: [ports.id] }), - ownershipHistory: many(yachtOwnershipHistory), - notes: many(yachtNotes), - tags: many(yachtTags), - interests: many(interests), - reservations: many(berthReservations), - documents: many(documents), -})); - -export const yachtOwnershipHistoryRelations = relations(yachtOwnershipHistory, ({ one }) => ({ - yacht: one(yachts, { fields: [yachtOwnershipHistory.yachtId], references: [yachts.id] }), -})); - -export const yachtNotesRelations = relations(yachtNotes, ({ one }) => ({ - yacht: one(yachts, { fields: [yachtNotes.yachtId], references: [yachts.id] }), -})); - -export const yachtTagsRelations = relations(yachtTags, ({ one }) => ({ - yacht: one(yachts, { fields: [yachtTags.yachtId], references: [yachts.id] }), - tag: one(tags, { fields: [yachtTags.tagId], references: [tags.id] }), -})); -``` - -- [ ] **Step 3: Add relations for companies + memberships + addresses + notes + tags** - -Mirror the pattern from Step 2. Include `companyMembershipsRelations` wiring both `company: one(companies, ...)` and `client: one(clients, ...)`. - -- [ ] **Step 4: Add relations for `berthReservations`** - -```typescript -export const berthReservationsRelations = relations(berthReservations, ({ one }) => ({ - berth: one(berths, { fields: [berthReservations.berthId], references: [berths.id] }), - port: one(ports, { fields: [berthReservations.portId], references: [ports.id] }), - client: one(clients, { fields: [berthReservations.clientId], references: [clients.id] }), - yacht: one(yachts, { fields: [berthReservations.yachtId], references: [yachts.id] }), - interest: one(interests, { - fields: [berthReservations.interestId], - references: [interests.id], - }), - contractFile: one(files, { - fields: [berthReservations.contractFileId], - references: [files.id], - }), -})); -``` - -- [ ] **Step 5: Extend `clientsRelations`** - -Add to the `clients` relations `many()` block: - -```typescript -companyMemberships: many(companyMemberships), -berthReservations: many(berthReservations), -``` - -Note: owned yachts (where client is the current owner) is a polymorphic lookup; query via `yachts` where `currentOwnerType='client' AND currentOwnerId=...`. Don't attempt a Drizzle relation for this — service layer handles it. - -- [ ] **Step 6: Extend `interestsRelations`** - -Add: `yacht: one(yachts, { fields: [interests.yachtId], references: [yachts.id] })`. - -- [ ] **Step 7: Extend `filesRelations`, `documentsRelations`** - -Add `yacht` and `company` one-to-one relations on each. - -- [ ] **Step 8: Commit** - -```bash -git add src/lib/db/schema/relations.ts -git commit -m "feat(schema): wire yacht, company, reservation relations in Drizzle" -``` - -### Task 1.8: Integration test for partial unique indexes - -**Files:** - -- Create: `tests/integration/schema-constraints.test.ts` - -- [ ] **Step 1: Write failing test** - -```typescript -import { describe, it, expect, beforeEach } from 'vitest'; -import { db } from '@/lib/db'; -import { yachts, yachtOwnershipHistory } from '@/lib/db/schema'; -import { berthReservations } from '@/lib/db/schema/reservations'; -import { makeClient, makePort, makeYacht, makeBerth } from 'tests/helpers/factories'; - -describe('schema constraints', () => { - it('partial unique: rejects a second active ownership row per yacht', async () => { - const port = await makePort(); - const client1 = await makeClient({ portId: port.id }); - const client2 = await makeClient({ portId: port.id }); - const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client1.id }); - // ownership row #1 already created by makeYacht - - await expect( - db.insert(yachtOwnershipHistory).values({ - yachtId: yacht.id, - ownerType: 'client', - ownerId: client2.id, - startDate: new Date(), - endDate: null, // another "active" row — should fail - createdBy: 'test', - }), - ).rejects.toThrow(/duplicate key/i); - }); - - it('partial unique: rejects a second active reservation per berth', async () => { - const port = await makePort(); - const client1 = await makeClient({ portId: port.id }); - const client2 = await makeClient({ portId: port.id }); - const yacht1 = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client1.id }); - const yacht2 = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client2.id }); - const berth = await makeBerth({ portId: port.id }); - - await db.insert(berthReservations).values({ - berthId: berth.id, - portId: port.id, - clientId: client1.id, - yachtId: yacht1.id, - status: 'active', - startDate: new Date(), - createdBy: 'test', - }); - - await expect( - db.insert(berthReservations).values({ - berthId: berth.id, - portId: port.id, - clientId: client2.id, - yachtId: yacht2.id, - status: 'active', - startDate: new Date(), - createdBy: 'test', - }), - ).rejects.toThrow(/duplicate key/i); - }); - - it('case-insensitive company name uniqueness per port', async () => { - const port = await makePort(); - await db.insert(companies).values({ portId: port.id, name: 'Aegean Holdings' }); - await expect( - db.insert(companies).values({ portId: port.id, name: 'AEGEAN HOLDINGS' }), - ).rejects.toThrow(/duplicate key/i); - }); -}); -``` - -- [ ] **Step 2: Add `makeYacht`, `makeBerth` factories to `tests/helpers/factories.ts`** - -```typescript -export async function makeYacht(args: { - portId: string; - ownerType: 'client' | 'company'; - ownerId: string; - overrides?: Partial; -}) { - const [yacht] = await db - .insert(yachts) - .values({ - portId: args.portId, - name: faker.word.noun() + ' ' + faker.word.adjective(), - currentOwnerType: args.ownerType, - currentOwnerId: args.ownerId, - ...args.overrides, - }) - .returning(); - await db.insert(yachtOwnershipHistory).values({ - yachtId: yacht!.id, - ownerType: args.ownerType, - ownerId: args.ownerId, - startDate: new Date(), - endDate: null, - createdBy: 'test', - }); - return yacht!; -} - -export async function makeBerth(args: { portId: string; overrides?: Partial }) { - const [berth] = await db - .insert(berths) - .values({ - portId: args.portId, - mooringNumber: faker.string.alphanumeric(6), - ...args.overrides, - }) - .returning(); - return berth!; -} -``` - -- [ ] **Step 3: Run the test — verify all three cases pass** - -```bash -pnpm vitest run tests/integration/schema-constraints.test.ts -``` - -Expected: 3 passed. - -- [ ] **Step 4: Commit** - -```bash -git add tests/integration/schema-constraints.test.ts tests/helpers/factories.ts -git commit -m "test(schema): verify partial unique indexes and case-insensitive company uniqueness" -``` - -### Task 1.9: Create PR 1 - -- [ ] Open PR from `refactor/data-model` (or push changes if working directly on it). -- [ ] PR title: `feat(schema): foundation tables for yacht/company refactor (PR 1 of 15)` -- [ ] Body: summary of tables created + link to spec. - ---- - -# PR 2 — New Services - -**Goal:** Build the four new services (yachts, companies, memberships, berth-reservations) + shared EOI context builder. Unit tests for every function. Integration tests for atomic operations. - -### Task 2.1: Validators — `yachts.ts` - -**Files:** - -- Create: `src/lib/validators/yachts.ts` - -- [ ] **Step 1: Write zod schemas** - -```typescript -import { z } from 'zod'; -import { baseListQuerySchema } from '@/lib/api/route-helpers'; - -export const ownerRefSchema = z.object({ - type: z.enum(['client', 'company']), - id: z.string().min(1), -}); - -export const createYachtSchema = z.object({ - name: z.string().min(1).max(200), - hullNumber: z.string().optional(), - registration: z.string().optional(), - flag: z.string().optional(), - yearBuilt: z.number().int().min(1800).max(2100).optional(), - builder: z.string().optional(), - model: z.string().optional(), - hullMaterial: z.string().optional(), - lengthFt: z.string().optional(), - widthFt: z.string().optional(), - draftFt: z.string().optional(), - lengthM: z.string().optional(), - widthM: z.string().optional(), - draftM: z.string().optional(), - owner: ownerRefSchema, // required; yacht must have an owner - status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'), - notes: z.string().optional(), - tagIds: z.array(z.string()).optional().default([]), -}); - -export const updateYachtSchema = createYachtSchema.partial().omit({ owner: true }); -// Owner changes go through /transfer, not PATCH. - -export const transferOwnershipSchema = z.object({ - newOwner: ownerRefSchema, - effectiveDate: z.coerce.date(), - transferReason: z - .enum(['sale', 'inheritance', 'gift', 'company_restructure', 'other']) - .optional(), - transferNotes: z.string().optional(), -}); - -export const listYachtsQuery = baseListQuerySchema.extend({ - ownerType: z.enum(['client', 'company']).optional(), - ownerId: z.string().optional(), - status: z.enum(['active', 'retired', 'sold_away']).optional(), - search: z.string().optional(), -}); - -export type CreateYachtInput = z.infer; -export type UpdateYachtInput = z.infer; -export type TransferOwnershipInput = z.infer; -export type ListYachtsInput = z.infer; -``` - -- [ ] **Step 2: Write unit tests for validators** - -```typescript -// tests/unit/validators/yachts.test.ts -import { describe, it, expect } from 'vitest'; -import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts'; - -describe('createYachtSchema', () => { - it('rejects empty name', () => { - const result = createYachtSchema.safeParse({ - name: '', - owner: { type: 'client', id: 'c1' }, - }); - expect(result.success).toBe(false); - }); - - it('requires owner', () => { - const result = createYachtSchema.safeParse({ name: 'Sea Breeze' }); - expect(result.success).toBe(false); - }); - - it('rejects invalid yearBuilt', () => { - const result = createYachtSchema.safeParse({ - name: 'Sea Breeze', - owner: { type: 'client', id: 'c1' }, - yearBuilt: 1700, - }); - expect(result.success).toBe(false); - }); - - it('accepts minimal valid input', () => { - const result = createYachtSchema.safeParse({ - name: 'Sea Breeze', - owner: { type: 'client', id: 'c1' }, - }); - expect(result.success).toBe(true); - }); -}); - -describe('transferOwnershipSchema', () => { - it('requires newOwner + effectiveDate', () => { - expect(transferOwnershipSchema.safeParse({}).success).toBe(false); - }); - - it('accepts valid input', () => { - const result = transferOwnershipSchema.safeParse({ - newOwner: { type: 'company', id: 'co1' }, - effectiveDate: new Date(), - transferReason: 'sale', - }); - expect(result.success).toBe(true); - }); -}); -``` - -- [ ] **Step 3: Run tests, commit** - -```bash -pnpm vitest run tests/unit/validators/yachts.test.ts -git add src/lib/validators/yachts.ts tests/unit/validators/yachts.test.ts -git commit -m "feat(yachts): add zod validators + tests" -``` - -### Task 2.2: Service — `yachts.service.ts` (create + list + get) - -**Files:** - -- Create: `src/lib/services/yachts.service.ts` -- Create: `tests/unit/services/yachts.test.ts` - -- [ ] **Step 1: Write failing test for `createYacht`** - -```typescript -// tests/unit/services/yachts.test.ts -import { describe, it, expect, beforeEach } from 'vitest'; -import { createYacht, getYachtById } from '@/lib/services/yachts.service'; -import { makeClient, makeCompany, makePort, makeAuditMeta } from 'tests/helpers/factories'; -import { db } from '@/lib/db'; -import { yachts, yachtOwnershipHistory } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; - -describe('yachts.service — createYacht', () => { - it('creates a yacht with a client owner and opens an ownership history row', async () => { - const port = await makePort(); - const client = await makeClient({ portId: port.id }); - - const yacht = await createYacht( - port.id, - { - name: 'Sea Breeze', - owner: { type: 'client', id: client.id }, - }, - makeAuditMeta(), - ); - - expect(yacht.currentOwnerType).toBe('client'); - expect(yacht.currentOwnerId).toBe(client.id); - - const history = await db - .select() - .from(yachtOwnershipHistory) - .where(eq(yachtOwnershipHistory.yachtId, yacht.id)); - expect(history).toHaveLength(1); - expect(history[0]!.endDate).toBeNull(); - }); - - it('rejects when ownerType=client but ownerId does not exist', async () => { - const port = await makePort(); - await expect( - createYacht( - port.id, - { name: 'Phantom', owner: { type: 'client', id: 'nonexistent' } }, - makeAuditMeta(), - ), - ).rejects.toThrow(/owner not found/i); - }); - - it('rejects when ownerType=company but ownerId does not exist', async () => { - const port = await makePort(); - await expect( - createYacht( - port.id, - { name: 'Phantom', owner: { type: 'company', id: 'nonexistent' } }, - makeAuditMeta(), - ), - ).rejects.toThrow(/owner not found/i); - }); - - it('rejects owner from a different tenant (cross-tenant guard)', async () => { - const portA = await makePort(); - const portB = await makePort(); - const clientInB = await makeClient({ portId: portB.id }); - await expect( - createYacht( - portA.id, - { name: 'Wrong Port', owner: { type: 'client', id: clientInB.id } }, - makeAuditMeta(), - ), - ).rejects.toThrow(/owner not found/i); - }); -}); -``` - -- [ ] **Step 2: Run test — verify 4 failures** - -```bash -pnpm vitest run tests/unit/services/yachts.test.ts -``` - -- [ ] **Step 3: Implement `createYacht`** - -```typescript -// src/lib/services/yachts.service.ts -import { and, eq, sql } from 'drizzle-orm'; -import { db } from '@/lib/db'; -import { yachts, yachtOwnershipHistory, clients } from '@/lib/db/schema'; -import { companies } from '@/lib/db/schema/companies'; -import { createAuditLog } from '@/lib/audit'; -import { NotFoundError, ValidationError } from '@/lib/errors'; -import { emitToRoom } from '@/lib/socket/server'; -import type { CreateYachtInput } from '@/lib/validators/yachts'; - -interface AuditMeta { - userId: string; - portId: string; - ipAddress: string; - userAgent: string; -} - -async function assertOwnerExists( - portId: string, - owner: { type: 'client' | 'company'; id: string }, -): Promise { - if (owner.type === 'client') { - const client = await db.query.clients.findFirst({ - where: and(eq(clients.id, owner.id), eq(clients.portId, portId)), - }); - if (!client) throw new ValidationError('owner not found'); - } else { - const company = await db.query.companies.findFirst({ - where: and(eq(companies.id, owner.id), eq(companies.portId, portId)), - }); - if (!company) throw new ValidationError('owner not found'); - } -} - -export async function createYacht(portId: string, data: CreateYachtInput, meta: AuditMeta) { - return await db.transaction(async (tx) => { - await assertOwnerExists(portId, data.owner); - - const [yacht] = await tx - .insert(yachts) - .values({ - portId, - name: data.name, - hullNumber: data.hullNumber ?? null, - registration: data.registration ?? null, - flag: data.flag ?? null, - yearBuilt: data.yearBuilt ?? null, - builder: data.builder ?? null, - model: data.model ?? null, - hullMaterial: data.hullMaterial ?? null, - lengthFt: data.lengthFt ?? null, - widthFt: data.widthFt ?? null, - draftFt: data.draftFt ?? null, - lengthM: data.lengthM ?? null, - widthM: data.widthM ?? null, - draftM: data.draftM ?? null, - currentOwnerType: data.owner.type, - currentOwnerId: data.owner.id, - status: data.status ?? 'active', - notes: data.notes ?? null, - }) - .returning(); - - await tx.insert(yachtOwnershipHistory).values({ - yachtId: yacht!.id, - ownerType: data.owner.type, - ownerId: data.owner.id, - startDate: new Date(), - endDate: null, - createdBy: meta.userId, - }); - - void createAuditLog({ - userId: meta.userId, - portId, - action: 'create', - entityType: 'yacht', - entityId: yacht!.id, - newValue: { name: yacht!.name, owner: data.owner }, - ipAddress: meta.ipAddress, - userAgent: meta.userAgent, - }); - - emitToRoom(`port:${portId}`, 'yacht:created', { yachtId: yacht!.id }); - - return yacht!; - }); -} - -export async function getYachtById(id: string, portId: string) { - const yacht = await db.query.yachts.findFirst({ - where: and(eq(yachts.id, id), eq(yachts.portId, portId)), - }); - if (!yacht) throw new NotFoundError('Yacht'); - return yacht; -} -``` - -- [ ] **Step 4: Run test — verify all 4 pass** - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/services/yachts.service.ts tests/unit/services/yachts.test.ts -git commit -m "feat(yachts): createYacht + getYachtById services with tests" -``` - -### Task 2.3: Service — `yachts.service.ts` (update + archive) - -**Files:** - -- Modify: `src/lib/services/yachts.service.ts` -- Modify: `tests/unit/services/yachts.test.ts` - -- [ ] **Step 1: Add failing tests for `updateYacht`, `archiveYacht`** - -Tests: update fields succeeds; owner fields cannot be changed via PATCH (must use transfer); archive sets `archivedAt`. - -- [ ] **Step 2: Implement `updateYacht` and `archiveYacht`** - -Pattern mirrors other services (`diffEntity`, audit log, socket emit). Reject any attempt to mutate `currentOwnerType` or `currentOwnerId` via update (throw `ValidationError('use /transfer to change ownership')`). - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -am "feat(yachts): updateYacht + archiveYacht" -``` - -### Task 2.4: Service — `yachts.service.ts` (transferOwnership — atomic) - -**Files:** - -- Modify: `src/lib/services/yachts.service.ts` -- Create: `tests/integration/ownership-transfer.test.ts` - -- [ ] **Step 1: Write integration test for atomicity** - -```typescript -// tests/integration/ownership-transfer.test.ts -import { describe, it, expect } from 'vitest'; -import { transferOwnership } from '@/lib/services/yachts.service'; -import { - makeClient, - makeCompany, - makePort, - makeYacht, - makeAuditMeta, -} from 'tests/helpers/factories'; -import { db } from '@/lib/db'; -import { yachtOwnershipHistory, yachts } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; - -describe('transferOwnership', () => { - it('closes prior history row and opens a new one atomically', async () => { - const port = await makePort(); - const clientA = await makeClient({ portId: port.id }); - const clientB = await makeClient({ portId: port.id }); - const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: clientA.id }); - - await transferOwnership( - yacht.id, - port.id, - { - newOwner: { type: 'client', id: clientB.id }, - effectiveDate: new Date(), - transferReason: 'sale', - }, - makeAuditMeta(), - ); - - const history = await db - .select() - .from(yachtOwnershipHistory) - .where(eq(yachtOwnershipHistory.yachtId, yacht.id)); - expect(history).toHaveLength(2); - const [prior, current] = history.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); - expect(prior!.endDate).not.toBeNull(); - expect(current!.endDate).toBeNull(); - expect(current!.ownerId).toBe(clientB.id); - - const updatedYacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yacht.id) }); - expect(updatedYacht!.currentOwnerId).toBe(clientB.id); - }); - - it('rejects when newOwner = currentOwner (no-op)', async () => { - const port = await makePort(); - const client = await makeClient({ portId: port.id }); - const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id }); - - await expect( - transferOwnership( - yacht.id, - port.id, - { newOwner: { type: 'client', id: client.id }, effectiveDate: new Date() }, - makeAuditMeta(), - ), - ).rejects.toThrow(/same owner/i); - }); - - it('partial unique index prevents concurrent double-open ownership rows', async () => { - // Prior to the atomic close-then-open, a naive impl would insert a new open row - // without closing the old one. Verify this would be blocked at the DB level. - const port = await makePort(); - const clientA = await makeClient({ portId: port.id }); - const clientB = await makeClient({ portId: port.id }); - const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: clientA.id }); - - await expect( - db.insert(yachtOwnershipHistory).values({ - yachtId: yacht.id, - ownerType: 'client', - ownerId: clientB.id, - startDate: new Date(), - endDate: null, - createdBy: 'test', - }), - ).rejects.toThrow(/duplicate key/i); - }); -}); -``` - -- [ ] **Step 2: Implement `transferOwnership`** - -```typescript -export async function transferOwnership( - yachtId: string, - portId: string, - data: TransferOwnershipInput, - meta: AuditMeta, -) { - return await db.transaction(async (tx) => { - const yacht = await tx.query.yachts.findFirst({ - where: and(eq(yachts.id, yachtId), eq(yachts.portId, portId)), - }); - if (!yacht) throw new NotFoundError('Yacht'); - - if ( - yacht.currentOwnerType === data.newOwner.type && - yacht.currentOwnerId === data.newOwner.id - ) { - throw new ValidationError('same owner — nothing to transfer'); - } - - await assertOwnerExists(portId, data.newOwner); - - // Close the currently-active history row - await tx - .update(yachtOwnershipHistory) - .set({ endDate: data.effectiveDate }) - .where( - and( - eq(yachtOwnershipHistory.yachtId, yachtId), - sql`${yachtOwnershipHistory.endDate} IS NULL`, - ), - ); - - // Open new row - await tx.insert(yachtOwnershipHistory).values({ - yachtId, - ownerType: data.newOwner.type, - ownerId: data.newOwner.id, - startDate: data.effectiveDate, - endDate: null, - transferReason: data.transferReason ?? null, - transferNotes: data.transferNotes ?? null, - createdBy: meta.userId, - }); - - // Update denormalized current-owner columns - const [updated] = await tx - .update(yachts) - .set({ - currentOwnerType: data.newOwner.type, - currentOwnerId: data.newOwner.id, - updatedAt: new Date(), - }) - .where(eq(yachts.id, yachtId)) - .returning(); - - void createAuditLog({ - userId: meta.userId, - portId, - action: 'update', - entityType: 'yacht', - entityId: yachtId, - newValue: { ownerTransferTo: data.newOwner, reason: data.transferReason }, - ipAddress: meta.ipAddress, - userAgent: meta.userAgent, - }); - - emitToRoom(`port:${portId}`, 'yacht:ownership_transferred', { - yachtId, - newOwner: data.newOwner, - }); - - return updated!; - }); -} -``` - -- [ ] **Step 3: Run tests, commit** - -```bash -pnpm vitest run tests/integration/ownership-transfer.test.ts -git commit -am "feat(yachts): atomic transferOwnership with partial-unique guard" -``` - -### Task 2.5: Service — `yachts.service.ts` (list + search + owner lookups) - -**Files:** - -- Modify: `src/lib/services/yachts.service.ts` - -- [ ] **Step 1: Write failing tests for `listYachts`** - -Tests: filters by ownerType+ownerId; filters by status; filters by search; respects pagination; tenant-scoped. - -- [ ] **Step 2: Implement `listYachts`** - -Uses `buildListQuery` helper. Filter predicates: ownerType, ownerId, status, name ILIKE. - -- [ ] **Step 3: Implement `listYachtsForOwner(ownerType, ownerId)`** — queries by polymorphic current owner, used by client detail page. - -- [ ] **Step 4: Implement `autocomplete(portId, q)`** — ILIKE match on name + hullNumber + registration, returns top 10. - -- [ ] **Step 5: Commit** - -```bash -git commit -am "feat(yachts): list + owner-scoped list + autocomplete" -``` - -### Task 2.6: Service — `companies.service.ts` - -**Files:** - -- Create: `src/lib/validators/companies.ts` -- Create: `src/lib/services/companies.service.ts` -- Create: `tests/unit/services/companies.test.ts` - -- [ ] **Step 1: Validators** — `createCompanySchema`, `updateCompanySchema`, `listCompaniesQuery`. Required: `name`. Optional: `legalName`, `taxId`, `registrationNumber`, `incorporationCountry`, `incorporationDate`, `status`, `billingEmail`, `notes`, `tagIds`. - -- [ ] **Step 2: Tests (TDD)** — `createCompany` happy path; case-insensitive name uniqueness (create "Aegean Holdings", then "AEGEAN HOLDINGS" → throws); `upsertByName` returns existing row on case-insensitive match; `archiveCompany` sets archivedAt; all tenant-scoped. - -- [ ] **Step 3: Implementation** mirrors yachts.service pattern. Notable: `upsertByName(portId, name)` uses `INSERT ... ON CONFLICT (portId, lower(name)) DO UPDATE SET updatedAt = NOW() RETURNING *` or a SELECT-then-INSERT transaction. Socket: emit `company:created`, `company:updated`, `company:archived`. - -- [ ] **Step 4: Run tests, commit** - -```bash -git commit -am "feat(companies): service + validators + unit tests" -``` - -### Task 2.7: Service — `company-memberships.service.ts` - -**Files:** - -- Create: `src/lib/validators/company-memberships.ts` -- Create: `src/lib/services/company-memberships.service.ts` -- Create: `tests/unit/services/company-memberships.test.ts` - -- [ ] **Step 1: Validators** — `addMembershipSchema` (companyId via path, body: clientId, role, roleDetail?, startDate, isPrimary?, notes?). `updateMembershipSchema`. `endMembershipSchema` (endDate). - -- [ ] **Step 2: Tests** — `addMembership` creates row; rejects duplicate exact (companyId+clientId+role+startDate via unique constraint); `setPrimary` ensures only one membership-per-company has `isPrimary=true` (enforce in service; unique partial index is `isPrimary=true` per company could also work). `endMembership` sets endDate. `listByCompany` and `listByClient` return active-only by default. - -- [ ] **Step 3: Implementation.** `setPrimary` runs in a transaction: un-primary all others for this company, set this one primary. Emit socket events: `company_membership:added`, `company_membership:ended`. - -- [ ] **Step 4: Commit** - -```bash -git commit -am "feat(company-memberships): service + validators + tests" -``` - -### Task 2.8: Service — `berth-reservations.service.ts` - -**Files:** - -- Create: `src/lib/validators/reservations.ts` -- Create: `src/lib/services/berth-reservations.service.ts` -- Create: `tests/unit/services/berth-reservations.test.ts` -- Create: `tests/integration/reservation-exclusivity.test.ts` - -- [ ] **Step 1: Validators** — `createPendingSchema`, `activateSchema`, `endReservationSchema`, `cancelSchema`. - -- [ ] **Step 2: Unit tests** — lifecycle transitions: pending → active; active → ended; pending → cancelled; active → cancelled; invalid transitions (e.g., ended → active) throw `ValidationError`. Cross-validation: yacht belongs to a client who matches the reservation's clientId (or represents a company that owns the yacht). - -- [ ] **Step 3: Integration test** — partial unique `idx_br_active`: two concurrent `activate` attempts on same berth → one succeeds, one throws with distinct error we catch and surface as `ConflictError`. - -- [ ] **Step 4: Implementation.** `activate(id)` reads current row, verifies status=pending, updates to active. If DB rejects with unique violation, re-query for the conflicting active row and throw `ConflictError` with that info. Emit `berth_reservation:created|activated|ended|cancelled`. - -- [ ] **Step 5: Commit** - -```bash -git commit -am "feat(reservations): service + validators + exclusivity tests" -``` - -### Task 2.9: Service — `eoi-context.ts` (shared EOI payload builder) - -**Files:** - -- Create: `src/lib/services/eoi-context.ts` -- Create: `tests/unit/services/eoi-context.test.ts` - -- [ ] **Step 1: Define the `EoiContext` type** - -```typescript -export type EoiContext = { - client: { - fullName: string; - nationality: string | null; - primaryEmail: string | null; - primaryPhone: string | null; - address: { street: string; city: string; country: string } | null; - }; - yacht: { - name: string; - lengthFt: string | null; - widthFt: string | null; - draftFt: string | null; - lengthM: string | null; - widthM: string | null; - draftM: string | null; - hullNumber: string | null; - flag: string | null; - yearBuilt: number | null; - }; - company: { - name: string; - legalName: string | null; - taxId: string | null; - billingAddress: string | null; - } | null; - owner: { - type: 'client' | 'company'; - name: string; - legalName?: string; - }; - berth: { - mooringNumber: string; - area: string | null; - lengthFt: string | null; - price: string | null; - priceCurrency: string; - tenureType: string; - }; - interest: { - stage: string; - leadCategory: string | null; - dateFirstContact: Date | null; - notes: string | null; - }; - port: { - name: string; - defaultCurrency: string; - }; - date: { - today: string; - year: string; - }; -}; -``` - -- [ ] **Step 2: Failing tests for `buildEoiContext`** - -Scenarios (each should return a correctly-populated context): - -- Client-owned yacht -- Company-owned yacht -- Company-owned yacht where interest's client is a company member (verify `client` is the interest's client, `company` is the yacht's owner, `owner.type === 'company'`) -- Missing berth (interest has no berth yet) — context.berth is still populated if linked; otherwise `throw ValidationError('interest has no berth')` (we need berth for EOI) -- Missing yacht (interest has no yacht) — throws - -- [ ] **Step 3: Implementation** - -Fetches interest + client + primary contact + primary address + yacht + yacht's current owner (polymorphic resolution) + berth + port. Returns fully-populated `EoiContext`. - -- [ ] **Step 4: Run tests, commit** - -```bash -git commit -am "feat(eoi): shared context builder + tests" -``` - -### Task 2.10: Create PR 2 - -- [ ] Push `refactor/data-model`. PR title: `feat(services): yachts, companies, memberships, reservations, EOI context (PR 2 of 15)`. - ---- - -# PR 3 — API Routes and Permissions - -**Goal:** Wire every new service to REST endpoints with proper permission gates. Add new permission keys. Every route has an integration test. - -### Task 3.1: Add new permission keys - -**Files:** - -- Modify: `src/lib/auth/permissions.ts` (or wherever permission keys are declared) -- Modify: seed for default roles (`src/lib/db/seed.ts`) or role-update migration - -- [ ] **Step 1: Add the following keys to the permissions enum/list:** - -``` -yachts:view -yachts:write -yachts:transfer -yachts:delete -companies:view -companies:write -companies:delete -memberships:write -reservations:view -reservations:write -``` - -- [ ] **Step 2: Update default role assignments** - -In whichever migration/seed owns the default role configuration: - -- `admin`: add all new keys -- `team_lead`: `yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view` -- `front_desk`: all `:view` keys - -- [ ] **Step 3: Test** — verify `requirePermission(context, 'yachts:transfer')` succeeds for admin, throws for team_lead. - -- [ ] **Step 4: Commit** - -```bash -git commit -am "feat(permissions): add yacht, company, membership, reservation keys" -``` - -### Task 3.2: Yacht API routes — list + create - -**Files:** - -- Create: `src/app/api/v1/yachts/route.ts` -- Create: tests alongside - -- [ ] **Step 1: Write route integration test** - -```typescript -// tests/integration/api/yachts.test.ts -import { describe, it, expect } from 'vitest'; -import { testFetch, makeTestSession } from 'tests/helpers/api'; -import { makeClient, makePort } from 'tests/helpers/factories'; - -describe('POST /api/v1/yachts', () => { - it('creates a yacht when user has yachts:write', async () => { - const { port, session } = await makeTestSession({ permissions: ['yachts:write'] }); - const client = await makeClient({ portId: port.id }); - - const res = await testFetch('POST', '/api/v1/yachts', { - session, - body: { name: 'Sea Breeze', owner: { type: 'client', id: client.id } }, - }); - expect(res.status).toBe(201); - const body = await res.json(); - expect(body.name).toBe('Sea Breeze'); - }); - - it('rejects when user lacks yachts:write', async () => { - const { port, session } = await makeTestSession({ permissions: ['yachts:view'] }); - const client = await makeClient({ portId: port.id }); - - const res = await testFetch('POST', '/api/v1/yachts', { - session, - body: { name: 'Sea Breeze', owner: { type: 'client', id: client.id } }, - }); - expect(res.status).toBe(403); - }); - - it('returns 400 on invalid body', async () => { - const { session } = await makeTestSession({ permissions: ['yachts:write'] }); - const res = await testFetch('POST', '/api/v1/yachts', { session, body: { name: '' } }); - expect(res.status).toBe(400); - }); -}); -``` - -- [ ] **Step 2: Implement route** - -```typescript -// src/app/api/v1/yachts/route.ts -import { NextRequest } from 'next/server'; -import { withContext } from '@/lib/api/route-helpers'; -import { requirePermission } from '@/lib/auth/permissions'; -import { createYachtSchema, listYachtsQuery } from '@/lib/validators/yachts'; -import { createYacht, listYachts } from '@/lib/services/yachts.service'; - -export async function GET(req: NextRequest) { - return withContext(req, async (ctx) => { - requirePermission(ctx, 'yachts:view'); - const query = listYachtsQuery.parse(Object.fromEntries(req.nextUrl.searchParams)); - const result = await listYachts(ctx.portId, query); - return Response.json(result); - }); -} - -export async function POST(req: NextRequest) { - return withContext(req, async (ctx) => { - requirePermission(ctx, 'yachts:write'); - const body = createYachtSchema.parse(await req.json()); - const yacht = await createYacht(ctx.portId, body, ctx.auditMeta); - return Response.json(yacht, { status: 201 }); - }); -} -``` - -- [ ] **Step 3: Run tests, commit** - -```bash -git commit -am "feat(api): GET/POST /api/v1/yachts" -``` - -### Task 3.3: Yacht API routes — detail, update, archive, transfer, history - -**Files:** - -- Create: `src/app/api/v1/yachts/[id]/route.ts` — GET, PATCH, DELETE -- Create: `src/app/api/v1/yachts/[id]/transfer/route.ts` — POST -- Create: `src/app/api/v1/yachts/[id]/ownership-history/route.ts` — GET -- Create: `src/app/api/v1/yachts/autocomplete/route.ts` — GET - -- [ ] **Step 1: Tests for each — mirror Task 3.2's pattern.** Specifically test that `PATCH` rejects owner-mutation (`use /transfer to change ownership`), that `POST /transfer` requires `yachts:transfer` (not just `:write`). - -- [ ] **Step 2: Implementations.** - -- [ ] **Step 3: Commit** - -```bash -git commit -am "feat(api): yacht detail, patch, archive, transfer, history, autocomplete" -``` - -### Task 3.4: Company API routes - -**Files:** - -- Create: `src/app/api/v1/companies/route.ts` -- Create: `src/app/api/v1/companies/autocomplete/route.ts` -- Create: `src/app/api/v1/companies/[id]/route.ts` - -- [ ] **Steps:** Follow Task 3.2-3.3 pattern for GET list/create, detail/patch/archive, autocomplete. - -- [ ] **Commit** after full file set works. - -### Task 3.5: Company memberships API - -**Files:** - -- Create: `src/app/api/v1/companies/[id]/members/route.ts` — GET list, POST add -- Create: `src/app/api/v1/companies/[id]/members/[mid]/route.ts` — PATCH, DELETE - -- [ ] **Steps:** `POST` requires `memberships:write`. `DELETE` sets endDate (soft). Test that a non-existent companyId returns 404 before permission check (avoid info leak? Actually 403 vs 404 is fine here; 404 after scoping). - -- [ ] **Commit.** - -### Task 3.6: Berth reservations API - -**Files:** - -- Create: `src/app/api/v1/berths/[id]/reservations/route.ts` -- Create: `src/app/api/v1/berth-reservations/[id]/route.ts` - -- [ ] **Steps:** `POST /berths/:id/reservations` creates pending. `PATCH /berth-reservations/:id` with body `{ action: 'activate' | 'end' | 'cancel', ...details }` performs state transition. Test every transition + invalid transitions → 400. - -- [ ] **Commit.** - -### Task 3.7: Socket + webhook event wiring - -**Files:** - -- Modify: `src/lib/services/webhooks.ts` (or wherever the event map lives) -- Modify: any socket event declaration file - -- [ ] **Step 1: Add new event names to the webhook event map** - -Events: yacht.created, yacht.updated, yacht.ownership_transferred, yacht.archived; company.created, company.updated, company.archived; company_membership.added, company_membership.ended; berth_reservation.created, berth_reservation.activated, berth_reservation.ended, berth_reservation.cancelled. - -- [ ] **Step 2: Update `tests/unit/webhook-event-map.test.ts`** to assert every new event is in the catalog. - -- [ ] **Step 3: Verify socket events are emitted by services** (already implemented in PR 2; spot-check). - -- [ ] **Step 4: Commit** - -```bash -git commit -am "feat(events): register yacht, company, membership, reservation events" -``` - -### Task 3.8: Create PR 3 - -- [ ] Push + PR title: `feat(api): new endpoints for yachts, companies, memberships, reservations (PR 3 of 15)`. - ---- - -# PR 4 — Seeder Rewrite - -**Goal:** `pnpm db:seed` produces realistic multi-cardinality fixtures that exercise every relationship the refactor introduces. - -### Task 4.1: Extend factory helpers - -**Files:** - -- Modify: `tests/helpers/factories.ts` - -- [ ] **Step 1: Add factories (if not already added in PR 1-2):** - -```typescript -export async function makeCompany(args: { portId: string; overrides?: Partial }) { ... } - -export async function makeMembership(args: { - companyId: string; - clientId: string; - role?: string; - startDate?: Date; - endDate?: Date | null; - isPrimary?: boolean; -}) { ... } - -export async function makeReservation(args: { - berthId: string; - clientId: string; - yachtId: string; - status: 'pending' | 'active' | 'ended' | 'cancelled'; - startDate?: Date; - endDate?: Date | null; -}) { ... } - -export async function makeOwnershipTransfer(args: { - yachtId: string; - fromOwner: { type; id }; - toOwner: { type; id }; - date: Date; -}) { ... } // closes existing, opens new -``` - -- [ ] **Step 2: Commit** - -### Task 4.2: Rewrite `src/lib/db/seed.ts` - -**Files:** - -- Modify: `src/lib/db/seed.ts` - -- [ ] **Step 1: Replace the seed with the scenarios defined in the spec** - -Creates (per port): - -- 3 companies: Aegean Holdings (active, 3 members), Blue Seas Marine (active, 1 member), Phantom SA (dissolved, all memberships ended) -- 8 clients: 3 personal-only, 2 members of one company, 2 members of two companies, 1 who's a member of the dissolved company -- 12 yachts: 7 client-owned (across the 8 clients), 5 company-owned (across 2 active companies). 3 yachts have a completed ownership transfer in history (e.g., from a client to a company). -- ~15 interests distributed across clients/yachts/berths with pipeline-stage variety -- 5 active berth reservations (one per owning client); 2 ended reservations with realistic historical dates; 1 cancelled -- Rich contact data: every client has 1-3 contacts (email + optional phone); every yacht has registration data; every company has a billing address - -- [ ] **Step 2: Seed uses the factory helpers** — no bespoke DB calls in `seed.ts`. - -- [ ] **Step 3: Run seed + verify** - -```bash -pnpm db:push && pnpm db:seed -pnpm db:studio -# Eyeball: yachts table has 12 rows across the 3 seeded ports; company_memberships shows realistic distribution -``` - -- [ ] **Step 4: Commit** - -```bash -git commit -am "feat(seed): rewrite seed for multi-cardinality refactor" -``` - -### Task 4.3: Create PR 4 - -- [ ] Push, title: `feat(seed): multi-cardinality dummy data (PR 4 of 15)`. - ---- - -# PR 5 — Yacht UI - -**Goal:** List, detail, create, edit, transfer, archive — all from the UI. Every button has a Playwright test in PR 14. - -### Task 5.1: Base components — `yacht-picker`, `owner-picker` - -**Files:** - -- Create: `src/components/shared/owner-picker.tsx` -- Create: `src/components/yachts/yacht-picker.tsx` - -- [ ] **Step 1: `owner-picker.tsx`** - -Polymorphic combobox. Props: `value: { type: 'client' | 'company'; id: string } | null`, `onChange`, `portId`. Toggle at top: `[Client] [Company]`. Below: autocomplete backed by `/api/v1/clients/autocomplete` or `/api/v1/companies/autocomplete` depending on toggle. - -- [ ] **Step 2: `yacht-picker.tsx`** - -Autocomplete combobox wrapping `/api/v1/yachts/autocomplete`. Props: `value: string | null`, `onChange`, `portId`, `ownerFilter?: { type; id }` (for interest form — filter to the interest's client's yachts). - -- [ ] **Step 3: Simple unit-level sanity: render with empty state, render with a value, change event fires.** - -- [ ] **Step 4: Commit.** - -### Task 5.2: Yacht form + validator wiring - -**Files:** - -- Create: `src/components/yachts/yacht-form.tsx` - -- [ ] **Step 1: Implement form** using `react-hook-form` + `@hookform/resolvers/zod` with `createYachtSchema`. Fields: name (required), dimensions (ft + m), hull/registration/flag/yearBuilt/builder/model/material, owner (via `owner-picker`), status, notes. - -- [ ] **Step 2: Submit handler** POSTs to `/api/v1/yachts`; on success, close dialog / navigate to detail. On 400, show field errors. - -- [ ] **Step 3: Commit.** - -### Task 5.3: Yacht detail page + tabs + ownership history - -**Files:** - -- Create: `src/components/yachts/yacht-detail.tsx` -- Create: `src/components/yachts/yacht-detail-header.tsx` -- Create: `src/components/yachts/yacht-tabs.tsx` -- Create: `src/components/yachts/yacht-ownership-history.tsx` -- Create: `src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx` - -- [ ] **Step 1: Page** — server component that fetches yacht detail (plus eager loads: ownershipHistory, reservations, notes, tags, documents, interests). Passes to ``. - -- [ ] **Step 2: `yacht-detail-header`** — name + dimensions + current-owner link + status badge + Edit / Archive / Transfer buttons. - -- [ ] **Step 3: `yacht-tabs`** — tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags. Uses existing shadcn tabs component. - -- [ ] **Step 4: `yacht-ownership-history`** — table: start date, end date (or "Current"), owner (with link), reason, notes. - -- [ ] **Step 5: Commit.** - -### Task 5.4: Yacht list page + columns - -**Files:** - -- Create: `src/components/yachts/yacht-columns.tsx` -- Create: `src/app/(dashboard)/[portSlug]/yachts/page.tsx` - -- [ ] **Step 1: Columns** — name, current owner (link, polymorphic), dimensions (LxW ft), status badge, actions (view, archive). - -- [ ] **Step 2: Page** — uses TanStack Table + existing list patterns. Filter UI: owner type select, status select, free-text search. - -- [ ] **Step 3: Commit.** - -### Task 5.5: Yacht transfer dialog - -**Files:** - -- Create: `src/components/yachts/yacht-transfer-dialog.tsx` - -- [ ] **Step 1: Dialog** with form: `owner-picker`, effective date, reason (select), notes (textarea), optional deed/sale-doc upload (file input → MinIO via existing upload flow). - -- [ ] **Step 2: Submit** calls `POST /api/v1/yachts/:id/transfer`. On success, emits local event to refetch ownership history; toast "Transferred successfully"; closes dialog. - -- [ ] **Step 3: Commit.** - -### Task 5.6: Nav entry + create-yacht flow - -**Files:** - -- Modify: `src/components/layout/sidebar.tsx` (or wherever nav is defined) - -- [ ] **Step 1: Add `Yachts` sidebar entry** with icon (lucide: `Anchor`) linked to `/[portSlug]/yachts`. - -- [ ] **Step 2: On the list page, "Create yacht" button** opens a dialog with ``. - -- [ ] **Step 3: Commit.** - -### Task 5.7: Create PR 5 - ---- - -# PR 6 — Company UI - -**Goal:** List, detail (with members + owned yachts), create, edit, archive. Add membership dialog. Uses existing shadcn patterns. - -### Task 6.1: Base — `company-picker` - -**Files:** - -- Create: `src/components/companies/company-picker.tsx` - -- [ ] **Step 1:** Autocomplete wrapping `/api/v1/companies/autocomplete`. -- [ ] **Step 2: Commit.** - -### Task 6.2: Company form - -**Files:** - -- Create: `src/components/companies/company-form.tsx` - -- [ ] **Step 1:** Form fields: name, legalName, taxId, registrationNumber, incorporationCountry, incorporationDate, status, billingEmail, notes, tagIds. Case-insensitive uniqueness is enforced server-side — show the 409 error as a form-level error. -- [ ] **Step 2: Commit.** - -### Task 6.3: Company detail + tabs - -**Files:** - -- Create: `src/components/companies/company-detail.tsx` -- Create: `src/components/companies/company-detail-header.tsx` -- Create: `src/components/companies/company-tabs.tsx` -- Create: `src/components/companies/company-members-tab.tsx` -- Create: `src/components/companies/company-owned-yachts-tab.tsx` -- Create: `src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx` - -- [ ] **Step 1: Page + detail shell + header.** -- [ ] **Step 2: Tabs:** Overview, Members (active + past toggle), Owned Yachts, Addresses, Documents, Notes, Tags. -- [ ] **Step 3: Members tab:** list with client link + role + dates. "Add member" button opens `add-membership-dialog`. "End membership" button on active rows. -- [ ] **Step 4: Owned yachts tab:** server-queries yachts where `currentOwnerType='company'` AND `currentOwnerId=`. Table: yacht name, dimensions, status, link to yacht detail. -- [ ] **Step 5: Commit.** - -### Task 6.4: Add-membership dialog - -**Files:** - -- Create: `src/components/companies/add-membership-dialog.tsx` - -- [ ] **Step 1:** Form: client combobox (existing `ClientPicker` if present, else implement), role select, roleDetail, startDate, isPrimary toggle, notes. -- [ ] **Step 2:** Submit → `POST /api/v1/companies/:id/members`. On 409 (duplicate exact), show "This membership already exists". -- [ ] **Step 3: Commit.** - -### Task 6.5: Company list page + columns + nav - -**Files:** - -- Create: `src/components/companies/company-columns.tsx` -- Create: `src/app/(dashboard)/[portSlug]/companies/page.tsx` -- Modify: sidebar config - -- [ ] **Step 1: Columns:** name, legalName, # active members, # owned yachts, status. -- [ ] **Step 2: Page + sidebar entry** (`Building2` lucide icon). -- [ ] **Step 3: Commit.** - -### Task 6.6: Create PR 6 - ---- - -# PR 7 — Berth Reservations UI + Ownership Wiring - -**Goal:** Berth detail surfaces reservations; reserve and ownership-transfer dialogs are end-to-end wired. - -### Task 7.1: `reservation-list` component - -**Files:** - -- Create: `src/components/reservations/reservation-list.tsx` - -- [ ] **Step 1:** Table: client link, yacht link, date range, status badge, tenure type, contract file link. Props: `reservations: BerthReservation[]`, `showBerth?: boolean`. -- [ ] **Step 2: Commit.** - -### Task 7.2: `berth-reserve-dialog` - -**Files:** - -- Create: `src/components/reservations/berth-reserve-dialog.tsx` - -- [ ] **Step 1:** Form: client combobox, yacht combobox (filtered to client's yachts; includes "Add new yacht" inline shortcut), startDate, endDate, tenureType, contract file upload. -- [ ] **Step 2:** Submit → `POST /api/v1/berths/:id/reservations` (creates pending). Second button "Create and activate" → creates pending then immediately PATCHes to active; on 409 (duplicate active), surface the existing active reservation as an error. -- [ ] **Step 3: Commit.** - -### Task 7.3: Berth detail — add reservations tab - -**Files:** - -- Modify: `src/components/berths/berth-detail.tsx` (or equivalent) -- Modify: `src/components/berths/berth-tabs.tsx` - -- [ ] **Step 1:** Add "Reservations" tab. Content: active reservation card (or "No active reservation") + "Reserve this berth" button + history table below. -- [ ] **Step 2: Commit.** - -### Task 7.4: Create PR 7 - ---- - -# PR 8 — Client Form Refactor - -**Goal:** Strip yacht/company/proxy fields from the client form. Clean person-only form. Yacht + company management moves to detail page tabs. - -### Task 8.1: Update validator - -**Files:** - -- Modify: `src/lib/validators/clients.ts` - -- [ ] **Step 1: Remove fields** from `createClientSchema`: - - `companyName`, `isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes` - - `yachtName`, `yachtLengthFt/WidthFt/DraftFt`, `yachtLengthM/WidthM/DraftM`, `berthSizeDesired` - -- [ ] **Step 2: Update tests** in `tests/unit/validators.test.ts`. Add assertions that deprecated fields are rejected (or just ignored — depends on your preference; `z.object.strict()` would reject; default `z.object` ignores unknown). Prefer `.strict()` for safety. - -- [ ] **Step 3: Commit.** - -### Task 8.2: Update `clients.service.ts` - -**Files:** - -- Modify: `src/lib/services/clients.service.ts` - -- [ ] **Step 1: Remove all references** to the deleted fields in create/update/diff/response shaping. - -- [ ] **Step 2: Extend `getClientById`** to return `yachts` (via polymorphic query), `companies` (via active memberships), `activeReservations`. - -- [ ] **Step 3: Update service unit tests** to reflect new shape. - -- [ ] **Step 4: Commit.** - -### Task 8.3: Refactor client form UI - -**Files:** - -- Modify: `src/components/clients/client-form.tsx` - -- [ ] **Step 1: Remove fields** yacht/company/proxy; keep: name, contacts, nationality, preferred contact method/language, timezone, source, sourceDetails, tagIds. - -- [ ] **Step 2: Commit.** - -### Task 8.4: Refactor client detail — add new tabs - -**Files:** - -- Modify: `src/components/clients/client-detail.tsx` -- Modify: `src/components/clients/client-tabs.tsx` - -- [ ] **Step 1:** New tabs: - - **Yachts** — list of client-owned yachts + "Add yacht" button (opens `YachtForm` pre-filled owner=this client). Also shows yachts represented via company memberships (read-only link). - - **Companies** — list of active memberships: company link, role, since date, "Set primary", "End membership" buttons. "Add membership" opens dialog. - - **Reservations** — list of active + historical reservations via `reservation-list`. - -- [ ] **Step 2: Remove** the old "Yacht details" and "Proxy" sections from Overview tab. - -- [ ] **Step 3: Update `client-columns.tsx`:** replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships with `isPrimary=true`). - -- [ ] **Step 4: Commit.** - -### Task 8.5: Update `portal.service.ts` + portal UI - -**Files:** - -- Modify: `src/lib/services/portal.service.ts` -- Modify: `src/app/(portal)/portal/dashboard/page.tsx` -- Create: `src/app/(portal)/portal/my-yachts/page.tsx` -- Create: `src/app/(portal)/portal/my-reservations/page.tsx` - -- [ ] **Step 1: `portal.service.ts`** — add these functions: - - `getPortalUserYachts(clientId, portId)` — returns yachts where `currentOwnerType='client' AND currentOwnerId=clientId`, PLUS yachts owned by any company where the client has an active `company_membership`. De-duped. - - `getPortalUserMemberships(clientId, portId)` — active memberships for the portal client. - - `getPortalUserReservations(clientId, portId)` — active + upcoming reservations. - -- [ ] **Step 2: Unit tests** for each — cover client-owned, company-represented, and dedup cases. - -- [ ] **Step 3: Dashboard update** — add three new cards: "My Yachts", "My Memberships", "My Active Reservations". - -- [ ] **Step 4: New pages** — `my-yachts` lists yachts with read-only detail; `my-reservations` lists reservations. - -- [ ] **Step 5: E2E test** — portal user with one client-owned + one company-owned yacht sees exactly two yachts in "My Yachts", not four. - -- [ ] **Step 6: Commit.** - -```bash -git commit -am "feat(portal): surface yachts, memberships, reservations for portal users" -``` - -### Task 8.6: Create PR 8 - ---- - -# PR 9 — Interest Form + Public Interest Form + Search + Recommendations - -**Goal:** Interest form requires `yachtId`. Public interest form creates client + yacht + optional company + membership + interest as a trio. - -### Task 9.1: Update interest validator + service - -**Files:** - -- Modify: `src/lib/validators/interests.ts` -- Modify: `src/lib/services/interests.service.ts` - -- [ ] **Step 1:** Validator: `yachtId` — nullable on create (for stage=open), required before leaving stage=open. Service enforces: reject promote-to-next-stage if `yachtId` is null. - -- [ ] **Step 2:** Service validates yacht belongs to the interest's client OR is owned by a company that the client actively represents. `ValidationError` otherwise. - -- [ ] **Step 3: Tests + commit.** - -### Task 9.2: Update interest form UI - -**Files:** - -- Modify: `src/components/interests/interest-form.tsx` - -- [ ] **Step 1: Add `yacht-picker`** — filtered to the interest's client (plus company-owned yachts for companies the client represents). Inline "Add new yacht" shortcut opens yacht-form dialog. - -- [ ] **Step 2: Commit.** - -### Task 9.3: Update public interest form - -**Files:** - -- Modify: `src/app/api/public/interests/route.ts` - -- [ ] **Step 1:** Extend request body to include yacht fields and optional company fields (separated from client fields). - -- [ ] **Step 2: Service changes** (can live in a helper): in one transaction, `createClient` + `createYacht` (owner = the new client) + optional `upsertCompany` + `addMembership` + `createInterest` (with yachtId, berthId if provided). All marked `source: 'public_submission'`. - -- [ ] **Step 3: Update public interest form UI** to match new body shape. - -- [ ] **Step 4: E2E test** — submit the public form; admin sees four new rows (client + yacht + optional company + interest) in the appropriate lists. - -- [ ] **Step 5: Commit.** - -### Task 9.4: Update `search.service.ts` — index yachts and companies - -**Files:** - -- Modify: `src/lib/services/search.service.ts` -- Modify: `src/hooks/use-search.ts` -- Modify: `src/components/search/search-result-item.tsx` -- Modify: `src/components/search/command-search.tsx` - -- [ ] **Step 1: Extend search** to query yachts (name, hullNumber, registration) and companies (name, legalName, taxId) alongside clients. Each result carries a `type: 'client' | 'yacht' | 'company' | 'interest' | 'berth'` tag. - -- [ ] **Step 2: Update UI** — result-item variants per type (distinct icon per entity); clicking routes to the correct detail page. - -- [ ] **Step 3: Unit test `search.service.ts`** — search term matching against seeded yachts and companies returns correct results with type tags. - -- [ ] **Step 4: E2E test** — global search for a yacht name returns yacht result; clicking navigates to yacht detail. - -- [ ] **Step 5: Commit.** - -```bash -git commit -am "feat(search): index yachts and companies alongside clients" -``` - -### Task 9.5: Update `recommendations.ts` — yacht dims from yachts table - -**Files:** - -- Modify: `src/lib/services/recommendations.ts` - -- [ ] **Step 1: Update berth-fit logic** — currently reads yacht dimensions from `clients.yacht*` fields. Switch to reading from `yachts` table via `interest.yachtId`. - -- [ ] **Step 2: Unit test** — generate berth recommendations for an interest whose yacht has known dimensions; verify the matcher respects them. - -- [ ] **Step 3: Commit.** - -```bash -git commit -am "feat(recommendations): read yacht dimensions from yachts table" -``` - -### Task 9.6: Create PR 9 - ---- - -# PR 10 — Invoice Billing Entity - -**Goal:** Invoices reference a polymorphic billing entity (client or company). `clientName` remains as a snapshot. - -### Task 10.1: Update validators + service - -**Files:** - -- Modify: `src/lib/validators/invoices.ts` -- Modify: `src/lib/services/invoices.service.ts` - -- [ ] **Step 1: Validator:** add `billingEntityType`, `billingEntityId`. Remove reliance on `clientName` for creation (it becomes a snapshot derived from the entity at create time). - -- [ ] **Step 2: Service:** on create, look up entity (client or company), copy its display name into `clientName`. Validate cross-tenant. - -- [ ] **Step 3: Tests + commit.** - -### Task 10.2: Update invoice form UI - -**Files:** - -- Modify: `src/components/invoices/invoice-form.tsx` - -- [ ] **Step 1: Add `billing-entity-picker`** at the top of the form. Selecting an entity populates `clientName` + `billingEmail` + `billingAddress` from the entity. -- [ ] **Step 2: Commit.** - -### Task 10.3: Create PR 10 - ---- - -# PR 11 — EOI Dual-Path + Shared Payload Builder - -**Goal:** Both Documenso and in-app paths wired end-to-end; UI picker chooses. Standard EOI template seeded for in-app. - -### Task 11.1: Audit Documenso template field names - -**Files:** - -- Create: `docs/eoi-documenso-field-mapping.md` - -- [ ] **Step 1: Read the old system's `client-portal/server/api/eoi/generate-quick-eoi.ts`** and enumerate every field name the Documenso template expects (look at the POST body of `/api/v1/templates/{id}/generate-document`). - -- [ ] **Step 2: Document each field in the new doc:** field name → schema source (e.g., `clientFullName → client.fullName`, `yachtLength → yacht.lengthFt`). - -- [ ] **Step 3: Commit this doc** — it's the reference for Spec 2's importer too. - -```bash -git add docs/eoi-documenso-field-mapping.md -git commit -m "docs(eoi): document Documenso template field name mapping" -``` - -### Task 11.2: Documenso payload builder - -**Files:** - -- Modify: `src/lib/services/documenso-client.ts` (or create `src/lib/services/documenso-payload.ts`) - -- [ ] **Step 1: Write test:** given an `EoiContext`, `buildDocumensoPayload(context)` returns the flat payload Documenso expects. - -- [ ] **Step 2: Implement** using the field mapping from Task 11.1. - -- [ ] **Step 3: Integration test** (with mocked Documenso API): generate an EOI through the service, assert the POST body contains every mapped field. - -- [ ] **Step 4: Commit.** - -### Task 11.3: Seed in-app Standard EOI template - -**Files:** - -- Create: `src/lib/pdf/templates/eoi-standard-inapp.ts` — exports `getStandardEoiTemplateHtml()` -- Modify: `src/lib/db/seed.ts` — also insert a `document_templates` row per port with this HTML - -- [ ] **Step 1: Write the HTML template** using the token namespace defined in spec: `{{client.fullName}}`, `{{yacht.name}}`, `{{yacht.lengthFt}}`, `{{company.name}}`, `{{company.legalName}}`, `{{berth.mooringNumber}}`, `{{interest.dateFirstContact}}`, `{{port.name}}`, `{{date.today}}`, etc. Mirror the structure of the Documenso EOI (a developer should review it side-by-side with the old EOI). - -- [ ] **Step 2: Seeder inserts one row per port** referencing this template. Template type: `'eoi'`. `isActive: true`. Name: "Standard EOI (in-app)". - -- [ ] **Step 3: Commit.** - -### Task 11.4: Extend `resolveTemplate` to new scopes - -**Files:** - -- Modify: `src/lib/services/document-templates.ts` - -- [ ] **Step 1: Update `MERGE_FIELDS`** — remove old `{{client.yachtName}}`, `{{client.companyName}}`, and yacht dimension tokens; add new `yacht`, `company`, `owner` sections. - -- [ ] **Step 2: Extend `resolveTemplate()`** — when context includes `interestId`, resolve via `buildEoiContext` and substitute `{{yacht.*}}` / `{{company.*}}` / `{{owner.*}}` tokens. - -- [ ] **Step 3: Unit tests** for every new token resolving correctly. - -- [ ] **Step 4: Commit.** - -### Task 11.5: EOI generation — dual-path service - -**Files:** - -- Modify: `src/lib/services/document-templates.ts` — `generateAndSign` gains a `pathway: 'documenso-template' | 'inapp'` parameter - -- [ ] **Step 1: Test** — for each pathway, generate an EOI for a seeded interest, verify the resulting file exists in MinIO and the `documents` row is created with correct metadata. - -- [ ] **Step 2: Implement** — if `pathway === 'documenso-template'`, call Documenso template-generate. Else, resolve in-app template → pdfme → upload PDF → optionally pass to Documenso for signing via existing `documensoCreate` + `documensoSend`. - -- [ ] **Step 3: Commit.** - -### Task 11.6: EOI-generate dialog UI - -**Files:** - -- Modify: (wherever the existing EOI-generate UI lives in `src/components/interests/` or `src/app/(dashboard)/[portSlug]/interests/`) - -- [ ] **Step 1: Add template dropdown** — list: Documenso Standard EOI + seeded in-app templates. Preview shows which context fields will be filled. - -- [ ] **Step 2: Submit** calls the dual-path service with the chosen pathway. - -- [ ] **Step 3: Commit.** - -### Task 11.7: Create PR 11 - ---- - -# PR 12 — Merge-Field Catalog Cleanup - -**Goal:** Any stale references to old tokens (`{{client.yachtName}}`, etc.) are removed from the codebase, seeded templates, and validator allow-lists. All remaining tokens route through the new schema. - -### Task 12.1: Remove stale tokens - -**Files:** - -- Modify: anywhere `{{client.yachtName|yachtLength|companyName}}` appears in code or seed data - -- [ ] **Step 1: `rg -n '{{client\\.(yacht|companyName)'` across the repo** — verify only historical seed data / test fixtures remain (code paths should be updated). - -- [ ] **Step 2: Update or remove all matches.** Any remaining template in the seeder should use new tokens. - -- [ ] **Step 3: Commit.** - -### Task 12.2: Validator tightening - -**Files:** - -- Modify: `src/lib/validators/document-templates.ts` - -- [ ] **Step 1:** If there's a token allow-list, update it to match the new `MERGE_FIELDS` structure. Unknown tokens rejected at creation time. - -- [ ] **Step 2: Commit.** - -### Task 12.3: Create PR 12 - ---- - -# PR 13 — Drop Old Columns from `clients` - -**Goal:** With all reads migrated, drop the deprecated columns. - -### Task 13.1: Drop columns - -**Files:** - -- Modify: `src/lib/db/schema/clients.ts` - -- [ ] **Step 1: Remove column definitions:** `yachtName`, `yachtLengthFt`, `yachtWidthFt`, `yachtDraftFt`, `yachtLengthM`, `yachtWidthM`, `yachtDraftM`, `berthSizeDesired`, `companyName`, `isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`. - -- [ ] **Step 2: `pnpm db:generate && pnpm db:push`** — generates a destructive migration (column drops). - -- [ ] **Step 3: Run full test suite** — verify no silent breakage. - -```bash -pnpm vitest run -pnpm playwright test tests/e2e/scenarios -``` - -- [ ] **Step 4: Commit.** - -```bash -git commit -am "refactor(clients): drop deprecated yacht/company/proxy columns" -``` - -### Task 13.2: Verify no callers remain - -**Files:** - -- N/A (exploration) - -- [ ] **Step 1: `rg -n 'yachtName|companyName|isProxy|proxyType|actualOwnerName|relationshipNotes|berthSizeDesired' src/`** — should return zero matches in code (tests may reference old column names in assertions that should also have been removed). - -- [ ] **Step 2: If matches remain, clean them up in this PR.** - -- [ ] **Step 3: Commit.** - -### Task 13.3: Create PR 13 - ---- - -# PR 14 — Exhaustive Click-Through Suite (Tier 3.5) - -**Goal:** Every interactive element on every new or changed page has been clicked and verified to not throw. - -### Task 14.1: `click-everything` helper - -**Files:** - -- Create: `tests/helpers/click-everything.ts` - -- [ ] **Step 1: Implement helper** - -```typescript -import { Page } from '@playwright/test'; - -export async function clickEverythingOnPage( - page: Page, - opts?: { - skip?: string[]; // CSS selectors to skip (destructive actions) - cleanupBetween?: () => Promise; - }, -): Promise<{ clicked: number; skipped: number; errors: string[] }> { - // Record starting URL so we can return after click-caused navigation - const startingUrl = page.url(); - const errors: string[] = []; - let clicked = 0; - let skipped = 0; - - // Attach console error listener - page.on('console', (msg) => { - if (msg.type() === 'error') errors.push(`[console] ${msg.text()}`); - }); - page.on('response', async (resp) => { - if (resp.status() >= 400) errors.push(`[network] ${resp.status()} ${resp.url()}`); - }); - - const elements = await page.locator(':is(button, a, [role="button"])').all(); - for (const el of elements) { - const selector = (await el.evaluate((n) => (n as HTMLElement).outerHTML)).slice(0, 120); - if (opts?.skip?.some((s) => selector.includes(s))) { - skipped++; - continue; - } - try { - if (await el.isVisible()) { - await el.click({ timeout: 2000 }); - clicked++; - await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); - // Close any opened dialog - const closeBtn = page.locator('[role="dialog"] [aria-label="Close"]').first(); - if (await closeBtn.isVisible()) await closeBtn.click(); - // If navigation happened, return to starting URL - if (page.url() !== startingUrl) await page.goto(startingUrl); - if (opts?.cleanupBetween) await opts.cleanupBetween(); - } - } catch (err) { - errors.push(`[click] ${selector} → ${(err as Error).message}`); - } - } - - return { clicked, skipped, errors }; -} -``` - -- [ ] **Step 2: Commit.** - -### Task 14.2-14.10: Per-domain exhaustive spec files - -For each of: `yachts`, `companies`, `reservations`, `client-detail-refactored`, `eoi-generate`, `invoice-form`, `berths-with-reservations`, `portal`, `navigation`: - -- [ ] **Step 1: Create `tests/e2e/exhaustive/.spec.ts`** - -Template (instantiate per domain): - -```typescript -import { test, expect } from '@playwright/test'; -import { clickEverythingOnPage } from '../../helpers/click-everything'; -import { loginAs, seedScenario } from '../../helpers/e2e'; - -test.describe('exhaustive: yachts', () => { - test('list page', async ({ page }) => { - await loginAs(page, 'admin'); - await seedScenario(page, 'multi-yacht-client'); - await page.goto('/port-1/yachts'); - - const result = await clickEverythingOnPage(page, { - skip: ['data-testid="archive"', 'data-testid="delete"'], // destructive - }); - - expect(result.errors).toEqual([]); - expect(result.clicked).toBeGreaterThanOrEqual( - (await page.locator(':is(button, a, [role="button"])').count()) - result.skipped, - ); - }); - - test('detail page — every tab and every button', async ({ page }) => { - /* similar shape, navigate to /yachts/, clickEverything, assert */ - }); -}); -``` - -- [ ] **Step 2: Per domain:** instantiate for list + detail + every sub-page, including the ownership-transfer dialog (open + fill + cancel — actual transfer happens in a separate destructive test). - -- [ ] **Step 3: Run all exhaustive specs, confirm zero errors.** - -- [ ] **Step 4: Commit after each domain** (not all at once — keep PR digestible). - -### Task 14.11: Destructive-action narrow tests - -**Files:** - -- Create: `tests/e2e/destructive/.spec.ts` - -- [ ] For each action in the allowlist (yachts.delete, yachts.transfer, companies.delete, memberships.end, reservations.cancel, reservations.end, invoices.delete), write a narrow test that: - - Creates a throwaway entity via API - - Performs the destructive action via UI - - Verifies the effect (archived, status changed, etc.) - -- [ ] **Commit.** - -### Task 14.12: CI config update - -**Files:** - -- Modify: `.github/workflows/*` (or whichever CI file runs Playwright) - -- [ ] **Step 1: Add a separate CI job** that runs `pnpm playwright test tests/e2e/exhaustive`. Sequential with the other Playwright job (shared test DB), but its own pass/fail summary. - -- [ ] **Step 2: Commit.** - -### Task 14.13: Create PR 14 - ---- - -# PR 15 — Documentation + Final Merge - -**Goal:** Update spec files, CLAUDE.md, and open the final merge to `main`. - -### Task 15.1: Update numbered spec files - -**Files:** - -- Modify: `01-CONSOLIDATED-SYSTEM-SPEC.md`, `02-*.md`, … (in repo root) - -- [ ] **Step 1:** Spot-check each numbered spec file for references to the old data model (yacht fields on client, `companyName`, proxy fields). Update any mentions. - -- [ ] **Step 2: Add a reference** in the system spec to the new yacht/company/reservation tables and their roles. - -- [ ] **Step 3: Commit.** - -### Task 15.2: Update CLAUDE.md - -**Files:** - -- Modify: `CLAUDE.md` - -- [ ] **Step 1:** Update the "Conventions" and "Project structure" sections to mention the new schema files (`yachts.ts`, `companies.ts`, `reservations.ts`) and component directories. - -- [ ] **Step 2: Commit.** - -### Task 15.3: Tier 4 golden-image template regression - -**Files:** - -- Create: `tests/e2e/templates/eoi-golden-image.spec.ts` -- Create: `tests/e2e/fixtures/eoi-golden/*.pdf` - -- [ ] **Step 1:** For each Tier 3 E2E scenario that generates an EOI, render the in-app PDF, compare against a committed reference PDF using a visual-diff library (e.g., `pixelmatch` or `playwright`'s built-in screenshot comparison). First time through, the PDFs are generated manually and committed as the golden set. - -- [ ] **Step 2: Commit.** - -### Task 15.4: Merge the feature branch to `main` - -- [ ] **Step 1:** All PRs 1-14 have been merged into `refactor/data-model`. CI green. -- [ ] **Step 2:** Open final PR `refactor/data-model` → `main`, title `refactor(data-model): yacht + company + reservation refactor (final merge)`. -- [ ] **Step 3:** After approval, squash-merge (or merge-commit, per repo convention). - -### Task 15.5: Close out - -- [ ] **Step 1:** Delete the feature branch. -- [ ] **Step 2:** Announce Spec 1 complete. Spec 2 (NocoDB + MinIO importer) can begin — schema is frozen. -- [ ] **Step 3:** File any follow-ups flagged in the spec's "Open questions" section as issues in the backlog. - ---- - -## Acceptance checklist (must all be true before Spec 1 marked complete) - -- [ ] All 15 PRs merged to `main` -- [ ] CI green on `main`: unit ≥ 90% on services, validators 100%, API routes ≥ 85%, overall ≥ 85% -- [ ] Exhaustive click-through suite: zero console errors, zero unexpected 4xx/5xx, 100% coverage (minus declared destructive-action allowlist) -- [ ] Tier 4 golden-image PDFs committed; visual-diff tests passing -- [ ] Tier 3 E2E scenarios 1-11 all pass against a freshly seeded dev instance -- [ ] Documenso path and in-app path both produce functional EOIs (manual verification required; visual comparison against old system output) -- [ ] `docs/eoi-documenso-field-mapping.md` exists and is accurate -- [ ] Numbered spec files (01-15) + CLAUDE.md reflect the new data model -- [ ] No grep hits for `yachtName`, `companyName`, `isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`, `berthSizeDesired` in `src/` -- [ ] Seeder produces realistic multi-cardinality fixtures; developer can manually walk through every scenario -- [ ] Spec 2 (NocoDB importer) can begin against a frozen schema diff --git a/docs/superpowers/plans/2026-04-29-mobile-foundation.md b/docs/superpowers/plans/2026-04-29-mobile-foundation.md deleted file mode 100644 index d02732a2..00000000 --- a/docs/superpowers/plans/2026-04-29-mobile-foundation.md +++ /dev/null @@ -1,1918 +0,0 @@ -# Mobile Foundation PR Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Land the infrastructure, mobile shell, and mobile-aware primitives that §3 of the design spec requires, so subsequent per-page migrations are wrap-and-tweak. After this PR merges, every authenticated page already gains: viewport meta, no clipped topbar, bottom-tab navigation, safe-area handling, and 44px touch targets — without any per-page edits. - -**Architecture:** Adaptive shell via a `data-form-factor` body attribute set server-side from the User-Agent (no middleware), with a CSS media-query fallback. Both desktop and mobile shells render to the DOM; CSS reveals one. Mobile-aware primitives (``, ``, ``, ``, ``, ``) live in `src/components/shared/` and switch presentation at the `lg` Tailwind breakpoint. - -**Tech Stack:** Next.js 15 App Router, React 19, TypeScript strict, Tailwind 3, Radix/shadcn, vaul (new — for native-feel bottom sheets), Lucide icons, vitest (unit), Playwright (visual + audit). - -**Spec reference:** `docs/superpowers/specs/2026-04-29-mobile-optimization-design.md` §3. - -**Spec deviation:** spec calls the vaul-wrapper primitive `` but `src/components/ui/sheet.tsx` already exists (shadcn slide-from-side using Radix Dialog). The plan uses `` instead — matches shadcn's official vaul-wrapper naming convention and avoids collision. - -**Out of scope** (separate follow-up plans, see spec §5): - -- Per-page migration (quick-win sweep, list pages, detail pages, heavy pages, forms, portal, tablet pass). -- Adopting `` everywhere existing `` is used. Foundation only ships the primitive; per-page work swaps imports. -- Final PWA icon designs — placeholders only. - ---- - -## File structure - -**New files:** - -- `src/hooks/use-is-mobile.ts` — viewport-driven mobile detection hook -- `src/lib/form-factor.ts` — pure UA-classification function (unit-testable) -- `src/components/layout/mobile/mobile-layout.tsx` -- `src/components/layout/mobile/mobile-topbar.tsx` -- `src/components/layout/mobile/mobile-bottom-tabs.tsx` -- `src/components/layout/mobile/more-sheet.tsx` -- `src/components/layout/mobile/mobile-layout-provider.tsx` -- `src/components/shared/drawer.tsx` — vaul wrapper (was `` in spec) -- `src/components/shared/data-view.tsx` -- `src/components/shared/page-header.tsx` -- `src/components/shared/action-row.tsx` -- `src/components/shared/detail-page-shell.tsx` -- `src/components/shared/filter-chips.tsx` -- `tests/unit/lib/form-factor.test.ts` — vitest -- `tests/unit/hooks/use-is-mobile.test.ts` — vitest -- `tests/e2e/fixtures/devices.ts` — anchor device descriptors -- `tests/e2e/visual/mobile-shell.spec.ts` — playwright visual snapshot for the mobile shell -- `public/icon-192.png` — placeholder PWA asset (solid blue 192×192) -- `public/icon-512.png` — placeholder PWA asset (solid blue 512×512) -- `public/icon-512-maskable.png` — placeholder PWA asset (solid blue 512×512 with safe zone padding) -- `public/apple-touch-icon.png` — placeholder PWA asset (solid blue 180×180) - -**Modified files:** - -- `src/app/layout.tsx` — add `viewport` export, theme-color, body data-form-factor, apple-mobile-web-app metas -- `src/app/(dashboard)/layout.tsx` — render `` alongside the existing ``/``; CSS hides the inactive shell -- `src/app/globals.css` — add `[data-form-factor]` reveal/hide rules + media-query fallback -- `tailwind.config.ts` — add `safe` spacing utilities (`pt-safe`/`pb-safe`/etc.) -- `src/components/ui/button.tsx` — bump `size: default` from `h-9` to `h-11` (and `sm`/`lg`/`icon` proportionally) -- `src/components/ui/input.tsx` — bump from `h-9` to `h-11`, drop `md:text-sm` (keep 16px to prevent iOS zoom) -- `src/components/ui/textarea.tsx` — drop `md:text-sm` (keep 16px) -- `src/components/ui/dialog.tsx` — adjust `DialogContent` to render full-screen on mobile (`inset-0 max-w-full sm:inset-auto sm:max-w-lg`) -- `package.json` — add `vaul` dependency - ---- - -## Task 1: Add `viewport` export, theme-color, and PWA metas to root layout - -**Files:** - -- Modify: `src/app/layout.tsx` - -- [ ] **Step 1: Add the `viewport` export and PWA-related metadata to the root layout** - -```ts -// src/app/layout.tsx -import type { Metadata, Viewport } from 'next'; -import { Inter, JetBrains_Mono } from 'next/font/google'; -import { Toaster } from 'sonner'; -import './globals.css'; - -const inter = Inter({ - subsets: ['latin'], - variable: '--font-sans', - display: 'swap', -}); - -const jetbrainsMono = JetBrains_Mono({ - subsets: ['latin'], - variable: '--font-mono', - display: 'swap', -}); - -export const viewport: Viewport = { - width: 'device-width', - initialScale: 1, - viewportFit: 'cover', - themeColor: '#1e2844', -}; - -export const metadata: Metadata = { - title: { - default: 'Port Nimara CRM', - template: '%s | Port Nimara CRM', - }, - description: 'Marina management system for Port Nimara', - appleWebApp: { - capable: true, - statusBarStyle: 'black-translucent', - title: 'Port Nimara', - }, - icons: { - icon: [ - { url: '/icon-192.png', sizes: '192x192', type: 'image/png' }, - { url: '/icon-512.png', sizes: '512x512', type: 'image/png' }, - ], - apple: '/apple-touch-icon.png', - }, -}; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - - ); -} -``` - -(The body `data-form-factor` attribute lands in Task 3.) - -- [ ] **Step 2: Verify the root layout still typechecks** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Verify the dev server still serves the page** - -Open `http://localhost:3000/login` in a browser, view source. Expected: `` and `` are present in ``. - -- [ ] **Step 4: Commit** - -```bash -git add src/app/layout.tsx -git commit -m "feat(mobile): add viewport meta, theme-color, and PWA metadata to root layout" -``` - ---- - -## Task 2: Add safe-area Tailwind utilities - -**Files:** - -- Modify: `tailwind.config.ts` - -- [ ] **Step 1: Add safe-area spacing utilities to the theme extension** - -Find the `extend:` block in `tailwind.config.ts`. Add these keys (place `spacing` before `keyframes`): - -```ts -spacing: { - safe: 'env(safe-area-inset-bottom)', - 'safe-top': 'env(safe-area-inset-top)', - 'safe-bottom': 'env(safe-area-inset-bottom)', - 'safe-left': 'env(safe-area-inset-left)', - 'safe-right': 'env(safe-area-inset-right)', -}, -``` - -This makes `pt-safe-top`, `pb-safe-bottom`, `pl-safe-left`, `pr-safe-right` (and `pt-safe`/`pb-safe` shorthand) available as Tailwind utilities. - -- [ ] **Step 2: Verify the config still parses** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Smoke-test the utility actually emits** - -Add a temporary `pb-safe-bottom` class to a test page (e.g., `src/app/(auth)/login/page.tsx`). Reload, inspect — element should have `padding-bottom: env(safe-area-inset-bottom)`. Remove the test class. - -- [ ] **Step 4: Commit** - -```bash -git add tailwind.config.ts -git commit -m "feat(mobile): add safe-area spacing utilities (pt-safe-top, pb-safe-bottom, etc.)" -``` - ---- - -## Task 3: Create UA-derived form-factor classifier (TDD) - -**Files:** - -- Create: `src/lib/form-factor.ts` -- Create: `tests/unit/lib/form-factor.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// tests/unit/lib/form-factor.test.ts -import { describe, it, expect } from 'vitest'; -import { classifyFormFactor } from '@/lib/form-factor'; - -describe('classifyFormFactor', () => { - it('returns "mobile" for an iPhone UA', () => { - expect( - classifyFormFactor( - 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148', - ), - ).toBe('mobile'); - }); - - it('returns "mobile" for an iPad UA', () => { - expect( - classifyFormFactor( - 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148', - ), - ).toBe('mobile'); - }); - - it('returns "mobile" for an Android UA', () => { - expect( - classifyFormFactor( - 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile Safari/537.36', - ), - ).toBe('mobile'); - }); - - it('returns "desktop" for a Mac Safari UA', () => { - expect( - classifyFormFactor( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15', - ), - ).toBe('desktop'); - }); - - it('returns "desktop" for a Linux Chrome UA', () => { - expect( - classifyFormFactor( - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', - ), - ).toBe('desktop'); - }); - - it('returns "desktop" for missing UA', () => { - expect(classifyFormFactor(null)).toBe('desktop'); - expect(classifyFormFactor(undefined)).toBe('desktop'); - expect(classifyFormFactor('')).toBe('desktop'); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pnpm exec vitest run tests/unit/lib/form-factor.test.ts` -Expected: FAIL with `Cannot find module '@/lib/form-factor'`. - -- [ ] **Step 3: Write the minimal implementation** - -```ts -// src/lib/form-factor.ts -export type FormFactor = 'mobile' | 'desktop'; - -const MOBILE_TOKENS = ['Mobile', 'iPhone', 'iPad', 'Android'] as const; - -/** - * Classify a User-Agent string as 'mobile' or 'desktop'. - * Defaults to 'desktop' when the UA is missing or unrecognized — the CSS - * media-query fallback in globals.css handles desktop browsers resized below - * the lg breakpoint, so a wrong-but-defaultish classification never breaks UX. - */ -export function classifyFormFactor(userAgent: string | null | undefined): FormFactor { - if (!userAgent) return 'desktop'; - return MOBILE_TOKENS.some((token) => userAgent.includes(token)) ? 'mobile' : 'desktop'; -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `pnpm exec vitest run tests/unit/lib/form-factor.test.ts` -Expected: 6 tests passing. - -- [ ] **Step 5: Wire it into the root layout** - -Modify `src/app/layout.tsx` — at the top, add the import: - -```ts -import { headers } from 'next/headers'; -import { classifyFormFactor } from '@/lib/form-factor'; -``` - -Change the `RootLayout` to async and read the form factor: - -```ts -export default async function RootLayout({ children }: { children: React.ReactNode }) { - const headerList = await headers(); - const formFactor = classifyFormFactor(headerList.get('user-agent')); - - return ( - - - {children} - - - - ); -} -``` - -- [ ] **Step 6: Verify it still builds** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 7: Verify the body attribute renders** - -Open `http://localhost:3000/login` in a browser, inspect the `` element. Expected: `` (since this Mac Chrome UA is desktop). Open in a mobile-emulated tab (Chrome devtools → toggle device toolbar → iPhone) and reload — expected: `data-form-factor="mobile"`. - -- [ ] **Step 8: Commit** - -```bash -git add src/lib/form-factor.ts tests/unit/lib/form-factor.test.ts src/app/layout.tsx -git commit -m "feat(mobile): set data-form-factor body attr from User-Agent in root layout" -``` - ---- - -## Task 4: Add CSS rules that reveal mobile/desktop shells based on form factor - -**Files:** - -- Modify: `src/app/globals.css` - -- [ ] **Step 1: Append the form-factor reveal rules to globals.css** - -Add at the end of `src/app/globals.css`: - -```css -/* ─── Form-factor shell visibility ────────────────────────────────────────── - * Two shells (desktop + mobile) render to the DOM on every page; CSS reveals - * one and hides the other. The data-form-factor body attribute is set - * server-side from User-Agent (see src/lib/form-factor.ts). The media-query - * fallback handles desktop browsers resized below lg (1024px), or stripped UAs. - */ -[data-shell='desktop'] { - display: block; -} -[data-shell='mobile'] { - display: none; -} - -@media (max-width: 1023.98px) { - [data-shell='desktop'] { - display: none; - } - [data-shell='mobile'] { - display: block; - } -} - -body[data-form-factor='mobile'] [data-shell='desktop'] { - display: none; -} -body[data-form-factor='mobile'] [data-shell='mobile'] { - display: block; -} -``` - -The shell components themselves will set `data-shell="desktop"` or `data-shell="mobile"` on their root element (Tasks 13, 14). - -- [ ] **Step 2: Verify globals.css still parses** - -Reload `http://localhost:3000/login` — page should render normally with no console CSS errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/app/globals.css -git commit -m "feat(mobile): add CSS rules to switch shells based on data-form-factor + viewport" -``` - ---- - -## Task 5: Create `useIsMobile()` hook (TDD) - -**Files:** - -- Create: `src/hooks/use-is-mobile.ts` -- Create: `tests/unit/hooks/use-is-mobile.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -// tests/unit/hooks/use-is-mobile.test.ts -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useIsMobile } from '@/hooks/use-is-mobile'; - -type Listener = (e: { matches: boolean }) => void; - -describe('useIsMobile', () => { - let mediaListeners: Listener[]; - let currentMatches: boolean; - - beforeEach(() => { - mediaListeners = []; - currentMatches = false; - vi.stubGlobal( - 'matchMedia', - vi.fn((query: string) => ({ - matches: currentMatches, - media: query, - onchange: null, - addEventListener: (_: string, l: Listener) => mediaListeners.push(l), - removeEventListener: (_: string, l: Listener) => { - mediaListeners = mediaListeners.filter((x) => x !== l); - }, - addListener: () => {}, - removeListener: () => {}, - dispatchEvent: () => true, - })), - ); - Object.defineProperty(window, 'matchMedia', { - configurable: true, - writable: true, - value: globalThis.matchMedia, - }); - }); - - it('returns false for desktop viewport', () => { - currentMatches = false; - const { result } = renderHook(() => useIsMobile()); - expect(result.current).toBe(false); - }); - - it('returns true for mobile viewport', () => { - currentMatches = true; - const { result } = renderHook(() => useIsMobile()); - expect(result.current).toBe(true); - }); - - it('updates when the media query changes', () => { - currentMatches = false; - const { result } = renderHook(() => useIsMobile()); - expect(result.current).toBe(false); - - act(() => { - mediaListeners.forEach((l) => l({ matches: true })); - }); - expect(result.current).toBe(true); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `pnpm exec vitest run tests/unit/hooks/use-is-mobile.test.ts` -Expected: FAIL with `Cannot find module '@/hooks/use-is-mobile'`. - -- [ ] **Step 3: Write the implementation** - -```ts -// src/hooks/use-is-mobile.ts -'use client'; - -import { useEffect, useState } from 'react'; - -const MOBILE_QUERY = '(max-width: 1023.98px)'; - -/** - * Returns true when the viewport is below the `lg` Tailwind breakpoint. - * Backed by a media-query listener; safe to call from any client component. - * Server renders return `false` (desktop default) — clients hydrate to the - * true viewport state on mount. - */ -export function useIsMobile(): boolean { - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const mq = window.matchMedia(MOBILE_QUERY); - const update = (e: { matches: boolean }) => setIsMobile(e.matches); - setIsMobile(mq.matches); - mq.addEventListener('change', update); - return () => mq.removeEventListener('change', update); - }, []); - - return isMobile; -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `pnpm exec vitest run tests/unit/hooks/use-is-mobile.test.ts` -Expected: 3 tests passing. - -- [ ] **Step 5: Commit** - -```bash -git add src/hooks/use-is-mobile.ts tests/unit/hooks/use-is-mobile.test.ts -git commit -m "feat(mobile): add useIsMobile() hook backed by matchMedia" -``` - ---- - -## Task 6: Add vaul dependency - -**Files:** - -- Modify: `package.json` (via `pnpm add`) - -- [ ] **Step 1: Install vaul** - -Run: `pnpm add vaul@^1.1.2` -Expected: `vaul` appears in `package.json` dependencies. - -- [ ] **Step 2: Verify install** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add package.json pnpm-lock.yaml -git commit -m "chore(deps): add vaul for native-feel bottom sheets" -``` - ---- - -## Task 7: Bump touch-target defaults on Button, Input, Textarea - -**Files:** - -- Modify: `src/components/ui/button.tsx` -- Modify: `src/components/ui/input.tsx` -- Modify: `src/components/ui/textarea.tsx` - -- [ ] **Step 1: Update Button size variants** - -In `src/components/ui/button.tsx`, change the `size` variants: - -```ts -size: { - default: "h-11 px-4 py-2", - sm: "h-9 rounded-md px-3 text-xs", - lg: "h-12 rounded-md px-8", - icon: "h-11 w-11", -}, -``` - -Rationale: 44px (h-11) hits the Apple HIG touch-target on default and icon. `sm` stays at 36px for dense desktop contexts (table inline actions); per-page work can opt into the larger size on mobile. - -- [ ] **Step 2: Update Input height + drop md:text-sm** - -In `src/components/ui/input.tsx`, change the className: - -```ts -className={cn( - "flex h-11 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", - className -)} -``` - -Removed: `md:text-sm`. Kept: `text-base` everywhere so iOS Safari doesn't zoom on focus (iOS zooms when focused input has a font-size below 16px). - -- [ ] **Step 3: Update Textarea — drop md:text-sm** - -In `src/components/ui/textarea.tsx`: - -```ts -className={cn( - "flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", - className -)} -``` - -Removed: `md:text-sm`. Bumped `min-h-[60px]` to `min-h-[80px]` — single textarea is easier to use larger. - -- [ ] **Step 4: Verify typecheck + visual smoke** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -Open `http://localhost:3000/port-nimara/invoices/new` in the browser. Buttons + inputs should be visibly taller. Existing pages should not look broken — desktop layouts that depended on the 36px button height may need follow-up tweaks tracked in spec §7. - -- [ ] **Step 5: Commit** - -```bash -git add src/components/ui/button.tsx src/components/ui/input.tsx src/components/ui/textarea.tsx -git commit -m "feat(mobile): bump touch-target heights on Button/Input/Textarea, keep 16px to prevent iOS zoom" -``` - ---- - -## Task 8: Make Dialog full-screen on mobile - -**Files:** - -- Modify: `src/components/ui/dialog.tsx` - -- [ ] **Step 1: Update DialogContent positioning** - -In `src/components/ui/dialog.tsx`, change `DialogContent`'s className: - -```ts -className={cn( - "fixed inset-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:top-[50%] sm:inset-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]", - className -)} -``` - -Below the `sm` breakpoint (640px) the Dialog renders full-screen (`inset-0 max-w-full`); at and above `sm` it keeps the centered modal behavior. - -- [ ] **Step 2: Verify a Dialog still works at desktop** - -Reload `http://localhost:3000/port-nimara/clients` in a desktop viewport and open any dialog (e.g., create new client). Expected: centered modal, looks unchanged. - -Resize the browser to 400px wide. Same dialog should now fill the viewport. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/ui/dialog.tsx -git commit -m "feat(mobile): render Dialog full-screen below sm, centered modal at sm+" -``` - ---- - -## Task 9: Add placeholder PWA assets - -**Files:** - -- Create: `public/icon-192.png` -- Create: `public/icon-512.png` -- Create: `public/icon-512-maskable.png` -- Create: `public/apple-touch-icon.png` - -- [ ] **Step 1: Generate solid-color placeholder PNGs** - -Use ImageMagick's `convert` (already on most macOS dev machines via Homebrew) to write four solid `#1e2844` (Port Nimara navy) PNGs at the right sizes. The maskable variant has a 20% transparent border on each side per the PWA maskable spec safe zone. - -```bash -convert -size 192x192 xc:'#1e2844' public/icon-192.png -convert -size 512x512 xc:'#1e2844' public/icon-512.png -convert -size 180x180 xc:'#1e2844' public/apple-touch-icon.png - -# Maskable: 410×410 navy centered on a 512×512 navy canvas (no transparent border; -# we want fully-bleeding navy so safe zone is purely a layout convention). -convert -size 512x512 xc:'#1e2844' public/icon-512-maskable.png -``` - -If `convert` is missing, install with `brew install imagemagick` first. - -- [ ] **Step 2: Verify the files exist and have correct dimensions** - -Run: `file public/icon-192.png public/icon-512.png public/apple-touch-icon.png public/icon-512-maskable.png` -Expected: each line reports the correct PNG dimensions. - -- [ ] **Step 3: Verify the PWA manifest reference resolves** - -Open `http://localhost:3000/port-nimara/scan/manifest.webmanifest` (the existing scanner manifest endpoint), confirm it references the icon paths. Open `http://localhost:3000/icon-192.png` in a tab — should show the navy square. - -- [ ] **Step 4: Commit** - -```bash -git add public/icon-192.png public/icon-512.png public/icon-512-maskable.png public/apple-touch-icon.png -git commit -m "chore(pwa): add placeholder icons (icon-192/512/512-maskable, apple-touch-icon)" -``` - ---- - -## Task 10: Create `` (context for topbar slots) - -**Files:** - -- Create: `src/components/layout/mobile/mobile-layout-provider.tsx` - -- [ ] **Step 1: Create the provider + hook** - -```tsx -// src/components/layout/mobile/mobile-layout-provider.tsx -'use client'; - -import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'; - -type MobileChromeState = { - title: string | null; - primaryAction: ReactNode | null; - showBackButton: boolean; -}; - -type MobileChromeApi = MobileChromeState & { - setChrome: (next: Partial) => void; -}; - -const MobileChromeContext = createContext(null); - -export function MobileLayoutProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ - title: null, - primaryAction: null, - showBackButton: false, - }); - - const value = useMemo( - () => ({ - ...state, - setChrome: (next) => setState((prev) => ({ ...prev, ...next })), - }), - [state], - ); - - return {children}; -} - -/** - * Page-level hook to push a title / back-button / primary action into the - * mobile topbar. The provider is only mounted by ``, so - * desktop-shell renders never call into this context. - */ -export function useMobileChrome() { - const ctx = useContext(MobileChromeContext); - if (!ctx) { - throw new Error('useMobileChrome must be used inside '); - } - return ctx; -} -``` - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/layout/mobile/mobile-layout-provider.tsx -git commit -m "feat(mobile): add MobileLayoutProvider context + useMobileChrome hook" -``` - ---- - -## Task 11: Create `` - -**Files:** - -- Create: `src/components/layout/mobile/mobile-topbar.tsx` - -- [ ] **Step 1: Create the topbar component** - -```tsx -// src/components/layout/mobile/mobile-topbar.tsx -'use client'; - -import { ChevronLeft } from 'lucide-react'; -import { useRouter, usePathname } from 'next/navigation'; - -import { cn } from '@/lib/utils'; -import { useMobileChrome } from './mobile-layout-provider'; - -/** - * Fixed compact topbar (52px + safe-area top inset). Renders the page title - * (auto-truncating), an optional back button, and an optional primary action - * — all driven by `useMobileChrome()` from the active page. - */ -export function MobileTopbar() { - const { title, primaryAction, showBackButton } = useMobileChrome(); - const router = useRouter(); - const pathname = usePathname(); - - // Fall back to the last path segment (Title Case) if no page-supplied title. - const fallbackTitle = - pathname - .split('/') - .filter(Boolean) - .pop() - ?.replace(/-/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara'; - - return ( -
- {showBackButton ? ( - - ) : ( -
- )} - -

- {title ?? fallbackTitle} -

- -
{primaryAction}
-
- ); -} -``` - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/layout/mobile/mobile-topbar.tsx -git commit -m "feat(mobile): add MobileTopbar with title, back-button, and primary-action slots" -``` - ---- - -## Task 12: Create `` - -**Files:** - -- Create: `src/components/layout/mobile/mobile-bottom-tabs.tsx` - -- [ ] **Step 1: Create the bottom tab bar** - -```tsx -// src/components/layout/mobile/mobile-bottom-tabs.tsx -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { LayoutDashboard, Users, Ship, Anchor, Menu } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -type TabSpec = { - label: string; - icon: typeof LayoutDashboard; - segment: string; // route segment after /[portSlug]/ -}; - -const TABS: TabSpec[] = [ - { label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' }, - { label: 'Clients', icon: Users, segment: 'clients' }, - { label: 'Yachts', icon: Ship, segment: 'yachts' }, - { label: 'Berths', icon: Anchor, segment: 'berths' }, -]; - -export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) { - const pathname = usePathname(); - - // Derive the active port slug from the URL so tab links always target the - // current port, even after a port-switch. The dashboard route shape is - // /[portSlug]/, so the slug is the first non-empty path segment. - const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara'; - - function isActive(segment: string): boolean { - return pathname.startsWith(`/${portSlug}/${segment}`); - } - - return ( - - ); -} -``` - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/layout/mobile/mobile-bottom-tabs.tsx -git commit -m "feat(mobile): add MobileBottomTabs with 5 fixed tabs (Dashboard/Clients/Yachts/Berths/More)" -``` - ---- - -## Task 13: Create `` (vaul wrapper) - -**Files:** - -- Create: `src/components/shared/drawer.tsx` - -- [ ] **Step 1: Create the vaul wrapper** - -```tsx -// src/components/shared/drawer.tsx -'use client'; - -import * as React from 'react'; -import { Drawer as VaulDrawer } from 'vaul'; - -import { cn } from '@/lib/utils'; - -const Drawer = ({ - shouldScaleBackground = true, - ...props -}: React.ComponentProps) => ( - -); -Drawer.displayName = 'Drawer'; - -const DrawerTrigger = VaulDrawer.Trigger; -const DrawerPortal = VaulDrawer.Portal; -const DrawerClose = VaulDrawer.Close; - -const DrawerOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerOverlay.displayName = 'DrawerOverlay'; - -const DrawerContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - -
- {children} - - -)); -DrawerContent.displayName = 'DrawerContent'; - -const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -DrawerHeader.displayName = 'DrawerHeader'; - -const DrawerTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerTitle.displayName = 'DrawerTitle'; - -const DrawerDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DrawerDescription.displayName = 'DrawerDescription'; - -export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerTitle, - DrawerDescription, -}; -``` - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/shared/drawer.tsx -git commit -m "feat(mobile): add Drawer (vaul wrapper) for native-feel bottom sheets" -``` - ---- - -## Task 14: Create `` - -**Files:** - -- Create: `src/components/layout/mobile/more-sheet.tsx` - -- [ ] **Step 1: Create the More bottom sheet** - -```tsx -// src/components/layout/mobile/more-sheet.tsx -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { - Building2, - Bookmark, - Receipt, - FileText, - FolderOpen, - Mail, - Bell, - ShieldAlert, - BarChart3, - Settings, - Shield, -} from 'lucide-react'; - -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, - DrawerClose, -} from '@/components/shared/drawer'; - -type MoreItem = { - label: string; - icon: typeof Building2; - segment: string; -}; - -const MORE_ITEMS: MoreItem[] = [ - { label: 'Companies', icon: Building2, segment: 'companies' }, - { label: 'Interests', icon: Bookmark, segment: 'interests' }, - { label: 'Invoices', icon: FileText, segment: 'invoices' }, - { label: 'Expenses', icon: Receipt, segment: 'expenses' }, - { label: 'Documents', icon: FolderOpen, segment: 'documents' }, - { label: 'Email', icon: Mail, segment: 'email' }, - { label: 'Alerts', icon: ShieldAlert, segment: 'alerts' }, - { label: 'Reports', icon: BarChart3, segment: 'reports' }, - { label: 'Reminders', icon: Bell, segment: 'reminders' }, - { label: 'Settings', icon: Settings, segment: 'settings' }, - { label: 'Admin', icon: Shield, segment: 'admin' }, -]; - -export function MoreSheet({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (next: boolean) => void; -}) { - const pathname = usePathname(); - const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara'; - return ( - - - - More - -
    - {MORE_ITEMS.map((item) => { - const Icon = item.icon; - return ( -
  • - - - - {item.label} - - -
  • - ); - })} -
-
-
- ); -} -``` - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/layout/mobile/more-sheet.tsx -git commit -m "feat(mobile): add MoreSheet (3-column grid of long-tail nav items in a bottom drawer)" -``` - ---- - -## Task 15: Create `` - -**Files:** - -- Create: `src/components/layout/mobile/mobile-layout.tsx` - -- [ ] **Step 1: Create the mobile layout shell** - -```tsx -// src/components/layout/mobile/mobile-layout.tsx -'use client'; - -import { useState, type ReactNode } from 'react'; - -import { MobileLayoutProvider } from './mobile-layout-provider'; -import { MobileTopbar } from './mobile-topbar'; -import { MobileBottomTabs } from './mobile-bottom-tabs'; -import { MoreSheet } from './more-sheet'; - -/** - * Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab - * bar. Renders only when CSS reveals it (data-shell="mobile") — both shells - * are in the DOM, see src/app/globals.css. The bottom tabs and More sheet - * derive the active port slug from the URL themselves, so this layout takes - * no portSlug prop. - */ -export function MobileLayout({ children }: { children: ReactNode }) { - const [moreOpen, setMoreOpen] = useState(false); - - return ( -
- - -
- {children} -
- setMoreOpen(true)} /> - -
-
- ); -} -``` - -- [ ] **Step 2: Verify typecheck** - -Run: `pnpm exec tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/layout/mobile/mobile-layout.tsx -git commit -m "feat(mobile): add MobileLayout shell composing topbar + content + bottom tabs + more sheet" -``` - ---- - -## Task 16: Wire `` into the dashboard layout - -**Files:** - -- Modify: `src/app/(dashboard)/layout.tsx` - -- [ ] **Step 1: Wrap the existing shell in a `data-shell="desktop"` div, render `` alongside it** - -Change the return JSX: - -```tsx -import { MobileLayout } from '@/components/layout/mobile/mobile-layout'; - -// ... - -return ( - - - - - {/* Desktop shell — hidden by CSS on mobile */} -
- -
- -
{children}
-
-
- - {/* Mobile shell — hidden by CSS on desktop */} - {children} -
-
-
-
-); -``` - -Note: `children` is rendered TWICE (once in each shell). React handles this fine because only one is visible. `` keeps both shells in sync via context. - -- [ ] **Step 2: Remove the legacy mobile-drawer hamburger from ``** - -The existing `` component renders both the desktop sidebar (`hidden md:flex`) and a mobile drawer with a hamburger button (`md:hidden fixed top-3 left-3`). With the new mobile shell, the mobile drawer is dead weight — there's no `md:hidden` zone visible anymore (we hide the entire desktop shell on mobile via `data-form-factor`). - -Open `src/components/layout/sidebar.tsx`. Find the `` block at the end of the component (the one with `` + `Menu` icon — currently lines ~384-407). Delete that entire block plus the surrounding `<>` fragment — leaving only the `