# Phase B — Insights, Alerts, and Operational Awareness

**Status:** Draft — awaiting review
**Date:** 2026-04-28
**Phase:** B of D (A = Documents hub + visual polish ✓ shipped; C = Website integration; D = Pre-prod ops)

## Overview

Phase A made the CRM look polished and finished the documents/signing surface. Phase B turns it into a tool that _tells operators what's happening_ — instead of forcing them to navigate every list to find pipeline drift, expiring documents, or stalled reservations. It also closes the seven highest-priority Nuxt→Next gaps the 2026-04-28 audit surfaced (analytics, berth-interests, EOI queue, OCR, alerts, audit log, expense dedup).

The product story changes from "system of record" to "system of attention." Operators land on the dashboard and immediately see what needs them today — not a flat list they have to filter.

## Scope boundaries

### In scope (this spec)

- **Analytics dashboard** — chart-driven KPI page replacing the current 4-tile placeholder; pipeline funnel, occupancy timeline, revenue breakdown, lead-source attribution, with date-range and per-port filters
- **Alert framework** — rule engine that evaluates conditions on a schedule and surfaces actionable cards (alerts) in the dashboard's right rail; dismissible per-user; deep-links into the offending entity
- **Interests-by-berth view** — `/[port]/berths/[id]/interests` panel showing every interest targeting a berth, sortable by stage/score/age
- **Expense duplicate detection** — heuristic match on (vendor + amount + date ± 3 days); surfaces in expense detail with "Merge" action; background scan on new expense
- **EOI queue** — saved-view filter on the existing documents hub for `documentType='eoi' AND status IN ('sent','partially_signed')`, surfaced as a hub tab and a dashboard alert link
- **OCR for expense receipts** — Claude Vision integration on the existing `/expenses/scan` route to extract vendor, amount, date, currency, line items from uploaded receipts; user confirms before save
- **Audit log read view** — admin-gated UI for the existing `audit_logs` table with filters (user, action, entity type, date range, entity id search) and per-port + global (super-admin) scopes

### Explicitly out of scope (deferred to later phases)

- Custom user-defined alert rules (Phase B v1 ships with a fixed catalog of ~10 rules; user-rule creation deferred to Phase D)
- Real-time alert push notifications (only socket-fired updates of the alert list; SMS/email push deferred)
- Alert grouping / digests (each alert is its own card)
- Predictive analytics, ML scoring (separate from existing AI feature flag)
- Cross-port roll-up dashboards for super-admins (per-port only in v1)
- Full audit-log retention / archival policy (Phase D)
- OCR for PDF receipts (only image formats: jpg/png/heic; PDF expense uploads bypass OCR and stay manual until Phase D)
- Excel/CSV import for bulk expense backfill
- Country / phone / timezone work (separate cross-cutting agenda at `2026-04-28-country-phone-timezone-design.md`)

## Information architecture

### URL surface

```
/[port]/dashboard               replaces existing; analytics-driven
/[port]/insights                deep-link analytics page (charts only, no alerts)
/[port]/alerts                  full alert list (admin filter, dismissed history)
/[port]/berths/[id]/interests   new tab on berth detail
/[port]/expenses/scan           extend existing route with Claude Vision OCR
/[port]/admin/audit             admin-gated audit log viewer
/[port]/documents               extended: 'EOI queue' tab pre-filters to EOI in flight
```

### Schema deltas

```sql
-- alerts: surfaces operational warnings the user should act on
CREATE TABLE alerts (
  id              text PRIMARY KEY DEFAULT generate_id('alrt'),
  port_id         text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
  rule_id         text NOT NULL,                      -- 'reservation.no_agreement', 'interest.stale', ...
  severity        text NOT NULL,                      -- 'info' | 'warning' | 'critical'
  title           text NOT NULL,
  body            text,
  link            text NOT NULL,                      -- relative path the card deep-links to
  entity_type     text,                                -- optional FK target ('interest', 'reservation', ...)
  entity_id       text,
  fingerprint     text NOT NULL,                      -- hash of (rule_id + entity_type + entity_id) — dedupe
  fired_at        timestamptz NOT NULL DEFAULT now(),
  dismissed_at    timestamptz,
  dismissed_by    text REFERENCES users(id),
  acknowledged_at timestamptz,                         -- "I'm on it" without dismissing
  acknowledged_by text REFERENCES users(id),
  resolved_at     timestamptz,                         -- auto-set when underlying condition clears
  metadata        jsonb DEFAULT '{}'                   -- per-rule extras (e.g. days_stale, amount_at_risk)
);
CREATE UNIQUE INDEX idx_alerts_fingerprint_open ON alerts (port_id, fingerprint) WHERE resolved_at IS NULL;
CREATE INDEX idx_alerts_port_fired ON alerts (port_id, fired_at DESC);
CREATE INDEX idx_alerts_port_severity_open ON alerts (port_id, severity) WHERE resolved_at IS NULL AND dismissed_at IS NULL;

-- expense duplicate detection (column-only, no new table)
ALTER TABLE expenses ADD COLUMN duplicate_of text REFERENCES expenses(id);
ALTER TABLE expenses ADD COLUMN dedup_scanned_at timestamptz;
CREATE INDEX idx_expenses_dedup ON expenses (port_id, vendor_name, amount, expense_date)
  WHERE duplicate_of IS NULL;

-- analytics support: materialized refresh tracking (avoids recomputing on every dashboard hit)
CREATE TABLE analytics_snapshots (
  port_id     text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
  metric_id   text NOT NULL,                       -- 'pipeline_funnel.30d', 'occupancy_timeline.90d', ...
  computed_at timestamptz NOT NULL DEFAULT now(),
  data        jsonb NOT NULL,
  PRIMARY KEY (port_id, metric_id)
);

-- audit_logs already exists; add a tsvector column for fast search
ALTER TABLE audit_logs ADD COLUMN search_text tsvector
  GENERATED ALWAYS AS (
    to_tsvector('simple',
      coalesce(action, '') || ' ' ||
      coalesce(entity_type, '') || ' ' ||
      coalesce(entity_id::text, '') || ' ' ||
      coalesce(actor_email, ''))
  ) STORED;
CREATE INDEX idx_audit_search ON audit_logs USING gin(search_text);

-- ocr extracted fields on receipt files (most fields already on expenses)
ALTER TABLE expenses ADD COLUMN ocr_status text DEFAULT 'pending'; -- 'pending'|'ok'|'failed'|'low_confidence'
ALTER TABLE expenses ADD COLUMN ocr_raw jsonb;                      -- the model's full response
ALTER TABLE expenses ADD COLUMN ocr_confidence numeric;             -- 0..1
```

After running migration on dev/staging, restart `next dev` to flush postgres.js prepared-statement cache (project convention).

### Service-layer changes

**New services:**

- `alerts.service.ts` — CRUD + fanout: `evaluateRules(portId)`, `dismissAlert(id, userId)`, `acknowledgeAlert(id, userId)`, `resolveStaleAlerts(portId)`
- `alert-rules.ts` — fixed catalog of evaluator functions, each takes `(portId, db)` and returns `Array<{ rule_id, severity, fingerprint, ... }>`
- `analytics.service.ts` — `getPipelineFunnel(portId, range)`, `getOccupancyTimeline(portId, range)`, `getRevenueBreakdown(portId, range)`, `getLeadSourceAttribution(portId, range)`; reads `analytics_snapshots` first, recomputes if stale
- `analytics-snapshot-job.ts` — BullMQ recurring job that recomputes snapshots every 15 min per port
- `expense-dedup.service.ts` — `scanForDuplicates(expenseId)`, returns candidate matches with confidence; called from BullMQ on `expense:created`
- `expense-ocr.service.ts` — Claude Vision wrapper: takes file URL, returns parsed expense fields; uses prompt caching for the system prompt to keep cost down
- `audit-search.service.ts` — wraps drizzle query with tsvector match + filters

**Extended services:**

- `documents.service.ts` — adds `getEoiQueueRows(portId, opts)` that joins documents + signers + last-reminder for the EOI queue tab
- `expenses.service.ts` — `createExpense` triggers OCR + dedup BullMQ jobs after row insert
- `notifications.service.ts` — fires `alert:created` and `alert:resolved` socket events

### Alert rule catalog (v1)

| Rule ID                      | Severity | Trigger                                                                                      | Resolves when                                | Why it matters               |
| ---------------------------- | -------- | -------------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------- |
| `reservation.no_agreement`   | warning  | active reservation > 3d old without a `reservation_agreement` doc in any non-cancelled state | doc reaches `sent`                           | flagged in Phase A spec      |
| `interest.stale`             | info     | `pipelineStage IN ('details_sent','in_communication','visited')` AND last activity > 14d     | activity timestamp updates                   | dropped leads                |
| `document.expiring_soon`     | warning  | `expires_at` within 7 days, `status IN ('sent','partially_signed')`                          | doc completed/cancelled or expires_at passes | nudge before contracts lapse |
| `document.signer_overdue`    | warning  | signer pending > 14d AND last reminder > 7d ago                                              | signer signs/declines                        | classic chase target         |
| `berth.under_offer_stalled`  | info     | berth `status='under_offer'` > 30d                                                           | status changes                               | reservation never closed     |
| `expense.duplicate`          | info     | `expense.duplicate_of IS NOT NULL`                                                           | merged or marked-not-duplicate               | bookkeeping cleanup          |
| `expense.unscanned`          | info     | expense with file but `ocr_status='pending'` > 1h                                            | `ocr_status='ok'`                            | OCR failed silently          |
| `interest.high_value_silent` | critical | `leadCategory='hot_lead'` AND last activity > 7d                                             | activity update                              | revenue at risk              |
| `eoi.unsigned_long`          | warning  | EOI doc `status='sent'` > 21d                                                                | doc completed/cancelled                      | EOI funnel leak              |
| `audit.suspicious_login`     | critical | >3 failed logins from same IP in 1h                                                          | manual dismiss                               | security awareness           |

Rules are pure functions; the engine takes their outputs, upserts on `(port_id, fingerprint)` to avoid spam, and auto-resolves alerts whose rule no longer fires.

## Per-feature design

### Analytics dashboard

Replaces the current 4-tile dashboard. Layout:

```
[ Gradient PageHeader: "Dashboard" · last-updated stamp · Date range picker (Today / 7d / 30d / 90d / custom) ]

[ KPI row (4 KPITiles, sparkline + delta vs prior period):
    Total Clients      Active Interests     Pipeline Value     Occupancy Rate
]

[ Pipeline funnel (recharts FunnelChart):                      | Alert rail (right column):
    horizontal bars per stage with conversion %                |   Critical (red) cards
    click bar → filtered interests list                        |   Warning (amber) cards
                                                                |   Info (blue) cards
                                                                |   "Show dismissed" toggle
]                                                              |

[ Revenue breakdown (recharts BarChart, stacked by source) ]   | (continues)

[ Occupancy timeline (recharts AreaChart, daily/weekly) ]      |

[ Lead source attribution (recharts PieChart with legend) ]
```

Charts are server-rendered via the recharts already-in-bundle. Data comes from `analytics.service.ts` which reads `analytics_snapshots` (refreshed every 15 min by cron) — first hit warms the cache, subsequent hits are sub-100ms.

Date-range picker re-runs `analytics.service` queries with the selected range; cache key includes the range so 30d and 90d don't fight.

Export: each chart card has a `[...]` overflow menu with "Download as CSV" and "Download as PNG"; uses recharts' `getDataUrl()` for PNG.

### Alert rail

Right column on `/dashboard`, full page at `/alerts`. Each alert is a card:

```
[severity-color stripe-left]
  [rule-icon] Title (entity name)
  Body — body text describing the condition
  Last fired N days ago · entity: link
  [Acknowledge] [Dismiss] [Open →]
```

- Acknowledge: marks `acknowledged_at` but stays visible (someone's on it)
- Dismiss: hides from the rail; appears in `/alerts` "Dismissed" tab
- Auto-resolve: when the rule re-evaluates and the condition no longer fires, alert moves to "Resolved" history

Real-time: socket emits `alert:created` / `alert:resolved` from the cron worker; React Query invalidates the alert list.

### Interests-by-berth view

New tab on `/[port]/berths/[id]` called "Interests" — count badge in tab.

```
[ Berth header (existing) ]

[ Tabs: Overview | Reservations | Interests (N) | Notes | Files | Activity ]

[ Interests tab body:
    [Filter: All stages | Active only | Lost] [Sort: Newest | Stage progress | Lead score]
    Table: client name | stage pill | source | category | last activity | score badge
    Click row → interest detail
]
```

Pure read; no mutations. The list filters interests where `interest.berthId = berth.id`. Already exists in DB; just needs the UI tab.

### Expense duplicate detection

When a new expense is created, BullMQ job `expense.dedup` runs:

```ts
async function scanForDuplicates(expenseId: string) {
  const e = await db.query.expenses.findFirst({ where: eq(expenses.id, expenseId) });
  const candidates = await db.query.expenses.findMany({
    where: and(
      eq(expenses.portId, e.portId),
      eq(expenses.vendorName, e.vendorName),
      eq(expenses.amount, e.amount),
      between(expenses.expenseDate, addDays(e.expenseDate, -3), addDays(e.expenseDate, 3)),
      ne(expenses.id, e.id),
    ),
  });
  if (candidates.length > 0) {
    await db
      .update(expenses)
      .set({ duplicate_of: candidates[0].id, dedup_scanned_at: new Date() })
      .where(eq(expenses.id, expenseId));
    // fires `expense.duplicate` alert via rule engine on next sweep
  }
}
```

Detail page: when `duplicate_of` is set, show a yellow banner: "Looks like a duplicate of {linked expense}. [Merge them] [Mark as not duplicate]". Merge: deletes the new expense and merges any line items into the original.

### EOI queue tab

Documents hub gets a new tab between "Awaiting them" and "Awaiting me":

```
Tabs: All | EOI queue (N) | Awaiting them | Awaiting me | Completed | Expired
```

`EOI queue` filters: `documentType='eoi' AND status IN ('sent','partially_signed')`. Same row chrome as the rest of the hub. Bulk-action bar adds an "EOI bulk reminder" preset that respects the rule engine's reminder cooldown.

### OCR for expense receipts

Existing `/expenses/scan` route — extend to call Claude Vision on upload:

```ts
// expense-ocr.service.ts (uses Anthropic SDK; already in deps)
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();

const SYSTEM_PROMPT = `You extract structured expense data from receipts...
Output JSON: { vendor, amount, currency, date (ISO), lineItems: [...], confidence (0-1) }
`; /* cached via ephemeral cache_control for cost savings */

export async function ocrReceipt(fileUrl: string) {
  const file = await fetch(fileUrl);
  const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');

  const message = await client.messages.create({
    model: 'claude-haiku-4-5-20251001', // haiku for cost; sonnet if quality needed
    max_tokens: 1024,
    system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
    messages: [
      {
        role: 'user',
        content: [
          { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
          { type: 'text', text: 'Extract expense fields from this receipt.' },
        ],
      },
    ],
  });

  return parseAndValidate(message.content[0].text);
}
```

UI: existing scan page now shows a 3-step flow:

1. Upload receipt photo
2. Wait for OCR (spinner; ~3s avg with Haiku)
3. Confirm extracted fields (pre-filled form, user can edit)
4. Save → existing expense create flow

Low-confidence (< 0.6) extractions show a yellow banner "Please verify all fields" and pre-select the file uploader.

### Audit log read view

Admin route `/[port]/admin/audit`:

```
[ PageHeader: "Audit Log" · "Last 30 days · 12,847 events" ]

[ Filter row:
    Search [tsvector]   Actor [combobox of users]   Action [pills]   Entity type [select]
    Date range [picker]   Severity [pills]   [Reset]
]

[ Table:
    Timestamp | Actor | Action | Entity | Diff button | IP | User-agent
    Click row → expand to show before/after JSON diff
]

[ Pagination · Export CSV button (admin-gated) ]
```

Server-side: `audit-search.service.ts` builds a drizzle query with the tsvector match + filters; supports cursor pagination on `(created_at, id)`.

Super-admin sees a port toggle that switches between current port and "All ports" view.

## Test plan

### Unit (`tests/unit/`)

- `alert-rules-evaluators.test.ts` — each rule tested with seeded data; covers fire/no-fire cases and resolution conditions
- `expense-dedup-heuristic.test.ts` — vendor/amount/date matching with edge cases (case-insensitive, ±3d window, currency mismatch ignored)
- `analytics-pipeline-funnel.test.ts` — funnel math against fixture interests
- `analytics-occupancy-timeline.test.ts` — daily aggregation against fixture berth status changes
- `audit-search-filters.test.ts` — tsvector + filter composition
- `ocr-prompt-caching.test.ts` — assert cache_control presence on system prompt; mocked Claude response

### Integration (`tests/integration/`)

- `alerts-engine.test.ts` — full evaluation cycle: seed conditions, run engine, assert correct alerts upserted, run again to assert dedupe via fingerprint, mutate state, assert auto-resolve
- `analytics-snapshot-refresh.test.ts` — recurring job: snapshot row written, served from cache on next read, refreshed on next tick
- `expense-dedup-flow.test.ts` — create A, create matching B, assert B.duplicate_of=A; merge B → A absorbs line items, B archived
- `audit-search-tsvector.test.ts` — seed audit_logs, query by free-text, assert returned ids
- `eoi-queue-listing.test.ts` — extends documents-hub test; assert EOI tab returns correct subset

### E2E smoke (`tests/e2e/smoke/`)

- New `27-analytics-dashboard.spec.ts` — dashboard renders charts; date-range picker re-renders; KPI tiles show non-zero data after seed
- New `28-alerts.spec.ts` — alert appears after seeding stale-interest condition; click-to-deep-link; dismiss persists; resolve hides
- New `29-interests-by-berth.spec.ts` — tab visible on berth detail; lists interests; sort works
- New `30-expense-dedup.spec.ts` — create two matching expenses; banner appears; merge button works
- New `31-ocr-flow.spec.ts` — uploads fixture receipt image; extracted fields pre-filled; user can edit and save
- New `32-audit-log.spec.ts` — admin page loads; search by entity id returns expected row; date filter narrows
- Extend `04-documents.spec.ts` — EOI queue tab presence + count badge

### E2E exhaustive (`tests/e2e/exhaustive/`)

- `15-analytics-dashboard.spec.ts` — crawl every chart's hover tooltips, legend toggles, export menu
- `16-alerts.spec.ts` — crawl alert card actions, severity filters, dismissed history, real-time arrival via socket emit
- `17-audit-log.spec.ts` — crawl filter combos, expand row diffs, super-admin all-ports toggle

### E2E real-API (`tests/e2e/realapi/`)

- New `claude-vision-receipt-ocr.spec.ts` — gates on `ANTHROPIC_API_KEY`; uploads two real fixture receipts (one clean, one blurry); asserts Haiku response shape and confidence score; verifies `cache_control` headers in HTTP trace; cleanup deletes test expense

### Test data fixtures

`global-setup.ts` extends:

- Seed one stale interest in `details_sent` stage with `last_activity_at = now - 20d` (fires `interest.stale`)
- Seed one active reservation without an agreement (fires `reservation.no_agreement`)
- Seed two matching expenses (fires `expense.duplicate`)
- Seed 90 days of pipeline activity for analytics charts
- Add a `tests/e2e/fixtures/receipts/` dir with two .jpg receipts for OCR tests

## Build sequence

| #   | Title                                                        | Effort | Depends on        |
| --- | ------------------------------------------------------------ | ------ | ----------------- |
| 1   | Schema + alert/analytics service skeletons                   | 1d     | —                 |
| 2   | Alert rules engine + recurring evaluator + socket            | 1.5d   | 1                 |
| 3   | Analytics snapshot job + service layer                       | 1d     | 1                 |
| 4   | Analytics dashboard page (KPI tiles + 4 charts + date-range) | 2.5d   | 1, 3, A's KPITile |
| 5   | Alert rail UI + `/alerts` page                               | 1.5d   | 2                 |
| 6   | EOI queue tab on documents hub                               | 0.5d   | A's hub           |
| 7   | Interests-by-berth tab on berth detail                       | 0.5d   | —                 |
| 8   | Expense duplicate detection (job + UI banner + merge)        | 1.5d   | 1                 |
| 9   | OCR for expense receipts (Claude Vision + 3-step UI)         | 1.5d   | —                 |
| 10  | Audit log read view (admin page + filters + tsvector search) | 1.5d   | 1                 |
| 11  | Real-API integration tests                                   | 1d     | 9                 |

### Critical path

```
1 → 2 → 5            (data → alert engine → alert UI)
1 → 3 → 4            (data → analytics service → analytics page)
8 → 2 (alert rule)   (dedup populates the data the alert reads)
9 (OCR) → 11 (realapi)
```

Wall-clock minimum ~10 days (one engineer, sequential critical path); realistic with overhead ~13 days; calendar 2.5–3 weeks.

### Acceptance gates per PR

- `pnpm tsc --noEmit` and `pnpm lint` clean
- Vitest unit + integration green (incl. new tests)
- Playwright smoke green for the surface touched
- Visual baselines regenerated and reviewed in PR diff
- For PRs touching external integrations (9 OCR, 11 realapi): relevant `realapi` spec verified locally before merge

### Risk register

| Risk                                                    | Mitigation                                                                                                                                                        |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Alert engine false positives spam users                 | Each rule has a "snooze" window in metadata; rules ship behind a feature flag `alerts.{rule_id}.enabled`; QA seeds production-shape data before flipping flags on |
| Analytics queries slow on large datasets                | `analytics_snapshots` materialized cache; cron recomputes off the request path; queries use existing per-port indexes                                             |
| Claude Vision OCR cost spirals                          | Default to Haiku 4.5 (~10× cheaper than Sonnet); ephemeral system-prompt cache hits ~80%; per-port quota with admin-visible meter                                 |
| OCR low-quality on blurry receipts                      | Confidence threshold (< 0.6) flips to "verify mode" — user must touch every field before save; failure metric tracked in admin/monitoring                         |
| Audit log table large (millions of rows)                | Already partitioned-friendly via the GIN tsvector index; pagination uses cursor on `(created_at, id)` not OFFSET                                                  |
| Alert socket fanout overwhelms client                   | Throttle the engine cron to once per 5min; client debounces React Query refetches                                                                                 |
| Interest stale rule fires for legitimately paused leads | Add a per-interest `paused_until` field as a follow-up if operators ask; v1 ships without                                                                         |

## Glossary

- **Alert** — operator-facing actionable card, rule-fired, dismissible
- **Rule** — a pure-function evaluator that takes (port, db) and returns alert candidates
- **Fingerprint** — `hash(rule_id + entity_type + entity_id)` used to dedupe alerts across re-evaluations
- **Snapshot** — cached chart data row in `analytics_snapshots`, refreshed on cron
- **EOI queue** — saved-view filter on the documents hub, not a separate page
- **OCR** — Claude Vision extraction of structured expense fields from receipt images
- **Audit log** — read view of the existing `audit_logs` table; no schema change beyond a tsvector column

## Open questions for the user

- Which port should be the **default landing dashboard** when a super-admin logs in (currently first-port-by-name; analytics page works the same)?
- Should the alert rail be **always visible on all dashboard pages** or only on `/dashboard` (currently spec'd as the latter)?
- Do you want the **Audit log retention policy** (delete > N days old) wired in v1 or deferred to Phase D?
- Should **OCR be opt-in per port** (admin toggle) or always-on with a quota?
