Files
pn-new-crm/CLAUDE.md
Matt 4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

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

27 KiB
Raw Blame History

Port Nimara CRM

Multi-tenant CRM for marina/port management. Built with Next.js 15 App Router (standalone output), React 19, TypeScript (strict), Tailwind CSS 3, and Drizzle ORM on PostgreSQL.

Quick reference

pnpm dev              # Start dev server
pnpm build            # Production build
pnpm lint             # ESLint
pnpm format           # Prettier
pnpm db:generate      # Generate Drizzle migrations
pnpm db:push          # Push schema to DB
pnpm db:studio        # Drizzle Studio GUI
pnpm db:seed          # Seed database (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

Tech stack

  • Framework: Next.js 15.1 App Router, output: 'standalone', experimental.typedRoutes
  • Auth: better-auth (session cookie: pn-crm.session_token)
  • Database: PostgreSQL via postgres driver + Drizzle ORM
  • Queue: BullMQ + Redis (ioredis)
  • Storage: MinIO (S3-compatible)
  • Realtime: Socket.IO with Redis adapter
  • UI: Radix UI primitives, shadcn/ui components (src/components/ui/), Lucide icons, CVA + tailwind-merge + clsx
  • Forms: react-hook-form + zod resolvers
  • Tables: TanStack Table
  • State: Zustand stores (src/stores/), TanStack React Query
  • PDF: pdfme
  • Email: nodemailer + imapflow + mailparser
  • AI: OpenAI SDK (optional)
  • Testing: Vitest (unit), Playwright (e2e)
  • Logging: pino + pino-pretty

Project structure

src/
  app/
    (auth)/          # Login/auth pages
    (dashboard)/     # Main app - route: /[portSlug]/...
    (portal)/        # Client portal
    api/             # API routes
  components/
    ui/              # shadcn/ui base components
    layout/          # Shell, sidebar, header
    [domain]/        # Domain components (clients, invoices, berths, etc.)
    shared/          # Cross-domain shared components
  hooks/             # React hooks (use-auth, use-permissions, use-socket, etc.)
  lib/
    api/             # API client utilities
    auth/            # better-auth config
    db/
      schema/        # Drizzle schema (one file per domain)
      migrations/    # Generated Drizzle migrations
    env.ts           # Zod env validation (SKIP_ENV_VALIDATION=1 bypasses)
    services/        # Business logic services
    validators/      # Zod schemas for API input validation
    utils/           # Shared utilities
  middleware.ts      # Auth middleware (cookie check, redirects)
  providers/         # React context providers
  stores/            # Zustand stores
  types/             # Shared TypeScript types

Conventions

  • TypeScript: Strict mode with noUncheckedIndexedAccess. No any (ESLint error).

  • Formatting: Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.

  • Lint: ESLint flat config extending next/core-web-vitals, next/typescript, prettier. Unused vars prefixed with _ are allowed.

  • Imports: Use @/* path alias (maps to src/*).

  • Components: shadcn/ui pattern - base components in src/components/ui/, domain components in src/components/[domain]/. Yacht / company / reservation domains live in components/yachts, components/companies, components/reservations respectively.

  • DB schema: One file per domain in src/lib/db/schema/, re-exported from index.ts. Relations in relations.ts. Domain files include clients.ts, yachts.ts, companies.ts, reservations.ts, interests.ts, berths.ts, documents.ts, invoices.ts, etc.

  • Polymorphic ownership: Yachts and invoice billing-entities use <entity>_type + <entity>_id column pairs ('client' | 'company'). Resolve owner identity through src/lib/services/yachts.service.ts / eoi-context.ts rather than reading the columns ad hoc — those services apply the type discriminator.

  • EOI generation: Two pathways share the same EoiContext (src/lib/services/eoi-context.ts). Documenso pathway calls the template-generate endpoint via documenso-payload.ts; in-app pathway fills the same source PDF (assets/eoi-template.pdf) via src/lib/pdf/fill-eoi-form.ts (pdf-lib AcroForm). Routed through generateAndSign(...) in src/lib/services/document-templates.ts with a pathway parameter.

  • Merge fields: Token catalog lives in src/lib/templates/merge-fields.ts; the createTemplateSchema validator uses VALID_MERGE_TOKENS as an allow-list, so unknown tokens are rejected at template creation time.

  • Documenso webhooks: Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the X-Documenso-Secret header — there is no HMAC. The receiver at src/app/api/webhooks/documenso/route.ts does a timing-safe equality check via verifyDocumensoSecret. Event names arrive as the uppercase Prisma enum on the wire (DOCUMENT_SIGNED, DOCUMENT_COMPLETED, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat. handleDocumentCompleted is idempotent — early-returns when doc.status === 'completed' && doc.signedFileId so Documenso retries on 5xx don't insert duplicate file rows + orphan blobs. The switch handles DOCUMENT_SIGNED|COMPLETED|REJECTED|DECLINED|OPENED|EXPIRED, plus v2 aliases RECIPIENT_VIEWED / RECIPIENT_SIGNED (logged + routed to v1 equivalents).

  • Documenso API responses: 2.x renamed iddocumentId and recipient idrecipientId; v1.13 still uses id. src/lib/services/documenso-client.ts runs every response through normalizeDocument() which reads either field name and surfaces the legacy id form to downstream consumers.

  • Documenso v1 vs v2 endpoint routing: getPortDocumensoConfig(portId) resolves the per-port apiVersion ('v1' | 'v2'). documenso-client.ts exports version-aware wrappers: getDocument, createDocument, sendDocument, sendReminder, downloadSignedPdf, voidDocument, placeFields. v2 → /api/v2/envelope/* (create is multipart with {payload, files}; distribute returns per-recipient signingUrl in one round-trip; redistribute for reminders; field/create-many for bulk placement with percent coords + fieldMeta). v1 → existing /api/v1/documents/* paths. Template flow is intentionally still v1 (/api/v1/templates/{id}/generate-document with formValues keyed by name) — v2 instances accept it via backward compat. Full v2 /template/use migration with prefillFields by ID needs per-template field-ID capture in admin settings and is deferred. Two per-port v2 settings now wired through buildDocumensoPayload + documensoCreate.meta: documenso_signing_order (PARALLEL/SEQUENTIAL — v2-enforced) and documenso_redirect_url (post-sign redirect; both versions honour). checkDocumensoHealth returns the resolved apiVersion for the admin Test button.

  • Email templates: Branded HTML lives in src/lib/email/templates/. The portal-auth flow uses portal-auth.ts (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and width:100% for responsive shrink. The <img> URLs reference s3.portnimara.com directly (will move to /public later).

  • Portal auth pages: /portal/login, /portal/activate, /portal/reset-password and the CRM /login, /reset-password, /set-password all wrap their content in <BrandedAuthShell> (src/components/shared/branded-auth-shell.tsx) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.

  • Sheet vs Drawer doctrine: <Sheet side="right"> (src/components/ui/sheet.tsx, Radix dialog) is the canonical side-panel for forms and previews on both desktop and mobile (w-3/4 ... sm:max-w-sm adapts naturally). Vaul <Drawer> (src/components/shared/drawer.tsx) is reserved for mobile-only bottom-sheet UX — currently just the MoreSheet nav (src/components/layout/mobile/more-sheet.tsx). If you need a side panel of any kind, use Sheet. Don't add new Vaul drawers without a mobile-bottom-sheet justification.

  • Inline editing pattern: detail pages (clients, yachts, companies, interests, residential clients/interests) use <InlineEditableField> (src/components/shared/inline-editable-field.tsx) for click-to-edit text/select/textarea fields and <InlineTagEditor> (src/components/shared/inline-tag-editor.tsx) for tag chips. Each entity exposes a PUT /api/v1/<entity>/[id]/tags endpoint backed by a set<Entity>Tags service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.

  • Notes (polymorphic across entity types): notes.service.ts dispatches across clientNotes, interestNotes, yachtNotes, companyNotes based on an entityType discriminator. <NotesList entityType="…" /> works for all four. companyNotes lacks an updatedAt column — the service substitutes createdAt so callers get a uniform shape.

  • Document folders: Per-port nestable tree (document_folders self-FK on parent_id; null parent = root). Documents and files carry a nullable folder_id (null = root). 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 every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in moveFolder walks the destination's ancestor chain.

    Three system roots (Clients/, Companies/, Yachts/) are auto-created on port init via ensureSystemRoots. Per-entity subfolders are created lazily on first auto-deposit / manual upload via ensureEntityFolder — concurrent callers race safely via the partial unique index uniq_document_folders_entity on (port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL. The chk_system_folder_shape CHECK pins the shape of system rows. Rename/move/delete on system_managed = true folders is rejected by assertNotSystemManaged (service-level, not DB-level). Entity rename auto-syncs the folder name via syncEntityFolderName; archive applies a (archived) suffix via applyEntityArchivedSuffix; hard-delete demotes (system_managed = false) + appends (deleted) via demoteSystemFolderOnEntityDelete.

    Auto-deposit on signing completion: handleDocumentCompleted resolves the owner via the Owner-wins chain (document.clientId ?? .companyId ?? .yachtId ?? interest.clientId), ensures the matching entity subfolder, and sets files.folder_id + the matching entity FK on the signed file row. Falls back to root when no owner is resolvable. (Notes: interests table has no companyId column, hence the chain's interest fallback omits it; interests.clientId is NOT NULL so an interest.yachtId tail branch — if added — would be structurally unreachable.)

    Aggregated projection: listFilesAggregatedByEntity / listInflightWorkflowsAggregatedByEntity walk the relationship graph from the requested entity (symmetric reach: Client ↔ Company via company_memberships filtered to active rows via isNull(end_date), ↔ Yacht via yachts.current_owner_type/id) and return results grouped by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT). Each group caps at 20 rows with a total for Show all (N). The files projection LEFT JOINs documents on signed_file_id to surface signedFromDocumentId per row — used by the UI's "view signing details" link. File-FK snapshot is the source of truth — historical files stay where they were filed even if the linked entity's relationships change. Defense-in-depth port_id filter at every join (per recommender precedent) — entry-point check alone is rejected. Completed workflows are hidden from folder views (listDocuments excludes status='completed' when folderId is set); the signed-PDF file surfaces in the Files section with a "view signing details" link to the workflow audit trail (via GET /api/v1/documents/[id]/signing-details).

    Hub UI: rebuilt around three render modes — HubRootView (no folder), EntityFolderView (system-managed entity subfolder, renders Signing-in-progress + Files via the aggregated projection), FlatFolderListing (any other folder). Sidebar shows lock markers on system folders and mutes archived entity folders. The signing-status tabs strip (in_progress / awaiting_them / etc.) was removed; folders are now the primary navigation.

    Permission gating: documents.view for read of folders + entity-aggregated listing; documents.manage_folders for create / rename / move / delete of user folders (system folders are immutable through the API entirely).

    Deploy: schema migration 0051_documents_hub_split.sql ships the columns; pnpm db:backfill:doc-folders (script scripts/backfill-document-folders.ts) runs after the migration and is idempotent (per-port pg_advisory_xact_lock).

  • Route handler exports: Next.js App Router route.ts files only allow specific named exports (GET|POST|…). Service-tested handler functions live in sibling handlers.ts files (e.g. src/app/api/v1/yachts/[id]/handlers.ts) and are imported by the colocated route.ts for withAuth(withPermission(...)) wrapping. Integration tests import from handlers.ts directly to bypass auth/permission middleware.

  • Multi-berth interest model: interest_berths is the source of truth for which berths an interest is linked to; interests.berth_id does not exist (dropped in migration 0029). Three role flags: is_primary (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), is_specific_interest (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), is_in_eoi_bundle (covered by the interest's EOI signature). Read/write through src/lib/services/interest-berths.service.ts helpers (getPrimaryBerth, getPrimaryBerthsForInterests, upsertInterestBerth, setPrimaryBerth, removeInterestBerth); never query interest_berths from outside that service.

  • Mooring number canonical format: ^[A-Z]+\d+$ (e.g. A1, B12, E18) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public /api/public/berths/[mooringNumber] route before any DB hit.

  • Public berths API: /api/public/berths (list) and /api/public/berths/[mooringNumber] (single) are the public-facing data feed for the marketing website. Output shape mirrors the legacy NocoDB Berths shape verbatim ("Mooring Number", "Side Pontoon", etc.) — see src/lib/services/public-berths.ts. Cache headers: s-maxage=300, stale-while-revalidate=60. Status mapping: "Sold" (berth.status=sold) > "Under Offer" (status=under_offer OR has any active interest_berths.is_specific_interest=true link with interests.outcome IS NULL) > "Available". The companion /api/public/health endpoint is dual-mode: anonymous callers get {status, timestamp} (uptime monitors, never 503); requests carrying a timing-safe-matched X-Intake-Secret (compared against WEBSITE_INTAKE_SECRET) get the full {status, env, appUrl, timestamp, checks: {db, redis}} payload and a 503 if any dependency is down. The website uses the authenticated form on startup so it refuses to start when its CRM_PUBLIC_URL points at a different deployment env.

  • Berth recommender: Pure SQL ranking (no AI). Lives in src/lib/services/berth-recommender.service.ts. Tier ladder A/B/C/D classifies each feasible berth based on its interest_berths aggregates. Heat scoring (recency / furthest stage / interest count / EOI count) only fires for tier B (lost/cancelled-only history); per-port admin tunes weights via system_settings keys (heat_weight_*, recommender_max_oversize_pct, recommender_top_n_default, fallthrough_policy, fallthrough_cooldown_days, tier_ladder_hide_late_stage). The recommender enforces multi-port isolation both at the entry point (rejects cross-port interest lookups) AND inside the SQL aggregates CTE (defense-in-depth i.port_id filter).

  • Berth rules engine: Per-port system_settings rules in src/lib/services/berth-rules-engine.ts. Seven triggers, all wired: eoi_sent, eoi_signed, deposit_received (invoices.ts), contract_signed (documents.service.ts), interest_archived / interest_completed (interests.service.ts), berth_unlinked (interest-berths.service.ts). Service callers fire evaluateRule(trigger, interestId, portId, meta) via dynamic import to avoid circular deps. Default modes vary (auto for state changes, suggest for recommendations, off for berth_unlinked); admins tune via berth_rules system_settings key. Webhook auto-advance pairs the rule with advanceStageIfBehind so the pipeline stage and berth status move together.

  • EOI bundle / range formatter: Multi-berth EOIs render the in-bundle berth set as a compact range string ("A1-A3, B5-B7") via formatBerthRange() in src/lib/templates/berth-range.ts. The output populates the existing Berth Number Documenso form field (single-berth output is byte-identical to the primary mooring, multi-berth shows the full range). CRM UI always shows berths as individual chips. The {{eoi.berthRange}} token is in VALID_MERGE_TOKENS for template body copy.

  • Pluggable storage backend: Code never imports MinIO/S3 directly. All file I/O goes through getStorageBackend() from src/lib/storage/. The StorageBackend interface requires put, get, head, delete, listByPrefix, presignUpload, presignDownload — any new backend must implement all seven. Configured via system_settings.storage_backend ('s3' | 'filesystem'). Switching backends is a settings change + pnpm tsx scripts/migrate-storage.ts run (the migrator round-trips every blob in files, berth_pdf_versions, brochure_versions, gdpr_exports and verifies SHA-256 — TABLES_WITH_STORAGE_KEYS populated in 9a5ba87; was no-op before). MinIO ops are wrapped in a 30s withTimeout to prevent TCP-blackhole worker stalls. Filesystem backend is single-node only: refuses to start when MULTI_NODE_DEPLOYMENT=true. Multi-node deployments must use the s3-compatible backend.

  • Per-berth PDFs: Versioned via berth_pdf_versions; berths.current_pdf_version_id always points to the latest active version. Storage key is UUID-based per upload (not version-numbered) so concurrent uploads can't collide on blob paths; pg_advisory_xact_lock per berth_id serializes the version-number allocation. 3-tier parser: AcroForm → OCR (Tesseract.js with positional heuristics) → optional AI (rep clicks "AI parse" only when OCR confidence is low). Magic-byte (%PDF-) check enforced on BOTH the in-server upload path AND the presigned-PUT path (the post-upload service streams the first 5 bytes via the storage backend). Mooring-number mismatch between PDF and target berth surfaces as a service-level ConflictError unless the apply call passes confirmMooringMismatch: true.

  • Brochures: Per-port; default brochure marked via is_default (enforced by partial unique index on (port_id) WHERE is_default=true AND archived_at IS NULL). Archived brochures retain version history. Same upload flow as berth PDFs (presign + magic-byte verification on the post-upload register endpoint).

  • Send-from accounts (sales send-outs): Configurable via system_settings; defaults to sales@portnimara.com for human-touch and noreply@portnimara.com for automation. SMTP/IMAP passwords are AES-256-GCM encrypted at rest; the API never returns decrypted secrets — only *PassIsSet boolean markers. Send-out audit goes to document_sends (separate from audit_logs because of volume + binary refs). Body markdown is XSS-safe via renderEmailBody() (escape-then-allowlist; tested against the standard XSS vector list). Rate limit: 50 sends/user/hour individual. Pre-send size threshold: files > email_attach_threshold_mb ship as a 24h signed-URL link rather than an attachment (avoids the duplicate-send race from async bounces). The download-link fallback HTML-escapes the filename to prevent injection from admin-supplied brochure names. Bounce monitoring requires IMAP credentials in addition to SMTP — without them, the size-rejection banner stays disabled.

  • NocoDB berth import: pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara re-imports from the legacy NocoDB Berths table. Idempotent: rows where updated_at > last_imported_at (the "human edited this since last import" guard) are skipped unless --force. Adds --update-snapshot to also rewrite src/lib/db/seed-data/berths.json. Uses pg_advisory_xact_lock so two simultaneous runs serialize. Pure helpers in src/lib/services/berth-import.ts are unit-tested.

  • Routes: Multi-tenant via [portSlug] dynamic segment. Typed routes enabled.

  • API response shapes: Conventional envelope is { data: <T> } for any endpoint that returns content (read OR write). Mutations that return nothing emit 204 No Content (new NextResponse(null, { status: 204 })). Don't use { success: true } for CRM mutations — it was a legacy pattern, normalized away in 2026-05-07. Public portal-auth endpoints are an exception: they return { success: true } because the frontend needs a non-error JSON body to chain on. List/paginated reads return { data: <T[]>, total?, hasMore? } (see /api/v1/clients for the shape). Errors always go through errorResponse(error) from @/lib/errors so request-id propagation and the audit-tier mapping stay uniform.

  • Body parsing: Always use parseBody(req, schema) from @/lib/api/route-helpers instead of await req.json(); schema.parse(body). The helper returns a uniform 400 with field-level errors that the frontend's toastError hook recognizes; raw req.json + schema.parse produces a generic 500 because the ZodError isn't caught in the same shape.

  • Pre-commit: Husky + lint-staged runs ESLint fix + Prettier on staged .ts/.tsx files. The hook also blocks .env* files (including .env.example) from being committed; pass them via a separate workflow if needed.

Schema migrations during dev

When you run a db:push or apply a migration via psql against a running dev server, restart the dev server afterwards. Drizzle/postgres.js keeps connection-level prepared statements that can hold stale column lists; a stale pool causes column X does not exist errors on pages that touch the migrated table even though the column is present in the DB. Symptom: pages return 500 with errorMissingColumn/42703 after a successful migration. Fix: kill next dev and restart it.

Environment

Copy .env.example to .env for local dev. See src/lib/env.ts for the full schema. Set SKIP_ENV_VALIDATION=1 to bypass validation (used in Docker build).

Required env gotchas:

  • DOCUMENSO_API_URLbare host only, never include /api/v1. The client appends versioned paths based on DOCUMENSO_API_VERSION (v1 for 1.13.x, v2 for 2.x). A double-pathed URL returns 404 on every call with no useful diagnostic.

Optional dev/test-only env vars (not in .env.example):

  • EMAIL_REDIRECT_TO=<address> — when set, every outbound email is rerouted to this address regardless of the requested recipient and the subject is prefixed with [redirected from <original>]. Dev safety net so seeded fake-client emails don't escape; must be unset in production.
  • IMAP_HOST / IMAP_PORT / IMAP_USER / IMAP_PASS — read by tests/e2e/realapi/portal-imap-activation.spec.ts to fetch the activation email from a real mailbox during the IMAP round-trip test. The spec skips when any are missing.

Testing

Five Playwright projects, defined in playwright.config.ts:

  • setup — global setup (seeds users, port, berths, system settings).
  • smoke — fast click-through over every major flow. Run on every change (~10 min, 125 specs).
  • exhaustive — deeper UI coverage that takes longer.
  • destructive — archive/delete/cancel paths against throwaway entities.
  • realapi — opt-in suite that hits real external services (Documenso send-side + IMAP round-trip). Requires DOCUMENSO_API_*, SMTP_*, IMAP_* env. Cloudflared tunnel needs to be running so Documenso can call the local webhook receiver.
  • visual — pixel-diff baselines for stable list/landing pages. Snapshots committed under tests/e2e/visual/snapshots.spec.ts-snapshots/. Regenerate with --update-snapshots after intentional UI changes.

Vitest covers unit + integration with mocked external services (tests/unit/, tests/integration/).

Docker

  • Dockerfile - Production multi-stage build (deps -> build -> runner)
  • Dockerfile.dev - Dev with bind-mounted source
  • Dockerfile.worker - BullMQ worker process
  • docker-compose.yml / docker-compose.dev.yml / docker-compose.prod.yml

Architecture docs

Numbered spec files in repo root (01-CONSOLIDATED-SYSTEM-SPEC.md through 15-DESIGN-TOKENS.md) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.

Domain-specific references:

  • docs/eoi-documenso-field-mapping.md — canonical mapping from EoiContext paths to the Documenso template's formValues keys, with the matching AcroForm field names used by the in-app pathway. The Berth Number field carries the formatBerthRange() output — single-berth EOIs populate it with just the primary mooring (e.g. A1), multi-berth EOIs with the compact range (A1-A3, B5). No separate Berth Range template field is needed (the dedicated field was retired 2026-05-14).
  • assets/README.md — what the in-app EOI source PDF must contain and how to override its path in dev/test.
  • docs/berth-recommender-and-pdf-plan.md — the comprehensive plan for the Phase 08 berth-recommender + PDF + send-outs work bundle. Single source of truth for the multi-berth interest model, recommender tier ladder, pluggable storage, per-berth PDF parser, and sales send-out flows.