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

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

505 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)