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>
This commit is contained in:
@@ -0,0 +1,435 @@
|
|||||||
|
# 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?
|
||||||
Reference in New Issue
Block a user