# 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)
