feat(insights): Phase B schema + service skeletons

PR1 of Phase B per docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md.
Lays the foundation that PRs 2-10 will fill in with behaviour.

Schema (migration 0014):
- alerts table with rule-engine fields (rule_id, severity, link,
  entity_type/id, fingerprint, fired/dismissed/acknowledged/resolved
  timestamps, jsonb metadata). Partial-unique fingerprint index keeps
  one open row per (port, rule, entity); separate indexes power
  severity-filtered and time-ordered queries.
- analytics_snapshots (port_id, metric_id) -> jsonb cache + computedAt
  for the 15-min recurring refresh.
- expenses: duplicate_of self-FK, dedup_scanned_at, ocr_status/raw/
  confidence; partial index on (port, vendor, amount, date) where
  duplicate_of IS NULL drives the dedup heuristic.
- audit_logs.search_text: GENERATED ALWAYS tsvector over
  action+entity_type+entity_id+user_id, GIN-indexed (drizzle can't
  model GENERATED ALWAYS in TS yet, so the migration appends manual
  ALTER + the GIN index).

Service skeletons in src/lib/services/:
- alerts.service.ts: fingerprintFor, reconcileAlertsForPort (upsert +
  auto-resolve), dismiss, acknowledge, listAlertsForPort.
- alert-rules.ts: RULE_REGISTRY of 10 rule evaluators (currently no-op);
  PR2 fills in the bodies.
- analytics.service.ts: readSnapshot/writeSnapshot with 15-min TTL +
  no-op compute* stubs for the four chart series; PR3 fills behavior.
- expense-dedup.service.ts: scanForDuplicates + markBestDuplicate
  using the partial dedup index. PR8 wires the BullMQ trigger.
- expense-ocr.service.ts: OcrResult/OcrLineItem types + ocrReceipt
  stub. PR9 wires Claude Vision (Haiku 4.5 + ephemeral system-prompt
  cache).
- audit-search.service.ts: tsvector @@ plainto_tsquery + cursor
  pagination on (createdAt, id). PR10 wires the admin UI.

tsc clean, lint clean, vitest 675/675 (one unrelated AES random-output
flake passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 14:43:01 +02:00
parent f1ed2a5f87
commit e77d55ac50
13 changed files with 10451 additions and 10 deletions

View File

@@ -0,0 +1,115 @@
/**
* Phase B alert framework — service layer.
*
* This is the skeleton: types, function shapes, and behaviour stubs. The
* actual rule evaluators live in `alert-rules.ts` (PR2). The cron
* dispatcher will compose this service with that catalogue.
*/
import { and, eq, isNull, sql } from 'drizzle-orm';
import { createHash } from 'crypto';
import { db } from '@/lib/db';
import { alerts, type Alert, type AlertSeverity, type AlertRuleId } from '@/lib/db/schema/insights';
export interface AlertCandidate {
ruleId: AlertRuleId;
severity: AlertSeverity;
title: string;
body?: string;
link: string;
entityType?: string;
entityId?: string;
metadata?: Record<string, unknown>;
}
/**
* Stable identity hash so re-evaluations of the same condition upsert
* onto the same row (via `idx_alerts_fingerprint_open`).
*/
export function fingerprintFor(
c: Pick<AlertCandidate, 'ruleId' | 'entityType' | 'entityId'>,
): string {
return createHash('sha1')
.update(`${c.ruleId}|${c.entityType ?? ''}|${c.entityId ?? ''}`)
.digest('hex');
}
/**
* Apply a batch of rule outputs against the open-alert table:
* - upsert open alerts (rule still firing)
* - resolve any open alert in scope whose fingerprint isn't in this batch
*/
export async function reconcileAlertsForPort(
portId: string,
ruleId: AlertRuleId,
candidates: AlertCandidate[],
): Promise<void> {
// Insert new / leave existing — only one open row per fingerprint
// thanks to the partial unique index.
for (const c of candidates) {
const fingerprint = fingerprintFor(c);
await db
.insert(alerts)
.values({
portId,
ruleId: c.ruleId,
severity: c.severity,
title: c.title,
body: c.body,
link: c.link,
entityType: c.entityType,
entityId: c.entityId,
fingerprint,
metadata: c.metadata ?? {},
})
.onConflictDoNothing();
}
// Auto-resolve open alerts for this rule whose fingerprint disappeared.
const liveFingerprints = new Set(candidates.map((c) => fingerprintFor(c)));
const open = await db.query.alerts.findMany({
where: and(eq(alerts.portId, portId), eq(alerts.ruleId, ruleId), isNull(alerts.resolvedAt)),
});
const stale = open.filter((a) => !liveFingerprints.has(a.fingerprint));
for (const a of stale) {
await db
.update(alerts)
.set({ resolvedAt: sql`now()` })
.where(eq(alerts.id, a.id));
}
}
export async function dismissAlert(alertId: string, userId: string): Promise<void> {
await db
.update(alerts)
.set({ dismissedAt: sql`now()`, dismissedBy: userId })
.where(eq(alerts.id, alertId));
}
export async function acknowledgeAlert(alertId: string, userId: string): Promise<void> {
await db
.update(alerts)
.set({ acknowledgedAt: sql`now()`, acknowledgedBy: userId })
.where(eq(alerts.id, alertId));
}
export interface ListAlertsOptions {
severity?: AlertSeverity[];
includeDismissed?: boolean;
includeResolved?: boolean;
}
export async function listAlertsForPort(
portId: string,
options: ListAlertsOptions = {},
): Promise<Alert[]> {
const conditions = [eq(alerts.portId, portId)];
if (!options.includeResolved) conditions.push(isNull(alerts.resolvedAt));
if (!options.includeDismissed) conditions.push(isNull(alerts.dismissedAt));
return db.query.alerts.findMany({
where: and(...conditions),
orderBy: (a, { desc }) => [desc(a.firedAt)],
limit: 100,
});
}