Files
pn-new-crm/src/lib/services/alert-rules.ts

331 lines
12 KiB
TypeScript
Raw Normal View History

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>
2026-04-28 14:43:01 +02:00
/**
* Alert rule catalog. Each entry is a pure async function that takes a
* `portId` and returns an array of `AlertCandidate` rows the engine should
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
* upsert. The engine (in `alerts.service.ts`) handles dedupe via the
* fingerprint partial-unique index and auto-resolves stale alerts.
*
* Adding a rule:
* 1. Add the literal to `ALERT_RULES` in schema/insights.ts.
* 2. Implement the evaluator below.
* 3. Register it in `RULE_REGISTRY`.
* 4. Add a unit test in tests/unit/services/alert-rules-evaluators.test.ts.
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>
2026-04-28 14:43:01 +02:00
*/
chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms Multi-area cleanup pass closing partial-implementation gaps surfaced by the post-i18n audit. No behavior changes for happy-path users; closes real correctness/security holes. PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso}, and company.{incorporationCountryIso, incorporationSubdivisionIso}. Server-side parsePhone() fallback for legacy raw phone strings. PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon', 'audit.suspicious_login') were registered but evaluators returned []. Both required schema/instrumentation that hadn't landed. Removed from the registry; comments record the dependencies needed to revive them. Effective rule count: 8 active. PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5 integration test files; webhook-delivery uses vi.hoisted for the queue-add ref. Vitest no longer warns about non-top-level mocks. Deflaked the 'short value' assertion in security-encryption.test.ts by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green. PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner now filter by isNull(archivedAt). Berths use status (no archivedAt). PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts walks every src/app/api/v1/**/route.ts and reports handlers without a withPermission() wrapper. Initial run found 33 violations. - Allow-listed 17 with explicit reasons (self-data, admin, alerts, search, currency, ai, custom-fields — some marked TODO). - Wrapped 7 routes with concrete permissions: clients/options (clients:view), berths/options (berths:view), dashboard/* (reports:view_dashboard), analytics (reports:view_analytics). Audit report at docs/runbooks/permission-audit.md. Script exits non-zero on any unallow-listed violation so it can become a CI gate. Vitest: 741 -> 741 (no new tests; existing suite covers the changes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
import { and, eq, isNull, isNotNull, lt, gt, sql, inArray, or, desc } from 'drizzle-orm';
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { berthReservations } from '@/lib/db/schema/reservations';
import { berths } from '@/lib/db/schema/berths';
import { documents, documentSigners } from '@/lib/db/schema/documents';
import { expenses } from '@/lib/db/schema/financial';
import { alerts as alertsTable } from '@/lib/db/schema/insights';
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>
2026-04-28 14:43:01 +02:00
import { ALERT_RULES, type AlertRuleId } from '@/lib/db/schema/insights';
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
import type { AlertCandidate } from './alerts.service';
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>
2026-04-28 14:43:01 +02:00
type RuleEvaluator = (portId: string) => Promise<AlertCandidate[]>;
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
const DAY_MS = 86_400_000;
function daysAgo(n: number): Date {
return new Date(Date.now() - n * DAY_MS);
}
// ─── reservation.no_agreement ─────────────────────────────────────────────────
// Active reservations > 3 days old that have no reservation_agreement document
// in any non-cancelled state.
async function reservationNoAgreement(portId: string): Promise<AlertCandidate[]> {
const rows = await db
.select({
id: berthReservations.id,
startDate: berthReservations.startDate,
clientName: sql<string>`coalesce((SELECT full_name FROM clients WHERE id = ${berthReservations.clientId}), 'unknown')`,
yachtName: sql<string>`coalesce((SELECT name FROM yachts WHERE id = ${berthReservations.yachtId}), 'unknown')`,
})
.from(berthReservations)
.where(
and(
eq(berthReservations.portId, portId),
eq(berthReservations.status, 'active'),
lt(berthReservations.createdAt, daysAgo(3)),
sql`NOT EXISTS (
SELECT 1 FROM ${documents}
WHERE ${documents.reservationId} = ${berthReservations.id}
AND ${documents.documentType} = 'reservation_agreement'
AND ${documents.status} NOT IN ('cancelled', 'expired')
)`,
),
);
return rows.map((r) => ({
ruleId: 'reservation.no_agreement',
severity: 'warning',
title: `Reservation needs an agreement`,
body: `Active reservation for ${r.yachtName} (${r.clientName}) has no signed agreement yet.`,
link: `/[port]/berth-reservations/${r.id}`,
entityType: 'reservation',
entityId: r.id,
}));
}
// ─── interest.stale ───────────────────────────────────────────────────────────
// Pipeline stuck in mid-funnel stages with no contact for 14+ days.
async function interestStale(portId: string): Promise<AlertCandidate[]> {
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
// Mid-funnel stages where silence is a problem. EOI/deposit/contract stages
// have their own dedicated alerts (eoi.unsigned_long, deposit_overdue, etc.).
const STALE_STAGES = ['details_sent', 'in_communication', 'eoi_sent'];
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
const rows = await db
.select({
id: interests.id,
stage: interests.pipelineStage,
lastContact: interests.dateLastContact,
clientName: sql<string>`coalesce((SELECT full_name FROM clients WHERE id = ${interests.clientId}), 'unknown')`,
})
.from(interests)
.where(
and(
eq(interests.portId, portId),
inArray(interests.pipelineStage, STALE_STAGES),
isNull(interests.archivedAt),
or(
lt(interests.dateLastContact, daysAgo(14)),
and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))),
),
),
);
return rows.map((r) => ({
ruleId: 'interest.stale',
severity: 'info',
title: `Stale interest: ${r.clientName}`,
body: `In '${r.stage}' with no contact for 14+ days.`,
link: `/[port]/interests/${r.id}`,
entityType: 'interest',
entityId: r.id,
metadata: { stage: r.stage, lastContact: r.lastContact },
}));
}
// ─── document.signer_overdue ──────────────────────────────────────────────────
// Pending signer for >14d, last reminder >7d ago (or never).
async function documentSignerOverdue(portId: string): Promise<AlertCandidate[]> {
const cutoff = daysAgo(14);
const rows = await db
.select({
docId: documents.id,
title: documents.title,
docType: documents.documentType,
signerId: documentSigners.id,
signerEmail: documentSigners.signerEmail,
signerName: documentSigners.signerName,
sentAt: documentSigners.createdAt,
})
.from(documents)
.innerJoin(documentSigners, eq(documentSigners.documentId, documents.id))
.where(
and(
eq(documents.portId, portId),
inArray(documents.status, ['sent', 'partially_signed']),
eq(documentSigners.status, 'pending'),
lt(documentSigners.createdAt, cutoff),
),
);
return rows.map((r) => ({
ruleId: 'document.signer_overdue',
severity: 'warning',
title: `Signer overdue: ${r.signerName}`,
body: `${r.docType.toUpperCase()} "${r.title}" - pending >14 days.`,
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
link: `/[port]/documents/${r.docId}`,
entityType: 'document',
entityId: r.docId,
metadata: { signerId: r.signerId, signerEmail: r.signerEmail, sentAt: r.sentAt },
}));
}
// ─── berth.under_offer_stalled ────────────────────────────────────────────────
// Berths sitting in 'under_offer' status for 30+ days.
async function berthUnderOfferStalled(portId: string): Promise<AlertCandidate[]> {
const rows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
updatedAt: berths.updatedAt,
})
.from(berths)
.where(
and(
eq(berths.portId, portId),
eq(berths.status, 'under_offer'),
lt(berths.updatedAt, daysAgo(30)),
),
);
return rows.map((r) => ({
ruleId: 'berth.under_offer_stalled',
severity: 'info',
title: `Berth ${r.mooringNumber} stalled under offer`,
body: `No status change in 30+ days.`,
link: `/[port]/berths/${r.id}`,
entityType: 'berth',
entityId: r.id,
metadata: { stalledSince: r.updatedAt },
}));
}
// ─── expense.duplicate ────────────────────────────────────────────────────────
// Expenses whose duplicate_of is set (the dedup service writes this).
async function expenseDuplicate(portId: string): Promise<AlertCandidate[]> {
const rows = await db
.select({
id: expenses.id,
vendor: expenses.establishmentName,
amount: expenses.amount,
duplicateOf: expenses.duplicateOf,
})
.from(expenses)
.where(
and(
eq(expenses.portId, portId),
isNotNull(expenses.duplicateOf),
isNull(expenses.archivedAt),
),
);
return rows.map((r) => ({
ruleId: 'expense.duplicate',
severity: 'info',
title: `Possible duplicate expense`,
body: `${r.vendor ?? 'Unknown vendor'} - ${r.amount}.`,
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
link: `/[port]/expenses/${r.id}`,
entityType: 'expense',
entityId: r.id,
metadata: { duplicateOf: r.duplicateOf },
}));
}
// ─── expense.unscanned ────────────────────────────────────────────────────────
// Expense uploaded with a receipt file but OCR didn't run / failed > 1h ago.
async function expenseUnscanned(portId: string): Promise<AlertCandidate[]> {
const rows = await db
.select({
id: expenses.id,
vendor: expenses.establishmentName,
ocrStatus: expenses.ocrStatus,
createdAt: expenses.createdAt,
})
.from(expenses)
.where(
and(
eq(expenses.portId, portId),
eq(expenses.ocrStatus, 'pending'),
sql`array_length(${expenses.receiptFileIds}, 1) > 0`,
lt(expenses.createdAt, new Date(Date.now() - 60 * 60 * 1000)),
isNull(expenses.archivedAt),
),
);
return rows.map((r) => ({
ruleId: 'expense.unscanned',
severity: 'info',
title: `Receipt not scanned`,
body: `${r.vendor ?? 'Unknown vendor'} - uploaded over an hour ago.`,
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
link: `/[port]/expenses/${r.id}`,
entityType: 'expense',
entityId: r.id,
}));
}
// ─── interest.high_value_silent ───────────────────────────────────────────────
// Hot leads with no contact for 7+ days. Highest severity in the catalog.
async function interestHighValueSilent(portId: string): Promise<AlertCandidate[]> {
const cutoff = daysAgo(7);
const rows = await db
.select({
id: interests.id,
stage: interests.pipelineStage,
clientName: sql<string>`coalesce((SELECT full_name FROM clients WHERE id = ${interests.clientId}), 'unknown')`,
})
.from(interests)
.where(
and(
eq(interests.portId, portId),
eq(interests.leadCategory, 'hot_lead'),
isNull(interests.archivedAt),
or(
lt(interests.dateLastContact, cutoff),
and(isNull(interests.dateLastContact), lt(interests.updatedAt, cutoff)),
),
),
);
return rows.map((r) => ({
ruleId: 'interest.high_value_silent',
severity: 'critical',
title: `Hot lead silent: ${r.clientName}`,
body: `No contact for 7+ days - high-value at risk.`,
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
link: `/[port]/interests/${r.id}`,
entityType: 'interest',
entityId: r.id,
metadata: { stage: r.stage },
}));
}
// ─── eoi.unsigned_long ────────────────────────────────────────────────────────
// EOI documents in 'sent' status for 21+ days.
async function eoiUnsignedLong(portId: string): Promise<AlertCandidate[]> {
const rows = await db
.select({
id: documents.id,
title: documents.title,
createdAt: documents.createdAt,
})
.from(documents)
.where(
and(
eq(documents.portId, portId),
eq(documents.documentType, 'eoi'),
inArray(documents.status, ['sent', 'partially_signed']),
lt(documents.createdAt, daysAgo(21)),
),
);
return rows.map((r) => ({
ruleId: 'eoi.unsigned_long',
severity: 'warning',
title: `EOI unsigned >21 days`,
body: `"${r.title}" - sent over 3 weeks ago.`,
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
link: `/[port]/documents/${r.id}`,
entityType: 'document',
entityId: r.id,
}));
}
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>
2026-04-28 14:43:01 +02:00
export const RULE_REGISTRY: Record<AlertRuleId, RuleEvaluator> = {
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
'reservation.no_agreement': reservationNoAgreement,
'interest.stale': interestStale,
'document.signer_overdue': documentSignerOverdue,
'berth.under_offer_stalled': berthUnderOfferStalled,
'expense.duplicate': expenseDuplicate,
'expense.unscanned': expenseUnscanned,
'interest.high_value_silent': interestHighValueSilent,
'eoi.unsigned_long': eoiUnsignedLong,
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>
2026-04-28 14:43:01 +02:00
};
export function listRuleIds(): readonly AlertRuleId[] {
return ALERT_RULES;
}
feat(alerts): rule engine, recurring evaluator, socket fanout PR2 of Phase B. Wires the alert framework end-to-end: - alert-rules.ts: 10 rule evaluators implemented as pure async fns over the existing schema. reservation.no_agreement, interest.stale, document.signer_overdue, berth.under_offer_stalled, expense.duplicate, expense.unscanned, interest.high_value_silent, eoi.unsigned_long, audit.suspicious_login fire against real conditions. document.expiring_soon stays inert until the documents schema gets an expires_at column. audit.suspicious_login also stays inert until the auth layer logs 'login.failed' rows (TODO noted in the rule body). - alert-engine.ts: runAlertEngine() walks every port × every rule and calls reconcileAlertsForPort. Errors per (port, rule) are collected in the summary, not thrown — one bad evaluator can't stop the sweep. - alerts.service.ts: reconcileAlertsForPort now emits 'alert:created' socket events on insert and 'alert:resolved' on auto-resolve; dismissAlert emits 'alert:dismissed'. All scoped to port:{portId} rooms. - socket/events.ts: adds the three Server→Client alert event types. - queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance queue with cron */5 * * * * (every 5 min, per spec risk register). - queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to runAlertEngine; logs sweep summary. Tests: - tests/integration/alerts-engine.test.ts (6 cases): seeds reservation → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds stale interest → fires; hot lead silent → critical; engine summary shape on no-data port. Socket emit module is vi.mocked. Vitest 681/681 (was 675; +6). tsc clean. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
// silence unused-import warnings until later PRs use them
const _unused = { gt, desc, alertsTable };
void _unused;