Skip to content

Cross-Channel Digest Notifications (Daily/Weekly Portfolio Summary) #365

Description

@devsimze

Problem Statement

Every notification the platform sends is event-triggered: an alert rule fires (#289/#324), the agent rebalances, a deposit confirms. There is no scheduled summary — the "here's how your money did this week" message that keeps a passive user engaged and gives them a periodic sanity check without needing to open anything. The data is all there (positions, YieldSnapshot series, Transaction history, RebalanceDecision records, goal progress), and every delivery channel already exists (WhatsApp via Twilio, Telegram, webhook, real-time stream). This issue adds cross-channel digest notifications: an opt-in daily/weekly/monthly portfolio summary assembled from a pure formatter and delivered over the user's chosen channels, with per-user scheduling and quiet hours.

Current State

  • src/jobs/alertRules.ts — the scheduled-job + multi-channel-delivery pattern: publishUserEvent (webhook leg + real-time stream), sendWhatsAppMessage via src/utils/twilio-client.ts, formatAlertTriggeredReply from src/whatsapp/formatters.ts.
  • src/telegram/handler.ts + src/telegram/formatters.ts — Telegram delivery + formatters.
  • src/agent/snapshotter.ts — hourly YieldSnapshot; src/analytics/riskService.ts — risk metrics; src/goals/service.tsGoalProgress.
  • prisma/schema.prismaUser (phone, email), AlertRule (has deliveryChannel + cooldownMinutes), Position, YieldSnapshot, Transaction.
  • src/utils/cadence.tsaddCadence; DepositCadence enum (WEEKLY|BIWEEKLY|MONTHLY).
  • docs/ALERTS.md, docs/WEBSOCKET_STREAMING.md.

Proposed Solution

1. Subscription model

model DigestSubscription {
  id           String   @id @default(uuid())
  userId       String
  frequency    String   // DAILY | WEEKLY | MONTHLY
  channels     String[] // WHATSAPP | TELEGRAM | EMAIL | WEBHOOK  (EMAIL needs the email-channel issue)
  sendHourUtc  Int      // 0..23 — user's preferred send time
  weeklyDayUtc Int?     // 0..6 for WEEKLY
  quietHours   Json?    // { startUtc, endUtc } — never send inside this window; defer to next allowed slot
  isActive     Boolean  @default(true)
  lastSentAt   DateTime?
  nextRunAt    DateTime
  createdAt    DateTime @default(now())
  @@index([isActive, nextRunAt])
}
  • POST/PATCH/DELETE /api/v1/notifications/digests + a sensible default offered at onboarding (opt-in, not opt-out).

2. Digest content (src/notifications/digest.ts, new — pure assembler)

  • buildDigest({ period, positions, snapshots, transactions, rebalanceDecisions, goals, riskMetrics }) → a channel-agnostic DigestModel:
    • Portfolio value now vs. start-of-period, absolute + %.
    • Yield earned this period; blended APY; best/worst position.
    • Agent activity: rebalances this period (count + net effect), pulled from RebalanceDecision.
    • Goal progress deltas ("House deposit: 41% → 44%, on track").
    • One risk line (e.g. current drawdown, or "volatility steady").
    • Notable transactions (deposits/withdrawals over a threshold).
  • Honest about gaps: a period with insufficient snapshots says so rather than showing a misleading number (same discipline as the analytics caveats).
  • Deterministic given inputs; fixture-tested.

3. Channel rendering + delivery

  • renderDigest(model, channel) per channel: whatsapp / telegram (concise text via existing formatters), email (richer, via the email-channel issue), webhook (the DigestModel JSON as a digest.generated event).
  • A digests job (mirrors alertRules.ts): atomic-claim due DigestSubscriptions, assemble once, render + deliver per channel, advance nextRunAt with addCadence/frequency, respect quietHours (defer, don't drop).
  • Delivery failures per channel are logged + retried a bounded number of times; one bad channel never blocks the others.

4. API + docs

  • GET /api/v1/notifications/digests/preview?frequency=WEEKLY — render the digest for the caller right now without scheduling, so they can see what they'll get.
  • docs/NOTIFICATIONS.md (new) covering digests + channels + quiet hours; docs/openapi.yaml.

Edge Cases & Failure Modes

  • User with no positions: a short "no active positions — deposit to get started" digest, or auto-skip after N empty periods (config), never an empty/broken message.
  • Channel not linked (e.g. WHATSAPP chosen but no phone): validation rejects adding that channel; if a linked channel is later unlinked, the digest skips it with a digest.channel_unavailable note, not an error.
  • Quiet hours cover the send slot: defer to the next allowed hour; if the whole day is quiet, send at quietHours.endUtc.
  • Snapshot gap / DST-ish edge: nextRunAt in UTC; a missed run (server down) sends once on recovery, not N times — lastSentAt guards catch-up storms.
  • Very active user: transaction/rebalance lists are capped ("+12 more") so the message stays within channel length limits.
  • Monthly on the 31st: addCadence monthly handling clamps to the last day; documented.
  • Idempotency: a digest occurrence is (subscriptionId, periodStart); a re-run never double-sends.

Security & Privacy Considerations

  • Owner-scoped; a digest contains only the caller's data; sub-account digests respect VIEW permission and never include a parent's other children.
  • Financial data over WhatsApp/Telegram/email — the message content is the same the user sees in-app; no secrets, no full wallet addresses beyond what's already shown.
  • quietHours and send-time are user preferences, not inferred.
  • Webhook digest payloads go only to the user's own registered endpoint (per the user-scoped-webhooks issue) — never operator webhooks.
  • Rate-limited preview endpoint.

Out of Scope

  • In-app notification center / push (push is a separate channel issue).
  • Configurable digest content blocks (v1 content is fixed; a later version can let users toggle sections).
  • Marketing / promotional content in digests.
  • Real-time "you hit a milestone" one-offs (those are event notifications, not digests).

Suggested Implementation Plan

  1. Schema: DigestSubscription + migration/rollback.
  2. src/notifications/digest.tsbuildDigest pure assembler + fixture tests (normal, empty, gappy period).
  3. renderDigest per channel (whatsapp/telegram/webhook now; email via sibling issue).
  4. src/jobs/digests.ts — atomic claim, assemble-once, per-channel deliver + bounded retry, quietHours deferral, addCadence advance, occurrence idempotency.
  5. CRUD + preview endpoints; onboarding opt-in.
  6. docs/NOTIFICATIONS.md + docs/openapi.yaml; metrics (sent/deferred/failed by channel).

Good first issue candidate: the pure buildDigest assembler + preview endpoint is a clean, well-specified slice.

Acceptance Criteria

  • DigestSubscription with DAILY/WEEKLY/MONTHLY frequency, multiple channels, preferred send hour, and quiet hours
  • Pure buildDigest produces a channel-agnostic model (value change, yield, agent activity, goal deltas, one risk line, notable txns) and is honest about insufficient-data periods; fixture-tested
  • A due-claim job assembles once and delivers per channel with bounded per-channel retry; one failing channel never blocks the others
  • Quiet hours defer (never drop) the send; missed runs send once on recovery, guarded by lastSentAt and (subscriptionId, periodStart) idempotency
  • GET /api/v1/notifications/digests/preview renders the current digest without scheduling
  • Owner-scoped content; unlinked channels skip with a note; webhook digests go only to the user's own endpoint
  • docs/NOTIFICATIONS.md + docs/openapi.yaml updated; unit + integration tests green

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Stellar WaveIssues in the Stellar wave program

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions