diff --git a/docs/ALERTS.md b/docs/ALERTS.md new file mode 100644 index 0000000..eca15b0 --- /dev/null +++ b/docs/ALERTS.md @@ -0,0 +1,134 @@ +# Custom Price & Yield Alert Rules + +User-defined rules that proactively notify a user when a market or portfolio +condition they care about is met — e.g. "tell me if Blend's APY drops below 5%" +or "alert me if my portfolio value falls under $1,000". + +This is **end-user** alerting and is deliberately distinct from the operator- +facing Prometheus/Grafana alerting in [`OBSERVABILITY.md`](./OBSERVABILITY.md) +(`agent_loop_status`, `cursor_lag_ledgers`, `dlq_size`, …), which watches system +health for on-call engineers rather than portfolio conditions for users. + +- Data model: `AlertRule` in [`prisma/schema.prisma`](../prisma/schema.prisma) +- Evaluation core (pure, unit-tested): [`src/services/alertEvaluator.ts`](../src/services/alertEvaluator.ts) +- Scheduled job: [`src/jobs/alertRules.ts`](../src/jobs/alertRules.ts) +- CRUD API: [`src/routes/alerts.ts`](../src/routes/alerts.ts) +- Delivery: reuses [`src/services/webhookDispatcher.ts`](../src/services/webhookDispatcher.ts) + (webhook) and [`src/whatsapp/formatters.ts`](../src/whatsapp/formatters.ts) (WhatsApp) + +## Rule model + +A rule is a single condition (compound/multi-condition rules are out of scope +for v1): + +| Field | Meaning | +| ----------------- | ------------------------------------------------------------------- | +| `metric` | `PROTOCOL_APY`, `PORTFOLIO_VALUE`, or `POSITION_DRAWDOWN` | +| `protocolName` | required for `PROTOCOL_APY`, rejected for the other metrics | +| `comparator` | `LT`, `LTE`, `GT`, `GTE` | +| `threshold` | compared against the observed value (units below) | +| `deliveryChannel` | `WEBHOOK`, `WHATSAPP`, or `BOTH` | +| `cooldownMinutes` | minimum gap between notifications for this rule (default 60) | +| `lastFiredAt` | when the rule last fired; drives cooldown | +| `isActive` | inactive rules are never evaluated | + +### Units per metric + +- **`PROTOCOL_APY`** — threshold and observed value are **percentages** + (`5` == 5%). `ProtocolRate.supplyApy` is stored as a fraction (`0.05`), so the + evaluator scales it by 100 before comparing. +- **`PORTFOLIO_VALUE`** — threshold and observed value are the **USD sum of the + user's ACTIVE positions' `currentValue`**. +- **`POSITION_DRAWDOWN`** — threshold and observed value are a **percentage + decline from a reference peak** (see below). + +## POSITION_DRAWDOWN reference window + +"Drawdown" is meaningless without a reference point, so we fix one explicitly: + +> Drawdown is measured against the **rolling 30-day peak of the user's total +> portfolio value**. + +The peak is the maximum of: + +1. every historical whole-portfolio value reconstructed from `YieldSnapshot` + rows (`principalAmount + yieldAmount`, summed across the user's positions per + snapshot instant) within the trailing 30 days, and +2. the current total portfolio value. + +Including the current value as a candidate means a fresh all-time high reports +**0% drawdown** rather than a spurious decline against a stale sample. + +``` +drawdown% = max(0, (peak - current) / peak * 100) +``` + +The window length is `WINDOW_DAYS` in [`src/jobs/alertRules.ts`](../src/jobs/alertRules.ts). + +## Evaluation & cooldown + +The job runs on a fixed interval (`ALERT_RULES_INTERVAL_MS`, default 60s). On +each tick it loads all `isActive` rules and, for each: + +1. Observes the current value for the rule's metric. +2. Checks the comparator against the threshold. +3. If the condition holds, **atomically claims a fire** with an `updateMany` + guarded on `{ id, isActive, lastFiredAt outside cooldown }`, setting + `lastFiredAt = now`. Only if that update matches exactly one row does it + deliver. + +The cooldown is essential: a rule sitting right at its threshold would otherwise +fire on every tick. With it, a rule notifies **at most once per +`cooldownMinutes`**. The condition does **not** have to flip false→true again — +if it is still true once the cooldown elapses, the rule re-fires. + +### Edge cases + +- **Condition true across many ticks** — cooldown suppresses repeats; the rule + stays active and re-fires after the cooldown if still true. +- **Rule deleted/deactivated mid-tick** — the atomic fire-claim matches 0 rows, + so delivery is skipped silently (no error, no send to a gone rule). +- **Protocol delisted** — a `PROTOCOL_APY` rule whose protocol has no + `ProtocolRate` row is **auto-deactivated** (`isActive = false`) with a logged + reason, rather than evaluated against missing/stale data. + +## Delivery & failed-delivery retry policy + +Delivery reuses the existing HMAC-signed webhook dispatcher +(`dispatchWebhookEvent('alert_rule.triggered', …)`) and/or the Twilio WhatsApp +sender. No new unsigned delivery path is introduced. + +**Decision (per issue #289): alert deliveries reuse `dispatchWebhookEvent` +as-is and get no additional retry sweep beyond its synchronous 3-attempt +exponential backoff (1s/2s/4s).** + +Rationale: alerts are about a *live* condition. A separate sweep that later +replays a `FAILED` delivery could fire a stale alert for a condition that has +since reversed. Instead: + +- If **all** requested channels hard-fail during a fire, the job **rolls back + `lastFiredAt`** to its prior value, so the next tick re-evaluates the *current* + condition and retries if it still holds (bounded by cooldown). A transient + failure therefore self-heals on the following tick without replaying stale + data. +- The webhook dispatcher still persists a `WebhookDelivery` row with + `status = FAILED` for observability, exactly as for every other event. + +If durable, at-least-once alert delivery is required later, the follow-up is a +dedicated retry sweep over `FAILED` `WebhookDelivery` rows — explicitly out of +scope here. + +## Configuration + +| Env var | Default | Meaning | +| ------------------------ | ------- | ------------------------------------ | +| `ALERT_RULES_INTERVAL_MS`| `60000` | Evaluation tick interval (ms) | + +## Conversational management (WhatsApp) + +Alert rules can be managed over WhatsApp via the NLP intents in +[`src/nlp/parser.ts`](../src/nlp/parser.ts) (`alert_create`, `alert_list`, +`alert_delete`), handled in [`src/whatsapp/handler.ts`](../src/whatsapp/handler.ts). +As with the other intents (#281/#282), the intent union, the `KNOWN_ACTIONS` +allowlist, and the handler switch are kept in sync **manually**. WhatsApp-created +rules default to `WHATSAPP` delivery. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7f5c59c..ac93ea1 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2575,6 +2575,131 @@ paths: '403': description: Caller may only read their own referrals + # ── Custom price & yield alert rules (#289) ────────────────────────────── + /api/v1/alerts: + post: + tags: [alerts] + operationId: createAlertRule + summary: Create an alert rule + description: > + Creates a user-defined price/yield alert rule owned by the authenticated + caller. The rule is evaluated on a schedule; when its comparator + condition holds against the live metric and it is outside its cooldown + window, a notification is delivered over the chosen channel(s). A + `PROTOCOL_APY` rule must name a `protocolName`; the other metrics must + not. See `docs/ALERTS.md` for metric semantics and the drawdown window. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAlertRuleRequest' + responses: + '201': + description: The created alert rule + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRule' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + # GET is keyed by the owning user id; PATCH/DELETE by the rule id. They share + # one URL template (disambiguated only by HTTP method in the Express router), + # so OpenAPI represents them under a single path item with a generic {id} + # whose meaning is documented per-operation. + /api/v1/alerts/{id}: + get: + tags: [alerts] + operationId: listAlertRules + summary: List a user's alert rules + description: > + Lists the alert rules owned by the user, newest first. Owner-scoped — a + caller may only read their own rules. Here the path parameter is the + owning **user id** (must match the authenticated caller). + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: The owning user's id (must match the authenticated caller) + responses: + '200': + description: Alert rules for the user + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRuleListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + patch: + tags: [alerts] + operationId: updateAlertRule + summary: Update an alert rule + description: > + Updates a rule the caller owns. Here the path parameter is the **rule + id**. The `PROTOCOL_APY` ↔ `protocolName` pairing is enforced against + the merged (stored + patch) state. + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: Alert rule id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateAlertRuleRequest' + responses: + '200': + description: The updated alert rule + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRule' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [alerts] + operationId: deleteAlertRule + summary: Delete an alert rule + description: Deletes a rule the caller owns. Future evaluations stop immediately. + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: Alert rule id + responses: + '204': + description: Deleted (no content) + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + components: securitySchemes: BearerAuth: @@ -3212,6 +3337,131 @@ components: items: $ref: '#/components/schemas/Referral' + AlertMetric: + type: string + enum: [PROTOCOL_APY, PORTFOLIO_VALUE, POSITION_DRAWDOWN] + description: > + What the rule watches. PROTOCOL_APY = a named protocol's supply APY in + percent; PORTFOLIO_VALUE = the user's total active-position value in USD; + POSITION_DRAWDOWN = percentage decline of that portfolio value from its + rolling 30-day peak (see docs/ALERTS.md). + AlertComparator: + type: string + enum: [LT, LTE, GT, GTE] + description: Comparison of the observed value against the threshold. + AlertDeliveryChannel: + type: string + enum: [WEBHOOK, WHATSAPP, BOTH] + description: Where a triggered alert is delivered. + AlertRule: + type: object + required: + - id + - userId + - metric + - comparator + - threshold + - deliveryChannel + - cooldownMinutes + - isActive + - createdAt + properties: + id: + type: string + format: uuid + userId: + type: string + format: uuid + metric: + $ref: '#/components/schemas/AlertMetric' + protocolName: + type: string + nullable: true + description: Required when metric is PROTOCOL_APY; null otherwise. + example: Blend + comparator: + $ref: '#/components/schemas/AlertComparator' + threshold: + type: number + description: > + Percent for PROTOCOL_APY/POSITION_DRAWDOWN, USD for PORTFOLIO_VALUE. + example: 5 + deliveryChannel: + $ref: '#/components/schemas/AlertDeliveryChannel' + cooldownMinutes: + type: integer + minimum: 1 + maximum: 10080 + default: 60 + description: Minimum minutes between repeat notifications while the condition holds. + lastFiredAt: + type: string + format: date-time + nullable: true + isActive: + type: boolean + description: > + A PROTOCOL_APY rule is auto-set to false if its protocol is delisted + (no rate data), rather than evaluating against stale data. + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + CreateAlertRuleRequest: + type: object + required: [metric, comparator, threshold, deliveryChannel] + properties: + metric: + $ref: '#/components/schemas/AlertMetric' + protocolName: + type: string + description: Required when metric is PROTOCOL_APY; rejected otherwise. + example: Blend + comparator: + $ref: '#/components/schemas/AlertComparator' + threshold: + type: number + example: 5 + deliveryChannel: + $ref: '#/components/schemas/AlertDeliveryChannel' + cooldownMinutes: + type: integer + minimum: 1 + maximum: 10080 + default: 60 + UpdateAlertRuleRequest: + type: object + minProperties: 1 + description: Partial update — at least one field must be provided. + properties: + metric: + $ref: '#/components/schemas/AlertMetric' + protocolName: + type: string + nullable: true + comparator: + $ref: '#/components/schemas/AlertComparator' + threshold: + type: number + deliveryChannel: + $ref: '#/components/schemas/AlertDeliveryChannel' + cooldownMinutes: + type: integer + minimum: 1 + maximum: 10080 + isActive: + type: boolean + AlertRuleListResponse: + type: object + required: [rules] + properties: + rules: + type: array + items: + $ref: '#/components/schemas/AlertRule' + responses: Unauthorized: description: Missing or invalid authentication diff --git a/prisma/migrations/20260720000000_add_alert_rules/migration.sql b/prisma/migrations/20260720000000_add_alert_rules/migration.sql new file mode 100644 index 0000000..a96d092 --- /dev/null +++ b/prisma/migrations/20260720000000_add_alert_rules/migration.sql @@ -0,0 +1,46 @@ +-- Custom price & yield alert rules (#289). User-defined conditions evaluated +-- on a schedule by src/jobs/alertRules.ts; distinct from operator-facing +-- Prometheus/Grafana alerting. + +-- CreateEnum +CREATE TYPE "AlertMetric" AS ENUM ('PROTOCOL_APY', 'PORTFOLIO_VALUE', 'POSITION_DRAWDOWN'); + +-- CreateEnum +CREATE TYPE "Comparator" AS ENUM ('LT', 'LTE', 'GT', 'GTE'); + +-- CreateEnum +CREATE TYPE "DeliveryChannel" AS ENUM ('WEBHOOK', 'WHATSAPP', 'BOTH'); + +-- CreateTable +CREATE TABLE "alert_rules" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "metric" "AlertMetric" NOT NULL, + "protocolName" TEXT, + "comparator" "Comparator" NOT NULL, + "threshold" DECIMAL(36,18) NOT NULL, + "deliveryChannel" "DeliveryChannel" NOT NULL, + "cooldownMinutes" INTEGER NOT NULL DEFAULT 60, + "lastFiredAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "alert_rules_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "alert_rules_userId_idx" ON "alert_rules"("userId"); + +-- CreateIndex +CREATE INDEX "alert_rules_isActive_metric_idx" ON "alert_rules"("isActive", "metric"); + +-- AddForeignKey +ALTER TABLE "alert_rules" ADD CONSTRAINT "alert_rules_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Optional WhatsApp delivery destination for alert rules. Nullable + unique. +-- AlterTable +ALTER TABLE "users" ADD COLUMN "phone" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone"); diff --git a/prisma/migrations/20260720000000_add_alert_rules/rollback.sql b/prisma/migrations/20260720000000_add_alert_rules/rollback.sql new file mode 100644 index 0000000..206a59d --- /dev/null +++ b/prisma/migrations/20260720000000_add_alert_rules/rollback.sql @@ -0,0 +1,21 @@ +-- Rollback for 20260720000000_add_alert_rules +-- Drops the user-defined alert rule table, its enums, and the users.phone +-- column added for WhatsApp delivery. +-- WARNING: Destroys all user-configured alert rules and their cooldown state +-- (lastFiredAt), plus any linked WhatsApp phone numbers. None of this is +-- reconstructible from other tables — rules are user input, not derived data. +-- Re-applying the migration restores the schema but not the rows. + +ALTER TABLE "alert_rules" DROP CONSTRAINT IF EXISTS "alert_rules_userId_fkey"; + +DROP INDEX IF EXISTS "users_phone_key"; + +ALTER TABLE "users" DROP COLUMN IF EXISTS "phone"; + +DROP TABLE IF EXISTS "alert_rules"; + +DROP TYPE IF EXISTS "DeliveryChannel"; + +DROP TYPE IF EXISTS "Comparator"; + +DROP TYPE IF EXISTS "AlertMetric"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa08fe4..6699656 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -101,6 +101,28 @@ enum GoalStatus { CANCELLED } +// User-defined alert rules (#289). Distinct from the operator-facing +// Prometheus/Grafana alerting in docs/OBSERVABILITY.md — these watch +// portfolio/market conditions the end user cares about. +enum AlertMetric { + PROTOCOL_APY + PORTFOLIO_VALUE + POSITION_DRAWDOWN +} + +enum Comparator { + LT + LTE + GT + GTE +} + +enum DeliveryChannel { + WEBHOOK + WHATSAPP + BOTH +} + model User { id String @id @default(uuid()) walletAddress String @unique @@ -108,6 +130,11 @@ model User { displayName String? email String? @unique avatarUrl String? + // E.164 WhatsApp number, when known. Nullable because most users onboard via + // wallet auth and never link a number. Used as the WhatsApp delivery + // destination for alert rules (#289); alerts on the WHATSAPP/BOTH channel are + // skipped (logged, not errored) for users without a number on file. + phone String? @unique riskTolerance Int @default(5) rebalanceStrategy String? // 'MAX_YIELD' | 'TARGET_ALLOCATION' | null (defaults to MAX_YIELD) strategyConfig Json? // e.g. { "targetAllocations": { "Blend": 50, "Stellar DEX": 30, "Luma": 20 } } @@ -123,6 +150,7 @@ model User { fiatOrders FiatOrder[] referralCode ReferralCode? referralConversion ReferralConversion? + alertRules AlertRule[] costBasisLots CostBasisLot[] lotDisposals LotDisposal[] savingsGoals SavingsGoal[] @@ -470,6 +498,37 @@ model WebhookDelivery { @@map("webhook_deliveries") } +/// User-defined price & yield alert rule (#289). +/// +/// Evaluated on a schedule by src/jobs/alertRules.ts against the latest +/// ProtocolRate (PROTOCOL_APY) or the user's Position/YieldSnapshot data +/// (PORTFOLIO_VALUE, POSITION_DRAWDOWN). Fires only when the comparator +/// condition holds AND lastFiredAt is outside the cooldown window, so a rule +/// sitting at its threshold notifies once per cooldown rather than every tick. +model AlertRule { + id String @id @default(uuid()) + userId String + metric AlertMetric + protocolName String? // required when metric = PROTOCOL_APY + comparator Comparator + // Compared against APY as a percentage (e.g. 5 == 5%) for PROTOCOL_APY, + // against USD value for PORTFOLIO_VALUE, and against percentage drawdown + // from the rolling 30-day peak for POSITION_DRAWDOWN. + threshold Decimal @db.Decimal(36, 18) + deliveryChannel DeliveryChannel + cooldownMinutes Int @default(60) + lastFiredAt DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([isActive, metric]) + @@map("alert_rules") +} + /// Fiat on-ramp / off-ramp order (#290). /// Tracks the off-chain payment leg of a fiat<->crypto conversion handled by a /// third-party provider (e.g. MoonPay). The on-chain settlement is reconciled diff --git a/src/config/env.ts b/src/config/env.ts index 9101a76..6c8d487 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -477,6 +477,10 @@ export const config = { /** Interval between protocol risk-score recomputations in ms (default: 6 hours) */ intervalMs: parseInt(process.env.PROTOCOL_RISK_INTERVAL_MS || '21600000'), }, + alertRules: { + /** Interval between user alert-rule evaluation sweeps in ms (default: 1 minute). */ + intervalMs: parseInt(process.env.ALERT_RULES_INTERVAL_MS || '60000'), + }, referral: { /** * Minimum confirmed deposit (in asset units) that a referred user must make diff --git a/src/index.ts b/src/index.ts index c916d37..7e15605 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ import { scheduleDataRetention } from './jobs/dataRetention' import { schedulePoolMetrics } from './jobs/poolMetrics' import { scheduleFiatReconciliation } from './jobs/fiatReconciliation' import { scheduleReferralPayout } from './jobs/referralPayout' +import { scheduleAlertRules } from './jobs/alertRules' import { startEventListener, stopEventListener } from './stellar/events' import { validateStellarNetworkReady } from './config/readiness' import healthRouter from './routes/health' @@ -67,6 +68,7 @@ import stellarRouter from './routes/stellar' import webhooksRouter from './routes/webhooks' import fiatRouter from './routes/fiat' import referralsRouter from './routes/referrals' +import alertsRouter from './routes/alerts' import { corsMiddleware, jsonBodyParser, @@ -96,6 +98,7 @@ let dataRetentionHandle: NodeJS.Timeout | null = null let poolMetricsHandle: NodeJS.Timeout | null = null let fiatReconciliationHandle: NodeJS.Timeout | null = null let referralPayoutHandle: NodeJS.Timeout | null = null +let alertRulesHandle: NodeJS.Timeout | null = null function allServicesReady(): boolean { return Object.values(serviceStatus).every((s) => s.ready) @@ -272,6 +275,7 @@ const apiRoutes: ApiRoute[] = [ { path: 'stellar', handlers: [stellarRouter] }, { path: 'fiat', handlers: [fiatRouter] }, { path: 'referrals', handlers: [referralsRouter] }, + { path: 'alerts', handlers: [alertsRouter] }, { path: 'admin', handlers: [adminRateLimiter, adminRouter] }, ] @@ -333,6 +337,12 @@ async function gracefulShutdown(signal: string): Promise { logger.info('[Shutdown] Referral payout timer cleared') } + if (alertRulesHandle) { + clearInterval(alertRulesHandle) + alertRulesHandle = null + logger.info('[Shutdown] Alert rules timer cleared') + } + if (!httpServer) { logger.warn('[Shutdown] No HTTP server to close') process.exit(0) @@ -488,6 +498,7 @@ async function main(): Promise { poolMetricsHandle = schedulePoolMetrics() fiatReconciliationHandle = scheduleFiatReconciliation() referralPayoutHandle = scheduleReferralPayout() + alertRulesHandle = scheduleAlertRules() } // ── Process-level error guards ──────────────────────────────────────────────── diff --git a/src/jobs/alertRules.ts b/src/jobs/alertRules.ts new file mode 100644 index 0000000..72495a4 --- /dev/null +++ b/src/jobs/alertRules.ts @@ -0,0 +1,360 @@ +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' +import { config } from '../config/env' +import { dispatchWebhookEvent } from '../services/webhookDispatcher' +import { sendWhatsAppMessage } from '../utils/twilio-client' +import { formatAlertTriggeredReply } from '../whatsapp/formatters' +import { + compare, + cooldownCutoff, + computeDrawdownPercent, + rollingPeak, + type AlertMetric, + type Comparator, +} from '../services/alertEvaluator' + +/** + * Custom price & yield alert rule evaluator (#289). + * + * On each tick this job loads ACTIVE rules, computes the current value for each + * rule's metric, and fires a notification when the comparator condition holds + * and the rule is outside its cooldown window. Fires go out over the webhook + * (HMAC-signed, via the existing dispatchWebhookEvent) and/or WhatsApp channels. + * + * Design decisions (see issue #289): + * + * • Cooldown: a rule sitting at its threshold notifies at most once per + * cooldownMinutes. The condition does NOT need to flip false→true again — it + * re-fires once the cooldown elapses if still true. + * + * • Fire-claim is atomic: before delivering, we updateMany the row with a + * guard on { id, isActive, lastFiredAt within-cooldown }. If the rule was + * deleted or deactivated earlier in the same tick (or already fired by a + * concurrent runner), the update matches 0 rows and we skip delivery — no + * error, no double-send. This covers the "user deletes a rule mid-tick" and + * "condition true across many ticks" edge cases. + * + * • PROTOCOL_APY drawdown reference: see computeDrawdownPercent. Drawdown is + * measured against the rolling 30-day peak portfolio value (documented in + * docs/ALERTS.md). + * + * • Delisted protocol: a PROTOCOL_APY rule whose protocol has no ProtocolRate + * row is auto-deactivated (isActive=false) with a clear log line rather than + * evaluated against stale/missing data. + * + * • Failed webhook delivery: we reuse dispatchWebhookEvent as-is. Its internal + * 3-attempt backoff is the only retry; there is no separate sweep for alert + * deliveries. Rationale documented in docs/ALERTS.md — the next tick re- + * evaluates the live condition, so a transient delivery failure self-heals + * on the following tick (bounded by cooldown) rather than replaying a stale + * alert. We do NOT advance lastFiredAt when the condition is still fresh and + * delivery hard-fails on all channels, so the alert is retried next tick. + */ + +const WINDOW_DAYS = 30 +const WINDOW_MS = WINDOW_DAYS * 24 * 60 * 60 * 1000 + +interface AlertRuleRow { + id: string + userId: string + metric: AlertMetric + protocolName: string | null + comparator: Comparator + threshold: unknown // Prisma Decimal + deliveryChannel: 'WEBHOOK' | 'WHATSAPP' | 'BOTH' + cooldownMinutes: number + lastFiredAt: Date | null +} + +const ASSET_SYMBOL = 'USDC' + +/** + * Resolve the observed value for a rule's metric, or null when it cannot be + * evaluated this tick (missing data). Returns `delisted: true` for a + * PROTOCOL_APY rule whose protocol has no rate data so the caller can + * auto-deactivate it. + */ +async function observeMetric( + rule: AlertRuleRow, + now: Date +): Promise<{ value: number | null; delisted?: boolean }> { + switch (rule.metric) { + case 'PROTOCOL_APY': { + if (!rule.protocolName) return { value: null } + const latestRate = await db.protocolRate.findFirst({ + where: { protocolName: rule.protocolName, assetSymbol: ASSET_SYMBOL }, + orderBy: { fetchedAt: 'desc' }, + select: { supplyApy: true }, + }) + if (!latestRate) { + // Protocol delisted/removed — no rate data to evaluate against. + return { value: null, delisted: true } + } + // supplyApy is stored as a fraction (0.0842 == 8.42%); thresholds are + // expressed in percent, so scale to percent for comparison. + return { value: Number(latestRate.supplyApy) * 100 } + } + + case 'PORTFOLIO_VALUE': { + const positions = await db.position.findMany({ + where: { userId: rule.userId, status: 'ACTIVE' }, + select: { currentValue: true }, + }) + const total = positions.reduce( + (sum, p) => sum + Number(p.currentValue), + 0 + ) + return { value: total } + } + + case 'POSITION_DRAWDOWN': { + // Drawdown of the user's total portfolio value from its rolling 30-day + // peak. The peak is the max of historical YieldSnapshot principal+yield + // samples and the current value (see docs/ALERTS.md for the exact window). + const positions = await db.position.findMany({ + where: { userId: rule.userId, status: 'ACTIVE' }, + select: { id: true, currentValue: true }, + }) + if (positions.length === 0) return { value: 0 } + + const currentValue = positions.reduce( + (sum, p) => sum + Number(p.currentValue), + 0 + ) + + const fromDate = new Date(now.getTime() - WINDOW_MS) + const snapshots = await db.yieldSnapshot.findMany({ + where: { + positionId: { in: positions.map((p) => p.id) }, + snapshotAt: { gte: fromDate }, + }, + select: { principalAmount: true, yieldAmount: true, snapshotAt: true }, + }) + + // Aggregate snapshots into per-instant portfolio values so the peak is a + // whole-portfolio high, not a single position's. + const valueByInstant = new Map() + for (const s of snapshots) { + const key = s.snapshotAt.getTime() + const v = Number(s.principalAmount) + Number(s.yieldAmount) + valueByInstant.set(key, (valueByInstant.get(key) ?? 0) + v) + } + const peak = rollingPeak( + Array.from(valueByInstant.values()), + currentValue + ) + return { value: computeDrawdownPercent(peak, currentValue) } + } + + default: + return { value: null } + } +} + +/** + * Atomically claim a fire for a rule: set lastFiredAt=now only if the rule is + * still active and still outside its cooldown. Returns true if this call won + * the claim (and should therefore deliver). Guards against delete/deactivate + * mid-tick and concurrent runners. + */ +async function claimFire(rule: AlertRuleRow, now: Date): Promise { + const cutoff = cooldownCutoff(rule.cooldownMinutes, now) + const result = await db.alertRule.updateMany({ + where: { + id: rule.id, + isActive: true, + OR: [{ lastFiredAt: null }, { lastFiredAt: { lte: cutoff } }], + }, + data: { lastFiredAt: now }, + }) + return result.count === 1 +} + +/** + * Deliver a triggered alert over the rule's channel(s). Returns true if at + * least one channel delivered (or was attempted without a hard local failure). + */ +async function deliverAlert( + rule: AlertRuleRow, + observedValue: number +): Promise { + const threshold = Number(rule.threshold) + const data = { + ruleId: rule.id, + userId: rule.userId, + metric: rule.metric, + protocolName: rule.protocolName, + comparator: rule.comparator, + threshold, + observedValue, + triggeredAt: new Date().toISOString(), + } + + const wantsWebhook = + rule.deliveryChannel === 'WEBHOOK' || rule.deliveryChannel === 'BOTH' + const wantsWhatsApp = + rule.deliveryChannel === 'WHATSAPP' || rule.deliveryChannel === 'BOTH' + + if (wantsWebhook) { + // HMAC-signed via the existing dispatcher; no new unsigned path. + await dispatchWebhookEvent('alert_rule.triggered', data) + } + + if (wantsWhatsApp) { + const user = await db.user.findUnique({ + where: { id: rule.userId }, + select: { phone: true }, + }) + if (!user?.phone) { + logger.warn( + `[AlertRules] Rule ${rule.id} requests WhatsApp delivery but user ${rule.userId} has no phone on file — skipping WhatsApp channel` + ) + } else { + const body = formatAlertTriggeredReply({ + metric: rule.metric, + protocolName: rule.protocolName, + comparator: rule.comparator, + threshold, + observedValue, + }) + await sendWhatsAppMessage({ to: `whatsapp:${user.phone}`, body }) + } + } +} + +export async function runAlertRules(now: Date = new Date()): Promise { + const correlationId = generateCorrelationId() + return runWithCorrelationIdAsync(correlationId, async () => { + const start = Date.now() + const jobName = 'alert_rules' + + let evaluated = 0 + let fired = 0 + let deactivated = 0 + + try { + const rules = (await db.alertRule.findMany({ + where: { isActive: true }, + select: { + id: true, + userId: true, + metric: true, + protocolName: true, + comparator: true, + threshold: true, + deliveryChannel: true, + cooldownMinutes: true, + lastFiredAt: true, + }, + })) as AlertRuleRow[] + + for (const rule of rules) { + evaluated++ + try { + const { value, delisted } = await observeMetric(rule, now) + + if (delisted) { + await db.alertRule.updateMany({ + where: { id: rule.id }, + data: { isActive: false }, + }) + deactivated++ + logger.warn( + `[AlertRules] Deactivated rule ${rule.id}: protocol "${rule.protocolName}" has no rate data (delisted/removed)` + ) + continue + } + + if (value === null) continue + + const conditionMet = compare( + rule.comparator, + value, + Number(rule.threshold) + ) + if (!conditionMet) continue + + // Atomically claim the fire (cooldown + delete/deactivate guard). + const won = await claimFire(rule, now) + if (!won) continue + + try { + await deliverAlert(rule, value) + fired++ + } catch (deliveryError) { + // Delivery failed after the claim advanced lastFiredAt. Roll the + // claim back so the alert is retried on the next tick if the + // condition is still true, rather than being silently swallowed for + // a full cooldown window. + await db.alertRule + .updateMany({ + where: { id: rule.id }, + data: { lastFiredAt: rule.lastFiredAt }, + }) + .catch(() => undefined) + logger.error( + `[AlertRules] Delivery failed for rule ${rule.id}; fire-claim rolled back for retry`, + { + error: + deliveryError instanceof Error + ? deliveryError.message + : String(deliveryError), + } + ) + } + } catch (ruleError) { + // One bad rule must not abort the sweep. + logger.error(`[AlertRules] Error evaluating rule ${rule.id}`, { + error: + ruleError instanceof Error + ? ruleError.message + : String(ruleError), + }) + } + } + + const durationMs = Date.now() - start + logBackgroundJob(jobName, 'success', durationMs / 1000, correlationId, { + evaluated, + fired, + deactivated, + }) + recordJobSuccess(jobName, durationMs) + } catch (error) { + const durationMs = Date.now() - start + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + logBackgroundJob(jobName, 'failed', durationMs / 1000, correlationId, { + error: errorMessage, + }) + recordJobFailure(jobName, durationMs) + } + }) +} + +/** + * Schedule the alert-rule evaluator. Runs once on startup then on the + * configured interval, following the same pattern as the other jobs. + * + * @returns NodeJS.Timeout handle — pass to clearInterval() on shutdown. + */ +export function scheduleAlertRules(): NodeJS.Timeout { + void runAlertRules() + + const intervalMs = config.alertRules.intervalMs + const handle = setInterval(() => { + void runAlertRules() + }, intervalMs) + + handle.unref?.() + + logger.info( + `[AlertRules] Alert-rule evaluation scheduled every ${intervalMs}ms` + ) + return handle +} diff --git a/src/nlp/parser.ts b/src/nlp/parser.ts index 2413a88..3a59101 100644 --- a/src/nlp/parser.ts +++ b/src/nlp/parser.ts @@ -10,12 +10,36 @@ export interface Intent { | 'earnings' | 'goal' | 'help' + | 'alert_create' + | 'alert_list' + | 'alert_delete' | 'unknown' amount?: number currency?: string all?: boolean + // Alert-rule fields (action = alert_*). Kept optional so the union stays flat. + metric?: 'PROTOCOL_APY' | 'PORTFOLIO_VALUE' | 'POSITION_DRAWDOWN' + protocolName?: string + comparator?: 'LT' | 'LTE' | 'GT' | 'GTE' + threshold?: number + alertId?: string } +// Actions the Claude tier is allowed to emit. Kept in sync MANUALLY with the +// Intent union above and the handler switch in src/whatsapp/handler.ts — same +// manual-sync caveat as the deposit/withdraw intents (#281/#282). A new action +// must be added here or parseWithClaude will drop it as unknown. +const KNOWN_ACTIONS = [ + 'deposit', + 'withdraw', + 'balance', + 'earnings', + 'help', + 'alert_create', + 'alert_list', + 'alert_delete', +] as const + const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY || 'dummy_key', }) @@ -29,6 +53,79 @@ const anthropicHttpClient = new HttpClientAdapter({ circuitBreakerResetMs: config.httpClient.circuitBreakerResetMs, }) +/** + * Parse alert-rule management phrases (create/list/delete) from a lowercased + * message. Returns null when the message isn't alert-related so the caller can + * fall through to the other regex tiers. Deliberately conservative — anything + * ambiguous is left to the Claude tier. + */ +export function parseAlertIntent(lowerMsg: string): Intent | null { + // List: "my alerts", "list alerts", "show my alert rules" + if ( + /\b(list|show|view|my)\b.*\balerts?\b|\balerts?\b.*\b(list|status)\b/i.test( + lowerMsg + ) + ) { + return { action: 'alert_list' } + } + + // Delete: "delete alert ", "remove alert ", "cancel alert " + const deleteMatch = lowerMsg.match( + /\b(delete|remove|cancel|stop)\s+alert(?:\s+rule)?\s+([0-9a-f-]{6,})/i + ) + if (deleteMatch) { + return { action: 'alert_delete', alertId: deleteMatch[2] } + } + if (/\b(delete|remove|cancel|stop)\b.*\balerts?\b/i.test(lowerMsg)) { + // Delete intent without a parseable id — still route to alert_delete so the + // handler can ask which rule to remove. + return { action: 'alert_delete' } + } + + // Create: "alert me if apy drops below 5", + // "notify me when my portfolio drops below 1000". + const isCreate = + /\b(alert|notify|tell|warn|ping)\s+me\b/i.test(lowerMsg) || + /\b(set|create|add)\s+(an?\s+)?alert\b/i.test(lowerMsg) + if (!isCreate) return null + + const intent: Intent = { action: 'alert_create' } + + // Metric + protocol + if (/\bportfolio\b/i.test(lowerMsg)) { + intent.metric = 'PORTFOLIO_VALUE' + } else if (/\bdrawdown\b/i.test(lowerMsg)) { + intent.metric = 'POSITION_DRAWDOWN' + } else if (/\bapy\b|\byield\b/i.test(lowerMsg)) { + intent.metric = 'PROTOCOL_APY' + // Grab the protocol name preceding "apy"/"yield" (e.g. "blend apy"). + const protoMatch = lowerMsg.match(/\b([a-z][a-z0-9 ]*?)\s+(?:apy|yield)\b/i) + if (protoMatch) { + intent.protocolName = protoMatch[1].trim() + } + } + + // Comparator + if ( + /\b(below|under|less than|drops? below|falls? below|<)\b/i.test(lowerMsg) + ) { + intent.comparator = 'LT' + } else if ( + /\b(above|over|greater than|exceeds?|rises? above|>)\b/i.test(lowerMsg) + ) { + intent.comparator = 'GT' + } + + // Threshold — first standalone number in the message. + const numMatch = lowerMsg.match(/([\d]+(?:\.[\d]+)?)/) + if (numMatch) { + const n = parseFloat(numMatch[1]) + if (!isNaN(n)) intent.threshold = n + } + + return intent +} + // Regex fallback export function parseWithRegex(message: string): Intent | null { const lowerMsg = message.toLowerCase().trim() @@ -54,6 +151,13 @@ export function parseWithRegex(message: string): Intent | null { } } + // Alert rules — list / delete / create. Checked before the generic + // "apy"/"yield" earnings keyword so "alert me when apy..." isn't swallowed. + const alertIntent = parseAlertIntent(lowerMsg) + if (alertIntent) { + return alertIntent + } + // Balance if (/balance|what'?s my balance|how much do i have/i.test(lowerMsg)) { return { action: 'balance' } @@ -90,8 +194,15 @@ Return ONLY a JSON object representing the intent, matching this TypeScript inte "action": "deposit" | "withdraw" | "balance" | "earnings" | "goal" | "help" | "unknown", "amount": number, // optional "currency": string, // optional - "all": boolean // for "withdraw everything" -}`, + "all": boolean, // for "withdraw everything" + // Alert fields (only for alert_* actions): + "metric": "PROTOCOL_APY" | "PORTFOLIO_VALUE" | "POSITION_DRAWDOWN", // what to watch + "protocolName": string, // required when metric = PROTOCOL_APY, e.g. "Blend" + "comparator": "LT" | "LTE" | "GT" | "GTE", // below=LT, above=GT + "threshold": number, // the trigger value (APY as a percent, e.g. 5 for 5%) + "alertId": string // for alert_delete, if the user named a specific rule id +} +Examples: "alert me if Blend APY drops below 5" -> {"action":"alert_create","metric":"PROTOCOL_APY","protocolName":"Blend","comparator":"LT","threshold":5}. "show my alerts" -> {"action":"alert_list"}. "delete alert abc-123" -> {"action":"alert_delete","alertId":"abc-123"}.`, messages: [{ role: 'user', content: message }], }) }, 'anthropic.parseIntent') diff --git a/src/routes/alerts.ts b/src/routes/alerts.ts new file mode 100644 index 0000000..4e6047c --- /dev/null +++ b/src/routes/alerts.ts @@ -0,0 +1,172 @@ +import { Router, Request, Response } from 'express' +import db from '../db' +import { requireAuth, enforceUserAccess } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { sendNotFound } from '../utils/errors' +import { + createAlertRuleSchema, + updateAlertRuleSchema, + alertIdParamSchema, + alertUserParamSchema, +} from '../validators/alert-validators' + +const router = Router() + +// All alert routes require auth. +router.use(requireAuth) + +// Fields returned to clients. `threshold` is Decimal in the DB; serialize it as +// a string via Prisma's default JSON handling to avoid float precision loss. +const alertSelect = { + id: true, + userId: true, + metric: true, + protocolName: true, + comparator: true, + threshold: true, + deliveryChannel: true, + cooldownMinutes: true, + lastFiredAt: true, + isActive: true, + createdAt: true, + updatedAt: true, +} as const + +/** + * POST /api/alerts + * Create a new alert rule owned by the authenticated user. + */ +router.post( + '/', + validate({ body: createAlertRuleSchema }), + async (req: Request, res: Response) => { + const userId = req.auth!.userId + const { + metric, + protocolName, + comparator, + threshold, + deliveryChannel, + cooldownMinutes, + } = req.body + + const rule = await (db as any).alertRule.create({ + data: { + userId, + metric, + protocolName: protocolName ?? null, + comparator, + threshold, + deliveryChannel, + cooldownMinutes, + }, + select: alertSelect, + }) + + return res.status(201).json(rule) + } +) + +/** + * GET /api/alerts/:userId + * List all alert rules for the given user. Owner-scoped: a caller may only + * read their own rules (enforceUserAccess compares :userId to req.auth.userId). + */ +router.get( + '/:userId', + validate({ params: alertUserParamSchema }), + enforceUserAccess, + async (req: Request, res: Response) => { + const userId = req.params.userId as string + + const rules = await (db as any).alertRule.findMany({ + where: { userId }, + select: alertSelect, + orderBy: { createdAt: 'desc' }, + }) + + return res.status(200).json({ rules }) + } +) + +/** + * PATCH /api/alerts/:id + * Update an alert rule. Ownership is enforced by scoping the lookup to the + * caller's userId, matching the pattern used by the other :id-keyed resources. + */ +router.patch( + '/:id', + validate({ params: alertIdParamSchema, body: updateAlertRuleSchema }), + async (req: Request, res: Response) => { + const userId = req.auth!.userId + + const existing = await (db as any).alertRule.findFirst({ + where: { id: req.params.id, userId }, + select: { id: true, metric: true, protocolName: true }, + }) + if (!existing) return sendNotFound(res, 'Alert rule') + + // Enforce the PROTOCOL_APY/protocolName pairing against the effective + // (post-update) state, since a PATCH may change either field alone. + const nextMetric = req.body.metric ?? existing.metric + const nextProtocolName = + req.body.protocolName !== undefined + ? req.body.protocolName + : existing.protocolName + + if (nextMetric === 'PROTOCOL_APY' && !nextProtocolName) { + return res.status(400).json({ + error: 'Validation failed', + details: [ + { + path: 'protocolName', + message: 'protocolName is required when metric is PROTOCOL_APY', + }, + ], + }) + } + if (nextMetric !== 'PROTOCOL_APY' && nextProtocolName) { + return res.status(400).json({ + error: 'Validation failed', + details: [ + { + path: 'protocolName', + message: 'protocolName is only valid when metric is PROTOCOL_APY', + }, + ], + }) + } + + const updated = await (db as any).alertRule.update({ + where: { id: req.params.id }, + data: req.body, + select: alertSelect, + }) + + return res.status(200).json(updated) + } +) + +/** + * DELETE /api/alerts/:id + * Delete an alert rule owned by the caller. + */ +router.delete( + '/:id', + validate({ params: alertIdParamSchema }), + async (req: Request, res: Response) => { + const userId = req.auth!.userId + + const existing = await (db as any).alertRule.findFirst({ + where: { id: req.params.id, userId }, + select: { id: true }, + }) + if (!existing) return sendNotFound(res, 'Alert rule') + + await (db as any).alertRule.delete({ where: { id: req.params.id } }) + + return res.status(204).send() + } +) + +export default router diff --git a/src/services/alertEvaluator.ts b/src/services/alertEvaluator.ts new file mode 100644 index 0000000..cc73d6a --- /dev/null +++ b/src/services/alertEvaluator.ts @@ -0,0 +1,124 @@ +/** + * Alert rule evaluation core (#289). + * + * Pure, side-effect-free helpers so the threshold-crossing and cooldown logic + * can be unit-tested in isolation from the scheduler and the database (see the + * suggested implementation plan in the issue). The job in + * src/jobs/alertRules.ts does the DB I/O and delivery; everything here is + * deterministic given its inputs. + */ + +export type AlertMetric = + 'PROTOCOL_APY' | 'PORTFOLIO_VALUE' | 'POSITION_DRAWDOWN' +export type Comparator = 'LT' | 'LTE' | 'GT' | 'GTE' + +/** + * Evaluate a comparator against an observed value and threshold. + * Returns true when the condition the user asked to be alerted about holds. + */ +export function compare( + comparator: Comparator, + observed: number, + threshold: number +): boolean { + switch (comparator) { + case 'LT': + return observed < threshold + case 'LTE': + return observed <= threshold + case 'GT': + return observed > threshold + case 'GTE': + return observed >= threshold + default: + return false + } +} + +/** + * Whether a rule is still inside its cooldown window and must NOT fire again. + * + * A rule that has never fired (lastFiredAt == null) is never in cooldown. The + * window is [lastFiredAt, lastFiredAt + cooldownMinutes). Once `now` reaches or + * passes the end of that window the rule may fire again — the condition does + * NOT have to flip false and back to true in between (see issue edge cases). + */ +export function isCooldownActive( + lastFiredAt: Date | null | undefined, + cooldownMinutes: number, + now: Date +): boolean { + if (!lastFiredAt) return false + const elapsedMs = now.getTime() - lastFiredAt.getTime() + return elapsedMs < cooldownMinutes * 60_000 +} + +/** + * The instant at which a rule's cooldown expires — anything with + * lastFiredAt <= this cutoff is eligible to fire again. Used to build the + * atomic fire-claim query in the job. + */ +export function cooldownCutoff(cooldownMinutes: number, now: Date): Date { + return new Date(now.getTime() - cooldownMinutes * 60_000) +} + +/** + * Percentage drawdown of a current value from a peak reference. + * + * Drawdown is defined as the decline from the rolling peak: + * drawdown% = max(0, (peak - current) / peak * 100) + * + * Clamped at 0 so a value at or above its peak reports no drawdown rather than + * a negative number. Returns 0 when peak <= 0 (no meaningful reference yet). + */ +export function computeDrawdownPercent( + peakValue: number, + currentValue: number +): number { + if (peakValue <= 0) return 0 + const drawdown = ((peakValue - currentValue) / peakValue) * 100 + return drawdown > 0 ? drawdown : 0 +} + +/** + * Reduce a set of historical portfolio-value samples plus the current value to + * a rolling peak. The current value is included as a candidate so a brand-new + * high is its own peak (zero drawdown), never a value below a stale sample. + */ +export function rollingPeak( + historicalValues: number[], + currentValue: number +): number { + return Math.max(currentValue, ...historicalValues, 0) +} + +export interface EvaluatableRule { + metric: AlertMetric + comparator: Comparator + threshold: number + cooldownMinutes: number + lastFiredAt: Date | null +} + +export interface EvaluationResult { + /** The comparator condition holds for the observed value. */ + conditionMet: boolean + /** The rule is eligible to fire now (condition met AND cooldown elapsed). */ + shouldFire: boolean +} + +/** + * Decide whether a rule should fire given the value observed this tick. + * Combines the threshold check with the cooldown suppression. + */ +export function evaluateRule( + rule: EvaluatableRule, + observedValue: number, + now: Date +): EvaluationResult { + const conditionMet = compare(rule.comparator, observedValue, rule.threshold) + const shouldFire = + conditionMet && + !isCooldownActive(rule.lastFiredAt, rule.cooldownMinutes, now) + return { conditionMet, shouldFire } +} diff --git a/src/validators/alert-validators.ts b/src/validators/alert-validators.ts new file mode 100644 index 0000000..f29c743 --- /dev/null +++ b/src/validators/alert-validators.ts @@ -0,0 +1,87 @@ +import { z } from 'zod' + +/** + * Validators for user-defined alert rules (#289). + * + * The metric/comparator/deliveryChannel enums mirror the Prisma enums in + * prisma/schema.prisma — keep them in sync manually (there is no generated + * shared source between Zod and Prisma enums in this codebase). + */ + +export const ALERT_METRICS = [ + 'PROTOCOL_APY', + 'PORTFOLIO_VALUE', + 'POSITION_DRAWDOWN', +] as const + +export const COMPARATORS = ['LT', 'LTE', 'GT', 'GTE'] as const + +export const DELIVERY_CHANNELS = ['WEBHOOK', 'WHATSAPP', 'BOTH'] as const + +export type AlertMetric = (typeof ALERT_METRICS)[number] +export type Comparator = (typeof COMPARATORS)[number] +export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number] + +const baseAlertRuleShape = { + metric: z.enum(ALERT_METRICS), + protocolName: z.string().trim().min(1).max(100).optional(), + comparator: z.enum(COMPARATORS), + threshold: z.number().finite(), + deliveryChannel: z.enum(DELIVERY_CHANNELS), + cooldownMinutes: z.number().int().min(1).max(10080).default(60), +} + +/** + * PROTOCOL_APY rules must name a protocol; the other metrics must not + * (protocolName is meaningless for portfolio-wide metrics and would be + * silently ignored, so we reject it to avoid confusing rules). + */ +function requireProtocolNameForApy< + T extends { metric?: AlertMetric; protocolName?: string }, +>(data: T, ctx: z.RefinementCtx): void { + if (data.metric === 'PROTOCOL_APY' && !data.protocolName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['protocolName'], + message: 'protocolName is required when metric is PROTOCOL_APY', + }) + } + if (data.metric && data.metric !== 'PROTOCOL_APY' && data.protocolName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['protocolName'], + message: 'protocolName is only valid when metric is PROTOCOL_APY', + }) + } +} + +export const createAlertRuleSchema = z + .object(baseAlertRuleShape) + .superRefine(requireProtocolNameForApy) + +/** + * PATCH allows partial updates. When metric is being changed we still enforce + * the protocolName pairing; when it is absent we can only validate the fields + * that are present, so the job re-derives requirements from the stored row. + */ +export const updateAlertRuleSchema = z + .object({ + metric: z.enum(ALERT_METRICS).optional(), + protocolName: z.string().trim().min(1).max(100).nullable().optional(), + comparator: z.enum(COMPARATORS).optional(), + threshold: z.number().finite().optional(), + deliveryChannel: z.enum(DELIVERY_CHANNELS).optional(), + cooldownMinutes: z.number().int().min(1).max(10080).optional(), + isActive: z.boolean().optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one field must be provided', + }) + +export const alertIdParamSchema = z.object({ + id: z.string().uuid('Invalid alert rule ID'), +}) + +export const alertUserParamSchema = z.object({ + userId: z.string().uuid('Invalid user ID format'), +}) diff --git a/src/validators/webhook-validators.ts b/src/validators/webhook-validators.ts index 966ded5..326670a 100644 --- a/src/validators/webhook-validators.ts +++ b/src/validators/webhook-validators.ts @@ -33,6 +33,7 @@ const WEBHOOK_EVENTS = [ 'withdraw.completed', 'fiat.order.settled', 'fiat.order.failed', + 'alert_rule.triggered', ] as const export const createWebhookSchema = z.object({ diff --git a/src/whatsapp/alertManager.ts b/src/whatsapp/alertManager.ts new file mode 100644 index 0000000..501b32e --- /dev/null +++ b/src/whatsapp/alertManager.ts @@ -0,0 +1,164 @@ +import db from '../db' +import { logger } from '../utils/logger' +import { + createAlertRuleSchema, + type DeliveryChannel, +} from '../validators/alert-validators' + +/** + * WhatsApp-facing alert-rule management (#289). + * + * The WhatsApp layer identifies users by wallet address (the in-memory phone + * store holds the custodial wallet), so these helpers resolve the DB user by + * walletAddress and then perform the same owner-scoped CRUD the HTTP routes do. + * A rule is only ever visible or mutable by its owner — the walletAddress → + * userId resolution IS the ownership check here. + */ + +export interface AlertRuleView { + id: string + metric: string + protocolName: string | null + comparator: string + threshold: number + deliveryChannel: string + cooldownMinutes: number + isActive: boolean +} + +const viewSelect = { + id: true, + metric: true, + protocolName: true, + comparator: true, + threshold: true, + deliveryChannel: true, + cooldownMinutes: true, + isActive: true, +} + +function toView(rule: { + id: string + metric: string + protocolName: string | null + comparator: string + threshold: unknown + deliveryChannel: string + cooldownMinutes: number + isActive: boolean +}): AlertRuleView { + return { + id: rule.id, + metric: rule.metric, + protocolName: rule.protocolName, + comparator: rule.comparator, + threshold: Number(rule.threshold), + deliveryChannel: rule.deliveryChannel, + cooldownMinutes: rule.cooldownMinutes, + isActive: rule.isActive, + } +} + +async function resolveUserId(walletAddress: string): Promise { + const user = await db.user.findUnique({ + where: { walletAddress }, + select: { id: true }, + }) + return user?.id ?? null +} + +export type CreateAlertResult = + { ok: true; rule: AlertRuleView } | { ok: false; error: string } + +/** + * Create an alert rule for the user owning `walletAddress`. Validates the + * (partially NLP-derived) input through the same Zod schema the HTTP route + * uses, so conversational and API rules share one validation source of truth. + */ +export async function createAlertRuleForWallet( + walletAddress: string, + input: { + metric?: string + protocolName?: string + comparator?: string + threshold?: number + deliveryChannel: DeliveryChannel + } +): Promise { + const userId = await resolveUserId(walletAddress) + if (!userId) { + return { + ok: false, + error: 'I could not find your account. Please try again.', + } + } + + const parsed = createAlertRuleSchema.safeParse({ + metric: input.metric, + protocolName: input.protocolName, + comparator: input.comparator, + threshold: input.threshold, + deliveryChannel: input.deliveryChannel, + }) + + if (!parsed.success) { + // Surface a single friendly hint rather than raw Zod detail over WhatsApp. + return { + ok: false, + error: + 'I couldn\'t understand that alert. Try e.g. "alert me when Blend apy below 5" or "notify me if portfolio value below 1000".', + } + } + + const rule = await db.alertRule.create({ + data: { + userId, + metric: parsed.data.metric, + protocolName: parsed.data.protocolName ?? null, + comparator: parsed.data.comparator, + threshold: parsed.data.threshold, + deliveryChannel: parsed.data.deliveryChannel, + cooldownMinutes: parsed.data.cooldownMinutes, + }, + select: viewSelect, + }) + + return { ok: true, rule: toView(rule) } +} + +/** List the alert rules owned by the user behind `walletAddress`. */ +export async function listAlertRulesForWallet( + walletAddress: string +): Promise { + const userId = await resolveUserId(walletAddress) + if (!userId) return [] + + const rules = await db.alertRule.findMany({ + where: { userId }, + select: viewSelect, + orderBy: { createdAt: 'desc' }, + }) + return rules.map(toView) +} + +/** + * Delete an alert rule by id, but only if it belongs to `walletAddress`. + * Returns true when a rule was deleted, false when none matched (unknown id or + * not owned by this user) — the caller cannot distinguish the two, by design. + */ +export async function deleteAlertRuleForWallet( + walletAddress: string, + alertId: string +): Promise { + const userId = await resolveUserId(walletAddress) + if (!userId) return false + + const result = await db.alertRule.deleteMany({ + where: { id: alertId, userId }, + }) + + if (result.count > 0) { + logger.info(`[AlertManager] Deleted alert ${alertId} for user ${userId}`) + } + return result.count > 0 +} diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts index 81e5efe..5de3764 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -191,3 +191,112 @@ export function formatWithdrawReply(input: { '_You will receive a confirmation once settled._', ].join('\n') } + +const ALERT_METRIC_LABELS: Record = { + PROTOCOL_APY: 'Protocol APY', + PORTFOLIO_VALUE: 'Portfolio value', + POSITION_DRAWDOWN: 'Position drawdown', +} + +const ALERT_COMPARATOR_LABELS: Record = { + LT: 'below', + LTE: 'at or below', + GT: 'above', + GTE: 'at or above', +} + +/** + * WhatsApp message sent when a user's alert rule fires (#289). Units follow the + * rule's metric: APY and drawdown are percentages, portfolio value is USD. + */ +export function formatAlertTriggeredReply(input: { + metric: string + protocolName?: string | null + comparator: string + threshold: number + observedValue: number +}): string { + const metricLabel = ALERT_METRIC_LABELS[input.metric] ?? input.metric + const comparatorLabel = + ALERT_COMPARATOR_LABELS[input.comparator] ?? input.comparator + const isPercent = + input.metric === 'PROTOCOL_APY' || input.metric === 'POSITION_DRAWDOWN' + const unit = isPercent ? '%' : '' + const prefix = isPercent ? '' : '$' + const subject = + input.metric === 'PROTOCOL_APY' && input.protocolName + ? `${metricLabel} (${input.protocolName})` + : metricLabel + + const fmt = (n: number): string => `${prefix}${n.toFixed(2)}${unit}` + + return [ + '🔔 *Alert triggered*', + `${subject} is ${comparatorLabel} *${fmt(input.threshold)}*.`, + `Current: *${fmt(input.observedValue)}*`, + ].join('\n') +} + +function describeAlertRule(rule: { + metric: string + protocolName?: string | null + comparator: string + threshold: number +}): string { + const metricLabel = ALERT_METRIC_LABELS[rule.metric] ?? rule.metric + const comparatorLabel = + ALERT_COMPARATOR_LABELS[rule.comparator] ?? rule.comparator + const isPercent = + rule.metric === 'PROTOCOL_APY' || rule.metric === 'POSITION_DRAWDOWN' + const value = isPercent + ? `${rule.threshold}%` + : `$${rule.threshold.toFixed(2)}` + const subject = + rule.metric === 'PROTOCOL_APY' && rule.protocolName + ? `${metricLabel} (${rule.protocolName})` + : metricLabel + return `${subject} ${comparatorLabel} ${value}` +} + +/** Confirmation shown after a user creates an alert rule over WhatsApp (#289). */ +export function formatAlertCreatedReply(rule: { + id: string + metric: string + protocolName?: string | null + comparator: string + threshold: number +}): string { + return [ + '✅ *Alert created*', + describeAlertRule(rule), + `_ID: ${rule.id}_`, + ].join('\n') +} + +/** Lists a user's alert rules over WhatsApp (#289). */ +export function formatAlertListReply( + rules: Array<{ + id: string + metric: string + protocolName?: string | null + comparator: string + threshold: number + isActive: boolean + }> +): string { + if (rules.length === 0) { + return '🔕 You have no alert rules yet. Try "alert me when Blend apy < 5".' + } + const lines = rules.slice(0, 10).map((rule) => { + const state = rule.isActive ? '' : ' _(inactive)_' + return `• ${describeAlertRule(rule)}${state}\n _${rule.id}_` + }) + return ['🔔 *Your alert rules*', lines.join('\n')].join('\n') +} + +/** Confirmation shown after deleting an alert rule over WhatsApp (#289). */ +export function formatAlertDeletedReply(found: boolean): string { + return found + ? '🗑️ *Alert deleted.*' + : "I couldn't find an alert with that ID that belongs to you." +} diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts index e3f03b1..e2a4e0a 100644 --- a/src/whatsapp/handler.ts +++ b/src/whatsapp/handler.ts @@ -11,6 +11,16 @@ import { getGoalStatus, decrementBalance, } from './userManager' +import { + createAlertRuleForWallet, + listAlertRulesForWallet, + deleteAlertRuleForWallet, +} from './alertManager' +import { + formatAlertCreatedReply, + formatAlertListReply, + formatAlertDeletedReply, +} from './formatters' import { logger } from '../utils/logger' import { config } from '../config' import { downloadTwilioMedia } from './mediaDownloader' @@ -45,7 +55,9 @@ function formatHelpMessage(): string { '- "deposit " → get deposit instructions', '- "withdraw " → withdraw funds (if available)', '- "earnings" → see your performance', - '- "goal" → check your savings goal progress', + '- "alert me when Blend apy < 5" → create a price/yield alert', + '- "list my alerts" → see your alert rules', + '- "delete alert " → remove an alert rule', '- "help" → show this message again', ].join('\n') } @@ -201,6 +213,51 @@ async function executeIntent( return { body: formatEarnings(summary) } } + case 'alert_create': { + const walletAddress = getUserWalletAddress(normalizedPhone) + if (!walletAddress) { + return { body: 'I could not find your account. Please try again.' } + } + const result = await createAlertRuleForWallet(walletAddress, { + metric: intent.metric, + protocolName: intent.protocolName, + comparator: intent.comparator, + threshold: intent.threshold, + // WhatsApp-originated rules deliver over WhatsApp by default. + deliveryChannel: 'WHATSAPP', + }) + if (!result.ok) { + return { body: result.error } + } + return { body: formatAlertCreatedReply(result.rule) } + } + + case 'alert_list': { + const walletAddress = getUserWalletAddress(normalizedPhone) + if (!walletAddress) { + return { body: 'I could not find your account. Please try again.' } + } + const rules = await listAlertRulesForWallet(walletAddress) + return { body: formatAlertListReply(rules) } + } + + case 'alert_delete': { + const walletAddress = getUserWalletAddress(normalizedPhone) + if (!walletAddress) { + return { body: 'I could not find your account. Please try again.' } + } + if (!intent.alertId) { + return { + body: 'Please tell me which alert to delete, e.g. "delete alert ".', + } + } + const deleted = await deleteAlertRuleForWallet( + walletAddress, + intent.alertId + ) + return { body: formatAlertDeletedReply(deleted) } + } + case 'unknown': default: return { body: formatUnknownMessage() } diff --git a/tests/unit/jobs/alertRules.test.ts b/tests/unit/jobs/alertRules.test.ts new file mode 100644 index 0000000..a01cb41 --- /dev/null +++ b/tests/unit/jobs/alertRules.test.ts @@ -0,0 +1,242 @@ +// Config env validation runs at import time; supply the required vars before +// any src import loads src/config/env (same pattern as the other unit tests). +process.env.NODE_ENV = 'test' +process.env.STELLAR_NETWORK = 'testnet' +process.env.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org' +process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55) +process.env.VAULT_CONTRACT_ID = 'C' + 'A'.repeat(55) +process.env.USDC_TOKEN_ADDRESS = 'C' + 'B'.repeat(55) +process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key' +process.env.DATABASE_URL = 'postgresql://localhost:5432/test' +process.env.JWT_SEED = '0'.repeat(64) +process.env.WALLET_ENCRYPTION_KEY = '0'.repeat(64) +process.env.TWILIO_AUTH_TOKEN = '0'.repeat(32) +process.env.TWILIO_ACCOUNT_SID = 'AC' + '0'.repeat(32) + +import { runAlertRules } from '../../../src/jobs/alertRules' +import db from '../../../src/db' +import { dispatchWebhookEvent } from '../../../src/services/webhookDispatcher' +import { sendWhatsAppMessage } from '../../../src/utils/twilio-client' + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: {}, +})) +jest.mock('../../../src/services/webhookDispatcher', () => ({ + dispatchWebhookEvent: jest.fn().mockResolvedValue(undefined), +})) +jest.mock('../../../src/utils/twilio-client', () => ({ + sendWhatsAppMessage: jest.fn().mockResolvedValue('sid-1'), +})) +jest.mock('../../../src/utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, + logBackgroundJob: jest.fn(), +})) +jest.mock('../../../src/utils/job-metrics', () => ({ + recordJobSuccess: jest.fn(), + recordJobFailure: jest.fn(), +})) + +const mockDb = db as any +const mockDispatch = dispatchWebhookEvent as jest.Mock +const mockSendWhatsApp = sendWhatsAppMessage as jest.Mock + +/** Build a fresh alertRule mock with sensible default resolved values. */ +function stubDb(rules: any[]): void { + mockDb.alertRule = { + findMany: jest.fn().mockResolvedValue(rules), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + } + mockDb.protocolRate = { findFirst: jest.fn().mockResolvedValue(null) } + mockDb.position = { findMany: jest.fn().mockResolvedValue([]) } + mockDb.yieldSnapshot = { findMany: jest.fn().mockResolvedValue([]) } + mockDb.user = { + findUnique: jest.fn().mockResolvedValue({ phone: '+15551230000' }), + } +} + +const NOW = new Date('2026-07-20T12:00:00.000Z') + +describe('runAlertRules', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('fires a PROTOCOL_APY webhook when APY drops below the threshold', async () => { + stubDb([ + { + id: 'rule-1', + userId: 'user-1', + metric: 'PROTOCOL_APY', + protocolName: 'Blend', + comparator: 'LT', + threshold: 5, // percent + deliveryChannel: 'WEBHOOK', + cooldownMinutes: 60, + lastFiredAt: null, + }, + ]) + // supplyApy stored as a fraction: 0.04 == 4% < 5% threshold → fires. + mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.04 }) + + await runAlertRules(NOW) + + expect(mockDb.alertRule.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'rule-1', isActive: true }), + data: { lastFiredAt: NOW }, + }) + ) + expect(mockDispatch).toHaveBeenCalledWith( + 'alert_rule.triggered', + expect.objectContaining({ + ruleId: 'rule-1', + observedValue: 4, + threshold: 5, + }) + ) + }) + + it('does not fire when the APY condition is not met', async () => { + stubDb([ + { + id: 'rule-1', + userId: 'user-1', + metric: 'PROTOCOL_APY', + protocolName: 'Blend', + comparator: 'LT', + threshold: 5, + deliveryChannel: 'WEBHOOK', + cooldownMinutes: 60, + lastFiredAt: null, + }, + ]) + mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.08 }) // 8% not < 5% + + await runAlertRules(NOW) + + expect(mockDb.alertRule.updateMany).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('auto-deactivates a PROTOCOL_APY rule whose protocol has no rate data', async () => { + stubDb([ + { + id: 'rule-1', + userId: 'user-1', + metric: 'PROTOCOL_APY', + protocolName: 'Ghost', + comparator: 'LT', + threshold: 5, + deliveryChannel: 'WEBHOOK', + cooldownMinutes: 60, + lastFiredAt: null, + }, + ]) + mockDb.protocolRate.findFirst.mockResolvedValue(null) // delisted + + await runAlertRules(NOW) + + expect(mockDb.alertRule.updateMany).toHaveBeenCalledWith({ + where: { id: 'rule-1' }, + data: { isActive: false }, + }) + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('does not deliver when the fire-claim matches 0 rows (deleted/deactivated mid-tick)', async () => { + stubDb([ + { + id: 'rule-1', + userId: 'user-1', + metric: 'PROTOCOL_APY', + protocolName: 'Blend', + comparator: 'LT', + threshold: 5, + deliveryChannel: 'WEBHOOK', + cooldownMinutes: 60, + lastFiredAt: null, + }, + ]) + mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.04 }) + // Claim loses (rule deleted/deactivated or already fired concurrently). + mockDb.alertRule.updateMany.mockResolvedValue({ count: 0 }) + + await runAlertRules(NOW) + + expect(mockDispatch).not.toHaveBeenCalled() + expect(mockSendWhatsApp).not.toHaveBeenCalled() + }) + + it('delivers over both channels for a BOTH rule and includes WhatsApp when a phone is on file', async () => { + stubDb([ + { + id: 'rule-1', + userId: 'user-1', + metric: 'PORTFOLIO_VALUE', + protocolName: null, + comparator: 'LT', + threshold: 1000, + deliveryChannel: 'BOTH', + cooldownMinutes: 60, + lastFiredAt: null, + }, + ]) + mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]) // < 1000 + + await runAlertRules(NOW) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(mockSendWhatsApp).toHaveBeenCalledWith( + expect.objectContaining({ to: 'whatsapp:+15551230000' }) + ) + }) + + it('skips the WhatsApp channel (without error) when the user has no phone', async () => { + stubDb([ + { + id: 'rule-1', + userId: 'user-1', + metric: 'PORTFOLIO_VALUE', + protocolName: null, + comparator: 'LT', + threshold: 1000, + deliveryChannel: 'WHATSAPP', + cooldownMinutes: 60, + lastFiredAt: null, + }, + ]) + mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]) + mockDb.user.findUnique.mockResolvedValue({ phone: null }) + + await runAlertRules(NOW) + + expect(mockSendWhatsApp).not.toHaveBeenCalled() + }) + + it('rolls back the fire-claim when delivery hard-fails so it retries next tick', async () => { + const prior = new Date('2026-07-20T10:00:00.000Z') + stubDb([ + { + id: 'rule-1', + userId: 'user-1', + metric: 'PORTFOLIO_VALUE', + protocolName: null, + comparator: 'LT', + threshold: 1000, + deliveryChannel: 'WEBHOOK', + cooldownMinutes: 60, + lastFiredAt: prior, + }, + ]) + mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]) + mockDispatch.mockRejectedValueOnce(new Error('delivery boom')) + + await runAlertRules(NOW) + + // First call claims (lastFiredAt=NOW); rollback restores the prior value. + expect(mockDb.alertRule.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { lastFiredAt: prior } }) + ) + }) +}) diff --git a/tests/unit/services/alertEvaluator.test.ts b/tests/unit/services/alertEvaluator.test.ts new file mode 100644 index 0000000..5be00a3 --- /dev/null +++ b/tests/unit/services/alertEvaluator.test.ts @@ -0,0 +1,141 @@ +import { + compare, + isCooldownActive, + cooldownCutoff, + computeDrawdownPercent, + rollingPeak, + evaluateRule, + type EvaluatableRule, +} from '../../../src/services/alertEvaluator' + +describe('alertEvaluator', () => { + describe('compare', () => { + it('evaluates LT (strictly below)', () => { + expect(compare('LT', 4.9, 5)).toBe(true) + expect(compare('LT', 5, 5)).toBe(false) + expect(compare('LT', 5.1, 5)).toBe(false) + }) + + it('evaluates LTE (at or below)', () => { + expect(compare('LTE', 5, 5)).toBe(true) + expect(compare('LTE', 4.9, 5)).toBe(true) + expect(compare('LTE', 5.1, 5)).toBe(false) + }) + + it('evaluates GT (strictly above)', () => { + expect(compare('GT', 5.1, 5)).toBe(true) + expect(compare('GT', 5, 5)).toBe(false) + }) + + it('evaluates GTE (at or above)', () => { + expect(compare('GTE', 5, 5)).toBe(true) + expect(compare('GTE', 5.1, 5)).toBe(true) + expect(compare('GTE', 4.9, 5)).toBe(false) + }) + }) + + describe('isCooldownActive', () => { + const now = new Date('2026-07-20T12:00:00.000Z') + + it('is never active for a rule that has never fired', () => { + expect(isCooldownActive(null, 60, now)).toBe(false) + expect(isCooldownActive(undefined, 60, now)).toBe(false) + }) + + it('is active while inside the cooldown window', () => { + const firedAt = new Date(now.getTime() - 30 * 60_000) // 30 min ago + expect(isCooldownActive(firedAt, 60, now)).toBe(true) + }) + + it('is inactive exactly at the cooldown boundary (re-fire allowed)', () => { + const firedAt = new Date(now.getTime() - 60 * 60_000) // exactly 60 min ago + expect(isCooldownActive(firedAt, 60, now)).toBe(false) + }) + + it('is inactive once the cooldown has elapsed', () => { + const firedAt = new Date(now.getTime() - 61 * 60_000) + expect(isCooldownActive(firedAt, 60, now)).toBe(false) + }) + }) + + describe('cooldownCutoff', () => { + it('returns the instant cooldownMinutes before now', () => { + const now = new Date('2026-07-20T12:00:00.000Z') + const cutoff = cooldownCutoff(60, now) + expect(cutoff.toISOString()).toBe('2026-07-20T11:00:00.000Z') + }) + }) + + describe('computeDrawdownPercent', () => { + it('computes decline from peak as a percentage', () => { + expect(computeDrawdownPercent(100, 90)).toBeCloseTo(10) + expect(computeDrawdownPercent(200, 150)).toBeCloseTo(25) + }) + + it('clamps to 0 when at or above the peak', () => { + expect(computeDrawdownPercent(100, 100)).toBe(0) + expect(computeDrawdownPercent(100, 120)).toBe(0) + }) + + it('returns 0 when there is no meaningful peak', () => { + expect(computeDrawdownPercent(0, 0)).toBe(0) + expect(computeDrawdownPercent(-5, 10)).toBe(0) + }) + }) + + describe('rollingPeak', () => { + it('takes the max of history and the current value', () => { + expect(rollingPeak([100, 120, 90], 110)).toBe(120) + }) + + it('treats a new high as its own peak', () => { + expect(rollingPeak([100, 120], 150)).toBe(150) + }) + + it('handles an empty history', () => { + expect(rollingPeak([], 80)).toBe(80) + }) + }) + + describe('evaluateRule', () => { + const now = new Date('2026-07-20T12:00:00.000Z') + const base: EvaluatableRule = { + metric: 'PROTOCOL_APY', + comparator: 'LT', + threshold: 5, + cooldownMinutes: 60, + lastFiredAt: null, + } + + it('fires when the condition is met and never fired before', () => { + const result = evaluateRule(base, 4.5, now) + expect(result.conditionMet).toBe(true) + expect(result.shouldFire).toBe(true) + }) + + it('does not fire when the condition is not met', () => { + const result = evaluateRule(base, 6, now) + expect(result.conditionMet).toBe(false) + expect(result.shouldFire).toBe(false) + }) + + it('suppresses a repeat fire while the condition stays true inside cooldown', () => { + const rule: EvaluatableRule = { + ...base, + lastFiredAt: new Date(now.getTime() - 10 * 60_000), // 10 min ago + } + const result = evaluateRule(rule, 4.5, now) + expect(result.conditionMet).toBe(true) // still true... + expect(result.shouldFire).toBe(false) // ...but suppressed by cooldown + }) + + it('re-fires once the cooldown elapses if the condition is still true', () => { + const rule: EvaluatableRule = { + ...base, + lastFiredAt: new Date(now.getTime() - 61 * 60_000), // cooldown passed + } + const result = evaluateRule(rule, 4.5, now) + expect(result.shouldFire).toBe(true) + }) + }) +})