Push Notifications
Divers get browser push reminders from the PWA:
| Trigger | Windows (days before event) |
|---|---|
| Event reminder | 7, 1 |
| Payment reminder | 21, 14, 7, 3, 1 (while amount owed) |
A single daily Cloudflare Worker cron picks every booking that lands in a window today, chooses the right reminder kind, and sends via Web Push. The push_notifications_sent table makes the cron idempotent — rerunning never double-notifies.
Pieces
supabase/migrations/20260422180000_push_notifications.sql tables + RLS
src/sw.ts service worker (push + notificationclick)
src/lib/push.ts client subscribe/unsubscribe
src/lib/push-reminders.ts pure selection logic (shared with worker)
src/pages/ProfilePage.tsx → NotificationsToggle opt-in UI
workers/push/ Cloudflare Worker cron senderOne-time setup
1. Generate VAPID keys
npx web-push generate-vapid-keysYou get a public + private key. The public key ships with the client and is also set on the worker — it appears under two names:
VITE_VAPID_PUBLIC_KEY— in.env.localand the GitHub Actions secret of the same name. TheVITE_prefix is what exposes it to the browser bundle.VAPID_PUBLIC_KEY— same value, set on the push worker viawrangler secret put.
The private key (VAPID_PRIVATE_KEY) lives only on the push worker — never in the SPA, never in GitHub.
2. Apply the migration
npm run db:push3. Configure the client env
Add to .env.local:
VITE_VAPID_PUBLIC_KEY=<public-key-from-step-1>Rebuild and deploy the frontend as usual (npm run deploy).
4. Configure the worker
cd workers/push
npm install
# Secrets (production):
npx wrangler secret put SUPABASE_URL
npx wrangler secret put SUPABASE_SERVICE_ROLE_KEY
npx wrangler secret put VAPID_PUBLIC_KEY
npx wrangler secret put VAPID_PRIVATE_KEY
npx wrangler secret put ADMIN_TRIGGER_SECRET # for /run manual trigger
# VAPID_SUBJECT is set in wrangler.toml [vars]; edit to a real mailto.For local development create workers/push/.dev.vars from .dev.vars.example and run:
npm run dev # starts wrangler dev --test-scheduled
# then in another terminal:
curl "http://localhost:8787/__scheduled?cron=0+2+*+*+*"5. Deploy the worker
make deploy-push # or: make deploy (to ship both workers together)The cron fires at 0 2 * * * UTC (= 10:00 Asia/Taipei) — edit wrangler.toml if you want a different hour.
Manual trigger (rollout / debugging)
Once deployed, you can run the cron on demand:
curl -H "Authorization: Bearer $ADMIN_TRIGGER_SECRET" \
https://fundive-push.<your-subdomain>.workers.dev/run
# → {"sent":3,"skipped":0}Admin one-off broadcast
The /admin-broadcast endpoint sends an immediate push (custom title + body) to every opted-in device. Surfaced in-app at /admin/notifications (Manage → "One-off notification") for admins; the worker also gates by reading profiles.role via the caller's JWT.
POST /admin-broadcast
Authorization: Bearer <admin user's session JWT>
{ "title": "Trip cancelled", "body": "Typhoon — see calendar.", "url": "/" }
→ { "sent": N, "skipped": M, "webhook": true | false | null }Links in a notification
Notification bodies are plain text and the inbox (/notifications) linkifies them on render (src/lib/linkify.ts), so a Drive folder pasted into an event broadcast is tappable for every diver on the event. Only http(s) runs become anchors, and they open in a new tab with rel="noopener noreferrer".
The optional url on /admin-broadcast is handled separately by broadcastLinkTargets (workers/push/src/pure.ts), because the service worker refuses to navigate anywhere but a same-origin path (audit M10, src/sw-notification-target.ts). An off-site link would therefore drop the diver on the home page, so it travels in the inbox row — where it renders as an "Open link" button — while the push tap goes to /notifications to find it. An in-app path stays the tap target as before.
Set SUPABASE_ANON_KEY (worker secret) so the admin gate can run. Use the legacy JWT-format anon key (the eyJ… value of VITE_SUPABASE_ANON_KEY) — Supabase's auth API rejects the publishable-format sb_publishable_… keys here as "Invalid API key." Set BROADCAST_WEBHOOK_URL if you also want the same {title, body} payload relayed to a webhook (LINE Messaging API relay, n8n / Make.com flow, etc.) — leaving it unset just skips the relay and webhook returns null in the response.
cd workers/push
npx wrangler secret put SUPABASE_ANON_KEY
npx wrangler secret put BROADCAST_WEBHOOK_URL # optionalPer-event status push (/admin-event-broadcast)
Lets an admin notify only the confirmed divers on a specific event with a status update — typically just before an event when conditions change. Surfaced in-app at /admin/events/:id ("Notify divers" button) and gated by profiles.role in the worker.
POST /admin-event-broadcast
Authorization: Bearer <admin user's session JWT>
{ "event_id": "<EO_dives._id | EO_courses._id>",
"event_type": "dive" | "course",
"status": "on" | "cancelled",
"body": "Free-form note that becomes the push body." }
→ { "sent": N, "skipped": M, "recipients": K }Title is auto-built: Event {display_title} is ON AS SCHEDULED! or Event {display_title} is CANCELLED :(. Tap target is /notifications (the inbox), so the diver can re-read the body after the system tray dismisses the push. Inbox rows are written kind = 'event_status'.
This endpoint is decoupled from EO_*.cancelled_at — sending a CANCELLED notification does not flip the column. Use the existing "Cancel event" flow for that; the two are intentionally independent so admins can also broadcast "back on" updates without restoring the row.
Event reschedule push (/admin-event-reschedule)
Fired automatically whenever an admin changes an event's date(s). Two SPA paths feed it (src/lib/reschedule.ts), both fire-and-forget:
- Calendar drag (
AdminEventsPage→notifyEventRescheduled) sendsfrom_date+to_datefor the single day that moved. - Edit form (
AdminEditEventPage→notifyEventScheduleChanged) sends neither — it fires only when the saved dates actually differ.
Notifies every non-cancelled registrant (confirmed, pending, and waitlisted) — wider than /admin-event-broadcast, because anyone holding a spot needs to know. Gated by profiles.role = 'admin' in the worker.
POST /admin-event-reschedule
Authorization: Bearer <admin user's session JWT>
{ "event_id": "<EO_dives._id | EO_courses._id>",
"event_type": "dive" | "course",
"from_date": "YYYY-MM-DD", // optional — both present = single-day move
"to_date": "YYYY-MM-DD" } // optional
→ { "sent": N, "skipped": M, "recipients": K }Title/body are auto-built (rescheduleNotificationText in workers/push/src/pure.ts): "Schedule change: {title}" with either "A day moved from {Sat, May 16} to {Mon, May 18}…" (both dates) or a generic "The schedule has changed…" (dates omitted). Tap target is /notifications; inbox rows are written kind = 'event_reschedule'. Both dates present and equal is a no-op. No new secrets; reuses the admin-gate SUPABASE_ANON_KEY + service-role key.
Event cancellation (/admin-event-cancellation + email)
Fired automatically when an admin cancels an event (sets cancelled_at) in AdminEventDetailPage. The SPA's notifyEventCancelled (src/lib/event-cancellation.ts) fans out to all three channels for every non-cancelled registrant (confirmed, pending, and waitlisted), best-effort so a notification failure never blocks the cancel. Restoring (un-cancelling) sends nothing.
Two backends, because of where the keys live:
- Push + in-app inbox —
POST /admin-event-cancellationon the push worker (it owns the VAPID key). Mirrors/admin-event-reschedule: admin-gated byprofiles.role, inbox row first then push fan-out, inboxkind = 'event_cancellation', tap target/notifications. - Email — the
notify-event-cancellationSupabase edge function (Cloudflare Workers can't run SMTP; Gmail SMTP lives in Deno). Gated on the caller's admin JWT, resolves each registrant's address viaauth.admin.getUserById, sends one email per recipient.
POST /admin-event-cancellation
Authorization: Bearer <admin user's session JWT>
{ "event_id": "<EO_dives._id | EO_courses._id>",
"event_type": "dive" | "course" }
→ { "sent": N, "skipped": M, "recipients": K }Push/inbox copy is cancellationNotificationText (workers/push/src/pure.ts); email copy is buildCancellationEmail (supabase/functions/_shared/event-cancellation-email.ts). No new secrets — reuses the worker's admin-gate keys and the existing GMAIL_USER / GMAIL_APP_PASSWORD. Requires redeploying the push worker and deploying the new edge function.
Duty-assigned push (/notify-duty)
When an admin assigns a duty (/admin/duty), the SPA fires a fire-and-forget POST so the assignee's device beeps immediately rather than waiting for the next daily cron tick.
POST /notify-duty
Authorization: Bearer <admin user's session JWT>
{ "duty_id": "<uuid>" }
→ { sent: N, skipped: M }The worker re-reads the duty under the caller's JWT (RLS gates on admin), then uses the service-role key to look up the assignee's push subscriptions and send. Errors are deliberately swallowed on the SPA side — the duty row is the source of truth, the push is just acceleration. See src/lib/duties.ts → notifyDutyAssigned.
CORS
The worker is on a different origin from the SPA, so browser POSTs to /admin-broadcast and /notify-duty trigger a preflight. The worker allowlists exactly https://app.fundiverstw.com and http://localhost:5173; other origins get no Access-Control-Allow-Origin and the browser blocks them. CORS is browser-side only — auth is still enforced per-handler via the Bearer JWT, so the allowlist is for UX, not security.
iOS caveat
On iPhone/iPad, Web Push only works when the PWA is installed to the Home Screen (Share → Add to Home Screen, then open from there). The NotificationsToggle section of the Profile page detects this and shows the install hint instead of the toggle.
Android, desktop Chrome/Edge/Firefox, and macOS Safari 16+ all support push in the regular browser without install.
Operational notes
- The worker cleans up subscriptions that return HTTP 404/410 — dead endpoints are deleted on the next cron run.
push_notifications_sentkeeps a permanent record per(user, event, kind). If you ever need to re-send a specific reminder, delete the matching row and wait for the next cron tick.- Toggling off in the Profile page deletes the DB row and calls
PushSubscription.unsubscribe()— the browser releases the endpoint. - Adding or removing a reminder window means editing
src/lib/push-reminders.ts(both the constants andeventKindForDays/paymentKindForDays) and thekindCHECK constraint onpush_notifications_sent. Always add a forward migration, never edit the existing one.