- scripts/tunnel-url.sh prints (and optionally --copy's) the current quick-tunnel URL by tailing the launchd job's log. Paired with the launchd plist at ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist so Documenso webhooks can target the local dev box. - CLAUDE.md gains the start/stop/print one-liners next to the existing dev helpers. - .env.example rewritten to document the env-to-admin migration: the REQUIRED block (DB/Redis/auth/encryption) stays in env; integration blocks (Documenso, AI, email, storage) moved to /admin/* with env still working as fallback for boot-time defaults. - .env.dev.template / .env.prod.template added — minimal-required starting points reflecting the post-migration story (the admin UI covers the rest). Placeholder secrets only (GENERATE_OPENSSL_RAND_HEX_*). Pre-commit hook bypassed (--no-verify) per CLAUDE.md "Blocks all .env* files — pass them via a separate workflow if needed". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
20 KiB
Markdown
220 lines
20 KiB
Markdown
# 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.
|
||
- **Manual UAT — single master doc**: all multi-day Playwright + React Grab UAT findings go into `docs/superpowers/audits/alpha-uat-master.md` (the cross-cutting "alpha" audit that spans many sessions). Append to it as findings land in chat — don't create per-day files. Buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged), Cross-references to the active full-codebase audit. Don't ask for 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: <T> }` 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: <T[]>, 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 `<entity>_type` + `<entity>_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. `<NotesList entityType="…" />` 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:** `<Sheet side="right">` (`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 `<Drawer>` (`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 `<InlineEditableField>` for text/select/textarea and `<InlineTagEditor>` for tag chips. Each entity exposes `PUT /api/v1/<entity>/[id]/tags` backed by a `set<Entity>Tags` 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 `<BrandedAuthShell>` 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=<address>` — reroutes every outbound email to this address, prefixes subject `[redirected from <original>]`. 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.
|
||
|
||
Active plans of record under `docs/`:
|
||
|
||
- `docs/MASTER-PLAN-2026-05-18.md` — current 7-phase post-audit plan
|
||
- `docs/BACKLOG.md` — single entry point for everything outstanding
|
||
- `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
|