You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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).
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
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,
YieldSnapshotseries,Transactionhistory,RebalanceDecisionrecords, 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),sendWhatsAppMessageviasrc/utils/twilio-client.ts,formatAlertTriggeredReplyfromsrc/whatsapp/formatters.ts.src/telegram/handler.ts+src/telegram/formatters.ts— Telegram delivery + formatters.src/agent/snapshotter.ts— hourlyYieldSnapshot;src/analytics/riskService.ts— risk metrics;src/goals/service.ts—GoalProgress.prisma/schema.prisma—User(phone,email),AlertRule(hasdeliveryChannel+cooldownMinutes),Position,YieldSnapshot,Transaction.src/utils/cadence.ts—addCadence;DepositCadenceenum (WEEKLY|BIWEEKLY|MONTHLY).docs/ALERTS.md,docs/WEBSOCKET_STREAMING.md.Proposed Solution
1. Subscription model
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-agnosticDigestModel:RebalanceDecision.caveats).3. Channel rendering + delivery
renderDigest(model, channel)per channel:whatsapp/telegram(concise text via existing formatters),email(richer, via the email-channel issue),webhook(theDigestModelJSON as adigest.generatedevent).digestsjob (mirrorsalertRules.ts): atomic-claim dueDigestSubscriptions, assemble once, render + deliver per channel, advancenextRunAtwithaddCadence/frequency, respectquietHours(defer, don't drop).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
WHATSAPPchosen but nophone): validation rejects adding that channel; if a linked channel is later unlinked, the digest skips it with adigest.channel_unavailablenote, not an error.quietHours.endUtc.nextRunAtin UTC; a missed run (server down) sends once on recovery, not N times —lastSentAtguards catch-up storms.addCadencemonthly handling clamps to the last day; documented.(subscriptionId, periodStart); a re-run never double-sends.Security & Privacy Considerations
VIEWpermission and never include a parent's other children.quietHoursand send-time are user preferences, not inferred.Out of Scope
Suggested Implementation Plan
DigestSubscription+ migration/rollback.src/notifications/digest.ts—buildDigestpure assembler + fixture tests (normal, empty, gappy period).renderDigestper channel (whatsapp/telegram/webhook now; email via sibling issue).src/jobs/digests.ts— atomic claim, assemble-once, per-channel deliver + bounded retry,quietHoursdeferral,addCadenceadvance, occurrence idempotency.previewendpoints; onboarding opt-in.docs/NOTIFICATIONS.md+docs/openapi.yaml; metrics (sent/deferred/failed by channel).Good first issue candidate: the pure
buildDigestassembler +previewendpoint is a clean, well-specified slice.Acceptance Criteria
DigestSubscriptionwith DAILY/WEEKLY/MONTHLY frequency, multiple channels, preferred send hour, and quiet hoursbuildDigestproduces a channel-agnostic model (value change, yield, agent activity, goal deltas, one risk line, notable txns) and is honest about insufficient-data periods; fixture-testedlastSentAtand(subscriptionId, periodStart)idempotencyGET /api/v1/notifications/digests/previewrenders the current digest without schedulingdocs/NOTIFICATIONS.md+docs/openapi.yamlupdated; unit + integration tests green