# 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_date` is 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_category` is `general_interest` - **Action:** Auto-promote `lead_category` to `specific_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`, advance `pipeline_stage` to `signed_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`, advance `pipeline_stage` to `signed_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: 1. Client has: full name, at least one email contact 2. Interest has: yacht name, yacht length, yacht width, yacht draft 3. At least one berth linked to the interest 4. 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 `declined` and the interest's `eoi_status` updates accordingly ### BR-022: EOI Completion - **Trigger:** All signers have signed (Documenso `DOCUMENT_COMPLETED` event) - **Actions:** 1. Download completed signed PDF from Documenso 2. Store in MinIO under client's EOI folder 3. Email signed PDF to all three parties 4. Set `eoi_status` = `signed` 5. Timestamp `all_signed_notified_at` ### BR-023: Signature Reminders - Gated by: per-interest `reminder_enabled` toggle 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_hash` unique index on `document_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_contacts` entry where `channel = '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_log` with 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`, plus `amount_usd` (converted) and `exchange_rate` used - Conversion performed at time of creation using current rate from `currency_rates` table - 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_expenses` junction 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 = true` and `reminder_days` set - **Check:** Has there been any activity (note, status change, email, call log) on this interest in the last `reminder_days` days? - **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_days` days. - **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 the `google_calendar_event_id` on 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_at` or mark as `dismissed`. - **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_until` is set. Reminder resurfaces as `pending` when 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_of` pointing 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 + `files` table 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_id` is 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_submissions` for `status = 'pending'` AND `expires_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 `eoi` is sent: auto-set `interest.date_eoi_sent` (if not already set) - When a document of type `eoi` is completed (all signed): auto-set `interest.date_eoi_signed` - When a document of type `contract` is sent: auto-set `interest.date_contract_sent` - When a document of type `contract` is completed: auto-set `interest.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 `available` from `under_offer` or `sold`): - 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_forms` permission can create, edit, or delete templates - All users with `documents.create` permission 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 `documents` table entry is created with `document_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 `ports` settings - 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)