Files
pn-new-crm/CLAUDE.md
Matt eaab14943b feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker
Phase 3b — EOI dialog field overrides:
- New EoiOverridesInput shape (clientEmail / clientPhone / yachtName)
  threaded through generate-and-sign validator + both pathways
  (in-app pdf-lib fill, Documenso template generate).
- src/lib/services/eoi-overrides.service.ts applies side-effects in one
  transaction: useOnlyForThisEoi writes documents.override_* and stops;
  setAsDefault demotes the prior primary + promotes (existing contactId)
  or inserts + promotes (fresh value); neither flag inserts a non-primary
  client_contacts row for future dropdown reuse.
- Document override columns persisted post-insert, with a 1-minute
  source_document_id backfill on freshly inserted contact rows.
- eoi-context route returns available.{emails, phones} so the dialog
  can render combobox options.
- <OverridableContactField> in eoi-generate-dialog.tsx renders the
  combobox + manual input + 2 checkboxes per field with mutually
  exclusive intent semantics.

Phase 3c — yacht spawn from EOI dialog:
- YachtForm gains createExtras + onCreated callbacks; the EOI dialog
  opens it as a nested Sheet pre-filled with the linked client as owner.
  On save the new yacht is stamped source='eoi-generated' and the
  interest is PATCHed with the new yachtId so the EOI context reflows.

Phase 3d — promote-to-primary + audit + [EOI] badge:
- POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary
  (transactional demote+promote via promoteContactToPrimary).
- src/lib/audit.ts AuditAction type adds eoi_field_override,
  promote_to_primary, eoi_spawn_yacht (DB column is free-text).
- ContactsEditor surfaces an [EOI] badge on non-primary rows where
  source='eoi-custom-input'.

Phase 4 — worker + TOD picker:
- processOverdueReminders refactored to UPDATE...RETURNING with a
  fired_at IS NULL gate so parallel workers can't double-fire. Uses
  the idx_reminders_due_unfired partial index from migration 0072.
- /settings gets a "Default reminder time" time-of-day picker; the
  value lands in user_profiles.preferences.digestTimeOfDay (validated
  HH:MM at the route). <ReminderForm> seeds its dueAt from this
  preference via a React-Query me-prefs fetch.

Phase 6 hardening:
- IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste
  of Google Workspace's 16-char App Password formatted as
  "abcd efgh ijkl mnop" still authenticates. Workspace activation
  procedure documented in MASTER-PLAN §Phase 6 (was previously written
  to CLAUDE.md, which was bloat — moved to the plan).

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00

19 KiB

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

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

# 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.

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). Bounce monitoring needs IMAP creds in addition to SMTP — without them, the size-rejection banner is disabled.

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.md15-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