Files
pn-new-crm/05-FINAL-ARCHITECTURE-DECISIONS.md
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00

20 KiB

Port Nimara CRM — Final Architecture Decisions

Compiled: 2026-03-11 Status: All decisions finalized and approved


Summary of Decisions

# Decision Choice
ADR-001 Database PostgreSQL + Drizzle ORM. NocoDB dropped entirely.
ADR-002 Framework Next.js (React) + REST API + Tailwind CSS
ADR-003 Authentication Better Auth (integrated). Keycloak dropped entirely.
ADR-004 E-Signatures Documenso (keep, self-hosted)
ADR-005 File Storage MinIO (keep, credentials moved to env vars)
ADR-006 Transactional Email Poste.io at mail.portnimara.com (existing infrastructure)
ADR-007 User Mailbox Email SMTP/IMAP with provider presets (Google Workspace, Outlook, Custom)
ADR-008 Real-time Socket.io (WebSocket alongside Next.js)
ADR-009 API Documentation OpenAPI/Swagger auto-generated from Zod schemas (public endpoints)
ADR-010 Job Queue BullMQ + Redis
ADR-011 PostgreSQL Hosting Docker container on same VPS, automated nightly backups to MinIO
ADR-012 Multi-tenancy Port-scoped data isolation with global + per-port role system

ADR-001: PostgreSQL + Drizzle ORM (NocoDB Dropped Entirely)

Status: Accepted

Context: NocoDB served as both database and admin UI via REST API. Caused: no transactions, no JOINs, no migrations, no type safety, N+1 queries, manual rollback code, data consistency bugs. Updating records was slow and cumbersome. The website berth map currently reads directly from NocoDB.

Decision: PostgreSQL as the single source of truth. Drizzle ORM for type-safe schema, migrations, and queries. NocoDB removed entirely — no shadow copy, no dual-write.

Migration path:

  • Website berth map reads from CRM's public REST API (GET /api/public/berths) instead of NocoDB
  • Interest registration from website posts to POST /api/public/interests
  • Spec sheet import (PDF/Excel) via AI-assisted parsing flow in the CRM admin
  • Export NocoDB data → transform → seed PostgreSQL → verify → decommission NocoDB

Consequences:

  • Every API endpoint rewritten against Drizzle ORM
  • Real transactions for multi-step operations (invoice creation, EOI generation, berth linking)
  • Proper foreign key constraints and referential integrity
  • Database migrations tracked in version control
  • AI-assisted berth spec import replaces manual NocoDB field editing

ADR-002: Next.js (React) + REST API + Tailwind CSS

Status: Accepted

Context: Evaluated four architecture options: Next.js + tRPC, Nuxt 3 + tRPC, SvelteKit + tRPC, and Next.js + REST. Development is AI-first (Claude Code / Codex writing ~90%+ of code). Self-hosted Docker deployment required. Real-time updates needed.

Decision: Next.js 15 (App Router) with REST API endpoints, Tailwind CSS styling with Port Nimara design tokens (see 15-DESIGN-TOKENS.md), and shadcn/ui as the component library foundation. Rich text editing via TipTap (headless, ProseMirror-based). See 14-TECHNICAL-DECISIONS.md for full dependency list.

Rationale:

  • React/Next.js has the largest AI training corpus — Claude Code and Codex produce the most reliable code
  • REST provides one consistent API pattern for both CRM frontend and external consumers (website berth map)
  • No tRPC means no dual-paradigm complexity (tRPC + REST coexisting)
  • Same REST endpoints serve CRM, website, and any future mobile app or integration
  • Largest component ecosystem of any framework
  • Self-hosting with standalone output mode + nginx reverse proxy is well-documented

Full stack:

Layer Technology
Framework Next.js 15 (App Router)
Language TypeScript (strict)
UI React 19
Styling Tailwind CSS + Maritime design tokens
Components shadcn/ui (Radix primitives, fully customizable) — confirmed
Rich text editor TipTap (headless, ProseMirror-based, extension architecture) — confirmed
API REST with Zod validation
Public API docs OpenAPI/Swagger auto-generated from Zod schemas
Real-time Socket.io (WebSocket)
Server state TanStack Query
UI state Zustand
ORM Drizzle ORM
Database PostgreSQL
Jobs BullMQ + Redis
Deployment Docker (node:20-alpine) + nginx reverse proxy

ADR-003: Better Auth (Keycloak Dropped)

Status: Accepted

Context: Keycloak worked but added a separate service to maintain, required redirect to external login page (disjointed UX), and the current implementation had critical security issues (dev bypass, weak internal auth). The CRM needs: integrated login page matching CRM styling, admin-created user accounts, email-based password set/reset, future 2FA support, multi-port tenancy with granular role permissions.

Decision: Better Auth running inside the Next.js app. No separate auth service. Keycloak decommissioned.

What Better Auth handles:

  • Password hashing (argon2/bcrypt)
  • Session cookies (httpOnly, secure, CSRF-protected)
  • Login/logout flows
  • Password set/reset via email (sent through Poste.io)
  • Future 2FA (TOTP plugin)

What the app handles (custom PostgreSQL tables):

  • Multi-port tenancy
  • Custom role definitions with granular permissions
  • Port-scoped role assignments
  • Global vs. per-port role overrides

User account lifecycle:

  1. Super admin creates user account in CRM admin panel
  2. Admin assigns user to one or more ports with specific roles
  3. System sends "Set your password" email via Poste.io
  4. User clicks link, sets password, can now log in
  5. Password resets follow the same email flow

Multi-tenancy data model:

users (managed by Better Auth)
├── id, email, password_hash, name, is_super_admin

ports
├── id, name, slug, settings

roles
├── id, name, permissions (JSON), is_global
├── Example: "Sales Manager" with {view_interests: true, delete_interests: false, export_expenses: true, ...}

port_role_overrides (optional per-port tweaks to global roles)
├── port_id, role_id, permission_overrides (JSON)
├── Example: Port B overrides "Sales Manager" to add {manage_expenses: true}

user_port_roles (who can access what)
├── user_id, port_id, role_id
├── Example: Sarah → Port Nimara → Sales Manager
├── Example: Sarah → Port B → Sales Manager
├── Example: Tom → Port Nimara → Read Only

Authorization flow:

  1. User logs in (Better Auth validates credentials, creates session)
  2. App checks user_port_roles — which ports can this user access?
  3. If multiple ports → show port switcher
  4. Every API request includes current port context (header or cookie)
  5. Every database query scoped to current port
  6. Permission check: load user's role at current port → check against role permissions (with port override if exists)
  7. Super admin bypasses port scoping — sees all data across all ports

ADR-004: Keep Documenso

Status: Accepted (unchanged from original ADR)

Decision: Keep Documenso (self-hosted). Consolidate two duplicate implementations into single EOIService. Normalize signature events as first-class database tables.


ADR-005: Keep MinIO

Status: Accepted (unchanged from original ADR)

Decision: Keep MinIO. Credentials moved to environment variables. Database-backed file metadata in PostgreSQL. Formalized storage namespaces. Also serves as backup destination for PostgreSQL dumps.


ADR-006: Transactional Email via Poste.io

Status: Accepted

Context: Existing Poste.io mail server at mail.portnimara.com, currently used for noreply@portnimara.com. No need for an external email service.

Decision: All system-generated email (password resets, EOI notifications, signature reminders, admin alerts) sent via Nodemailer pointed at Poste.io SMTP. Email templates extracted to proper template files (MJML or HTML with variable substitution).

Consequences:

  • No external email service dependency
  • No sending limits
  • Self-hosted infrastructure consistent with everything else
  • Need to ensure DKIM/SPF records are properly configured for deliverability

ADR-007: User Mailbox via SMTP/IMAP with Provider Presets

Status: Accepted

Context: Team uses Google Workspace for email. May switch to Outlook in the future. Need a provider-agnostic solution.

Decision: Generic SMTP/IMAP connection in CRM settings with provider presets:

Preset SMTP Server Port IMAP Server Port
Google Workspace smtp.gmail.com 587 (TLS) imap.gmail.com 993 (TLS)
Outlook / Microsoft 365 smtp.office365.com 587 (TLS) outlook.office365.com 993 (TLS)
Custom User-provided User-provided User-provided User-provided

User selects preset (auto-fills server/port), enters email + App Password, credentials stored encrypted in PostgreSQL. Switching providers is just changing preset and entering new credentials.

Future upgrade path: Google OAuth / Microsoft OAuth as alternative connection method (no App Password needed). Same underlying Nodemailer/IMAP code — only credential acquisition changes.


ADR-008: Real-time via Socket.io

Status: Accepted

Context: CRM needs live updates — berth status changes, interest updates, signature events visible to all connected users without page refresh.

Decision: Socket.io alongside Next.js. Events emitted server-side when data changes (berth linked → emit berth:statusChanged, interest updated → emit interest:updated). Client subscribes to relevant channels. Port-scoped — users only receive events for their current port.

Consequences:

  • Redis adapter for Socket.io enables multi-instance scaling
  • WebSocket connection established on app load, scoped to user's current port
  • Event-driven updates for: berth status, interest changes, EOI signature progress, new email threads

ADR-009: OpenAPI for Public API

Status: Accepted

Context: Website berth map and interest registration form need documented API endpoints. Future mobile apps or integrations may consume the same API.

Decision: Auto-generate OpenAPI/Swagger documentation from Zod schemas for public-facing endpoints. Available at /api-docs. Internal CRM endpoints documented optionally.

Setup: swagger-jsdoc or next-swagger-doc with Zod-to-OpenAPI bridge. Near-zero ongoing maintenance since docs stay in sync with validation schemas.


ADR-010: Job Queue via BullMQ + Redis

Status: Accepted (unchanged from original ADR)

Decision: BullMQ + Redis replaces Nitro experimental tasks and setInterval/node-cron. All background jobs (notification processing, signature polling, EOI reminders, email sync, currency refresh, database backups) run through BullMQ with proper tracking, retries, and dead letter queue.

Redis also serves as: Socket.io adapter (multi-instance), session cache, and general application cache.


ADR-011: PostgreSQL in Docker on Same VPS

Status: Accepted

Context: All services (MinIO, Documenso, Poste.io, CRM) run as Docker containers on a single VPS. Data volume is small (hundreds of berths, hundreds to low thousands of interests).

Decision: PostgreSQL as a Docker container in the same Docker Compose stack. Named volume for data persistence. Automated nightly backups via pg_dump cron job, backup files stored in MinIO.

Consequences:

  • Simplest deployment — same pattern as all other services
  • Named Docker volume survives container restarts and upgrades
  • MinIO backup provides off-disk redundancy
  • When multi-port grows to significant scale, can migrate to dedicated database server without application changes (just update connection string)

ADR-012: Multi-Port Tenancy

Status: Accepted

Context: Port Nimara CRM will expand to manage multiple marina ports. Each port has its own dataset (berths, interests, expenses, invoices). Users can be assigned to one or more ports with different roles at each.

Decision: Port-scoped data isolation at the database level. Every core table has a port_id foreign key. Every query filters by current port context. Role system supports global templates with per-port overrides.

Data scoping:

  • Every record (berth, interest, expense, invoice, file, audit log) has port_id
  • API middleware extracts current port from request context
  • All Drizzle queries include .where(eq(table.portId, currentPortId))
  • Super admin can query across ports (unified dashboard, future feature)

Role system:

  • Global roles defined once, available across all ports
  • Per-port overrides allow customization without duplicating role definitions
  • New ports inherit all global roles automatically
  • Admin at each port can assign users to roles within their port
  • Super admin manages global roles and cross-port user assignments

Final Technology Stack

Layer Current (v1) Rebuild (v2)
Framework Nuxt 3 (Vue 3) Next.js 15 (React 19)
UI Vuetify + Maritime CSS + Nuxt UI Tailwind CSS + shadcn/ui + Maritime tokens
Server State Mixed (1 Pinia store + refs + payload) TanStack Query
UI State Scattered refs Zustand
API ~100 mixed REST/RPC endpoints ~247 REST endpoints + Zod validation
Public API docs None OpenAPI/Swagger (auto-generated)
Real-time None Socket.io
Database NocoDB (REST API) PostgreSQL + Drizzle ORM
Auth Keycloak OIDC (external) Better Auth (integrated)
File Storage MinIO (hardcoded creds) MinIO (env vars, DB-backed metadata)
E-Signatures Documenso Documenso (consolidated EOIService)
Transactional Email Raw SMTP (mixed configs) Nodemailer → Poste.io
User Email IMAP/SMTP (dual impl, in-memory creds) IMAP/SMTP with provider presets (encrypted DB storage)
Email Templates Inline HTML strings MJML template files
PDF PDFKit + @pdfme @pdfme only
Jobs Nitro tasks + node-cron + setInterval BullMQ + Redis
Cache File-based + in-memory Redis
Icons Lucide + mdi Lucide only
Logging console.log Structured logger (pino or consola)
Testing None Vitest + Playwright
Deployment Docker + Gitea CI/CD (no tests) Docker + Gitea CI/CD (with test stage)
Multi-tenancy None Port-scoped with global + per-port roles
Spec Import Manual NocoDB editing AI-assisted PDF/Excel parsing

Docker Compose Services (Production)

services:
  crm-app        # Next.js (CRM frontend + API + Socket.io)
  postgres        # PostgreSQL database
  redis           # BullMQ jobs, Socket.io adapter, cache, sessions
  minio           # S3-compatible file storage (existing)
  documenso       # E-signature service (existing)
  nginx           # Reverse proxy, TLS termination, rate limiting

Existing services (separate from CRM stack):

  • Poste.io at mail.portnimara.com (transactional email)
  • Gitea (CI/CD, git hosting)

Build Phases (Revised)

Phase 1 — Foundation (weeks 1-2)

  1. Next.js 15 project with TypeScript strict mode
  2. PostgreSQL + Drizzle ORM with initial schema (ports, users, roles, permissions)
  3. Better Auth integration (login, logout, password set/reset, session management)
  4. Tailwind CSS with Maritime design tokens
  5. Multi-port middleware (port context in every request)
  6. Layout shell (sidebar, port switcher, mobile nav, routing)
  7. Redis + Socket.io setup

Phase 2 — Core CRUD (weeks 3-4) 8. Berth management (list, detail, status, specs) 9. Interest management (list, detail, create, edit, delete) 10. Berth-to-interest linking with auto-status rules 11. Role-based access control (permission checks on every endpoint + UI) 12. Public API for website berth map + interest registration 13. OpenAPI documentation for public endpoints

Phase 3 — Business Workflows (weeks 5-7) 14. EOI document generation (consolidated EOIService) + Documenso integration 15. Email composer + thread viewer (SMTP/IMAP with provider presets) 16. Expense management with TanStack Query 17. Invoice creation and payment tracking 18. File management (MinIO + DB-backed metadata) 19. AI-assisted berth spec sheet import (PDF/Excel)

Phase 4 — Admin & Operations (week 8) 20. Admin dashboard (user management, role builder, port management) 21. Audit logging (all domains, consistent) 22. Alert settings and monitoring dashboard 23. Reminder/notification system 24. BullMQ job dashboard

Phase 5 — Polish & Migration (weeks 9-10) 25. Data migration from NocoDB → PostgreSQL 26. Real-time events (Socket.io) for all live-update scenarios 27. Performance optimization (pagination, query optimization) 28. Testing suite (Vitest unit + Playwright E2E) 29. Security hardening (rate limiting, CSP headers, input sanitization) 30. Docker Compose production configuration with nginx