Files
pn-new-crm/docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md
Matt Ciaccio f1ed2a5f87
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m4s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
docs(spec): Phase B — insights, alerts, and operational awareness
Full execution plan for the next phase. Closes the seven priority gaps
the 2026-04-28 Nuxt→Next audit surfaced (analytics, alerts,
interests-by-berth, expense dedup, EOI queue, OCR, audit log read view).

Scope:
- Analytics dashboard with KPI tiles, pipeline funnel, occupancy
  timeline, revenue breakdown, lead-source attribution; cached via
  `analytics_snapshots` recurring job.
- Alert framework: 10-rule v1 catalog, rule engine evaluates on cron,
  fingerprint dedupes, auto-resolves when condition clears, surfaces
  in dashboard right rail + dedicated /alerts page.
- Interests-by-berth tab on berth detail.
- Expense duplicate detection (vendor + amount + date ±3d) with
  merge action.
- OCR for expense receipts via Claude Vision (Haiku 4.5 + ephemeral
  system-prompt cache).
- Audit log admin read view with tsvector search + cursor pagination.
- EOI queue: saved-view tab on the documents hub.

11 PRs, ~10-13 dev days, calendar 2.5-3 weeks. Critical path
graphed. Risk register includes alert false-positive mitigation,
OCR cost ceiling via Haiku + cache, and audit-log scale.

Four open questions for the user in the spec footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:00:01 +02:00

26 KiB
Raw Blame History

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

-- 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.tsgetPipelineFunnel(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.tsscanForDuplicates(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.tscreateExpense 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:

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:

// 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.53 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
  • Fingerprinthash(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?