Files
ketopath/docs/decisions/0003-push-notifications.md
T
lucianoandClaude Opus 4.7 bb48357179 feat: web push notifications, mobile-ready (PRD §5.6)
ADR 0003: VAPID self-hosted today, DeviceToken model agnostic to
platform so iOS/Android (Expo/APNs/FCM) plug in as new senders later.

Backend (apps/api/src/modules/notifications)
- sender.ts: NotificationSender interface, WebPushSender via VAPID
- notifications.routes.ts: GET /me/notifications/config, POST/DELETE
  /me/device-tokens, PATCH /me/notifications/settings, POST /me/notifications/test
- scheduler.ts: node-cron Mon 09:00 Europe/Rome for weekly weigh-in
  reminder; auto-cleanup of expired tokens on 404/410
- env: VAPID_PUBLIC_KEY/PRIVATE_KEY/SUBJECT (all optional → push gracefully off)

Frontend
- public/sw.js minimal (push + notificationclick)
- lib/notifications/push-client.ts: subscribe / unsubscribe / getCurrentSubscription
- profile/notifications-{actions,panel}.tsx: editorial panel with toggles,
  device list, "send test", per-device removal
- pushReady requires both permission AND active subscription (covers the
  case where the user revoked the SW but kept the browser permission)

Schema
- DeviceToken { userId, platform, endpoint, p256dh, auth, token, userAgent,
  createdAt, lastSeenAt } with unique(userId, endpoint)
- ExtendedPrismaClient type exported from @ketopath/db
- NotificationSettings zod schema in @ketopath/shared

Tooling
- lint-staged: split .js out of eslint glob so service worker is only
  formatted (it lives outside the TS project)

i18n
- Notifications namespace (it) with typed error keys

Smoke tested: POST /me/device-tokens 201, POST /me/notifications/test 200,
real push delivered to a macOS Chrome device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:58:35 +02:00

5.7 KiB
Raw Blame History

ADR 0003 — Notifiche push (web ora, mobile dopo)

  • Status: accepted
  • Date: 2026-04-29
  • Decision makers: Luciano (PO), Claude
  • Related: PRD §5.6 (Notifiche & promemoria), CLAUDE.md ("Privacy first", "GDPR by design"), ADR 0001 (auth EU/self-hosted)

Contesto

PRD §5.6 prevede promemoria settimanali (pesata, digiuno) e notifiche push lifecycle. Su web esiste lo standard W3C Push API; per il mobile (iOS, Android) si usano canali nativi (APNs, FCM). Il PO ha già escluso provider US‑centric quando possibile (vedi ADR 0001) e sa che è verosimile l'arrivo di un'app nativa post‑MVP.

L'ADR fissa come spedire le push oggi e come restare facili da estendere domani, senza vendor lock‑in.

Opzioni considerate

A) Web Push standard (VAPID, self‑hosted)

  • Browser + Service Worker ricevono push da un endpoint VAPID gestito dall'API KetoPath. Nessun account terzo.
  • Funziona su Chrome/Edge/Firefox. Su iOS funziona solo se l'utente installa la PWA sulla Home (iOS 16.4+).
  • Costi: zero. Conforme GDPR, nessuna catena di custodia con terzi.

B) Firebase Cloud Messaging (FCM) anche per web

  • Web Push proxato attraverso FCM (legacy: spesso usato per uniformità con Android).
  • Pro: una sola pipeline.
  • Contro: dipendenza Google su tutto lo stack, stesso problema di residenza dei dati che abbiamo evitato in 0001.

C) Servizi gestiti (OneSignal, Pusher Beams, Knock…)

  • SaaS che astraggono web/iOS/Android.
  • Pro: zero codice, multi‑canale.
  • Contro: data residency, costi a scaglione, tracking di terze parti — fuori dai vincoli "Privacy first" di CLAUDE.md.

Decisione

  1. Spedizione oggi: Web Push standard con VAPID, spedito dall'API tramite la libreria web-push.
  2. Modellazione mobile‑ready: nel DB la subscription si chiama DeviceToken, non PushSubscription, e ha un campo platform (web | ios | android). Per web valorizziamo endpoint + p256dh + auth; per le altre piattaforme useremo token (vedi struttura sotto). Tutto resta sullo stesso modello.
  3. Architettura sender: l'API espone un'interfaccia NotificationSender con un metodo send(token, payload). Implementazione iniziale unica WebPushSender. Le piattaforme mobile, quando arriveranno, saranno ExpoPushSender o ApnsDirectSender / FcmSender — senza toccare la logica di scheduling, preferenze e opt‑in.
  4. Scheduling: per il MVP, node-cron in‑process per i promemoria settimanali. Niente coda Redis/BullMQ finché non avremo job paralleli o picchi.

Schema (estratto)

model DeviceToken {
  id         String   @id @default(cuid())
  userId     String   @map("user_id")
  platform   String   // 'web' | 'ios' | 'android'
  endpoint   String?  // Web Push: URL endpoint
  p256dh     String?  // Web Push key
  auth       String?  // Web Push auth secret
  token      String?  // iOS/Android device token (Expo/APNs/FCM)
  userAgent  String?  @map("user_agent")
  createdAt  DateTime @default(now()) @map("created_at")
  lastSeenAt DateTime @default(now()) @map("last_seen_at")
  user       User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, endpoint, token])
  @@map("device_tokens")
}

Le preferenze sono già su Preferences.notificationSettings (Json). Tipiamo i flag in un'interfaccia condivisa:

interface NotificationSettings {
  weeklyWeighIn: boolean; // PRD §5.6
  fastingMilestones: boolean; // PRD §5.6 — per iterazione futura
}

Mobile readiness — cosa cambia il giorno X

Capability Web (oggi) iOS nativa Android nativa Cosa scrivo lato server
Trasporto VAPID/HTTP APNs (via Expo o JWT) FCM (via Expo) Nuovo NotificationSender da plug‑in al servizio core
Token format endpoint device token Apple FCM token Nessun cambio schema (platform + colonna token)
Permission Notif API UNUserNotifications NotificationManager Nessun cambio backend
Costo 0 €99/anno Apple Dev $25 una tantum n/a

Se scegliamo Expo per l'app cross‑platform, l'unico cambio backend è importare expo-server-sdk e aggiungere l'ExpoPushSender: nessuna migration, nessuna modifica di scheduling.

Sicurezza & GDPR

  • VAPID private key: solo in env API (VAPID_PRIVATE_KEY), mai committata, mai esposta al client.
  • Payload: niente dati sanitari nel body della push (titolo + testo generico, l'utente apre l'app per i dettagli). Allinea con CLAUDE.md "niente log dei dati sanitari".
  • Consenso esplicito: il toggle in /profile è opt‑in, default off. Niente notifiche di marketing senza consenso separato.
  • Cleanup automatico: subscription che restituiscono 404/410 vengono cancellate dal DB nello stesso ciclo di invio.

Conseguenze

  • + Architettura pronta per l'app nativa con un solo Sender extra.
  • + Zero costi e zero dipendenze di terze parti per il MVP.
  • + Conforme alle direttive privacy che abbiamo già fissato.
  • − Su iOS (web) la spedizione richiede che l'utente installi la PWA: per ora la documenteremo come limite noto, in attesa dell'app nativa.

Riferimenti