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>
25 KiB
Port Nimara CRM — Business Rules Specification
Compiled: 2026-03-11 Core Principle: The system never forces a workflow. All automated actions are convenience features that can be manually overridden by the salesperson at any time.
1. Berth Status Rules
BR-001: Configurable Berth Status Transition Rules
Berth status transitions are admin-configurable via a rules engine stored in system_settings (key: berth_status_rules, per-port). Each rule defines: a trigger action, a target status, and a mode.
Modes:
- auto — Status changes silently with an audit log entry. No user interaction needed.
- suggest — A toast/confirmation prompt appears: "Change berth B-12 status to Under Offer?" — salesperson accepts or dismisses. If dismissed, status stays unchanged.
- off — No status change, no prompt. The trigger is ignored.
Default rules (shipped out of the box, admin can modify):
| Trigger Action | Default Mode | Target Status | Notes |
|---|---|---|---|
| First active interest linked to berth | suggest | under_offer |
Only fires when berth is currently available |
| All active interests unlinked/archived from berth | suggest | available |
Only fires when berth is currently under_offer |
| EOI sent on any linked interest | auto | under_offer |
Sending an EOI signals real engagement |
| EOI fully signed on any linked interest | auto | under_offer |
Keeps under_offer (doesn't escalate yet) |
Deposit received (interest reaches deposit_10pct) |
suggest | sold |
Big moment — prompt to confirm |
Contract signed (interest reaches contract or completed) |
suggest | sold |
Final confirmation |
| Interest manually archived (sole link to berth) | suggest | available |
Same as unlink |
Admin configuration UI: Settings → Berth Status Rules page. Table with columns: Trigger, Mode (dropdown: auto/suggest/off), Target Status (dropdown: available/under_offer/sold). Admin can also add custom rules for future trigger types.
Rule evaluation order: Rules are evaluated in the order listed above. First matching rule wins. If multiple rules match the same action, only the first applies.
Manual override always available: Regardless of rule configuration, the salesperson can manually set any berth status at any time from the berth detail panel. Manual changes are never blocked by the rules engine — rules only govern automatic or suggested transitions.
Audit trail: Every status change (auto, suggested-and-accepted, or manual) is logged in the audit log with: old status, new status, trigger action (or "manual"), rule mode used, and the user who accepted/performed it.
BR-002: Berth Status on Website Map
- Public API serves berth status directly. Website map color-codes: green = available, orange = under_offer, red = sold.
- Status changes propagate to the website in real-time via the public API (no caching on berth status).
BR-003: Berth Tenure Expiration
- Trigger: Daily job checks all fixed-term berths
- Condition: Berth
tenure_end_dateis within configurable warning period (default: 6 months) - Action: Create notification for admin. If waiting list entries exist, create notifications for waiting list clients.
- On expiration: Berth does NOT auto-change status. Admin must manually decide next steps.
2. Interest Pipeline Rules
BR-010: Pipeline Stages
Ordered stages: open → details_sent → in_communication → visited → signed_eoi_nda → deposit_10pct → contract → completed
- All forward and backward transitions are allowed
- Salesperson can set any stage at any time (no enforced progression)
- Stage changes are logged in the audit log with before/after values
BR-011: Auto-Promote on Vessel Dimensions
- Trigger: Interest updated with yacht length, width, and draft values (all three populated)
- Condition: Current
lead_categoryisgeneral_interest - Action: Auto-promote
lead_categorytospecific_qualified - Override: Category can be manually changed back
BR-012: Auto-Stage on EOI Send
- Trigger: EOI document generated and sent via Documenso
- Action: Set
eoi_status=waiting_for_signatures, advancepipeline_stagetosigned_eoi_nda(if not already at or past that stage) - Override: Stage can be manually changed
BR-013: Auto-Stage on Manual EOI Upload
- Trigger: Manually uploaded signed EOI document
- Action: Set
eoi_status=signed, advancepipeline_stagetosigned_eoi_nda - Override: Stage can be manually changed
BR-014: Interest Archiving
- Archived interests are excluded from: active pipeline views, dashboard metrics, berth occupancy counts, auto-status calculations
- Archived interests remain searchable via archive view and global search
- Archiving an interest that is the sole link to a berth triggers BR-002 (berth status reset check)
3. EOI & Document Rules
BR-020: EOI Generation Prerequisites
Generation is blocked unless ALL of:
- Client has: full name, at least one email contact
- Interest has: yacht name, yacht length, yacht width, yacht draft
- At least one berth linked to the interest
- No existing manual/uploaded EOI documents on the interest (unless user explicitly overrides)
BR-021: EOI Signing Order
- Sequential 3-party signing: Client (order 1) → Developer (order 2) → Sales/Approver (order 3)
- Each signer receives notification only after the previous signer completes
- If any signer declines, the document is marked as
declinedand the interest'seoi_statusupdates accordingly
BR-022: EOI Completion
- Trigger: All signers have signed (Documenso
DOCUMENT_COMPLETEDevent) - Actions:
- Download completed signed PDF from Documenso
- Store in MinIO under client's EOI folder
- Email signed PDF to all three parties
- Set
eoi_status=signed - Timestamp
all_signed_notified_at
BR-023: Signature Reminders
- Gated by: per-interest
reminder_enabledtoggle AND system-wide cooldown window - Send window: configurable hours (e.g., 09:00-16:00 in port's timezone)
- Cooldown: minimum time between reminders for the same document (configurable, default 24h)
- Delivered via Poste.io transactional email
BR-024: Document Deduplication
- Documenso webhook events deduplicated via
signature_hashunique index ondocument_events - Prevents double-processing when webhooks are retried or fallback polling fires simultaneously
BR-025: Generic Document Signing
- Any document type (not just EOIs) can be sent through Documenso
- Same webhook infrastructure handles all document types
- Document type tag determines which pipeline auto-actions fire (only EOI type triggers pipeline stage changes)
4. Client & Duplicate Rules
BR-030: Website Registration Duplicate Check
- Trigger: New interest registration from website (
POST /api/public/interests) - Check: Does the submitted email match any existing
client_contactsentry wherechannel = 'email'? - Match found: Create new interest under existing client. Do NOT create new client. Notify salesperson of new interest on existing client.
- No match: Create new client + new interest. Run fuzzy duplicate check (BR-031).
BR-031: Fuzzy Duplicate Detection
- Trigger: New client created (manual or website)
- Matching criteria (scored):
- Same email across any contact entry: score 1.0 (this triggers auto-merge per BR-030)
- Same phone number: score 0.9
- Similar name (Levenshtein distance < 3) + same phone: score 0.8
- Similar name + similar address: score 0.7
- Threshold: Score ≥ 0.7 creates a duplicate alert for manual review
- Below threshold: No alert
BR-032: Client Merge
- Surviving client keeps all their own data
- Merged client's data fills in any blank fields on the surviving record
- All of the merged client's interests, notes, files, timeline entries, and relationships transfer to the surviving client
- The merge is logged in
client_merge_logwith full detail of which fields came from which record - The merged client record is deleted after transfer
- Merge is reversible from the audit log (super admin only)
BR-033: Notes Edit Window
- Notes (on clients or interests) are editable by the author for 15 minutes after creation
- After 15 minutes, notes are locked (
is_locked = true) - Locked notes cannot be edited or deleted (except by super admin via audit log revert)
5. Financial Rules
BR-040: Expense Currency Conversion
- All expenses store: original
amount+currency, plusamount_usd(converted) andexchange_rateused - Conversion performed at time of creation using current rate from
currency_ratestable - Primary business currency: USD
- Local currency: ECD (Eastern Caribbean Dollar)
- Common client currencies: EUR, GBP
- If rate unavailable, expense is saved without conversion and flagged for manual rate entry
BR-041: Invoice Auto-Numbering
- Format:
INV-YYYYMM-### - Sequential within each port per month
- Example: INV-202603-001, INV-202603-002
- Counter resets each month
BR-042: Invoice Net 10 Discount
- When
payment_terms=net10, apply 2% discount on subtotal - Discount calculated automatically on invoice creation/update
discount_pct= 2,discount_amount= subtotal × 0.02- Configurable: the 2% rate can be changed in system settings
BR-043: Parent Company Export
- Expense export for parent company includes:
- All expenses in selection with receipt images
- EUR subtotal (all expenses converted to EUR)
- 5% processing fee on the EUR subtotal
- Configurable: the 5% rate can be changed in system settings
BR-044: Invoice Overdue Detection
- Trigger: Daily job checks all invoices with status
sent - Condition:
due_date< today - Action: Update status to
overdue, create notification for invoice creator and admin
BR-045: Invoice-Expense Integrity
- When creating an invoice from expenses, both the invoice and each linked expense are updated in a single database transaction
- If any part fails, the entire operation rolls back
- Bidirectional link maintained via
invoice_expensesjunction table (no comma-separated IDs)
6. Notification Rules
BR-050: Notification Creation
Notifications are created by the system (not users). Events that trigger notifications:
| Event | Recipient(s) | Type |
|---|---|---|
| New website registration | Salesperson(s) at port | new_registration |
| Reminder due today | Reminder assignee | reminder_due |
| Reminder overdue | Reminder assignee + creator | reminder_overdue |
| EOI signer completed | Next signer + interest owner | eoi_signed |
| All signers completed | All signers + interest owner | eoi_completed |
| Duplicate client detected | All users with clients.create permission |
duplicate_alert |
| Invoice overdue | Invoice creator + admin | invoice_overdue |
| Berth became available (waiting list) | Waiting list clients (via email) + salesperson | waiting_list |
| Background job failure | Super admin | system_alert |
| Follow-up reminder auto-created | Interest owner / assigned salesperson | follow_up_created |
| Berth tenure expiring | Admin | tenure_expiring |
BR-051: Notification Preferences
- Each user can configure per-notification-type: in-app (on/off), email (on/off)
- System alerts to super admin are always delivered (cannot be suppressed)
- Defaults: all notification types on for both in-app and email
BR-052: Notification Cooldown
- Notifications of the same type for the same entity are throttled
- Default cooldown: 1 hour (configurable per notification type)
- Prevents notification spam for rapid successive events
7. Reminder & Calendar Rules
BR-060: Follow-Up Reminder (Auto-Generated)
- Trigger: Interest has
reminder_enabled = trueandreminder_daysset - Check: Has there been any activity (note, status change, email, call log) on this interest in the last
reminder_daysdays? - No activity: Create a CRM reminder "Follow up with {client name}" assigned to the interest's salesperson (marked
auto_generated = true), and create a notification - Activity found: Reset the timer. Check again in
reminder_daysdays. - Schedule: Checked hourly via BullMQ recurring job
- Google Calendar: Auto-generated follow-up reminders are NOT automatically pushed to Google Calendar (salesperson can manually toggle sync on each one)
BR-061: Google Calendar Sync
- Push: When a reminder is created/updated with
sync_to_calendar = true, create/update a Google Calendar event via the API. Store thegoogle_calendar_event_idon the reminder record. - Pull: Three sync triggers: (1) Background poll every 30 minutes for all connected users, (2) On user login if calendar is connected, (3) On navigation to any calendar-displaying page (dashboard, reminders, client detail) if last sync > 5 min ago. All syncs fetch upcoming events (next 14 days) and upsert into
google_calendar_cache. - Two-way detection: On each pull sync, if a CRM-pushed event was deleted or moved in Google Calendar, update the corresponding CRM reminder's
due_ator mark asdismissed. - Token refresh: If access token is expired, use refresh token to obtain a new one. If refresh fails (revoked), mark the connection as disconnected and notify the user.
- Scope: Calendar sync is per-user, not per-port. A user's calendar connection works across all ports they have access to.
BR-062: Reminder Snooze
- Action: Salesperson clicks "Snooze" on a reminder → selects snooze duration (1 hour, 4 hours, tomorrow, next week, custom)
- Effect: Reminder status changes to
snoozed,snoozed_untilis set. Reminder resurfaces aspendingwhen the snooze time passes. - Google Calendar: If synced, the Google Calendar event is updated to the new time.
8. Multi-Tenancy Rules
BR-070: Port Scoping
- Every database query for port-scoped tables MUST include
WHERE port_id = :currentPortId - Middleware extracts current port from session/header and injects into request context
- Drizzle query builder enforced via wrapper functions:
withPortScope(query, portId) - Missing port context = 400 error (never fall through to unscoped query)
BR-071: Super Admin Bypass
- Super admin (
is_super_admin = true) can:- Query across all ports (omit port_id filter)
- Access any port's admin panel
- Manage global roles and system settings
- View unified cross-port dashboard
- Still respects audit logging (all super admin actions logged)
BR-072: Port Switcher & Single-Port Mode
- Single-port mode: When only 1 active port exists, all multi-port UI is hidden (port switcher, port columns in tables, port selection on entity forms, port filter dropdowns). Port context set automatically behind the scenes.
- Multi-port mode: When 2+ active ports exist, users with roles at multiple ports see a port switcher. Switching port changes all data context immediately.
- Mode is derived dynamically from
SELECT count(*) FROM ports WHERE is_active = true— no manual toggle needed. - Current port stored in session, not URL (avoid URL manipulation)
- Port switch logged in audit log
BR-073: New Port Setup
- Creating a new port auto-inherits: all global roles, all global system settings
- Port-specific overrides start empty (inherit globals until overridden)
- Onboarding wizard guides: port details → berth import → user assignment → branding
9. Audit & Undo Rules
BR-080: Audit Log Completeness
- Every create, update, delete, archive, restore, merge, login, logout action is logged
- Update logs include: field name, old value (JSON), new value (JSON)
- No exceptions — even bulk operations log individual changes
BR-081: Undo Eligibility
- Undoable: Field value changes, status changes, berth link/unlink, tag changes, role changes
- Not undoable: File deletions (file removed from storage), sent emails, signed documents, completed Documenso flows
- Undo creates a new audit entry with
revert_ofpointing to the original change - Undo only available to super admin
BR-082: Audit Retention
- Audit logs are never automatically deleted
- Exportable as CSV for archival
- Future: configurable retention policy (archive logs older than X years)
10. File Management Rules
BR-090: Client-Grouped Storage
- Files uploaded via a client context are stored in
/clients/{client_id}/{category}/ - Category determined by upload context (EOI page → eoi folder, expense → receipts folder)
- Files uploaded without client context go to
/port/{port_id}/general/
BR-091: File Deletion
- File deletion removes: MinIO object +
filestable entry - Deletion logged in audit log (with filename and path for reference)
- Files referenced by other records (e.g., receipt on an expense, PDF on an invoice) cannot be deleted until the reference is removed
BR-092: S3 Migration
- One-time migration job: scan all existing MinIO files → attempt to match to clients → propose reorganization
- Uncertain matches flagged for manual review
- Migration runs as a BullMQ job with progress tracking
- Rollback: original paths stored in migration log, can undo file moves
11. Search Rules
BR-100: Global Search Scope
- Searches: client full name, company name, email, phone, yacht name; berth mooring number, area; interest pipeline stage; document titles; invoice numbers; expense establishment names; note content
- Results scoped to current port (unless super admin doing cross-port search)
- Archived records included in results but marked as archived
- Results ranked by relevance with entity type grouping
BR-101: Duplicate Search on Create
- Before creating a new client, the create form runs a real-time search to show potential matches
- Uses same fuzzy matching as BR-031 but displays as suggestions, not blocks
- User can choose to proceed with creation or select an existing client
12. Webhook Rules
BR-110: Webhook Delivery
- Webhooks fire asynchronously via BullMQ (do not block the triggering operation)
- Retry: 3 attempts with exponential backoff (1s, 10s, 100s)
- After 3 failures: move to dead letter queue, create system alert
- Payload signed with HMAC-SHA256 using the webhook's secret (header:
X-CRM-Signature)
BR-111: Webhook Events
Events that can trigger webhooks:
client.created, client.updated, interest.created, interest.updated, interest.stage_changed, berth.status_changed, document.signed, document.completed, invoice.created, invoice.paid, expense.created, registration.new
BR-112: Webhook Port Scoping
- Webhooks are port-scoped: each webhook belongs to a specific port and only fires for events within that port
- Super admin cross-port actions trigger the webhook for the port where the data resides (not the super admin's "home" port)
- Webhook
port_idis set on creation and cannot be changed (create a new webhook for a different port)
13. Scheduled Report Rules
BR-120: Report Generation
- Reports generated as PDF files via BullMQ scheduled job
- Date range automatically calculated: daily (last 24h), weekly (last 7 days), monthly (last calendar month)
- Generated PDFs stored temporarily in MinIO, attached to email, then cleaned up after 7 days
- If generation fails, retry once, then create system alert
BR-121: Report Delivery
- Sent via Poste.io to all configured recipients
- Both CRM users (by user ID → email) and external email addresses supported
- Delivery failures logged but do not prevent delivery to other recipients
14. Form & Credential Rules
BR-130: Form Expiry
- Form submission tokens have a configurable expiry (default: 7 days)
- Trigger: Hourly background job checks
form_submissionsforstatus = 'pending'ANDexpires_at < now() - Action: Set status to
expired. Expired forms return a friendly "this link has expired" page. - Salesperson can regenerate a new form link for the same client/interest
BR-131: Email Account Credential Security
- User email credentials encrypted with AES-256-GCM before storage in
email_accounts.credentials_enc - Encryption key stored in environment variable (never in database or source code)
- Credentials decrypted only at point of use (SMTP/IMAP connection), never logged or exposed in API responses
- On credential update, old credentials are overwritten (not versioned)
- Failed IMAP/SMTP connection after 3 consecutive attempts disables the account and notifies the user
BR-132: Custom Field Validation
- Required custom fields are enforced on entity create/update
- Select-type fields validate against defined options
- Deleting a custom field definition also deletes all corresponding values (cascade)
- Custom fields are included in export operations
BR-133: Milestone Auto-Population
- When a document of type
eoiis sent: auto-setinterest.date_eoi_sent(if not already set) - When a document of type
eoiis completed (all signed): auto-setinterest.date_eoi_signed - When a document of type
contractis sent: auto-setinterest.date_contract_sent - When a document of type
contractis completed: auto-setinterest.date_contract_signed - All auto-populated dates are overridable by manual edit
BR-134: Waiting List Priority Notification Order
- When a berth becomes available (status changes to
availablefromunder_offerorsold):- High-priority waiting list clients are notified first
- Within same priority level, notify in position order (first in line first)
- Notifications staggered: 1 hour delay between each client notification to allow first responders to claim
- First client to respond and confirm interest gets the berth linked (salesperson confirms manually)
15. Document Template Rules
BR-140: Merge Field Resolution
- When generating a document from a template, all
{{entity.field}}tokens are resolved against the provided context (client, interest, berth, port) - Missing required context (e.g., template references
{{berth.mooring_number}}but no berth provided) → return validation error listing missing data - Optional merge fields that resolve to null/empty are replaced with empty string (not left as raw tokens)
- Merge fields support nested access:
{{client.contacts.primary_email}},{{interest.berth.mooring_number}}
BR-141: Template Management
- Only users with
admin.manage_formspermission can create, edit, or delete templates - All users with
documents.createpermission can generate documents from templates - Deleting a template does NOT delete previously generated documents — they are standalone files
- Templates are port-scoped (each port has its own template library)
BR-142: Generated Document Storage
- Documents generated from templates are stored as PDF files in MinIO under the client's correspondence folder
- A
documentstable entry is created withdocument_type = 'template_generated'and reference to the source template - Generated documents appear in the client's file list and document history
16. Record PDF Export Rules
BR-150: PDF Branding
- All record export PDFs include the port's logo and primary color from
portssettings - If no logo configured, a text-only header with the port name is used
- PDF layout is consistent across all record types (header, content sections, footer with generation timestamp)
BR-151: PDF Content Scope
- Client PDF: includes all non-archived data — contacts, vessel details, linked interests with current status, recent 20 activity entries, files list (names only, not embedded)
- Berth PDF: includes full spec sheet data, current status, pricing in all configured currencies, linked interests summary, maintenance log summary
- Interest PDF: includes client info, berth info, pipeline stage, EOI/contract status, all milestones, all notes, recent 20 timeline entries
- Custom fields are included in exports for the relevant entity type
BR-152: PDF Generation
- PDFs generated server-side via @pdfme (same library used for EOI generation)
- Generation runs synchronously for single-record exports (no background job needed — fast enough)
- If generation fails, return error to user (no silent failure)