Some checks are pending
CI / validate (push) Waiting to run
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>
106 lines
4.6 KiB
Markdown
106 lines
4.6 KiB
Markdown
# 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** (22–08 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.
|