How notifications travel
The architecture is opaque to most coaches, but understanding the plumbing helps when things look slow or weird.
The pipeline
[Coach edits event] → [worker writes audit_log row] ↓ [fanoutNotifications] ├── if change is "material" (time/location/coach-note/cancel/…) │ ├── if ≤48h: spawn NotificationWorkflow per family (immediate) │ └── if >48h: write digest_pending + spawn DigestWorkflow │ per (family, day) — sleeps until 6pm club tz └── for each other coach with access + notify-on for this calendar: fire web push directly (no queue) ↓ NotificationWorkflow (immediate): ├── validate-family (skip if archived) ├── send-telegram (once per unique channel, with retry) ├── enqueue-push (fan out via tscal-push-queue) └── record-sent (notification_sent for /coach/feedback) ↓ DigestWorkflow (per family per day): ├── sleep until 6pm club tz ├── gather all digest_pending rows for this family ├── send-emails — one per opted-in contact └── clear-pendingWhy ≤48h has no email
Push + Telegram cover the urgency case. Email is reserved for the daily 6pm digest where families want a combined “here’s what changed today.” This also keeps us comfortably under Cloudflare Email Workers’ 1000/day cap.
What if a family has push, Telegram, AND email enabled?
For a ≤48h change: push fires + Telegram posts + no email. They see the change three places (lock screen, Telegram channel, the in-app banner next time they open) but only get one phone buzz from push (Telegram has its own).
For a >48h change: just the daily digest email at 6pm. Family page banner is also there but doesn’t push.
What if a family has none of those?
They see changes the next time they open their family page (the “what’s changed since last visit” banner + the chat panel below). This is the no-notification baseline.
Latency
Immediate sends fire within seconds of the audit row landing — the workflow runs as soon as the queue scheduler picks it up, then the push queue fans out per device in parallel. No 5-min cron lag any more.
Other workflows quietly working in the background
In addition to the immediate + digest pipelines above, the worker runs:
| Workflow | When | What it does |
|---|---|---|
| DailyBriefWorkflow | Spawned at 06:00 UTC; sleeps to each coach’s brief_time | Pushes today’s events to each coach with brief_time set |
| RsvpReminderWorkflow | Spawned at event create when an RSVP deadline is set | Pushes pending families at -24h and -1h before close |
| InviteReminderWorkflow | Spawned when an invite email is sent | Day-3 and day-6 reminder emails until the invite is claimed |
| BotHealthWorkflow | Monday 13:00 UTC | Probes every Telegram channel via getChat; writes a bot-health feedback row when a channel is broken |
| PushFailureWorkflow | Triggered from the push DLQ | Records failed_push and falls back to email when the family has zero active push subscriptions left |
Each is a durable Cloudflare Workflow — the sleeps survive worker restarts, deploys, and CF runtime upgrades. Status of all instances is visible in the Cloudflare dashboard under Workers → Workflows.
Where to investigate when something looks off
/coach/feedback— first stop. Bot-health alerts, failed push deliveries, and parent-submitted issues all surface here.- Cloudflare Workers Logs (dashboard → Workers → tscal-v2-worker → Logs) — 3-day retention, includes step-by-step output from every workflow run.
- Cloudflare Workers → Workflows — every workflow instance with its status, current step, and (for sleeping ones) when it’ll wake.