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