359 lines
20 KiB
Markdown
359 lines
20 KiB
Markdown
|
|
# 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
|