From 8f8f1dc6a6b32079e24ea10f95ae83360abd333f Mon Sep 17 00:00:00 2001 From: Anuoluwapo25 Date: Tue, 21 Jul 2026 08:09:49 +0100 Subject: [PATCH 1/3] feat(alerts): custom price & yield alert rules (#289) Add user-defined alert rules that notify on portfolio/market conditions (protocol APY, portfolio value, position drawdown) over webhook/WhatsApp. - AlertRule model + migration; nullable unique User.phone for WhatsApp delivery - CRUD API with owner-scoped access (POST/GET/PATCH/DELETE /api/alerts) - Scheduled evaluator with atomic cooldown fire-claim (guards delete-mid-tick, concurrent runners, and threshold-sitting spam) - Delisted PROTOCOL_APY protocols auto-deactivate rather than eval stale data - Reuses HMAC-signed dispatchWebhookEvent (new alert_rule.triggered event) and WhatsApp formatter; no separate failed-delivery sweep (documented decision) - NLP intents + WhatsApp CRUD for conversational rule management - POSITION_DRAWDOWN measured against rolling 30-day portfolio peak - Unit tests for evaluator logic and job behavior; docs/ALERTS.md; openapi.yaml --- docs/ALERTS.md | 134 +++++++ docs/openapi.yaml | 252 ++++++++++++ .../migration.sql | 46 +++ prisma/schema.prisma | 59 +++ src/config/env.ts | 4 + src/index.ts | 11 + src/jobs/alertRules.ts | 360 ++++++++++++++++++ src/nlp/parser.ts | 125 +++++- src/routes/alerts.ts | 172 +++++++++ src/services/alertEvaluator.ts | 126 ++++++ src/validators/alert-validators.ts | 87 +++++ src/validators/webhook-validators.ts | 1 + src/whatsapp/alertManager.ts | 162 ++++++++ src/whatsapp/formatters.ts | 110 ++++++ src/whatsapp/handler.ts | 53 +++ tests/unit/jobs/alertRules.test.ts | 236 ++++++++++++ tests/unit/services/alertEvaluator.test.ts | 141 +++++++ 17 files changed, 2069 insertions(+), 10 deletions(-) create mode 100644 docs/ALERTS.md create mode 100644 prisma/migrations/20260720000000_add_alert_rules/migration.sql create mode 100644 src/jobs/alertRules.ts create mode 100644 src/routes/alerts.ts create mode 100644 src/services/alertEvaluator.ts create mode 100644 src/validators/alert-validators.ts create mode 100644 src/whatsapp/alertManager.ts create mode 100644 tests/unit/jobs/alertRules.test.ts create mode 100644 tests/unit/services/alertEvaluator.test.ts 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 40661c6..70cf6ae 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -54,6 +54,8 @@ tags: description: Fiat on-ramp / off-ramp (buy and sell crypto with fiat via a payment provider) - name: referrals description: Referral rewards program (share a code, earn when referred users deposit) + - name: alerts + description: User-defined price & yield alert rules (notify on protocol APY, portfolio value, or drawdown conditions) - name: admin description: Admin-only management endpoints - name: metrics @@ -2273,6 +2275,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: @@ -2756,6 +2883,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/schema.prisma b/prisma/schema.prisma index 440d1ec..dd428a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -82,6 +82,28 @@ enum ReferralStatus { EXPIRED } +// 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 @@ -89,6 +111,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 } } @@ -104,6 +131,7 @@ model User { fiatOrders FiatOrder[] referralCode ReferralCode? referralConversion ReferralConversion? + alertRules AlertRule[] @@map("users") } @@ -439,6 +467,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 d5bd4f2..bc6af76 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -458,6 +458,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 f927e35..73907ee 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] }, ] @@ -344,6 +348,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) @@ -499,6 +509,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..e41e469 --- /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 a141f9c..55a66d1 100644 --- a/src/nlp/parser.ts +++ b/src/nlp/parser.ts @@ -3,12 +3,42 @@ import { HttpClientAdapter } from '../utils/http-client' import { config } from '../config' export interface Intent { - action: 'deposit' | 'withdraw' | 'balance' | 'earnings' | 'help' | 'unknown' + action: + | 'deposit' + | 'withdraw' + | 'balance' + | 'earnings' + | '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', }) @@ -22,6 +52,71 @@ 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() @@ -47,6 +142,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' } @@ -72,14 +174,21 @@ export async function parseWithClaude(message: string): Promise { return anthropic.messages.create({ model: 'claude-3-haiku-20240307', max_tokens: 150, - system: `You are an intent parser for a financial bot. Determine if the user wants to deposit, withdraw, check balance, view earnings/performance, or needs help. + system: `You are an intent parser for a financial bot. Determine what the user wants: deposit, withdraw, check balance, view earnings/performance, manage price/yield alert rules, or get help. Return ONLY a JSON object representing the intent, matching this TypeScript interface exactly without any wrapper text or markdown: { - "action": "deposit" | "withdraw" | "balance" | "earnings" | "help" | "unknown", + "action": "deposit" | "withdraw" | "balance" | "earnings" | "help" | "alert_create" | "alert_list" | "alert_delete" | "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') @@ -93,11 +202,7 @@ Return ONLY a JSON object representing the intent, matching this TypeScript inte ) if (jsonStr) { const parsed = JSON.parse(jsonStr) - if ( - ['deposit', 'withdraw', 'balance', 'earnings', 'help'].includes( - parsed.action - ) - ) { + if ((KNOWN_ACTIONS as readonly string[]).includes(parsed.action)) { return parsed as Intent } } diff --git a/src/routes/alerts.ts b/src/routes/alerts.ts new file mode 100644 index 0000000..23946b5 --- /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..20a75b0 --- /dev/null +++ b/src/services/alertEvaluator.ts @@ -0,0 +1,126 @@ +/** + * 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..55fa850 --- /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 ffbb2ad..ab137f2 100644 --- a/src/validators/webhook-validators.ts +++ b/src/validators/webhook-validators.ts @@ -12,6 +12,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..4d23cc1 --- /dev/null +++ b/src/whatsapp/alertManager.ts @@ -0,0 +1,162 @@ +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 255349d..0ab10b0 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -138,3 +138,113 @@ 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 a463b73..75f6233 100644 --- a/src/whatsapp/handler.ts +++ b/src/whatsapp/handler.ts @@ -9,6 +9,16 @@ import { getPortfolioYieldSummary, decrementBalance, } from './userManager' +import { + createAlertRuleForWallet, + listAlertRulesForWallet, + deleteAlertRuleForWallet, +} from './alertManager' +import { + formatAlertCreatedReply, + formatAlertListReply, + formatAlertDeletedReply, +} from './formatters' export type WhatsAppResponse = { body: string @@ -21,6 +31,9 @@ function formatHelpMessage(): string { '- "deposit " → get deposit instructions', '- "withdraw " → withdraw funds (if available)', '- "earnings" → see your performance', + '- "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') } @@ -147,6 +160,46 @@ export async function handleWhatsAppMessage( 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..76524c5 --- /dev/null +++ b/tests/unit/jobs/alertRules.test.ts @@ -0,0 +1,236 @@ +// 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..ee8f2fc --- /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); + }); + }); +}); From dddf52eb48c2e5768e25d2c41b33001288230af7 Mon Sep 17 00:00:00 2001 From: Anuoluwapo25 Date: Thu, 23 Jul 2026 21:10:58 +0100 Subject: [PATCH 2/3] fix: repair main merge that broke prisma schema, build, and migration gate The "Merge branch 'main' into main" (d9fabff) dropped code while keeping its usages, failing every CI check. Restore what the merge clobbered: - schema.prisma: re-add PriceSource (#284) and GoalStatus (#281) enums, which the merge replaced with #289's alert enums instead of keeping both. Their usages (User.priceSource, SavingsGoal.status) remained, so `prisma generate` failed P1012 and blocked ci, build, and contract/migration smoke jobs. - nlp/parser.ts: restore the 'goal' member of the Intent action union, dropped by the same merge while parser.ts and handler.ts still emit/switch on it, breaking `tsc` (TS2322/TS2678) and the production build. - add rollback.sql for the #289 alert_rules migration so the rollback gate (scripts/check-migration-rollback.sh) passes. --- .../rollback.sql | 21 +++++++++++++++++++ prisma/schema.prisma | 14 +++++++++++++ src/nlp/parser.ts | 1 + 3 files changed, 36 insertions(+) create mode 100644 prisma/migrations/20260720000000_add_alert_rules/rollback.sql 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 7d1016c..6699656 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -87,6 +87,20 @@ enum ReferralStatus { EXPIRED } +// Where an acquisition/disposal USD price came from (#284). Only stablecoins +// are priced in v1; anything else is stored with a null price and surfaced as +// unpriced in the tax report — never silently zeroed. +enum PriceSource { + STABLECOIN_ASSUMPTION +} + +enum GoalStatus { + ACTIVE + ACHIEVED + MISSED + 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. diff --git a/src/nlp/parser.ts b/src/nlp/parser.ts index b38a961..973549c 100644 --- a/src/nlp/parser.ts +++ b/src/nlp/parser.ts @@ -8,6 +8,7 @@ export interface Intent { | 'withdraw' | 'balance' | 'earnings' + | 'goal' | 'help' | 'alert_create' | 'alert_list' From 58b49da5d85b749042877b3d8d7a239556812bd1 Mon Sep 17 00:00:00 2001 From: Anuoluwapo25 Date: Fri, 24 Jul 2026 10:23:38 +0100 Subject: [PATCH 3/3] style: apply Prettier to alert-rule files merged into main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #289 alerts merge landed 11 files that Prettier had not been run over, so `format:check` fails on main and blocks build/test for every PR branched from it. Formatting only — no behaviour change. --- src/jobs/alertRules.ts | 200 ++++++++++----------- src/nlp/parser.ts | 14 +- src/routes/alerts.ts | 78 ++++---- src/services/alertEvaluator.ts | 62 ++++--- src/validators/alert-validators.ts | 28 +-- src/validators/webhook-validators.ts | 2 +- src/whatsapp/alertManager.ts | 14 +- src/whatsapp/formatters.ts | 5 +- src/whatsapp/handler.ts | 9 +- tests/unit/jobs/alertRules.test.ts | 170 +++++++++--------- tests/unit/services/alertEvaluator.test.ts | 148 +++++++-------- 11 files changed, 374 insertions(+), 356 deletions(-) diff --git a/src/jobs/alertRules.ts b/src/jobs/alertRules.ts index e41e469..72495a4 100644 --- a/src/jobs/alertRules.ts +++ b/src/jobs/alertRules.ts @@ -1,14 +1,14 @@ -import db from '../db'; -import { logger, logBackgroundJob } from '../utils/logger'; +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'; +} 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, @@ -16,7 +16,7 @@ import { rollingPeak, type AlertMetric, type Comparator, -} from '../services/alertEvaluator'; +} from '../services/alertEvaluator' /** * Custom price & yield alert rule evaluator (#289). @@ -56,22 +56,22 @@ import { * 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; +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; + 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'; +const ASSET_SYMBOL = 'USDC' /** * Resolve the observed value for a rule's metric, or null when it cannot be @@ -81,35 +81,35 @@ const ASSET_SYMBOL = 'USDC'; */ async function observeMetric( rule: AlertRuleRow, - now: Date, + now: Date ): Promise<{ value: number | null; delisted?: boolean }> { switch (rule.metric) { case 'PROTOCOL_APY': { - if (!rule.protocolName) return { value: null }; + 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 }; + 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 }; + 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 }; + 0 + ) + return { value: total } } case 'POSITION_DRAWDOWN': { @@ -119,40 +119,40 @@ async function observeMetric( const positions = await db.position.findMany({ where: { userId: rule.userId, status: 'ACTIVE' }, select: { id: true, currentValue: true }, - }); - if (positions.length === 0) return { value: 0 }; + }) + if (positions.length === 0) return { value: 0 } const currentValue = positions.reduce( (sum, p) => sum + Number(p.currentValue), - 0, - ); + 0 + ) - const fromDate = new Date(now.getTime() - WINDOW_MS); + 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(); + 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 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) }; + currentValue + ) + return { value: computeDrawdownPercent(peak, currentValue) } } default: - return { value: null }; + return { value: null } } } @@ -163,7 +163,7 @@ async function observeMetric( * mid-tick and concurrent runners. */ async function claimFire(rule: AlertRuleRow, now: Date): Promise { - const cutoff = cooldownCutoff(rule.cooldownMinutes, now); + const cutoff = cooldownCutoff(rule.cooldownMinutes, now) const result = await db.alertRule.updateMany({ where: { id: rule.id, @@ -171,8 +171,8 @@ async function claimFire(rule: AlertRuleRow, now: Date): Promise { OR: [{ lastFiredAt: null }, { lastFiredAt: { lte: cutoff } }], }, data: { lastFiredAt: now }, - }); - return result.count === 1; + }) + return result.count === 1 } /** @@ -181,9 +181,9 @@ async function claimFire(rule: AlertRuleRow, now: Date): Promise { */ async function deliverAlert( rule: AlertRuleRow, - observedValue: number, + observedValue: number ): Promise { - const threshold = Number(rule.threshold); + const threshold = Number(rule.threshold) const data = { ruleId: rule.id, userId: rule.userId, @@ -193,27 +193,27 @@ async function deliverAlert( threshold, observedValue, triggeredAt: new Date().toISOString(), - }; + } const wantsWebhook = - rule.deliveryChannel === 'WEBHOOK' || rule.deliveryChannel === 'BOTH'; + rule.deliveryChannel === 'WEBHOOK' || rule.deliveryChannel === 'BOTH' const wantsWhatsApp = - rule.deliveryChannel === 'WHATSAPP' || rule.deliveryChannel === 'BOTH'; + rule.deliveryChannel === 'WHATSAPP' || rule.deliveryChannel === 'BOTH' if (wantsWebhook) { // HMAC-signed via the existing dispatcher; no new unsigned path. - await dispatchWebhookEvent('alert_rule.triggered', data); + 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`, - ); + `[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, @@ -221,21 +221,21 @@ async function deliverAlert( comparator: rule.comparator, threshold, observedValue, - }); - await sendWhatsAppMessage({ to: `whatsapp:${user.phone}`, body }); + }) + await sendWhatsAppMessage({ to: `whatsapp:${user.phone}`, body }) } } } export async function runAlertRules(now: Date = new Date()): Promise { - const correlationId = generateCorrelationId(); + const correlationId = generateCorrelationId() return runWithCorrelationIdAsync(correlationId, async () => { - const start = Date.now(); - const jobName = 'alert_rules'; + const start = Date.now() + const jobName = 'alert_rules' - let evaluated = 0; - let fired = 0; - let deactivated = 0; + let evaluated = 0 + let fired = 0 + let deactivated = 0 try { const rules = (await db.alertRule.findMany({ @@ -251,41 +251,41 @@ export async function runAlertRules(now: Date = new Date()): Promise { cooldownMinutes: true, lastFiredAt: true, }, - })) as AlertRuleRow[]; + })) as AlertRuleRow[] for (const rule of rules) { - evaluated++; + evaluated++ try { - const { value, delisted } = await observeMetric(rule, now); + const { value, delisted } = await observeMetric(rule, now) if (delisted) { await db.alertRule.updateMany({ where: { id: rule.id }, data: { isActive: false }, - }); - deactivated++; + }) + deactivated++ logger.warn( - `[AlertRules] Deactivated rule ${rule.id}: protocol "${rule.protocolName}" has no rate data (delisted/removed)`, - ); - continue; + `[AlertRules] Deactivated rule ${rule.id}: protocol "${rule.protocolName}" has no rate data (delisted/removed)` + ) + continue } - if (value === null) continue; + if (value === null) continue const conditionMet = compare( rule.comparator, value, - Number(rule.threshold), - ); - if (!conditionMet) continue; + Number(rule.threshold) + ) + if (!conditionMet) continue // Atomically claim the fire (cooldown + delete/deactivate guard). - const won = await claimFire(rule, now); - if (!won) continue; + const won = await claimFire(rule, now) + if (!won) continue try { - await deliverAlert(rule, value); - fired++; + 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 @@ -296,7 +296,7 @@ export async function runAlertRules(now: Date = new Date()): Promise { where: { id: rule.id }, data: { lastFiredAt: rule.lastFiredAt }, }) - .catch(() => undefined); + .catch(() => undefined) logger.error( `[AlertRules] Delivery failed for rule ${rule.id}; fire-claim rolled back for retry`, { @@ -304,8 +304,8 @@ export async function runAlertRules(now: Date = new Date()): Promise { deliveryError instanceof Error ? deliveryError.message : String(deliveryError), - }, - ); + } + ) } } catch (ruleError) { // One bad rule must not abort the sweep. @@ -314,27 +314,27 @@ export async function runAlertRules(now: Date = new Date()): Promise { ruleError instanceof Error ? ruleError.message : String(ruleError), - }); + }) } } - const durationMs = Date.now() - start; + const durationMs = Date.now() - start logBackgroundJob(jobName, 'success', durationMs / 1000, correlationId, { evaluated, fired, deactivated, - }); - recordJobSuccess(jobName, durationMs); + }) + recordJobSuccess(jobName, durationMs) } catch (error) { - const durationMs = Date.now() - start; + const durationMs = Date.now() - start const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', durationMs / 1000, correlationId, { error: errorMessage, - }); - recordJobFailure(jobName, durationMs); + }) + recordJobFailure(jobName, durationMs) } - }); + }) } /** @@ -344,17 +344,17 @@ export async function runAlertRules(now: Date = new Date()): Promise { * @returns NodeJS.Timeout handle — pass to clearInterval() on shutdown. */ export function scheduleAlertRules(): NodeJS.Timeout { - void runAlertRules(); + void runAlertRules() - const intervalMs = config.alertRules.intervalMs; + const intervalMs = config.alertRules.intervalMs const handle = setInterval(() => { - void runAlertRules(); - }, intervalMs); + void runAlertRules() + }, intervalMs) - handle.unref?.(); + handle.unref?.() logger.info( - `[AlertRules] Alert-rule evaluation scheduled every ${intervalMs}ms`, - ); - return handle; + `[AlertRules] Alert-rule evaluation scheduled every ${intervalMs}ms` + ) + return handle } diff --git a/src/nlp/parser.ts b/src/nlp/parser.ts index 973549c..3a59101 100644 --- a/src/nlp/parser.ts +++ b/src/nlp/parser.ts @@ -61,7 +61,11 @@ const anthropicHttpClient = new HttpClientAdapter({ */ 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)) { + if ( + /\b(list|show|view|my)\b.*\balerts?\b|\balerts?\b.*\b(list|status)\b/i.test( + lowerMsg + ) + ) { return { action: 'alert_list' } } @@ -102,9 +106,13 @@ export function parseAlertIntent(lowerMsg: string): Intent | null { } // Comparator - if (/\b(below|under|less than|drops? below|falls? below|<)\b/i.test(lowerMsg)) { + 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)) { + } else if ( + /\b(above|over|greater than|exceeds?|rises? above|>)\b/i.test(lowerMsg) + ) { intent.comparator = 'GT' } diff --git a/src/routes/alerts.ts b/src/routes/alerts.ts index 23946b5..4e6047c 100644 --- a/src/routes/alerts.ts +++ b/src/routes/alerts.ts @@ -1,19 +1,19 @@ -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 { 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'; +} from '../validators/alert-validators' -const router = Router(); +const router = Router() // All alert routes require auth. -router.use(requireAuth); +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. @@ -30,7 +30,7 @@ const alertSelect = { isActive: true, createdAt: true, updatedAt: true, -} as const; +} as const /** * POST /api/alerts @@ -40,7 +40,7 @@ router.post( '/', validate({ body: createAlertRuleSchema }), async (req: Request, res: Response) => { - const userId = req.auth!.userId; + const userId = req.auth!.userId const { metric, protocolName, @@ -48,7 +48,7 @@ router.post( threshold, deliveryChannel, cooldownMinutes, - } = req.body; + } = req.body const rule = await (db as any).alertRule.create({ data: { @@ -61,11 +61,11 @@ router.post( cooldownMinutes, }, select: alertSelect, - }); + }) - return res.status(201).json(rule); - }, -); + return res.status(201).json(rule) + } +) /** * GET /api/alerts/:userId @@ -77,17 +77,17 @@ router.get( validate({ params: alertUserParamSchema }), enforceUserAccess, async (req: Request, res: Response) => { - const userId = req.params.userId as string; + 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 }); - }, -); + return res.status(200).json({ rules }) + } +) /** * PATCH /api/alerts/:id @@ -98,21 +98,21 @@ router.patch( '/:id', validate({ params: alertIdParamSchema, body: updateAlertRuleSchema }), async (req: Request, res: Response) => { - const userId = req.auth!.userId; + 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'); + }) + 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 nextMetric = req.body.metric ?? existing.metric const nextProtocolName = req.body.protocolName !== undefined ? req.body.protocolName - : existing.protocolName; + : existing.protocolName if (nextMetric === 'PROTOCOL_APY' && !nextProtocolName) { return res.status(400).json({ @@ -123,7 +123,7 @@ router.patch( message: 'protocolName is required when metric is PROTOCOL_APY', }, ], - }); + }) } if (nextMetric !== 'PROTOCOL_APY' && nextProtocolName) { return res.status(400).json({ @@ -134,18 +134,18 @@ router.patch( 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); - }, -); + return res.status(200).json(updated) + } +) /** * DELETE /api/alerts/:id @@ -155,18 +155,18 @@ router.delete( '/:id', validate({ params: alertIdParamSchema }), async (req: Request, res: Response) => { - const userId = req.auth!.userId; + 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'); + }) + if (!existing) return sendNotFound(res, 'Alert rule') - await (db as any).alertRule.delete({ where: { id: req.params.id } }); + await (db as any).alertRule.delete({ where: { id: req.params.id } }) - return res.status(204).send(); - }, -); + return res.status(204).send() + } +) -export default router; +export default router diff --git a/src/services/alertEvaluator.ts b/src/services/alertEvaluator.ts index 20a75b0..cc73d6a 100644 --- a/src/services/alertEvaluator.ts +++ b/src/services/alertEvaluator.ts @@ -9,10 +9,8 @@ */ export type AlertMetric = - | 'PROTOCOL_APY' - | 'PORTFOLIO_VALUE' - | 'POSITION_DRAWDOWN'; -export type Comparator = 'LT' | 'LTE' | 'GT' | 'GTE'; + 'PROTOCOL_APY' | 'PORTFOLIO_VALUE' | 'POSITION_DRAWDOWN' +export type Comparator = 'LT' | 'LTE' | 'GT' | 'GTE' /** * Evaluate a comparator against an observed value and threshold. @@ -21,19 +19,19 @@ export type Comparator = 'LT' | 'LTE' | 'GT' | 'GTE'; export function compare( comparator: Comparator, observed: number, - threshold: number, + threshold: number ): boolean { switch (comparator) { case 'LT': - return observed < threshold; + return observed < threshold case 'LTE': - return observed <= threshold; + return observed <= threshold case 'GT': - return observed > threshold; + return observed > threshold case 'GTE': - return observed >= threshold; + return observed >= threshold default: - return false; + return false } } @@ -48,11 +46,11 @@ export function compare( export function isCooldownActive( lastFiredAt: Date | null | undefined, cooldownMinutes: number, - now: Date, + now: Date ): boolean { - if (!lastFiredAt) return false; - const elapsedMs = now.getTime() - lastFiredAt.getTime(); - return elapsedMs < cooldownMinutes * 60_000; + if (!lastFiredAt) return false + const elapsedMs = now.getTime() - lastFiredAt.getTime() + return elapsedMs < cooldownMinutes * 60_000 } /** @@ -61,7 +59,7 @@ export function isCooldownActive( * atomic fire-claim query in the job. */ export function cooldownCutoff(cooldownMinutes: number, now: Date): Date { - return new Date(now.getTime() - cooldownMinutes * 60_000); + return new Date(now.getTime() - cooldownMinutes * 60_000) } /** @@ -75,11 +73,11 @@ export function cooldownCutoff(cooldownMinutes: number, now: Date): Date { */ export function computeDrawdownPercent( peakValue: number, - currentValue: number, + currentValue: number ): number { - if (peakValue <= 0) return 0; - const drawdown = ((peakValue - currentValue) / peakValue) * 100; - return drawdown > 0 ? drawdown : 0; + if (peakValue <= 0) return 0 + const drawdown = ((peakValue - currentValue) / peakValue) * 100 + return drawdown > 0 ? drawdown : 0 } /** @@ -89,24 +87,24 @@ export function computeDrawdownPercent( */ export function rollingPeak( historicalValues: number[], - currentValue: number, + currentValue: number ): number { - return Math.max(currentValue, ...historicalValues, 0); + return Math.max(currentValue, ...historicalValues, 0) } export interface EvaluatableRule { - metric: AlertMetric; - comparator: Comparator; - threshold: number; - cooldownMinutes: number; - lastFiredAt: Date | null; + metric: AlertMetric + comparator: Comparator + threshold: number + cooldownMinutes: number + lastFiredAt: Date | null } export interface EvaluationResult { /** The comparator condition holds for the observed value. */ - conditionMet: boolean; + conditionMet: boolean /** The rule is eligible to fire now (condition met AND cooldown elapsed). */ - shouldFire: boolean; + shouldFire: boolean } /** @@ -116,11 +114,11 @@ export interface EvaluationResult { export function evaluateRule( rule: EvaluatableRule, observedValue: number, - now: Date, + now: Date ): EvaluationResult { - const conditionMet = compare(rule.comparator, observedValue, rule.threshold); + const conditionMet = compare(rule.comparator, observedValue, rule.threshold) const shouldFire = conditionMet && - !isCooldownActive(rule.lastFiredAt, rule.cooldownMinutes, now); - return { conditionMet, shouldFire }; + !isCooldownActive(rule.lastFiredAt, rule.cooldownMinutes, now) + return { conditionMet, shouldFire } } diff --git a/src/validators/alert-validators.ts b/src/validators/alert-validators.ts index 55fa850..f29c743 100644 --- a/src/validators/alert-validators.ts +++ b/src/validators/alert-validators.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod' /** * Validators for user-defined alert rules (#289). @@ -12,15 +12,15 @@ export const ALERT_METRICS = [ 'PROTOCOL_APY', 'PORTFOLIO_VALUE', 'POSITION_DRAWDOWN', -] as const; +] as const -export const COMPARATORS = ['LT', 'LTE', 'GT', 'GTE'] as const; +export const COMPARATORS = ['LT', 'LTE', 'GT', 'GTE'] as const -export const DELIVERY_CHANNELS = ['WEBHOOK', 'WHATSAPP', 'BOTH'] 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]; +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), @@ -29,7 +29,7 @@ const baseAlertRuleShape = { 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 @@ -44,20 +44,20 @@ function requireProtocolNameForApy< 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); + .superRefine(requireProtocolNameForApy) /** * PATCH allows partial updates. When metric is being changed we still enforce @@ -76,12 +76,12 @@ export const updateAlertRuleSchema = z }) .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 1a1d7c6..5e55180 100644 --- a/src/validators/webhook-validators.ts +++ b/src/validators/webhook-validators.ts @@ -13,7 +13,7 @@ const WEBHOOK_EVENTS = [ 'fiat.order.settled', 'fiat.order.failed', 'alert_rule.triggered', -] as const; +] as const export const createWebhookSchema = z.object({ url: z.string().url('Must be a valid URL'), diff --git a/src/whatsapp/alertManager.ts b/src/whatsapp/alertManager.ts index 4d23cc1..501b32e 100644 --- a/src/whatsapp/alertManager.ts +++ b/src/whatsapp/alertManager.ts @@ -68,8 +68,7 @@ async function resolveUserId(walletAddress: string): Promise { } export type CreateAlertResult = - | { ok: true; rule: AlertRuleView } - | { ok: false; error: string } + { ok: true; rule: AlertRuleView } | { ok: false; error: string } /** * Create an alert rule for the user owning `walletAddress`. Validates the @@ -84,11 +83,14 @@ export async function createAlertRuleForWallet( 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.' } + return { + ok: false, + error: 'I could not find your account. Please try again.', + } } const parsed = createAlertRuleSchema.safeParse({ @@ -126,7 +128,7 @@ export async function createAlertRuleForWallet( /** List the alert rules owned by the user behind `walletAddress`. */ export async function listAlertRulesForWallet( - walletAddress: string, + walletAddress: string ): Promise { const userId = await resolveUserId(walletAddress) if (!userId) return [] @@ -146,7 +148,7 @@ export async function listAlertRulesForWallet( */ export async function deleteAlertRuleForWallet( walletAddress: string, - alertId: string, + alertId: string ): Promise { const userId = await resolveUserId(walletAddress) if (!userId) return false diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts index 2f8e6d3..5de3764 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -228,8 +228,7 @@ export function formatAlertTriggeredReply(input: { ? `${metricLabel} (${input.protocolName})` : metricLabel - const fmt = (n: number): string => - `${prefix}${n.toFixed(2)}${unit}` + const fmt = (n: number): string => `${prefix}${n.toFixed(2)}${unit}` return [ '🔔 *Alert triggered*', @@ -283,7 +282,7 @@ export function formatAlertListReply( 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".' diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts index 9c46023..2759a0d 100644 --- a/src/whatsapp/handler.ts +++ b/src/whatsapp/handler.ts @@ -206,9 +206,14 @@ export async function handleWhatsAppMessage( 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 ".' } + return { + body: 'Please tell me which alert to delete, e.g. "delete alert ".', + } } - const deleted = await deleteAlertRuleForWallet(walletAddress, intent.alertId) + const deleted = await deleteAlertRuleForWallet( + walletAddress, + intent.alertId + ) return { body: formatAlertDeletedReply(deleted) } } diff --git a/tests/unit/jobs/alertRules.test.ts b/tests/unit/jobs/alertRules.test.ts index 76524c5..a01cb41 100644 --- a/tests/unit/jobs/alertRules.test.ts +++ b/tests/unit/jobs/alertRules.test.ts @@ -1,64 +1,66 @@ // 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'; +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; +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' }) }; + } + 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'); +const NOW = new Date('2026-07-20T12:00:00.000Z') describe('runAlertRules', () => { beforeEach(() => { - jest.clearAllMocks(); - }); + jest.clearAllMocks() + }) it('fires a PROTOCOL_APY webhook when APY drops below the threshold', async () => { stubDb([ @@ -73,23 +75,27 @@ describe('runAlertRules', () => { cooldownMinutes: 60, lastFiredAt: null, }, - ]); + ]) // supplyApy stored as a fraction: 0.04 == 4% < 5% threshold → fires. - mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.04 }); + mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.04 }) - await runAlertRules(NOW); + 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 }), - ); - }); + expect.objectContaining({ + ruleId: 'rule-1', + observedValue: 4, + threshold: 5, + }) + ) + }) it('does not fire when the APY condition is not met', async () => { stubDb([ @@ -104,14 +110,14 @@ describe('runAlertRules', () => { cooldownMinutes: 60, lastFiredAt: null, }, - ]); - mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.08 }); // 8% not < 5% + ]) + mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.08 }) // 8% not < 5% - await runAlertRules(NOW); + await runAlertRules(NOW) - expect(mockDb.alertRule.updateMany).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); - }); + 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([ @@ -126,17 +132,17 @@ describe('runAlertRules', () => { cooldownMinutes: 60, lastFiredAt: null, }, - ]); - mockDb.protocolRate.findFirst.mockResolvedValue(null); // delisted + ]) + mockDb.protocolRate.findFirst.mockResolvedValue(null) // delisted - await runAlertRules(NOW); + await runAlertRules(NOW) expect(mockDb.alertRule.updateMany).toHaveBeenCalledWith({ where: { id: 'rule-1' }, data: { isActive: false }, - }); - expect(mockDispatch).not.toHaveBeenCalled(); - }); + }) + expect(mockDispatch).not.toHaveBeenCalled() + }) it('does not deliver when the fire-claim matches 0 rows (deleted/deactivated mid-tick)', async () => { stubDb([ @@ -151,16 +157,16 @@ describe('runAlertRules', () => { cooldownMinutes: 60, lastFiredAt: null, }, - ]); - mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.04 }); + ]) + mockDb.protocolRate.findFirst.mockResolvedValue({ supplyApy: 0.04 }) // Claim loses (rule deleted/deactivated or already fired concurrently). - mockDb.alertRule.updateMany.mockResolvedValue({ count: 0 }); + mockDb.alertRule.updateMany.mockResolvedValue({ count: 0 }) - await runAlertRules(NOW); + await runAlertRules(NOW) - expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockSendWhatsApp).not.toHaveBeenCalled(); - }); + 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([ @@ -175,16 +181,16 @@ describe('runAlertRules', () => { cooldownMinutes: 60, lastFiredAt: null, }, - ]); - mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]); // < 1000 + ]) + mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]) // < 1000 - await runAlertRules(NOW); + await runAlertRules(NOW) - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(1) expect(mockSendWhatsApp).toHaveBeenCalledWith( - expect.objectContaining({ to: 'whatsapp:+15551230000' }), - ); - }); + expect.objectContaining({ to: 'whatsapp:+15551230000' }) + ) + }) it('skips the WhatsApp channel (without error) when the user has no phone', async () => { stubDb([ @@ -199,17 +205,17 @@ describe('runAlertRules', () => { cooldownMinutes: 60, lastFiredAt: null, }, - ]); - mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]); - mockDb.user.findUnique.mockResolvedValue({ phone: null }); + ]) + mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]) + mockDb.user.findUnique.mockResolvedValue({ phone: null }) - await runAlertRules(NOW); + await runAlertRules(NOW) - expect(mockSendWhatsApp).not.toHaveBeenCalled(); - }); + 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'); + const prior = new Date('2026-07-20T10:00:00.000Z') stubDb([ { id: 'rule-1', @@ -222,15 +228,15 @@ describe('runAlertRules', () => { cooldownMinutes: 60, lastFiredAt: prior, }, - ]); - mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]); - mockDispatch.mockRejectedValueOnce(new Error('delivery boom')); + ]) + mockDb.position.findMany.mockResolvedValue([{ currentValue: 500 }]) + mockDispatch.mockRejectedValueOnce(new Error('delivery boom')) - await runAlertRules(NOW); + await runAlertRules(NOW) // First call claims (lastFiredAt=NOW); rollback restores the prior value. expect(mockDb.alertRule.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ data: { lastFiredAt: prior } }), - ); - }); -}); + expect.objectContaining({ data: { lastFiredAt: prior } }) + ) + }) +}) diff --git a/tests/unit/services/alertEvaluator.test.ts b/tests/unit/services/alertEvaluator.test.ts index ee8f2fc..5be00a3 100644 --- a/tests/unit/services/alertEvaluator.test.ts +++ b/tests/unit/services/alertEvaluator.test.ts @@ -6,136 +6,136 @@ import { rollingPeak, evaluateRule, type EvaluatableRule, -} from '../../../src/services/alertEvaluator'; +} 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); - }); + 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); - }); + 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); - }); + 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); - }); - }); + 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'); + 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); - }); + 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); - }); + 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); - }); + 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); - }); - }); + 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'); - }); - }); + 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); - }); + 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); - }); + 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); - }); - }); + 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); - }); + expect(rollingPeak([100, 120, 90], 110)).toBe(120) + }) it('treats a new high as its own peak', () => { - expect(rollingPeak([100, 120], 150)).toBe(150); - }); + expect(rollingPeak([100, 120], 150)).toBe(150) + }) it('handles an empty history', () => { - expect(rollingPeak([], 80)).toBe(80); - }); - }); + expect(rollingPeak([], 80)).toBe(80) + }) + }) describe('evaluateRule', () => { - const now = new Date('2026-07-20T12:00:00.000Z'); + 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); - }); + 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); - }); + 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 - }); + } + 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); - }); - }); -}); + } + const result = evaluateRule(rule, 4.5, now) + expect(result.shouldFire).toBe(true) + }) + }) +})