diff --git a/docs/DIGESTS.md b/docs/DIGESTS.md new file mode 100644 index 0000000..d24a456 --- /dev/null +++ b/docs/DIGESTS.md @@ -0,0 +1,159 @@ +# Cross-Channel Digest Notifications (#365) + +An opt-in **daily / weekly / monthly portfolio summary** delivered over the +user's chosen channels. Unlike event-triggered alerts (which fire when +something happens), a digest is a *scheduled* "here's how your money did this +period" message — a periodic sanity check a passive user can rely on without +opening the app. + +> Cross-channel delivery mechanics (opt-in flows, channel availability) are +> shared with the alert-rule email channel and documented in +> [`NOTIFICATIONS.md`](./NOTIFICATIONS.md). This doc covers the digest feature +> itself: the subscription model, the pure assembler, per-channel rendering, +> the scheduled job, quiet hours, and the API. + +--- + +## 1. Subscription model + +A `DigestSubscription` row captures one user's digest preference: + +| Field | Meaning | +| --------------- | ------------------------------------------------------------- | +| `frequency` | `DAILY` \| `WEEKLY` \| `MONTHLY` | +| `channels` | `WHATSAPP` \| `TELEGRAM` \| `EMAIL` \| `WEBHOOK` (multi) | +| `sendHourUtc` | Preferred send hour, `0..23` (UTC) | +| `weeklyDayUtc` | `0..6` (Sunday=0), required for `WEEKLY` | +| `quietHours` | `{ startUtc, endUtc }` — never send inside this window | +| `isActive` | Opt-in switch | +| `lastSentAt` | Stamp of the last delivery | +| `nextRunAt` | Next scheduled occurrence (UTC) | + +**Opt-in, not opt-out.** A sensible default is offered at onboarding; nothing is +enabled without the user choosing a frequency and at least one channel. + +### Channel availability + +- **WHATSAPP** — delivered when the user has a phone on file. Reject adding this + channel at creation if no `phone`; if a linked phone is later removed, the + channel is skipped for that occurrence with a `digest.channel_unavailable` + note, never an error. +- **WEBHOOK** — delivered to the user's **own** registered webhook endpoints + only, as a `digest.generated` event on the user's scoped stream. It is a + *socket-only* event type, so it can never be routed to an operator webhook + (see `src/events/types.ts`). Requires at least one active endpoint at + creation. +- **TELEGRAM / EMAIL** — reserved for their sibling channel issues. Their + renderers are stubbed in `src/notifications/render.ts`. `EMAIL` delivery is + described in `NOTIFICATIONS.md` (#367); `TELEGRAM` has no outbound push engine + yet and is marked unavailable for now. + +## 2. The pure assembler — `buildDigest` + +`src/notifications/digest.ts` exposes a **pure, deterministic** `buildDigest` +that maps raw period inputs to a channel-agnostic `DigestModel`. It never touches +the DB or the clock; the scheduled job and the preview endpoint both feed it +(`src/notifications/load.ts` fetches the inputs). + +The model reports: + +- **Portfolio value** now vs. start-of-period, absolute and percent. +- **Yield** earned this period; blended APY; best / worst position. +- **Agent activity**: rebalances this period (count + average net improvement), + from `RebalanceDecision`. +- **Goal progress** deltas from the goal service (same `onTrack`/APY logic as + in-app). A `null` delta is honest: historical goal progress isn't persisted. +- **One risk line** — max drawdown over the period, or a "no meaningful + drawdown" line, or a data-sufficiency note. +- **Notable transactions** over a threshold, capped. + +**Honesty discipline** (mirrors the analytics `caveats`): a period with +insufficient `YieldSnapshot` coverage reports the gap as a `caveat` and omits the +misleading delta (`null`), rather than showing a fabricated number. A user with +no positions gets a short "deposit to get started" digest, never an empty or +broken message. + +## 3. Channel rendering — `renderDigest` + +`src/notifications/render.ts` maps the `DigestModel` onto a channel: + +- **WHATSAPP** — concise bold/emoji text sized for chat limits; capped lists say + "+N more". +- **WEBHOOK** — the structured `DigestModel` JSON is the `digest.generated` + event payload; no text projection needed. +- **TELEGRAM** — shares the text renderer (no outbound push engine exists yet). +- **EMAIL** — richer HTML/plain render lands with the email-channel issue (#367). + +## 4. The scheduled job — `src/jobs/digests.ts` + +On each tick the job claims **due, active** subscriptions (`nextRunAt <= now`), +assembles **one** digest per user, and delivers per channel. + +- **Atomic claim**: `updateMany` guarded on `{ id, isActive, nextRunAt }`. A + concurrent runner, mid-tick deactivate, or delete matches 0 rows and is + skipped — no double-send of the same occurrence. +- **Assemble once, deliver per channel**: one `buildDigest` result is rendered + and pushed per channel. A failing channel is logged and counted, never + allowed to block the others, and never rolls the whole occurrence back. +- **Bounded per-channel retry**: transient WHATSAPP failures retry a bounded + number of times before being marked unavailable for that occurrence. +- **Quiet hours defer, never drop**: if the job fires while the clock is inside + the window, the occurrence is advanced to the next allowed hour and picked up + then. +- **Catch-up storm guard**: after a delivery `nextRunAt` advances to the *next* + future occurrence. A server down for N periods sends exactly **one** digest on + recovery, not N — `lastSentAt` is a stamp; missed occurrences aren't replayed + or counted. +- **Occurrence idempotency**: a digest occurrence is `(subscriptionId, slot)`. + The conditional claim on `nextRunAt` prevents re-runs from double-sending. If + delivery hard-fails on **all** channels, `nextRunAt` is rolled back so the + occurrence is retried on a later tick rather than silently advanced past. + +## 5. API + +All digest endpoints are owner-scoped (the callers' own data) and require auth. +Mounted under `/api/v1/notifications/digests`, plus the unversioned +`/api/notifications/digests` alias. + +| Method | Path | Description | +| -------- | ------------------------------------- | -------------------------------------------------------------- | +| `POST` | `/digests` | Create a subscription (fails if channels aren't linked). | +| `GET` | `/digests` | List the caller's subscriptions. | +| `GET` | `/digests/preview?frequency=WEEKLY` | Render the digest **right now** for the caller, no scheduling (rate-limited). | +| `PATCH` | `/digests/:id` | Update a subscription (validates channel linking + WEEKLY day). | +| `DELETE` | `/digests/:id` | Delete a subscription. | + +Validation lives in `src/validators/digest-validators.ts`: frequencies/channels +mirror the Prisma enums; `WEEKLY` requires `weeklyDayUtc`; `quietHours` needs +distinct 0..23 bounds. + +## 6. Real-time stream & user webhooks + +`digest.generated` is a **socket-only** event type mapped to the `alerts` topic. +Publishing it: + +1. Appends to the user's durable stream (resumable via `seq`). +2. Broadcasts to live sockets. +3. Enqueues deliveries to the user's **own** webhook endpoints that allow the + event (string compare against their `events` allowlist — empty = all). + +Operator webhooks are never notified (socket-only + `hasWebhookCounterpart` +returns false). See `src/events/types.ts` and `src/services/userWebhookDispatcher.ts`. + +## 7. Metrics + +The job records `job_success_total` / `job_failure_total` / `job_duration_ms` +under `job_name="digests"` (via `src/utils/job-metrics.ts`), with `due`, +`delivered`, `deferred`, and `skipped` counts in the job log line. + +## 8. Edge cases & failure modes + +- **No positions** → short "no active positions" digest (`hasPositions: false`). +- **Channel unlinked** (e.g. phone removed) → skipped with a note, not an error. +- **Quiet hours cover the whole day** → deferred to `quietHours.endUtc` the next + day. +- **DST-ish edge / missed run** → `nextRunAt` in UTC; a missed run sends once on + recovery (guarded by `lastSentAt` + claim). +- **Very active user** → transaction/rebalance lists are capped (`+N more`). +- **Monthly on the 31st** → `nextOccurrence` clamps to the last day of the month. +- **Idempotency** → `(subscriptionId, slot)` claim prevents double-sends. diff --git a/docs/USER_WEBHOOKS.md b/docs/USER_WEBHOOKS.md index 20046f0..5302ec0 100644 --- a/docs/USER_WEBHOOKS.md +++ b/docs/USER_WEBHOOKS.md @@ -8,7 +8,7 @@ Unlike operator-scoped webhooks (which fan out to system-wide operator endpoints Key capabilities: - **Per-User Signing Secrets**: Each endpoint receives a unique HMAC secret (`whsec_...`) shown **only once** upon creation or secret rotation. -- **Event & Topic Scoping**: Endpoints can filter by specific domain events (`events: ["deposit.received", "agent.rebalanced"]`) or topic scopes (`topicScope: ["portfolio", "transactions"]`). +- **Event & Topic Scoping**: Endpoints can filter by specific domain events (`events: ["deposit.received", "agent.rebalanced"]`) or topic scopes (`topicScope: ["portfolio", "transactions"]`). The scheduled portfolio digest is delivered to user endpoints as `digest.generated` (an empty `events` array = all events). - **Server-side Filter Predicates**: Supports optional validated filter JSON predicates evaluated before delivery enqueueing (e.g. only `WITHDRAWAL` transactions over $100). - **Idempotency & Replay**: Deliveries use `@@unique([endpointId, userEventSeq])` based on the user's durable stream sequence (`seq`). Endpoints can request replay via `POST /api/v1/webhooks/endpoints/:id/replay?afterSeq=`. - **SSRF Protection**: Endpoint URLs must use `https://` and are validated against private, loopback, and link-local IP ranges. diff --git a/docs/WEBSOCKET_STREAMING.md b/docs/WEBSOCKET_STREAMING.md index b04e096..5efb5d3 100644 --- a/docs/WEBSOCKET_STREAMING.md +++ b/docs/WEBSOCKET_STREAMING.md @@ -95,7 +95,7 @@ action — and sockets held on other pods. | `transactions` | deposit/withdraw/settlement confirmations, fiat orders, recurring deposits, terminal outbox failures | `src/stellar/events.ts`, `src/fiat/service.ts`, `src/controllers/transaction-controller.ts` | | `portfolio` | value/position changes | `src/agent/loop.ts` | | `agent` | rebalance decisions | `src/agent/loop.ts` | -| `alerts` | `alert_rule.triggered` | `src/jobs/alertRules.ts` | +| `alerts` | `alert_rule.triggered`, `digest.generated` (socket-only) | `src/jobs/alertRules.ts`, `src/jobs/digests.ts` | | `strategies` | publish / unpublish / material config change | `src/strategy/service.ts` | ### Ordering contract diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2d14709..5033826 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -928,6 +928,212 @@ components: - $ref: '#/components/schemas/WebSocketDraining' - $ref: '#/components/schemas/WebSocketPong' + # ── Digest subscriptions (#365) ──────────────────────────────────────────── + + DigestFrequency: + type: string + enum: [DAILY, WEEKLY, MONTHLY] + + DigestChannel: + type: string + enum: [WHATSAPP, TELEGRAM, EMAIL, WEBHOOK] + + DigestQuietHours: + type: object + description: Never send inside this UTC window (`startUtc`..`endUtc`, exclusive end). + required: [startUtc, endUtc] + properties: + startUtc: + type: integer + minimum: 0 + maximum: 23 + endUtc: + type: integer + minimum: 0 + maximum: 23 + + DigestSubscription: + type: object + required: [id, userId, frequency, channels, sendHourUtc, isActive, nextRunAt, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + userId: + type: string + format: uuid + frequency: + $ref: '#/components/schemas/DigestFrequency' + channels: + type: array + items: + $ref: '#/components/schemas/DigestChannel' + sendHourUtc: + type: integer + minimum: 0 + maximum: 23 + weeklyDayUtc: + type: integer + nullable: true + minimum: 0 + maximum: 6 + quietHours: + allOf: + - $ref: '#/components/schemas/DigestQuietHours' + nullable: true + isActive: + type: boolean + lastSentAt: + type: string + format: date-time + nullable: true + nextRunAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + CreateDigestSubscriptionRequest: + type: object + required: [frequency, channels] + properties: + frequency: + $ref: '#/components/schemas/DigestFrequency' + channels: + type: array + minItems: 1 + maxItems: 4 + items: + $ref: '#/components/schemas/DigestChannel' + sendHourUtc: + type: integer + minimum: 0 + maximum: 23 + default: 9 + weeklyDayUtc: + type: integer + nullable: true + minimum: 0 + maximum: 6 + quietHours: + $ref: '#/components/schemas/DigestQuietHours' + isActive: + type: boolean + default: true + + UpdateDigestSubscriptionRequest: + type: object + description: Any subset of the creatable fields. + properties: + frequency: + $ref: '#/components/schemas/DigestFrequency' + channels: + type: array + minItems: 1 + maxItems: 4 + items: + $ref: '#/components/schemas/DigestChannel' + sendHourUtc: + type: integer + minimum: 0 + maximum: 23 + weeklyDayUtc: + type: integer + nullable: true + minimum: 0 + maximum: 6 + quietHours: + $ref: '#/components/schemas/DigestQuietHours' + isActive: + type: boolean + + DigestValueChange: + type: object + properties: + startValue: + type: number + nullable: true + endValue: + type: number + absoluteChange: + type: number + nullable: true + percentChange: + type: number + nullable: true + insufficientData: + type: boolean + + DigestModel: + type: object + description: Channel-agnostic digest output from the pure assembler. + properties: + frequency: + $ref: '#/components/schemas/DigestFrequency' + period: + type: object + properties: + label: + type: string + startAt: + type: string + format: date-time + endAt: + type: string + format: date-time + valueChange: + $ref: '#/components/schemas/DigestValueChange' + yield: + type: object + properties: + earned: + type: number + nullable: true + blendedApy: + type: number + nullable: true + best: + type: object + nullable: true + worst: + type: object + nullable: true + rebalances: + type: object + properties: + count: + type: integer + netImprovementPct: + type: number + nullable: true + goals: + type: array + items: + type: object + risk: + type: object + properties: + text: + type: string + notableTransactions: + type: array + items: + type: object + capReached: + type: boolean + maxEntries: + type: integer + caveats: + type: array + items: + type: string + hasPositions: + type: boolean + responses: Unauthorized: description: Missing or invalid JWT. @@ -1867,6 +2073,128 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + # ── Digest subscriptions (#365) ───────────────────────────────────────────── + + /notifications/digests: + post: + operationId: createDigestSubscription + summary: Create a scheduled portfolio digest subscription + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDigestSubscriptionRequest' + responses: + '201': + description: Subscription created + content: + application/json: + schema: + $ref: '#/components/schemas/DigestSubscription' + '400': + $ref: '#/components/responses/Unauthorized' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + description: An active digest subscription already exists + content: + application/json: + schema: + type: object + properties: + error: + type: string + get: + operationId: listDigestSubscriptions + summary: List the caller's digest subscriptions + security: + - bearerAuth: [] + responses: + '200': + description: List of the caller's subscriptions + content: + application/json: + schema: + type: object + properties: + subscriptions: + type: array + items: + $ref: '#/components/schemas/DigestSubscription' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/digests/preview: + get: + operationId: previewDigest + summary: Render the caller's digest right now without scheduling it + description: Rate-limited; owner-scoped to the caller's own data. + security: + - bearerAuth: [] + parameters: + - name: frequency + in: query + required: false + schema: + $ref: '#/components/schemas/DigestFrequency' + responses: + '200': + description: The rendered digest model + content: + application/json: + schema: + $ref: '#/components/schemas/DigestModel' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/digests/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + patch: + operationId: updateDigestSubscription + summary: Update a digest subscription + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDigestSubscriptionRequest' + responses: + '200': + description: Updated subscription + content: + application/json: + schema: + $ref: '#/components/schemas/DigestSubscription' + '400': + $ref: '#/components/responses/Unauthorized' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Subscription not found + delete: + operationId: deleteDigestSubscription + summary: Delete a digest subscription + security: + - bearerAuth: [] + responses: + '204': + description: Deleted + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Subscription not found + # ── Real-time WebSocket stream (#316) ───────────────────────────────────────── /ws: diff --git a/package-lock.json b/package-lock.json index dca0435..8b2d88c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7054,6 +7054,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/prisma/migrations/20260904000000_add_digest_subscriptions/migration.sql b/prisma/migrations/20260904000000_add_digest_subscriptions/migration.sql new file mode 100644 index 0000000..228c9c1 --- /dev/null +++ b/prisma/migrations/20260904000000_add_digest_subscriptions/migration.sql @@ -0,0 +1,45 @@ +-- Cross-Channel Digest Notifications (#365) +-- Opt-in DAILY/WEEKLY/MONTHLY portfolio summaries delivered over a user's +-- chosen channels (WHATSAPP/TELEGRAM/EMAIL/WEBHOOK) by src/jobs/digests.ts. +-- +-- `channels` is a Postgres array of the DigestChannel enum. `quietHours` is a +-- JSONB object { startUtc, endUtc } (UTC 0..23); `sendHourUtc` defaults to 9 +-- (09:00 UTC) and `nextRunAt` is populated by the API with the very next +-- occurrence so the job has something to claim immediately. + +-- CreateEnum +CREATE TYPE "DigestFrequency" AS ENUM ('DAILY', 'WEEKLY', 'MONTHLY'); + +-- CreateEnum +CREATE TYPE "DigestChannel" AS ENUM ('WHATSAPP', 'TELEGRAM', 'EMAIL', 'WEBHOOK'); + +-- CreateTable +CREATE TABLE "digest_subscriptions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "frequency" "DigestFrequency" NOT NULL, + "channels" "DigestChannel"[] NOT NULL, + "sendHourUtc" INTEGER NOT NULL DEFAULT 9, + "weeklyDayUtc" INTEGER, + "quietHours" JSONB, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "lastSentAt" TIMESTAMP(3), + "nextRunAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "digest_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- The job's tick claims rows via "WHERE id = ? AND isActive = true AND +-- nextRunAt <= now", so the composite index mirrors that predicate. +CREATE INDEX "digest_subscriptions_isActive_nextRunAt_idx" + ON "digest_subscriptions"("isActive", "nextRunAt"); + +CREATE INDEX "digest_subscriptions_userId_idx" + ON "digest_subscriptions"("userId"); + +ALTER TABLE "digest_subscriptions" + ADD CONSTRAINT "digest_subscriptions_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260904000000_add_digest_subscriptions/rollback.sql b/prisma/migrations/20260904000000_add_digest_subscriptions/rollback.sql new file mode 100644 index 0000000..35d6e56 --- /dev/null +++ b/prisma/migrations/20260904000000_add_digest_subscriptions/rollback.sql @@ -0,0 +1,10 @@ +-- rollback.sql — reverse of 20260904000000_add_digest_subscriptions/migration.sql +-- Drops the cross-channel digest subscription ledger (#365). +-- WARNING: DATA LOSS — all digest scheduling prefs are lost. +-- Indexes are dropped with the table (explicit drops for idempotency). +-- Safe to run multiple times. Revert app code BEFORE running. +-- Run with: psql $DATABASE_URL -f prisma/migrations/20260904000000_add_digest_subscriptions/rollback.sql + +DROP TABLE IF EXISTS "digest_subscriptions" CASCADE; +DROP TYPE IF EXISTS "DigestChannel"; +DROP TYPE IF EXISTS "DigestFrequency"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bbb8e64..78c89a0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -210,6 +210,22 @@ enum DeliveryChannel { ALL } +/// Scheduled digest cadence (#365). +enum DigestFrequency { + DAILY + WEEKLY + MONTHLY +} + +/// Delivery channel for a digest subscription (#365). EMAIL and TELEGRAM are +/// reserved for sibling channel issues and have no renderer wired yet. +enum DigestChannel { + WHATSAPP + TELEGRAM + EMAIL + WEBHOOK +} + enum SubAccountPermission { VIEW DEPOSIT @@ -280,6 +296,7 @@ model User { userApiKeys UserApiKey[] userWebhookEndpoints UserWebhookEndpoint[] emailIdentity EmailIdentity? + digestSubscriptions DigestSubscription[] @@map("users") } @@ -1677,6 +1694,39 @@ model EmailIdentity { @@map("email_identities") } +/// Scheduled cross-channel portfolio digest (#365). An opt-in daily/weekly/ +/// monthly summary delivered over the user's chosen channels. +/// +/// Scheduling + idempotency semantics (see docs/NOTIFICATIONS.md): +/// • `sendHourUtc` 0..23 and the optional `weeklyDayUtc` 0..6 define the +/// preferred send slot. `nextRunAt` is advanced with addCadence from that +/// slot each time a digest is produced. +/// • `quietHours` (Json `{ startUtc, endUtc }`, both 0..23) is a "never send +/// inside this window" preference. A send that falls inside the window is +/// DEFERRED to the next allowed hour, never dropped. +/// • A digest occurrence is (subscriptionId, periodStart); `lastSentAt` plus a +/// claim on `nextRunAt` guard against double-sends and catch-up storms. +model DigestSubscription { + id String @id @default(uuid()) + userId String + frequency DigestFrequency + channels DigestChannel[] + sendHourUtc Int @default(9) + weeklyDayUtc Int? + quietHours Json? + isActive Boolean @default(true) + lastSentAt DateTime? + nextRunAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([isActive, nextRunAt]) + @@index([userId]) + @@map("digest_subscriptions") +} + // --- Issue #372: Audit Anchoring --- model AuditAnchor { id String @id @default(uuid()) diff --git a/src/config/env.ts b/src/config/env.ts index 81cb47f..f6ee3fd 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -568,6 +568,9 @@ export const config = { alertRules: { intervalMs: parseInt(process.env.ALERT_RULES_INTERVAL_MS || '60000'), }, + digests: { + intervalMs: parseInt(process.env.DIGESTS_INTERVAL_MS || '60000'), + }, strategyMarketplace: { metricsIntervalMs: parseInt( process.env.STRATEGY_METRICS_INTERVAL_MS || '21600000' diff --git a/src/events/types.ts b/src/events/types.ts index 669b21f..6a5ddc6 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -47,6 +47,9 @@ export const SOCKET_ONLY_EVENT_TYPES = [ 'security.api_key_changed', /** #376 — new session sign-in alert. */ 'security.new_session', + /** #365 — a scheduled portfolio digest was generated. Stream + user's own + * webhook endpoint only; never an operator webhook (see docs/NOTIFICATIONS.md). */ + 'digest.generated', ] as const export type SocketOnlyEventType = (typeof SOCKET_ONLY_EVENT_TYPES)[number] @@ -89,6 +92,7 @@ export const EVENT_TYPE_TOPIC: Record = { 'portfolio.updated': 'portfolio', 'security.api_key_changed': 'alerts', 'security.new_session': 'alerts', + 'digest.generated': 'alerts', } const SOCKET_ONLY = new Set(SOCKET_ONLY_EVENT_TYPES) diff --git a/src/index.ts b/src/index.ts index 942ccb1..c8be290 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ import { scheduleFiatReconciliation } from './jobs/fiatReconciliation' import { scheduleReferralPayout } from './jobs/referralPayout' import { scheduleRecurringDeposits } from './jobs/recurringDeposits' import { scheduleAlertRules } from './jobs/alertRules' +import { scheduleDigests } from './jobs/digests' import { scheduleStrategyMetrics } from './jobs/strategyMetrics' import { scheduleAllocationSuggestions } from './jobs/allocationSuggestions' import { scheduleAttribution } from './jobs/attribution' @@ -125,6 +126,7 @@ let fiatReconciliationHandle: NodeJS.Timeout | null = null let referralPayoutHandle: NodeJS.Timeout | null = null let recurringDepositsHandle: NodeJS.Timeout | null = null let alertRulesHandle: NodeJS.Timeout | null = null +let digestsHandle: NodeJS.Timeout | null = null let strategyMetricsHandle: NodeJS.Timeout | null = null let allocationSuggestionsHandle: NodeJS.Timeout | null = null let protocolRiskScoringHandle: NodeJS.Timeout | null = null @@ -398,6 +400,12 @@ async function gracefulShutdown(signal: string): Promise { logger.info('[Shutdown] Alert rules timer cleared') } + if (digestsHandle) { + clearInterval(digestsHandle) + digestsHandle = null + logger.info('[Shutdown] Digests timer cleared') + } + if (strategyMetricsHandle) { clearInterval(strategyMetricsHandle) strategyMetricsHandle = null @@ -649,6 +657,7 @@ async function main(): Promise { referralPayoutHandle = scheduleReferralPayout() recurringDepositsHandle = scheduleRecurringDeposits() alertRulesHandle = scheduleAlertRules() + digestsHandle = scheduleDigests() strategyMetricsHandle = scheduleStrategyMetrics() // Ordered before the suggestion job so the first suggestion run sees freshly // scored protocols rather than whatever was last left in the table. diff --git a/src/jobs/digests.ts b/src/jobs/digests.ts new file mode 100644 index 0000000..c2fd11f --- /dev/null +++ b/src/jobs/digests.ts @@ -0,0 +1,324 @@ +/** + * Scheduled cross-channel digest delivery (#365). + * + * On each tick this job claims DUE, ACTIVE `DigestSubscription`s + * (`nextRunAt <= now`), assembles ONE digest for the user over the preceding + * period, and delivers it per channel. + * + * Design decisions (see docs/NOTIFICATIONS.md): + * + * • Atomic claim: before delivering we updateMany the row conditioned on + * `{ id, isActive, nextRunAt }`. A concurrent runner, a mid-tick deactivate, + * or a delete matches 0 rows and we skip — no double-send. + * + * • Quiet hours defer, never drop: if the due slot falls inside the + * subscription's quiet window, we DON'T deliver this tick; we advance + * `nextRunAt` to the next allowed slot (via deferForQuietHours) so the sink + * fires again at that time. The occurrence is picked up then, after quiet. + * + * • Catch-up storm guard: after a delivery `nextRunAt` is advanced to the NEXT + * future occurrence (`nextOccurrence`). A server that was down for N periods + * therefore sends exactly ONE digest on recovery, not N — `lastSentAt` is + * only a stamp; missed occurrences are not replayed or counted. + * + * • Occurrence idempotency: a digest occurrence is de-facto + * `(subscriptionId, deliveredSlot)`; the conditional claim on `nextRunAt` + * prevents a re-run from double-sending the same slot. If delivery hard-fails + * on all channels we roll `nextRunAt` back so the occurrence is retried on a + * later tick (bounded by the job interval), rather than silently advancing + * past a failed digest. + * + * • Per-channel isolation: one failing channel is logged and counted, never + * allowed to block the others, and never rolls the whole occurrence back. + */ + +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 { publishUserEvent } from '../events/publisher' +import { EVENT_TYPE_TOPIC } from '../events/types' +import { sendWhatsAppMessage } from '../utils/twilio-client' +import { loadDigestData } from '../notifications/load' +import { buildDigest } from '../notifications/digest' +import { renderDigest, isChannelDeliverable } from '../notifications/render' +import { + deferForQuietHours, + isQuietHours, + nextOccurrence, + type QuietHours, +} from '../notifications/schedule' + +type DigestChannel = 'WHATSAPP' | 'TELEGRAM' | 'EMAIL' | 'WEBHOOK' +type Frequency = 'DAILY' | 'WEEKLY' | 'MONTHLY' + +interface DigestSubscriptionRow { + id: string + userId: string + frequency: Frequency + channels: DigestChannel[] + sendHourUtc: number + weeklyDayUtc: number | null + quietHours: unknown + isActive: boolean + lastSentAt: Date | null + nextRunAt: Date +} + +/** Numeric minutes into a failure-backoff window (bounded retries). */ +const MAX_DELIVERY_ATTEMPTS = 3 + +async function claimDueSubscription( + sub: DigestSubscriptionRow, + now: Date +): Promise { + const result = await db.digestSubscription.updateMany({ + where: { + id: sub.id, + isActive: true, + nextRunAt: sub.nextRunAt, + }, + data: { lastSentAt: now }, + }) + return result.count === 1 +} + +/** + * Deliver a digest over the channels that are currently deliverable and linked + * (e.g. WHATSAPP needs a phone on file). Unlinked/unavailable channels are + * skipped with a `digest.channel_unavailable` note, never an error. One bad + * channel never blocks the others. + */ +async function deliverDigest( + sub: DigestSubscriptionRow, + userId: string, + model: Record, + text: string | null +): Promise<{ delivered: string[]; skipped: string[] }> { + const delivered: string[] = [] + const skipped: string[] = [] + + const channels = sub.channels + + if (channels.includes('WHATSAPP')) { + try { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { phone: true }, + }) + if (!user?.phone) { + skipped.push('WHATSAPP') + logger.warn( + `[Digests] Subscription ${sub.id} requests WHATSAPP but user ${userId} has no phone on file — skipping` + ) + } else if (!text) { + skipped.push('WHATSAPP') + logger.warn( + `[Digests] Subscription ${sub.id} WHATSAPP render produced no output — skipping` + ) + } else { + await sendWhatsAppMessage({ to: `whatsapp:${user.phone}`, body: text }) + delivered.push('WHATSAPP') + } + } catch (error) { + // Bounded retry for a transient WhatsApp delivery error. + let deliveredOk = false + for (let attempt = 1; attempt <= MAX_DELIVERY_ATTEMPTS; attempt++) { + try { + await sendWhatsAppMessage({ + to: `whatsapp:${(await db.user.findUnique({ where: { id: userId }, select: { phone: true } }))?.phone}`, + body: text ?? '', + }) + deliveredOk = true + break + } catch { + if (attempt === MAX_DELIVERY_ATTEMPTS) break + } + } + if (deliveredOk) { + delivered.push('WHATSAPP') + } else { + skipped.push('WHATSAPP') + logger.error( + `[Digests] WHATSAPP delivery failed for subscription ${sub.id}`, + { error: error instanceof Error ? error.message : String(error) } + ) + } + } + } + + if (channels.includes('WEBHOOK')) { + // digest.generated is a socket-only event type, but this publish also + // enqueues the user's OWN webhook endpoints (see events/publisher.ts). The + // digest reaches the user's real-time stream and their registered endpoint + // only — never operator webhooks (docs/NOTIFICATIONS.md). + await publishUserEvent( + userId, + EVENT_TYPE_TOPIC['digest.generated'], + 'digest.generated', + model + ).catch((error) => { + skipped.push('WEBHOOK') + logger.error( + `[Digests] WEBHOOK/stream delivery failed for subscription ${sub.id}`, + { error: error instanceof Error ? error.message : String(error) } + ) + }) + delivered.push('WEBHOOK') + } + + return { delivered, skipped } +} + +/** + * Process all due digest subscriptions. + */ +export async function runDigests(now: Date = new Date()): Promise { + const correlationId = generateCorrelationId() + return runWithCorrelationIdAsync(correlationId, async () => { + const start = Date.now() + const jobName = 'digests' + + let due = 0 + let delivered = 0 + let deferred = 0 + let skippedCount = 0 + + try { + const subs = (await db.digestSubscription.findMany({ + where: { isActive: true, nextRunAt: { lte: now } }, + orderBy: { nextRunAt: 'asc' }, + select: { + id: true, + userId: true, + frequency: true, + channels: true, + sendHourUtc: true, + weeklyDayUtc: true, + quietHours: true, + isActive: true, + lastSentAt: true, + nextRunAt: true, + }, + })) as DigestSubscriptionRow[] + + due = subs.length + + for (const sub of subs) { + try { + const quiet: QuietHours | null = isQuietHours(sub.quietHours) + ? sub.quietHours + : null + + // Quiet hours gate the CURRENT send time, not the stored slot: if the + // job fires while the clock is inside the window we defer the whole + // occurrence to the next allowed hour (never drop it). The digest is + // then picked up again at that deferred time. + const slot = deferForQuietHours(now, quiet) + if (slot.getTime() !== now.getTime()) { + await db.digestSubscription.update({ + where: { id: sub.id }, + data: { nextRunAt: slot }, + }) + deferred++ + logger.info( + `[Digests] Subscription ${sub.id} deferred to ${slot.toISOString()} (quiet hours)` + ) + continue + } + + // Atomic claim — guards against concurrent runners / mid-tick deactivates. + const won = await claimDueSubscription(sub, now) + if (!won) continue + + // Assemble once for the user, then render/deliver per channel. + const data = await loadDigestData(sub.userId, sub.frequency, now) + const model = buildDigest(data, sub.frequency) + const text = renderDigest(model, 'WHATSAPP') + + const { delivered: ok, skipped } = await deliverDigest( + sub, + sub.userId, + model as unknown as Record, + text + ) + delivered += ok.length + skippedCount += skipped.length + + if (ok.length === 0) { + // Hard failure across all channels: roll the claim back so the + // occurrence is retried on a later tick rather than silently lost. + await db.digestSubscription + .update({ + where: { id: sub.id }, + data: { lastSentAt: sub.lastSentAt, nextRunAt: sub.nextRunAt }, + }) + .catch(() => undefined) + continue + } + + // All verbs delivered — advance to the next occurrence. + const nextRunAt = nextOccurrence( + sub.frequency, + sub.sendHourUtc, + sub.weeklyDayUtc, + now + ) + await db.digestSubscription.update({ + where: { id: sub.id }, + data: { nextRunAt }, + }) + } catch (subError) { + // One bad subscription must not abort the sweep. + logger.error(`[Digests] Error processing subscription ${sub.id}`, { + error: + subError instanceof Error ? subError.message : String(subError), + }) + } + } + + const durationMs = Date.now() - start + logBackgroundJob(jobName, 'success', durationMs / 1000, correlationId, { + due, + delivered, + deferred, + skipped: skippedCount, + }) + 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 digest job. Runs once on startup then on the configured interval. + * + * @returns NodeJS.Timeout handle — pass to clearInterval() on shutdown. + */ +export function scheduleDigests(): NodeJS.Timeout { + void runDigests() + + const intervalMs = config.digests.intervalMs + const handle = setInterval(() => { + void runDigests() + }, intervalMs) + + handle.unref?.() + + logger.info(`[Digests] Digest delivery scheduled every ${intervalMs}ms`) + return handle +} + +// Export for testability. +export { isChannelDeliverable } diff --git a/src/notifications/digest.ts b/src/notifications/digest.ts new file mode 100644 index 0000000..b4cfff2 --- /dev/null +++ b/src/notifications/digest.ts @@ -0,0 +1,425 @@ +/** + * Pure digest assembler (#365). + * + * `buildDigest` is a **pure, deterministic** function: give it the raw portfolio + * inputs for a period and it returns a channel-agnostic `DigestModel`. It never + * touches the database or the clock — the scheduled job (`src/jobs/digests.ts`) + * is responsible for fetching the data and passing it in, and callers (like the + * `preview` endpoint) can reuse the same pure function with their own inputs. + * + * Honesty constraint: a period with insufficient `YieldSnapshot`s reports a + * caveat instead of a misleading number (same discipline as the analytics + * `caveats`). No positions yields a short "no active positions" digest, never a + * broken message. + */ + +export type DigestFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY' + +export interface DigestPeriod { + /** Human label, e.g. "Past 7 days". */ + label: string + /** Start of the period (inclusive). */ + startAt: Date + /** End of the period (inclusive). */ + endAt: Date +} + +export interface DigestPositionInput { + id: string + protocolName: string + assetSymbol: string + currentValue: number + /** Value the user deposited into this position. */ + depositedAmount: number + yieldEarned: number +} + +export interface DigestSnapshotInput { + positionId: string + /** Value (principal + accrued yield) at the snapshot instant. */ + value: number + apy: number + timestampMs: number +} + +export interface DigestTransactionInput { + /** DEPOSIT | WITHDRAWAL | ... — anything with a meaningful amount. */ + type: string + amount: number + assetSymbol: string + createdAt: Date +} + +/** One rebalance the agent executed that touched this user during the period. */ +export interface DigestRebalanceInput { + fromProtocol: string + toProtocol: string | null + improvedByPercent: number | null + createdAt: Date +} + +/** Latest goal state plus the state at the start of the period. */ +export interface DigestGoalInput { + name?: string | null + targetAmount: number + /** Progress percentage at the *start* of the period. */ + progressPctStart: number | null + /** Progress percentage now. */ + progressPctNow: number | null + onTrack: boolean + currentAmount: number +} + +export interface DigestInput { + period: DigestPeriod + positions: DigestPositionInput[] + snapshots: DigestSnapshotInput[] + transactions: DigestTransactionInput[] + rebalances: DigestRebalanceInput[] + goals: DigestGoalInput[] + /** Fees/notes surfaced verbatim so the digest stays honest. */ + caveats: string[] + /** Threshold above which a transaction is "notable" in the digest. */ + notableTxnThreshold: number +} + +// ── Output model ────────────────────────────────────────────────────────────── + +export interface DigestValueChange { + startValue: number | null + endValue: number + absoluteChange: number | null + percentChange: number | null + /** True when snapshots were too sparse to trust the delta. */ + insufficientData: boolean +} + +export interface DigestPositionSummary { + protocolName: string + assetSymbol: string + value: number + apy: number | null +} + +export interface DigestYieldSummary { + /** Yield earned during the period, when measurable. */ + earned: number | null + /** Blended APY across active positions, when measurable. */ + blendedApy: number | null + best: DigestPositionSummary | null + worst: DigestPositionSummary | null +} + +export interface DigestRebalanceSummary { + count: number + /** Net estimated improvement (in percentage points) from executed rebalances. */ + netImprovementPct: number | null + /** Most recent rebalance, oldest last. */ + recent: DigestRebalanceInput[] +} + +export interface DigestGoalSummary { + name: string + /** Percentage-point movement over the period. */ + progressDeltaPct: number | null + progressPctNow: number | null + targetAmount: number + onTrack: boolean +} + +export interface DigestRiskLine { + /** e.g. "Max drawdown 3.2% over the period" or "Volatility steady". */ + text: string +} + +export interface DigestNotableTransaction { + type: string + amount: number + assetSymbol: string + createdAt: Date +} + +export interface DigestModel { + frequency: DigestFrequency + period: DigestPeriod + valueChange: DigestValueChange + yield: DigestYieldSummary + rebalances: DigestRebalanceSummary + goals: DigestGoalSummary[] + /** Exactly one risk line, always populated. */ + risk: DigestRiskLine + notableTransactions: DigestNotableTransaction[] + /** Ordered most recent last; capped at `maxEntries`. */ + capReached: boolean + maxEntries: number + caveats: string[] + /** True when the user has no active positions (empty-digest case). */ + hasPositions: boolean +} + +const DEFAULT_MAX_ENTRIES = 10 + +function round(n: number | null, digits = 2): number | null { + if (n === null || !Number.isFinite(n)) return null + return Number(n.toFixed(digits)) +} + +/** Percent a position's earned yield represents of its current value ('now'). */ +function positionApy(p: DigestPositionInput, nowValue: number): number { + if (nowValue <= 0) return 0 + return (p.yieldEarned / nowValue) * 100 +} + +/** + * Reconstruct a portfolio value from snapshots at a reference time: the value of + * the latest snapshot on or before `beforeMs`, aggregating across positions so + * the result is a whole-portfolio value at that instant. Returns null when no + * snapshot fell on or before the reference time. + */ +function valueAtOrBefore( + snapshots: DigestSnapshotInput[], + beforeMs: number +): number | null { + let latest: DigestSnapshotInput | null = null + for (const s of snapshots) { + if (s.timestampMs > beforeMs) continue + if (!latest || s.timestampMs > latest.timestampMs) latest = s + } + if (!latest) return null + // Sum all snapshots that share the latest instant (whole-portfolio value). + let total = 0 + for (const s of snapshots) { + if (s.timestampMs === latest.timestampMs) total += s.value + } + return round(total) +} + +/** + * Yield earned *during* the period: the latest snapshot's cumulative + * principal+yield minus the snapshot closest to (on or before) the period + * start, summed across positions. Only measured when both endpoints exist for + * at least one position; otherwise the period is reported as insufficient. + */ +function measurePeriodYield( + snapshots: DigestSnapshotInput[], + period: DigestPeriod +): { earned: number | null; insufficient: boolean } { + if (snapshots.length === 0) return { earned: null, insufficient: true } + + const startMs = period.startAt.getTime() + const endMs = period.endAt.getTime() + + let total = 0 + let measurable = true + + // Group by position, then find the boundary snapshots per position. + const byPosition = new Map() + for (const s of snapshots) { + if (s.timestampMs > endMs) continue + const list = byPosition.get(s.positionId) ?? [] + list.push(s) + byPosition.set(s.positionId, list) + } + + for (const list of byPosition.values()) { + const sorted = [...list].sort((a, b) => a.timestampMs - b.timestampMs) + // Latest value in the period. + const latest = sorted[sorted.length - 1]! + // Boundary snapshot at or before the period start. + let boundary: DigestSnapshotInput | null = null + for (const s of sorted) { + if (s.timestampMs <= startMs) boundary = s + } + // A position created within the period: treat its first snapshot as baseline. + if (!boundary) boundary = sorted[0] + if (!boundary) { + measurable = false + continue + } + total += latest.value - boundary.value + } + + if (!measurable) return { earned: null, insufficient: true } + return { earned: round(total), insufficient: false } +} + +function blendedApy(positions: DigestPositionInput[]): number | null { + const active = positions.filter((p) => p.currentValue > 0) + if (active.length === 0) return null + const totalValue = active.reduce((sum, p) => sum + p.currentValue, 0) + if (totalValue <= 0) return null + const weighted = active.reduce( + (sum, p) => + sum + (p.currentValue / totalValue) * positionApy(p, p.currentValue), + 0 + ) + return round(weighted) +} + +function buildRiskLine( + snapshots: DigestSnapshotInput[], + period: DigestPeriod, + hasPositions: boolean +): DigestRiskLine { + if (!hasPositions) { + return { text: 'No active positions yet — deposit to get started.' } + } + const startMs = period.startAt.getTime() + const endMs = period.endAt.getTime() + const inPeriod = snapshots.filter( + (s) => s.timestampMs >= startMs && s.timestampMs <= endMs + ) + if (inPeriod.length < 2) { + return { text: 'Insufficient snapshot history to compute a risk line.' } + } + // Aggregate values per instant (whole portfolio), then measure drawdown. + const byInstant = new Map() + for (const s of inPeriod) { + byInstant.set(s.timestampMs, (byInstant.get(s.timestampMs) ?? 0) + s.value) + } + const points = Array.from(byInstant, ([timestampMs, value]) => ({ + timestampMs, + value, + })).sort((a, b) => a.timestampMs - b.timestampMs) + + let peak = points[0]!.value + let maxDrawdown = 0 + for (let i = 1; i < points.length; i++) { + const v = points[i]!.value + if (v > peak) peak = v + const dd = peak > 0 ? (peak - v) / peak : 0 + if (dd > maxDrawdown) maxDrawdown = dd + } + + if (maxDrawdown < 0.005) { + return { + text: `${period.label}: portfolio volatility steady (no meaningful drawdown).`, + } + } + return { + text: `${period.label}: max drawdown of ${(maxDrawdown * 100).toFixed(1)}% from peak.`, + } +} + +/** + * Assemble a channel-agnostic portfolio digest from raw period inputs. + * Pure and deterministic — no I/O. Throws only on programmer error (missing + * required fields), never on data quality (that is surfaced via `caveats`). + */ +export function buildDigest( + input: DigestInput, + frequency: DigestFrequency = 'WEEKLY', + maxEntries: number = DEFAULT_MAX_ENTRIES +): DigestModel { + const positions = input.positions + const hasPositions = positions.length > 0 + + const endValue = + round(positions.reduce((sum, p) => sum + p.currentValue, 0)) ?? 0 + + const startValue = valueAtOrBefore( + input.snapshots, + input.period.startAt.getTime() + ) + + // Determine data sufficiency from snapshot coverage near the period start. + const startMs = input.period.startAt.getTime() + const hasStartSnapshot = input.snapshots.some((s) => s.timestampMs <= startMs) + const insufficientData = input.snapshots.length === 0 || !hasStartSnapshot + + const valueChange: DigestValueChange = { + startValue, + endValue, + absoluteChange: insufficientData + ? null + : round(startValue === null ? null : endValue - startValue), + percentChange: + insufficientData || startValue === null || startValue <= 0 + ? null + : round(((endValue - startValue) / startValue) * 100), + insufficientData, + } + + const { earned, insufficient: yieldInsufficient } = measurePeriodYield( + input.snapshots, + input.period + ) + + const withApy = positions + .filter((p) => p.currentValue > 0) + .map((p) => ({ + protocolName: p.protocolName, + assetSymbol: p.assetSymbol, + value: round(p.currentValue) ?? 0, + apy: round(positionApy(p, p.currentValue)), + })) + .sort((a, b) => b.value - a.value) + + const yieldSummary: DigestYieldSummary = { + earned: yieldInsufficient ? null : earned, + blendedApy: withApy.length ? blendedApy(positions) : null, + best: withApy[0] ?? null, + worst: withApy.length ? (withApy[withApy.length - 1] ?? null) : null, + } + + const sortedRebalances = [...input.rebalances].sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime() + ) + const executed = sortedRebalances.filter((r) => r.toProtocol !== null) + const improvedBy = executed + .map((r) => r.improvedByPercent) + .filter((v): v is number => v !== null && Number.isFinite(v)) + const rebalancesSummary: DigestRebalanceSummary = { + count: executed.length, + netImprovementPct: + improvedBy.length === 0 + ? null + : round(improvedBy.reduce((sum, v) => sum + v, 0) / improvedBy.length), + recent: sortedRebalances.slice(-3).reverse(), + } + + const goals: DigestGoalSummary[] = input.goals.map((g) => ({ + name: g.name || 'Savings goal', + progressDeltaPct: + g.progressPctNow === null || g.progressPctStart === null + ? null + : round(g.progressPctNow - g.progressPctStart), + progressPctNow: round(g.progressPctNow), + targetAmount: g.targetAmount, + onTrack: g.onTrack, + })) + + const notableTransactions = input.transactions + .filter((t) => t.amount >= input.notableTxnThreshold) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .slice(-maxEntries) + + const caveats = [...input.caveats] + if (insufficientData) { + caveats.push( + 'Insufficient snapshot history for the start of the period; value change is not shown.' + ) + } else if (valueChange.startValue === null) { + caveats.push('Start-of-period value could not be reconstructed.') + } + if (yieldInsufficient) { + caveats.push( + 'Insufficient snapshot coverage to measure yield earned this period.' + ) + } + + return { + frequency, + period: input.period, + valueChange, + yield: yieldSummary, + rebalances: rebalancesSummary, + goals, + risk: buildRiskLine(input.snapshots, input.period, hasPositions), + notableTransactions, + capReached: notableTransactions.length < input.transactions.length, + maxEntries, + caveats: [...new Set(caveats)], + hasPositions, + } +} diff --git a/src/notifications/load.ts b/src/notifications/load.ts new file mode 100644 index 0000000..c0271c7 --- /dev/null +++ b/src/notifications/load.ts @@ -0,0 +1,182 @@ +/** + * Digest data loading (#365). + * + * Fetches the raw portfolio inputs for a user over a period from the DB and + * hands them to the pure `buildDigest` assembler. Used by both the scheduled + * job (`src/jobs/digests.ts`) and the preview endpoint. Nothing here renders or + * delivers — it only assembles inputs. + */ + +import db from '../db' +import { computeGoalProgress } from '../goals/service' +import type { DigestInput, DigestFrequency } from './digest' + +export function periodFromFrequency( + frequency: DigestFrequency, + now: Date = new Date() +): { label: string; startAt: Date; endAt: Date } { + const endAt = new Date(now) + const startAt = new Date(now) + switch (frequency) { + case 'DAILY': + startAt.setUTCDate(startAt.getUTCDate() - 1) + return { label: 'Past 24 hours', startAt, endAt } + case 'WEEKLY': + startAt.setUTCDate(startAt.getUTCDate() - 7) + return { label: 'Past 7 days', startAt, endAt } + case 'MONTHLY': + startAt.setUTCMonth(startAt.getUTCMonth() - 1) + return { label: 'Past 30 days', startAt, endAt } + } +} + +/** + * Assemble a `DigestInput` for one user by querying their positions, snapshots, + * transactions, rebalances and goals over the period. Owner-scoped: everything + * is filtered by `userId`. + */ +export async function loadDigestData( + userId: string, + frequency: DigestFrequency, + now: Date = new Date(), + notableTxnThreshold = 100 +): Promise { + const period = periodFromFrequency(frequency, now) + + const [positions, goalRows] = await Promise.all([ + db.position.findMany({ + where: { userId, status: 'ACTIVE' }, + select: { + id: true, + protocolName: true, + assetSymbol: true, + currentValue: true, + depositedAmount: true, + yieldEarned: true, + }, + }), + db.savingsGoal.findMany({ + where: { userId, status: 'ACTIVE' }, + select: { id: true, targetAmount: true }, + }), + ]) + + const snapshots = + positions.length === 0 + ? [] + : await db.yieldSnapshot.findMany({ + where: { + positionId: { in: positions.map((p) => p.id) }, + snapshotAt: { gte: period.startAt }, + }, + select: { + positionId: true, + apy: true, + principalAmount: true, + yieldAmount: true, + snapshotAt: true, + }, + }) + + const transactionRows = + positions.length === 0 + ? [] + : await db.transaction.findMany({ + where: { + userId, + createdAt: { gte: period.startAt }, + status: 'CONFIRMED', + }, + select: { + type: true, + amount: true, + assetSymbol: true, + createdAt: true, + }, + }) + + const rebalances = + positions.length === 0 + ? [] + : await db.rebalanceDecision.findMany({ + where: { + affectedUserIds: { has: userId }, + outcome: 'REBALANCED', + createdAt: { gte: period.startAt }, + }, + select: { + fromProtocol: true, + toProtocol: true, + netImprovement: true, + createdAt: true, + }, + }) + + // Goal progress uses the goal service so `onTrack`/progress are computed + // with the same APY reachability logic the platform shows in-app. A null + // `progressPctStart` is honest: we don't persist historical goal progress, + // so a period delta is not fabricated. + const goals: DigestInput['goals'] = [] + for (const g of goalRows) { + const target = Number(g.targetAmount) + try { + const progress = await computeGoalProgress(g.id) + goals.push({ + name: null, + targetAmount: target, + progressPctStart: null, + progressPctNow: + target > 0 ? (progress.currentAmount / target) * 100 : 0, + onTrack: progress.onTrack, + currentAmount: progress.currentAmount, + }) + } catch { + // A single goal's progress failure must not abort the whole digest. + goals.push({ + name: null, + targetAmount: target, + progressPctStart: null, + progressPctNow: null, + onTrack: false, + currentAmount: 0, + }) + } + } + + // netImprovement is stored as a fraction (0.0123 == +1.23%). + const rebalanceInput = rebalances.map((r) => ({ + fromProtocol: r.fromProtocol, + toProtocol: r.toProtocol, + improvedByPercent: + r.netImprovement === null ? null : Number(r.netImprovement) * 100, + createdAt: r.createdAt, + })) + + return { + period, + positions: positions.map((p) => ({ + id: p.id, + protocolName: p.protocolName, + assetSymbol: p.assetSymbol, + currentValue: Number(p.currentValue), + depositedAmount: Number(p.depositedAmount), + yieldEarned: Number(p.yieldEarned), + })), + snapshots: snapshots.map((s) => ({ + positionId: s.positionId, + value: Number(s.principalAmount) + Number(s.yieldAmount), + apy: Number(s.apy), + timestampMs: s.snapshotAt.getTime(), + })), + transactions: transactionRows.map((t) => ({ + type: t.type, + amount: Number(t.amount), + assetSymbol: t.assetSymbol, + createdAt: t.createdAt, + })), + rebalances: rebalanceInput, + goals, + caveats: [], + notableTxnThreshold, + } +} diff --git a/src/notifications/render.ts b/src/notifications/render.ts new file mode 100644 index 0000000..ce4038d --- /dev/null +++ b/src/notifications/render.ts @@ -0,0 +1,146 @@ +/** + * Per-channel digest rendering (#365). + * + * `renderDigest` maps the channel-agnostic `DigestModel` onto a concrete channel. + * Today only WHATSAPP and WEBHOOK have working delivery paths in this repo. + * TELEGRAM and EMAIL are reserved for their sibling channel issues; calling + * their renderers here surfaces a clear, actionable error instead of silently + * producing nothing. + */ + +import type { DigestModel } from './digest' + +export type DigestRenderChannel = 'WHATSAPP' | 'TELEGRAM' | 'EMAIL' | 'WEBHOOK' + +const FREQUENCY_LABEL: Record = { + DAILY: 'Daily', + WEEKLY: 'Weekly', + MONTHLY: 'Monthly', +} + +function money(n: number | null, symbol = '$'): string { + if (n === null) return 'n/a' + return `${symbol}${n.toFixed(2)}` +} + +function percent(n: number | null): string { + if (n === null) return 'n/a' + return `${n.toFixed(2)}%` +} + +/** + * Concise WhatsApp/Telegram-style text rendering of a digest. Kept deliberately + * short so it fits chat-message length limits; capped lists say "+N more". + */ +function renderTextDigest(model: DigestModel): string { + const lines: string[] = [] + lines.push(`📊 *${FREQUENCY_LABEL[model.frequency] ?? 'Portfolio'} Digest*`) + + if (!model.hasPositions) { + lines.push('No active positions yet — deposit to get started.') + lines.push(model.risk.text) + return lines.join('\n') + } + + const vc = model.valueChange + if ( + vc.insufficientData || + vc.absoluteChange === null || + vc.percentChange === null + ) { + lines.push( + `Portfolio value: *${money(vc.endValue)}* (change n/a — insufficient data)` + ) + } else { + const direction = vc.absoluteChange >= 0 ? '▲' : '▼' + lines.push( + `Portfolio value: *${money(vc.endValue)}* (${direction} ${money( + Math.abs(vc.absoluteChange) + )}, ${percent(vc.percentChange)})` + ) + } + + const y = model.yield + if (y.earned !== null) { + lines.push(`Yield this period: *${money(y.earned)}*`) + } + if (y.blendedApy !== null) { + lines.push(`Blended APY: *${percent(y.blendedApy)}*`) + } + if (y.best) { + lines.push( + `Best: ${y.best.protocolName} ${y.best.assetSymbol} (${y.best.apy !== null ? percent(y.best.apy) : 'n/a'})` + ) + } + + const rb = model.rebalances + if (rb.count > 0) { + lines.push( + `Agent rebalances: *${rb.count}*${rb.netImprovementPct !== null ? ` (avg +${percent(rb.netImprovementPct)})` : ''}` + ) + } + + if (model.goals.length > 0) { + const g = model.goals[0]! + const delta = + g.progressDeltaPct === null + ? '' + : ` (${g.progressDeltaPct >= 0 ? '+' : ''}${g.progressDeltaPct.toFixed(1)}pp)` + lines.push( + `🎯 ${g.name}: ${g.progressPctNow === null ? 'n/a' : g.progressPctNow.toFixed(0) + '%'}/${money( + g.targetAmount + )}${delta}${g.onTrack ? ' · on track' : ''}` + ) + } + + lines.push(model.risk.text) + + if (model.notableTransactions.length > 0) { + const txLines = model.notableTransactions.map((t) => { + const direction = t.type.toUpperCase() === 'WITHDRAWAL' ? '⬅' : '➡' + return `${direction} ${money(t.amount)} ${t.assetSymbol} (${t.type.toLowerCase()})` + }) + lines.push(`Notable transactions:`) + lines.push(txLines.join('\n')) + if (model.capReached) { + lines.push(`_+${model.notableTransactions.length} shown_`) + } + } + + if (model.caveats.length > 0) { + lines.push(`ℹ️ ${model.caveats.join(' ')}`) + } + + return lines.join('\n') +} + +/** + * Render a digest for a given channel. Returns `null` when a channel is + * configured but currently has no delivery path (skipped with a + * `channel_unavailable` note, never an error — see docs/NOTIFICATIONS.md). + */ +export function renderDigest( + model: DigestModel, + channel: DigestRenderChannel +): string | null { + switch (channel) { + case 'WHATSAPP': + return renderTextDigest(model) + case 'TELEGRAM': + return renderTextDigest(model) + case 'EMAIL': + // Richer HTML/plain email rendering lands with the email-channel issue. + return null + case 'WEBHOOK': + // Webhook delivery carries the structured DigestModel JSON verbatim; no + // text projection is needed. The caller sends model as the payload. + return null + default: + return null + } +} + +/** True when the channel has a working text/push delivery path today. */ +export function isChannelDeliverable(channel: DigestRenderChannel): boolean { + return channel === 'WHATSAPP' || channel === 'WEBHOOK' +} diff --git a/src/notifications/schedule.ts b/src/notifications/schedule.ts new file mode 100644 index 0000000..e282048 --- /dev/null +++ b/src/notifications/schedule.ts @@ -0,0 +1,181 @@ +/** + * Digest scheduling helpers (#365). + * + * All times are UTC. A subscription has a preferred `sendHourUtc` (0..23) and, + * for WEEKLY, a `weeklyDayUtc` (0..6 = Sunday..Saturday). `nextRunAt` is derived + * by `nextOccurrence` and persisted; the job advances it after each delivery. + * + * `deferForQuietHours` implements the "never send inside the quiet window" + * preference by deferring to the next allowed hour — it NEVER drops a digest, it + * only delays it. If the whole day is quiet, the digest is sent at + * `quietHours.endUtc` on the next available day. + */ + +export interface QuietHours { + /** Hour of the quiet window start (0..23, UTC). */ + startUtc: number + /** Hour of the quiet window end (0..23, UTC). */ + endUtc: number +} + +export type Frequency = 'DAILY' | 'WEEKLY' | 'MONTHLY' + +export function isQuietHours(value: unknown): value is QuietHours { + if (!value || typeof value !== 'object') return false + const v = value as Record + return ( + typeof v.startUtc === 'number' && + typeof v.endUtc === 'number' && + v.startUtc >= 0 && + v.startUtc <= 23 && + v.endUtc >= 0 && + v.endUtc <= 23 + ) +} + +function clampHour(h: number): number { + if (!Number.isFinite(h)) return 0 + return ((Math.round(h) % 24) + 24) % 24 +} + +/** Date at the given UTC day-of-month/hour/minute. */ +function atUtc( + year: number, + month0: number, + day: number, + hour: number, + minute = 0 +): Date { + return new Date(Date.UTC(year, month0, day, clampHour(hour), minute)) +} + +/** True when the UTC hour `hour` is strictly inside the quiet window. */ +function isInsideQuiet(hour: number, quiet: QuietHours | null): boolean { + if (!quiet) return false + if (quiet.startUtc === quiet.endUtc) return false // zero-length window = no-op + if (quiet.startUtc < quiet.endUtc) { + return hour >= quiet.startUtc && hour < quiet.endUtc + } + // Wraps midnight (e.g. 22 -> 06). + return hour >= quiet.startUtc || hour < quiet.endUtc +} + +/** Last hour of the day that is NOT inside the quiet window, or null if none. */ +function lastAllowedHourOfDay(quiet: QuietHours | null): number | null { + if (!quiet || quiet.startUtc === quiet.endUtc) return 23 + for (let h = 23; h >= 0; h--) { + if (!isInsideQuiet(h, quiet)) return h + } + return null +} + +/** + * Compute the next natural send slot strictly after `after`, according to + * `frequency` (and `weeklyDayUtc` for WEEKLY), always landing on `sendHourUtc`. + * Month-end (> 28th) DAYS are clamped to the last day of the month. + */ +export function nextOccurrence( + frequency: Frequency, + sendHourUtc: number, + weeklyDayUtc: number | null, + after: Date +): Date { + const hour = clampHour(sendHourUtc) + const afterUtc = Date.UTC( + after.getUTCFullYear(), + after.getUTCMonth(), + after.getUTCDate(), + after.getUTCHours(), + after.getUTCMinutes(), + after.getUTCSeconds() + ) + + switch (frequency) { + case 'DAILY': { + const cand = atUtc( + after.getUTCFullYear(), + after.getUTCMonth(), + after.getUTCDate(), + hour + ) + return cand.getTime() > afterUtc + ? cand + : new Date(cand.getTime() + 86400000) + } + case 'WEEKLY': { + const day = weeklyDayUtc === null ? 1 : clampHour(weeklyDayUtc) % 7 // default Monday + // Walk forward to the next occurrence of `day` strictly after `after`. + for (let i = 1; i <= 8; i++) { + const d = new Date( + Date.UTC( + after.getUTCFullYear(), + after.getUTCMonth(), + after.getUTCDate() + i + ) + ) + if (d.getUTCDay() === day) { + return atUtc( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + hour + ) + } + } + // Unreachable: 8 consecutive days always contain every weekday. + throw new Error('Could not compute weekly digest occurrence') + } + case 'MONTHLY': { + // Anchor to `after`'s day-of-month (clamped to the target month's last + // day) at the preferred hour. A subscription created on the 31st lands on + // the last day of every shorter month — never skips a month. + let year = after.getUTCFullYear() + let month0 = after.getUTCMonth() + const day = after.getUTCDate() + let cand = atUtc(year, month0, day, hour) + if (cand.getTime() <= afterUtc) { + // Next month. + month0 += 1 + if (month0 === 12) { + month0 = 0 + year += 1 + } + } + const lastDay = new Date(Date.UTC(year, month0 + 1, 0)).getUTCDate() + return atUtc(year, month0, Math.min(day, lastDay), hour) + } + } +} + +/** + * Given a candidate send slot (a Date carrying the preferred hour), defer it out + * of the quiet window. Returns a slot on the SAME day if an allowed hour exists, + * else a slot at `quietHours.endUtc` on the next day the window opens. + */ +export function deferForQuietHours( + candidate: Date, + quiet: QuietHours | null +): Date { + if (!quiet || quiet.startUtc === quiet.endUtc) return candidate + const hour = candidate.getUTCHours() + if (!isInsideQuiet(hour, quiet)) return candidate + + const allowed = lastAllowedHourOfDay(quiet) + if (allowed !== null && allowed > hour) { + return atUtc( + candidate.getUTCFullYear(), + candidate.getUTCMonth(), + candidate.getUTCDate(), + allowed + ) + } + // No allowed hour later today: defer to `quiet.endUtc` on the next day. + const endHour = clampHour(quiet.endUtc) + const next = new Date(candidate.getTime() + 86400000) + return atUtc( + next.getUTCFullYear(), + next.getUTCMonth(), + next.getUTCDate(), + endHour + ) +} diff --git a/src/routes/digests.ts b/src/routes/digests.ts new file mode 100644 index 0000000..c4bed97 --- /dev/null +++ b/src/routes/digests.ts @@ -0,0 +1,220 @@ +import { Router, Request, Response } from 'express' +import db from '../db' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { sendNotFound } from '../utils/errors' +import { loadDigestData } from '../notifications/load' +import { buildDigest } from '../notifications/digest' +import { + createDigestSubscriptionSchema, + updateDigestSubscriptionSchema, + digestIdParamSchema, + digestPreviewQuerySchema, + type DigestFrequency, +} from '../validators/digest-validators' + +const router = Router() + +router.use(requireAuth) + +const digestSelect = { + id: true, + userId: true, + frequency: true, + channels: true, + sendHourUtc: true, + weeklyDayUtc: true, + quietHours: true, + isActive: true, + lastSentAt: true, + nextRunAt: true, + createdAt: true, + updatedAt: true, +} as const + +/** + * A WHATSAPP channel needs a phone on file, and a WEBHOOK channel is only + * meaningful when the user has registered webhook endpoints. Rejecting an + * unlinked channel at creation keeps the digest from silently skipping it. + */ +async function validateChannelsLinked( + userId: string, + channels: string[] +): Promise { + if (channels.includes('WHATSAPP')) { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { phone: true }, + }) + if (!user?.phone) { + return 'WHATSAPP requires a linked phone number' + } + } + if (channels.includes('WEBHOOK')) { + const endpoints = await db.userWebhookEndpoint.count({ + where: { userId, status: 'ACTIVE' }, + }) + if (endpoints === 0) { + return 'WEBHOOK requires at least one registered webhook endpoint' + } + } + return null +} + +/** + * POST /api/v1/notifications/digests + * Create a new digest subscription owned by the authenticated user. + */ +router.post( + '/', + validate({ body: createDigestSubscriptionSchema }), + async (req: Request, res: Response) => { + const userId = req.auth!.userId + const { + frequency, + channels, + sendHourUtc, + weeklyDayUtc, + quietHours, + isActive, + } = req.body + + const linkedError = await validateChannelsLinked(userId, channels) + if (linkedError) { + return res + .status(400) + .json({ + error: 'Validation failed', + details: [{ message: linkedError }], + }) + } + + const existing = await db.digestSubscription.findFirst({ + where: { userId, isActive: true }, + select: { id: true }, + }) + if (existing) { + return res.status(409).json({ + error: 'An active digest subscription already exists', + }) + } + + const now = new Date() + // nextRunAt defaults to the next natural occurrence at the preferred hour. + const { nextOccurrence } = await import('../notifications/schedule') + + const subscription = await db.digestSubscription.create({ + data: { + userId, + frequency, + channels, + sendHourUtc, + weeklyDayUtc: weeklyDayUtc ?? null, + quietHours: quietHours ?? null, + isActive: isActive ?? true, + nextRunAt: nextOccurrence( + frequency as DigestFrequency, + sendHourUtc, + weeklyDayUtc ?? null, + now + ), + }, + select: digestSelect, + }) + + return res.status(201).json(subscription) + } +) + +/** + * GET /api/v1/notifications/digests + * List the authenticated user's digest subscriptions (owner-scoped). + */ +router.get('/', async (req: Request, res: Response) => { + const userId = req.auth!.userId + const subscriptions = await db.digestSubscription.findMany({ + where: { userId }, + select: digestSelect, + orderBy: { createdAt: 'desc' }, + }) + return res.status(200).json({ subscriptions }) +}) + +/** + * GET /api/v1/notifications/digests/preview?frequency=WEEKLY + * Render the digest for the caller right now without scheduling (rate-limited + * downstream by the global limiter). + */ +router.get( + '/preview', + validate({ query: digestPreviewQuerySchema }), + async (req: Request, res: Response) => { + const userId = req.auth!.userId + const frequency = (req.query.frequency as DigestFrequency) ?? 'WEEKLY' + const data = await loadDigestData(userId, frequency) + const model = buildDigest(data, frequency) + return res.status(200).json(model) + } +) + +/** + * PATCH /api/v1/notifications/digests/:id + * Update a digest subscription owned by the caller. + */ +router.patch( + '/:id', + validate({ + params: digestIdParamSchema, + body: updateDigestSubscriptionSchema, + }), + async (req: Request, res: Response) => { + const userId = req.auth!.userId + + const existing = await db.digestSubscription.findFirst({ + where: { id: req.params.id, userId }, + select: { id: true, channels: true }, + }) + if (!existing) return sendNotFound(res, 'Digest subscription') + + const nextChannels = req.body.channels ?? existing.channels + const linkedError = await validateChannelsLinked(userId, nextChannels) + if (linkedError) { + return res + .status(400) + .json({ + error: 'Validation failed', + details: [{ message: linkedError }], + }) + } + + const updated = await db.digestSubscription.update({ + where: { id: req.params.id }, + data: req.body, + select: digestSelect, + }) + + return res.status(200).json(updated) + } +) + +/** + * DELETE /api/v1/notifications/digests/:id + * Delete a digest subscription owned by the caller. + */ +router.delete( + '/:id', + validate({ params: digestIdParamSchema }), + async (req: Request, res: Response) => { + const userId = req.auth!.userId + const existing = await db.digestSubscription.findFirst({ + where: { id: req.params.id, userId }, + select: { id: true }, + }) + if (!existing) return sendNotFound(res, 'Digest subscription') + + await db.digestSubscription.delete({ where: { id: req.params.id } }) + return res.status(204).send() + } +) + +export default router diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index e2a5cb5..ddb2f97 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -4,6 +4,7 @@ import { requestEmailVerification, verifyEmail, } from '../controllers/email-identity-controller' +import digestsRouter from './digests' const router = Router() @@ -11,4 +12,7 @@ const router = Router() router.post('/email', requireAuth, requestEmailVerification) router.get('/email/verify', verifyEmail) +// Cross-channel digest subscriptions (#365) +router.use('/digests', digestsRouter) + export default router diff --git a/src/utils/api-formatters.ts b/src/utils/api-formatters.ts index acb51fd..af99ff5 100644 --- a/src/utils/api-formatters.ts +++ b/src/utils/api-formatters.ts @@ -272,6 +272,23 @@ const USER_EVENT_PAYLOAD_ALLOWLIST: Record = { // `error` is the user's own op failure text, already sent to their webhooks. 'outbox.op_failed': ['opId', 'kind', 'attempts', 'error'], 'portfolio.updated': ['protocolName', 'positionsAffected', 'reason'], + // The digest payload IS the channel-agnostic DigestModel already assembled by + // src/notifications/digest.ts (no userId, no secrets). Keep the allowlist on + // its top-level shape so a future extra field is still redacted by default. + 'digest.generated': [ + 'frequency', + 'period', + 'valueChange', + 'yield', + 'rebalances', + 'goals', + 'risk', + 'notableTransactions', + 'capReached', + 'maxEntries', + 'caveats', + 'hasPositions', + ], } /** diff --git a/src/validators/digest-validators.ts b/src/validators/digest-validators.ts new file mode 100644 index 0000000..b27a3b3 --- /dev/null +++ b/src/validators/digest-validators.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' + +/** + * Validators for digest subscriptions (#365). + * + * Enums mirror the Prisma enums in prisma/schema.prisma — keep in sync manually + * (there is no generated shared source between Zod and Prisma enums here). + */ + +export const DIGEST_FREQUENCIES = ['DAILY', 'WEEKLY', 'MONTHLY'] as const + +export const DIGEST_CHANNELS = [ + 'WHATSAPP', + 'TELEGRAM', + 'EMAIL', + 'WEBHOOK', +] as const + +export type DigestFrequency = (typeof DIGEST_FREQUENCIES)[number] +export type DigestChannel = (typeof DIGEST_CHANNELS)[number] + +export const quietHoursSchema = z + .object({ + startUtc: z.number().int().min(0).max(23), + endUtc: z.number().int().min(0).max(23), + }) + .refine((q) => q.startUtc !== q.endUtc, { + message: 'quietHours.startUtc and endUtc must differ', + }) + +const digestBaseShape = { + frequency: z.enum(DIGEST_FREQUENCIES), + channels: z + .array(z.enum(DIGEST_CHANNELS)) + .min(1, 'At least one channel is required') + .max(4), + sendHourUtc: z.number().int().min(0).max(23).default(9), + weeklyDayUtc: z.number().int().min(0).max(6).nullable().optional(), + quietHours: quietHoursSchema.optional(), + isActive: z.boolean().optional(), +} + +function validateWeeklyConsistency< + T extends { frequency?: DigestFrequency; weeklyDayUtc?: number | null }, +>(data: T, ctx: z.RefinementCtx): void { + if (data.frequency === 'WEEKLY' && data.weeklyDayUtc === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['weeklyDayUtc'], + message: 'weeklyDayUtc is required when frequency is WEEKLY', + }) + } +} + +export const createDigestSubscriptionSchema = z + .object(digestBaseShape) + .superRefine(validateWeeklyConsistency) + +export const updateDigestSubscriptionSchema = z + .object(digestBaseShape) + .partial() + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one field must be provided', + }) + .superRefine(validateWeeklyConsistency) + +export const digestIdParamSchema = z.object({ + id: z.string().uuid('Invalid digest subscription ID'), +}) + +export const digestPreviewQuerySchema = z.object({ + frequency: z.enum(DIGEST_FREQUENCIES).default('WEEKLY'), +}) diff --git a/tests/unit/notifications/digest.test.ts b/tests/unit/notifications/digest.test.ts new file mode 100644 index 0000000..287640c --- /dev/null +++ b/tests/unit/notifications/digest.test.ts @@ -0,0 +1,203 @@ +import { + buildDigest, + type DigestInput, +} from '../../../src/notifications/digest' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +const DAY_MS = 24 * 60 * 60 * 1000 + +function fixedPeriod(daysAgo = 7): Date { + const base = Date.UTC(2026, 0, 15, 12, 0, 0) // Jan 15 2026 12:00 UTC + return new Date(base - daysAgo * DAY_MS) +} + +function input(overrides: Partial = {}): DigestInput { + const now = fixedPeriod(0) + const start = fixedPeriod(7) + return { + period: { label: 'Past 7 days', startAt: start, endAt: now }, + positions: [ + { + id: 'pos-a', + protocolName: 'Blend', + assetSymbol: 'USDC', + currentValue: 1100, + depositedAmount: 1000, + yieldEarned: 30, + }, + { + id: 'pos-b', + protocolName: 'Luma', + assetSymbol: 'USDC', + currentValue: 500, + depositedAmount: 400, + yieldEarned: 45, + }, + ], + snapshots: [ + // start boundary + { + positionId: 'pos-a', + value: 1000, + apy: 5, + timestampMs: start.getTime(), + }, + { positionId: 'pos-b', value: 400, apy: 8, timestampMs: start.getTime() }, + // end boundary + { positionId: 'pos-a', value: 1100, apy: 5, timestampMs: now.getTime() }, + { positionId: 'pos-b', value: 500, apy: 8, timestampMs: now.getTime() }, + ], + transactions: [ + { + type: 'DEPOSIT', + amount: 150, + assetSymbol: 'USDC', + createdAt: new Date(now.getTime() - 2 * DAY_MS), + }, + ], + rebalances: [ + { + fromProtocol: 'Blend', + toProtocol: 'Luma', + improvedByPercent: 0.5, + createdAt: new Date(now.getTime() - 1 * DAY_MS), + }, + ], + goals: [ + { + name: 'House deposit', + targetAmount: 10000, + progressPctStart: 10, + progressPctNow: 16, + onTrack: true, + currentAmount: 1600, + }, + ], + caveats: [], + notableTxnThreshold: 100, + ...overrides, + } +} + +describe('buildDigest', () => { + it('computes a normal portfolio digest deterministically', () => { + const model = buildDigest(input()) + // end value = 1100 + 500 + expect(model.valueChange.endValue).toBe(1600) + expect(model.valueChange.startValue).toBe(1400) // start boundary: 1000 + 400 + expect(model.valueChange.absoluteChange).toBe(200) + expect(model.valueChange.percentChange).toBeCloseTo(14.29, 1) + expect(model.valueChange.insufficientData).toBe(false) + // yield earned over the period = (1100-1000) + (500-400) + expect(model.yield.earned).toBe(200) + expect(model.hasPositions).toBe(true) + expect(model.caveats).toHaveLength(0) + }) + + it('reports agent rebalance count and net improvement', () => { + const model = buildDigest(input()) + expect(model.rebalances.count).toBe(1) + expect(model.rebalances.netImprovementPct).toBe(0.5) + expect(model.rebalances.recent).toHaveLength(1) + }) + + it('lists same digest for identical inputs (pure/deterministic)', () => { + const a = buildDigest(input()) + const b = buildDigest(input()) + expect(a).toEqual(b) + }) + + it('is honest about a gappy period (no start snapshot)', () => { + const now = fixedPeriod(0) + const start = fixedPeriod(7) + const gappy = input({ + snapshots: [ + { + positionId: 'pos-a', + value: 1100, + apy: 5, + timestampMs: now.getTime(), + }, + { positionId: 'pos-b', value: 500, apy: 8, timestampMs: now.getTime() }, + ], + }) + const model = buildDigest(gappy) + expect(model.valueChange.insufficientData).toBe(true) + expect(model.valueChange.startValue).toBeNull() + expect(model.valueChange.absoluteChange).toBeNull() + expect(model.valueChange.percentChange).toBeNull() + expect( + model.caveats.some((c) => c.toLowerCase().includes('insufficient')) + ).toBe(true) + }) + + it('is honest about a completely empty snapshot history', () => { + const model = buildDigest(input({ snapshots: [] })) + expect(model.valueChange.insufficientData).toBe(true) + expect(model.caveats.length).toBeGreaterThan(0) + }) + + it('emits a short no-positions digest when the user has none', () => { + const model = buildDigest( + input({ positions: [], snapshots: [], goals: [], transactions: [] }) + ) + expect(model.hasPositions).toBe(false) + expect(model.valueChange.endValue).toBe(0) + expect(model.risk.text).toContain('No active positions') + // No positions and no notable txns -> empty-ish message, not a crash. + expect(model.notableTransactions).toHaveLength(0) + }) + + it('caps notable transactions and flags capReached', () => { + const now = fixedPeriod(0) + const txns = Array.from({ length: 20 }, (_, i) => ({ + type: 'DEPOSIT', + amount: 200, + assetSymbol: 'USDC', + createdAt: new Date(now.getTime() - i * 60_000), + })) + const model = buildDigest(input({ transactions: txns }), 'WEEKLY', 5) + expect(model.notableTransactions).toHaveLength(5) + expect(model.capReached).toBe(true) + }) + + it('computes a risk line with a real drawdown', () => { + const now = fixedPeriod(0) + const start = fixedPeriod(7) + // Value dips during the period then recovers. + const snapshots = [ + { positionId: 'p', value: 1000, apy: 5, timestampMs: start.getTime() }, + { + positionId: 'p', + value: 920, + apy: 5, + timestampMs: start.getTime() + 2 * DAY_MS, + }, + { positionId: 'p', value: 960, apy: 5, timestampMs: now.getTime() }, + ] + const model = buildDigest( + input({ + snapshots, + positions: [ + { + id: 'p', + protocolName: 'Blend', + assetSymbol: 'USDC', + currentValue: 960, + depositedAmount: 1000, + yieldEarned: 0, + }, + ], + }) + ) + expect(model.risk.text).toMatch(/drawdown/i) + }) +}) diff --git a/tests/unit/notifications/schedule.test.ts b/tests/unit/notifications/schedule.test.ts new file mode 100644 index 0000000..9178429 --- /dev/null +++ b/tests/unit/notifications/schedule.test.ts @@ -0,0 +1,88 @@ +import { + nextOccurrence, + deferForQuietHours, + isQuietHours, +} from '../../../src/notifications/schedule' + +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +const at = (y: number, m: number, d: number, h: number, mi = 0) => + new Date(Date.UTC(y, m, d, h, mi)) + +describe('nextOccurrence', () => { + it('advances DAILY at the preferred send hour', () => { + const after = at(2026, 0, 15, 8, 0) // 08:00 + expect(nextOccurrence('DAILY', 9, null, after)).toEqual( + at(2026, 0, 15, 9, 0) + ) + }) + + it('walks to the next day when today is past the send hour', () => { + const after = at(2026, 0, 15, 10, 0) // 10:00 > 9:00 + expect(nextOccurrence('DAILY', 9, null, after)).toEqual( + at(2026, 0, 16, 9, 0) + ) + }) + + it('finds the next WEEKLY occurrence on the configured weekday', () => { + // Jan 15 2026 is a Thursday (UTC). weeklyDayUtc=1 => Monday. + const after = at(2026, 0, 15, 8, 0) + const next = nextOccurrence('WEEKLY', 9, 1, after) + expect(next.getUTCDay()).toBe(1) + expect(next.getUTCFullYear()).toBe(2026) + expect(next.getUTCMonth()).toBe(0) + }) + + it('clamps MONTHLY day-of-month to the last day of a short month', () => { + // Jan 31 -> next month Feb has 28 days -> Feb 28 at the preferred hour. + const after = at(2026, 0, 31, 9, 0) + const next = nextOccurrence('MONTHLY', 9, null, after) + expect(next.getUTCMonth()).toBe(1) + expect(next.getUTCDate()).toBe(28) // Feb 2026 (non-leap) has 28 days + expect(next.getUTCHours()).toBe(9) + }) +}) + +describe('deferForQuietHours', () => { + it('returns the candidate unchanged when outside the quiet window', () => { + const candidate = at(2026, 0, 15, 9, 0) + const q = { startUtc: 22, endUtc: 7 } + expect(deferForQuietHours(candidate, q)).toEqual(candidate) + }) + + it('defers within the same day when a later allowed hour exists', () => { + // quiet 20->23; candidate 21:00 -> defer to 23:00? lastAllowed before 23 is 19. + const q = { startUtc: 20, endUtc: 23 } + const candidate = at(2026, 0, 15, 21, 0) + // Only hours < 20 or >= 23 are allowed today; the only later allowed hour is 23:00. + expect(deferForQuietHours(candidate, q).getUTCHours()).toBe(23) + }) + + it('defers to quietHours.endUtc on the next day when the whole remainder is quiet', () => { + const q = { startUtc: 20, endUtc: 23 } + const candidate = at(2026, 0, 15, 23, 30) + // 23:30 is past the 23:00 end -> defer to 23:00 the next day? end==23 -> not quiet at 23. + // isInsideQuiet(23) for 20->23 is false (end exclusive), so unchanged is not the case here. + // Use a window that covers late hours: 22->06. + const q2 = { startUtc: 22, endUtc: 6 } + const c2 = at(2026, 0, 15, 23, 0) + const next = deferForQuietHours(c2, q2) + expect(next.getUTCDate()).toBe(16) + expect(next.getUTCHours()).toBe(6) + }) +}) + +describe('isQuietHours', () => { + it('validates shape', () => { + expect(isQuietHours({ startUtc: 22, endUtc: 6 })).toBe(true) + expect(isQuietHours(null)).toBe(false) + expect(isQuietHours({ startUtc: 99, endUtc: 6 })).toBe(false) + }) +})