seepuls/docs/NOTIFICATIONS.md
Till JS 6f77d0095b
Some checks are pending
CI / validate (push) Waiting to run
feat: echte Suche, Amenities/Region ausgespielt, Folgen→Web-Push
Vier Review-Verbesserungen:

- Suche real: /api/v1/search liefert echte unified Events+Orte (war Stub);
  Event-q trifft auch Venue-Namen; Startseite zeigt Orte-Treffer bei Suche.
- Amenities/Öffnungszeiten/Region ausgespielt: Venue-Liste liefert
  openingHours/amenities/smoking/heroUrl, neuer amenity- + q-Filter;
  Orte-Seite mit Ausstattungs-/Region-Chips, „Jetzt geöffnet"-Badge
  (isOpenNow, über-Mitternacht-fest) + Amenity-Badges; Region-Chips auch
  auf der Startseite.
- Folgen → Web Push (login-frei): kanal-agnostischer Kern
  (push_endpoints + venue_follows + notification_outbox, Migration 0005),
  Crawler-Auslöser (nur neue Events, best-effort), notification-worker
  (Drain + Ruhezeiten 22–8h + Pruning toter Endpoints), öffentliche
  /api/v1/push/*-Routen, Service Worker + lib/push.ts + Schalter auf
  /gemerkt (iOS-PWA-Hinweis). web-push-Lib (lädt unter Bun).
  Ohne VAPID-Keys bleibt Push schlafend (subscribe→503). Doku:
  docs/NOTIFICATIONS.md. Offen vor Push-Live: VAPID-Keys+SOPS, VVT-Eintrag.

Tests: 112 API + 34 Web grün; api+web type-check grün; web build grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:51:50 +02:00

106 lines
4.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Benachrichtigungen — „Folgen → Push"
> Stand: 2026-06-02. Erster Kanal: **Web Push, login-frei**. Der Kern ist
> kanal-agnostisch gebaut; APNs (nativ) und E-Mail-Digest sind als weitere
> Endstücke vorgesehen, aber noch nicht verdrahtet.
## Warum so
„Folgen" war im Web rein gerätelokal (localStorage) und ohne Wirkung — man
folgte einem Ort, aber es passierte nichts. Diese Funktion gibt dem Folgen
einen Zweck, **ohne die Local-First-Haltung aufzugeben**: kein Konto, keine
E-Mail. Die Web-Push-Subscription (eine unratbare Endpoint-URL, die der
Browser vergibt) ist selbst die pseudonyme Identität. Der Server lernt
nur „dieses anonyme Gerät folgt diesen Orten".
## Architektur
Vier Schichten, der Kanal ist nur das Endstück:
```
Crawler findet NEUES Event an gefolgtem Ort
│ (enqueueNewEventNotifications)
notification_outbox ── pending ──► notification-worker ──► Web Push
(Dedup je Gerät+Event) (Drain, Ruhezeiten) (RFC 8291)
```
1. **Abo** (`venue_follows`) — ein `push_endpoint` folgt einem `venue_slug`
mit einer `cadence` (`instant` | `daily` | `off`). Gespiegelt aus der
lokalen „Folgen"-Liste beim Aktivieren der Benachrichtigungen.
2. **Gerät** (`push_endpoints`) — pseudonyme Web-Push-Subscription
(endpoint + p256dh/auth). `user_id` optional (Cross-Device, noch ungenutzt).
3. **Outbox** (`notification_outbox`) — Warteschlange. `(endpoint_id, event_id)`
ist unique → Re-Crawls doppeln nie. Robust gegen Crashes, auditierbar.
4. **Worker** (`notification-worker.ts`) — zieht `pending`, stellt per Web
Push zu, markiert `sent`/`failed`, **prunt tote Endpoints** (404/410),
respektiert **Ruhezeiten** (2208 Uhr Europe/Berlin).
## Auslöser
In `event-source-crawl.ts`: nur **frisch eingefügte** Events (nicht
Re-Crawl-Updates) werden gesammelt und nach der Schleife best-effort
eingereiht. Die Zustellung darf den Crawl **nie** brechen (try/catch in
`enqueueNewEventNotifications`). Da der Re-Crawl im 24h-Takt läuft, ist
Push batch-ig, nicht sekundengenau — passt zu „was ist neu", nicht Spam.
## API (öffentlich, login-frei)
| Method | Pfad | Zweck |
|---|---|---|
| `GET` | `/api/v1/push/vapid-public-key` | Public Key für `PushManager.subscribe` |
| `POST` | `/api/v1/push/subscribe` | Gerät + gefolgte Orte registrieren/abgleichen |
| `POST` | `/api/v1/push/unsubscribe` | Gerät + Abos löschen |
`subscribe` setzt das Folgen-Set **absolut** (nicht additiv): der Client
schickt die volle Liste, der Server gleicht ab. Rate-Limit 60/min/IP.
## Web-Client
- Service Worker: `apps/web/public/sw.js` (Scope `/`) — `push` + `notificationclick`.
- Client-Lib: `apps/web/src/lib/push.ts` — subscribe/sync/disable.
- UI: Schalter „🔔 Bei neuen Events benachrichtigen" auf `/gemerkt` in der
Sektion „Gefolgte Orte". Hält das Server-Set bei jeder Folgen-Änderung
nach (debounced).
- **iOS:** Web Push geht nur als installierte PWA (Safari 16.4+). Im
normalen Safari zeigt die UI den Hinweis „Zum Home-Bildschirm hinzufügen".
Für iPhone-Nutzer ohne PWA ist APNs (native App) der vorgesehene Weg.
## Betrieb
VAPID-Schlüsselpaar einmalig erzeugen und in SOPS/age ablegen:
```bash
npx web-push generate-vapid-keys --json
```
Env (siehe `.env.example`):
- `SEEPULS_VAPID_PUBLIC_KEY` / `SEEPULS_VAPID_PRIVATE_KEY` / `SEEPULS_VAPID_SUBJECT`
- `SEEPULS_PUBLIC_API_URL` (Web): API-Basis, die der Browser anspricht
- `SEEPULS_NOTIFICATIONS=off` schaltet den Worker ganz aus
- `SEEPULS_NOTIFY_TICK_MS` (Default 20000)
**Ohne Keys** bleibt alles still: Worker idle, `subscribe`/`vapid` → 503,
der Rest der App läuft normal.
## DSGVO
`push_endpoints` ist pseudonym + zweckgebunden (nur Benachrichtigung),
EU-Storage. Kein Tracking über die Subscription hinaus. Löschung:
`POST /push/unsubscribe` (Cascade räumt `venue_follows` +
`notification_outbox`). Bei verknüpfter `user_id` zusätzlich über den
Service-Key-DSGVO-Pfad. **Vor Live: VVT-Eintrag ergänzen.**
## Offen / nächste Schritte
- **VVT-Eintrag** für `push_endpoints` (vor Live-Schaltung).
- **APNs** (native App) als zweiter Kanal — nutzt den vorhandenen
Favoriten-Sync + `user_id`.
- **E-Mail-Digest** via mana-notify für eingeloggte Nutzer (`cadence: daily`).
- **`daily`-Digest-Job** für Web Push (heute wird nur `instant` zugestellt).
- **iCal-Folge-Feed** (`/api/v1/venues/follow.ics?venues=…`) als billiger
login-freier Zusatzkanal.
- **Push-Icon** als PNG (manche Plattformen rendern SVG-Notification-Icons nicht).
- Live-Smoke nach Deploy: Migration `0005` angewandt, echte Subscription +
echter Crawl-Auslöser → Zustellung.