# Implementierungsplan: Push-Benachrichtigungen für Logbuch-Owner

**Ziel:** Der Owner eines Logbuchs soll per Web Push informiert werden, wenn ein eingeladenes Crewmitglied (Collaborator mit WRITE) Änderungen synchronisiert — auch wenn die App geschlossen ist.

**Stand Codebase:** Push MVP ist implementiert (`web-push`, Prisma-Modelle, `routes/push.ts`, `pushNotify.ts`, Custom SW `sw.ts`, Settings-UI). API-Auth erfolgt über **HttpOnly-Session-Cookie** (`daagbok_session`) nach WebAuthn-Login — nicht mehr über `X-User-Id`.

---

## 1. Anforderungen

### Funktional (MVP)

| ID | Anforderung |
|----|-------------|
| N-01 | Owner kann Push-Benachrichtigungen global aktivieren/deaktivieren (Opt-in). |
| N-02 | Bei erfolgreichem Sync-Push durch einen **Nicht-Owner-Collaborator** erhält der Owner **eine** zusammengefasste Benachrichtigung pro Logbuch und Request (nicht pro Queue-Item). |
| N-03 | Klick auf die Benachrichtigung öffnet die App auf dem betroffenen Logbuch (Deep-Link `/logbook/:id` o. ä.). |
| N-04 | Benachrichtigungstext ist **generisch** (Zero-Knowledge: Server kann Titel/Inhalt nicht lesen). |
| N-05 | DE/EN über i18n-Keys; Sprache aus Browser/`Accept-Language` oder gespeicherter App-Sprache in Subscription-Metadaten. |
| N-06 | Abgelaufene/ungültige Subscriptions werden beim Fehlerversand gelöscht (410 Gone). |

### Nicht im MVP (später)

- Push an Collaborators bei Owner-Änderungen (bidirektional).
- Pro-Logbuch Ein/Aus (nur global reicht zunächst).
- Inhaltliche Details („Eintrag #3 bearbeitet“) — würde Klartext auf dem Server erfordern.
- E-Mail/SMS als Fallback.
- „Quiet hours“ / Do-not-disturb-Zeiten.

### Akzeptanzkriterien (UAT)

1. Owner aktiviert Push in den Einstellungen → Browser fragt Berechtigung → Subscription liegt in DB.
2. Collaborator bearbeitet Eintrag, App des Collaborators synct → Owner erhält Push innerhalb weniger Sekunden (Gerät online, Berechtigung erteilt).
3. Owner mit deaktivierten Push-Einstellungen erhält nichts.
4. Bulk-Sync (10 Items) → genau **eine** Push-Nachricht.
5. Klick öffnet installierte PWA oder Browser-Tab mit korrektem Logbuch.

---

## 2. Architektur

```mermaid
sequenceDiagram
  participant Crew as Crew-Client
  participant API as Express API
  participant DB as PostgreSQL
  participant Push as web-push (VAPID)
  participant SW as Service Worker (Owner)
  participant Owner as Owner-Gerät

  Crew->>API: POST /api/sync/push (Session-Cookie)
  API->>DB: Payloads speichern
  API->>API: collaborator change? → notify owner
  API->>DB: PushSubscriptions (owner)
  API->>Push: sendNotification (pro Endpoint)
  Push->>SW: Push Event
  SW->>Owner: System-Benachrichtigung
  Owner->>SW: notificationclick
  SW->>Owner: openWindow(/logbook/:id)
```

### Komponenten

| Schicht | Neu/Geändert | Aufgabe |
|---------|--------------|---------|
| **Prisma** | Neu | `PushSubscription`, optional `UserNotificationPrefs` |
| **Server** | Neu | `routes/push.ts`, `services/pushNotify.ts`, Env VAPID |
| **sync.ts** | Änderung | Nach erfolgreichem Collaborator-Push Owner benachrichtigen |
| **Client SW** | Neu | Custom SW (`injectManifest`) mit `push` + `notificationclick` |
| **Client UI** | Neu | Einstellungen: Toggle, Permission-Flow, Status |
| **Client Service** | Neu | `pushNotifications.ts` — subscribe, unsubscribe, sync mit API |

---

## 3. Plattform- und Produkt-Hinweise

| Thema | Auswirkung |
|-------|------------|
| **iOS** | Web Push für installierte PWAs ab **iOS 16.4+**. Nutzer müssen App zum Home Screen hinzufügen und Push erlauben. |
| **Android / Desktop** | Chrome/Edge/Firefox: gut unterstützt; PWA installiert empfohlen. |
| **HTTPS** | Web Push nur über HTTPS (Produktion erfüllt das). |
| **Zero-Knowledge** | Text z. B. „Neue Änderung in einem Ihrer Logbücher“ + `logbookId` nur im `data`-Payload (nicht im sichtbaren Titel nötig). |
| **Datenschutz** | Push-Endpoints sind personenbezogen → in Datenschutzerklärung erwähnen; Löschung bei Account-Löschung (Cascade). |

---

## 4. Datenmodell (Prisma)

```prisma
model PushSubscription {
  id        String   @id @default(uuid())
  userId    String
  endpoint  String   @unique
  p256dh    String   // keys.p256dh (base64url)
  auth      String   // keys.auth (base64url)
  userAgent String?  // optional, Debugging
  locale    String?  // "de" | "en" — für Notification-Text
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
}

model UserNotificationPrefs {
  userId                    String  @id
  collaboratorChangesEnabled Boolean @default(false)
  updatedAt                 DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
```

`User`-Relationen ergänzen: `pushSubscriptions`, `notificationPrefs`.

**Migration:** `npx prisma migrate dev --name add_push_subscriptions`

---

## 5. Server-Implementierung

### 5.1 Abhängigkeit & Umgebung

```bash
npm install web-push --workspace=server
```

`.env` (Beispiel):

```env
ORIGIN=https://kapteins-daagbok.eu
SESSION_SECRET=...          # min. 32 Zeichen, Pflicht in Produktion
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
```

Keys einmalig erzeugen:

```bash
npx web-push generate-vapid-keys
```

Öffentlichen Key zusätzlich als `VITE_VAPID_PUBLIC_KEY` für den Client (nur Public Key).

### 5.2 API-Routen (`/api/push`)

| Methode | Pfad | Auth | Beschreibung |
|---------|------|------|--------------|
| `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` |
| `PUT` | `/subscription` | Session-Cookie | Upsert Subscription (endpoint + keys) |
| `DELETE` | `/subscription` | Session-Cookie | Body: `{ endpoint }` — Gerät abmelden |
| `GET` | `/prefs` | Session-Cookie | Liest `collaboratorChangesEnabled` |
| `PUT` | `/prefs` | Session-Cookie | Body: `{ collaboratorChangesEnabled: boolean }` |

`requireUser` in `server/src/middleware/auth.ts` — liest und verifiziert `daagbok_session` (HMAC-signiert). Client sendet `credentials: 'include'` (`client/src/services/api.ts`).

### 5.3 Benachrichtigungs-Service

**Datei:** `server/src/services/pushNotify.ts`

```ts
// Pseudocode — Kernlogik
export async function notifyOwnerOfCollaboratorChanges(
  logbookId: string,
  ownerUserId: string,
  actorUserId: string,
  changeCount: number
): Promise<void>
```

Ablauf:

1. `UserNotificationPrefs`: wenn `collaboratorChangesEnabled !== true` → return.
2. Alle `PushSubscription` für `ownerUserId` laden.
3. Payload (Web Push JSON):

```json
{
  "title": "Kapteins Daagbok",
  "body": "Neue Änderung in einem Ihrer Logbücher.",
  "tag": "logbook-change-{logbookId}",
  "renotify": false,
  "data": { "url": "/logbook/{logbookId}", "logbookId": "{logbookId}", "changeCount": 3 }
}
```

4. `webpush.sendNotification(subscription, payload, options)` parallel mit `Promise.allSettled`.
5. Bei Status **410** oder **404**: Subscription aus DB löschen.
6. Fehler loggen, Sync-Response **nicht** fehlschlagen lassen (Push ist Best-Effort).

**Deduplizierung / Rate-Limit (empfohlen):**

- In-Memory-Map `ownerId:logbookId → lastSentAt` mit TTL 2–5 Minuten, **oder**
- Redis/DB-Tabelle `NotificationThrottle` mit `lastSentAt`.

Verhindert Push-Spam bei großen Offline-Queues.

### 5.4 Hook in `sync.ts`

Nach der Schleife über `items` (oder innerhalb, mit Sammellogik):

```ts
// Pro Request sammeln:
const ownerNotifications = new Map<string, { logbookId: string; count: number }>()

// Bei jedem erfolgreichen Item:
if (res.status === 'success' && !isOwner && isCollaborator) {
  if (action === 'create' || action === 'update') {
    const ownerId = logbook.userId
    const key = `${ownerId}:${logbookId}`
    const prev = ownerNotifications.get(key) ?? { logbookId, count: 0 }
    prev.count++
    ownerNotifications.set(key, prev)
  }
}

// Nach der Schleife, async fire-and-forget:
for (const [key, { logbookId, count }] of ownerNotifications) {
  const ownerId = key.split(':')[0]
  void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
}
```

**Wichtig:** Owner, der selbst als „Crew“ irrtümlich synct, ist `isOwner` — kein Push.

**Optional später:** auch `delete`-Aktionen einbeziehen (gleiche Logik).

### 5.5 `index.ts`

```ts
import pushRouter from './routes/push.js'
app.use('/api/push', pushRouter)
```

---

## 6. Client-Implementierung

### 6.1 Service Worker (Custom `injectManifest`)

`vite.config.ts` anpassen:

```ts
VitePWA({
  strategies: 'injectManifest',
  srcDir: 'src',
  filename: 'sw.ts',
  injectRegister: 'auto',
  // manifest unverändert
})
```

**Datei:** `client/src/sw.ts`

- `precacheAndRoute` von Workbox importieren (wie vite-plugin-pwa-Doku).
- `self.addEventListener('push', …)`:
  - `event.data.json()` parsen
  - `self.registration.showNotification(title, { body, tag, data, icon: '/logo.png' })`
- `notificationclick`:
  - `event.notification.close()`
  - `clients.openWindow(data.url || '/')` — absolute URL mit `self.location.origin`

**i18n im SW:** MVP mit serverseitigem `locale` in Subscription; alternativ nur EN/DE-Body vom Server senden.

### 6.2 Client-Service `pushNotifications.ts`

| Funktion | Beschreibung |
|----------|--------------|
| `isPushSupported()` | `'serviceWorker' in navigator && 'PushManager' in window` |
| `getPermissionState()` | `Notification.permission` |
| `subscribeToPush()` | SW ready → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })` → `PUT /api/push/subscription` |
| `unsubscribeFromPush()` | `subscription.unsubscribe()` + `DELETE` API |
| `syncPrefs(enabled)` | `PUT /api/push/prefs` |
| `ensureSubscriptionOnLogin()` | Wenn Prefs an und Permission granted, Subscription erneuern (Key-Rotation) |

`applicationServerKey`: VAPID Public Key von `GET /api/push/vapid-public-key` oder Build-Time `import.meta.env.VITE_VAPID_PUBLIC_KEY`.

### 6.3 UI (Settings)

**Ort:** `SettingsForm.tsx` (nur für Owner sichtbar, nicht bei `readOnly` / Crew-Logbuch).

Ablauf beim Einschalten:

1. `Notification.requestPermission()` — bei `denied` Hinweis + Link zu Browser-Einstellungen.
2. `subscribeToPush()` + `syncPrefs(true)`.
3. Bei Erfolg: grüner Status „Push aktiv“.

Beim Ausschalten:

1. `syncPrefs(false)` + optional `unsubscribeFromPush()` auf diesem Gerät.

**Hinweis-Banner** wenn `!isPushSupported()` oder iOS & nicht installiert → Verweis auf `PwaInstallPrompt`.

### 6.4 Deep-Link beim Öffnen

In `App.tsx` oder Router: beim Start `url` aus `notificationclick` via `clients.matchAll` nicht nötig — SW öffnet direkt.

Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route) existiert und Auth-Gate passiert.

### 6.5 Bestehenden SW-Update-Flow

`usePwaUpdate.ts` bleibt kompatibel mit `injectManifest`, sofern `virtual:pwa-register` weiter registriert wird — vite-plugin-pwa-Doku für `injectManifest` + React beachten.

---

## 7. Sicherheit

| Risiko | Maßnahme |
|--------|----------|
| Fremde subscriben mit fremder `userId` | Session-Cookie nach WebAuthn; `userId` kommt aus verifiziertem Token, nicht aus Client-Header. |
| Push an falschen User | `notifyOwner` nur mit `logbook.userId` aus DB, nie aus Client-Body. |
| Endpoint-Injection | `endpoint` muss HTTPS-URL sein; Länge begrenzen. |
| Spam durch Crew | Rate-Limit + nur `create`/`update` im MVP. |
| VAPID Private Key | Nur Server-Env, nie im Client. |

---

## 8. Implementierungsphasen

### Phase 1 — Infrastruktur (1–2 Tage)

- [ ] VAPID-Keys für Dev/Prod
- [ ] Prisma-Modelle + Migration
- [ ] `web-push` + `pushNotify.ts` + Unit-Test mit Mock-Subscription
- [ ] Routen `/api/push/*`
- [ ] `GET /vapid-public-key`

### Phase 2 — Service Worker (1 Tag)

- [ ] Umstellung auf `injectManifest` + `sw.ts`
- [ ] `push` / `notificationclick` Handler
- [ ] Manueller Test: `web-push` CLI oder kleines Admin-Skript sendet Test-Push

### Phase 3 — Trigger & Client-Anbindung (1–2 Tage)

- [ ] Hook in `sync.ts` mit Aggregation
- [ ] `pushNotifications.ts`
- [ ] Settings-UI + i18n (`de.json` / `en.json`)
- [ ] Plausible-Event optional: `push_enabled`, `push_denied`

### Phase 4 — Härtung (1 Tag)

- [ ] Rate-Limit / `tag`-basierte Ersetzung gleicher Logbuch-Pushes
- [ ] 410-Cleanup
- [ ] README + Datenschutz-Hinweis
- [ ] E2E-Manual-Testmatrix (iOS PWA, Android Chrome, Desktop)

### Phase 5 — Deployment

- [ ] Env-Variablen in Produktion (Docker/Hosting)
- [ ] Nginx: `sw.js` weiterhin `no-cache` (bereits in `nginx.conf`)
- [ ] Smoke-Test nach Deploy

**Geschätzter Gesamtaufwand:** 4–6 Entwicklertage für MVP.

---

## 9. Testplan

| # | Szenario | Erwartung |
|---|----------|-----------|
| T1 | Push nicht unterstützt (alter Browser) | UI zeigt „nicht verfügbar“, kein Fehler |
| T2 | Permission denied | Toggle aus, erklärender Hinweis |
| T3 | Owner aktiviert, Crew synct 1 Eintrag | 1 Push |
| T4 | Crew synct 5 Einträge in einem Request | 1 Push |
| T5 | Owner Prefs aus | Kein Push |
| T6 | Ungültige Subscription | 410 → DB-Eintrag weg, nächster Push an andere Geräte ok |
| T7 | notificationclick | App öffnet richtiges Logbuch |
| T8 | Owner ändert selbst | Kein Push an sich selbst |

**Dev-Test ohne zweites Gerät:** Zwei Browser-Profile (Owner + Crew), Crew-Einladung wie in Produktion.

---

## 10. Offene Entscheidungen (vor Start klären)

1. **Nur Owner oder auch andere Collaborators?** — MVP: nur Owner.
2. **Rate-Limit-Dauer:** 2 min vs. 5 min — Empfehlung: **3 min** pro Logbuch.
3. **Mehrere Geräte des Owners:** alle Subscriptions benachrichtigen — ja (Standard).
4. ~~**Auth verbessern**~~ — erledigt: HttpOnly-Session-Cookie für alle geschützten Routen inkl. Push.

---

## 11. Referenzen

- [web-push (npm)](https://www.npmjs.com/package/web-push)
- [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
- [vite-plugin-pwa: injectManifest](https://vite-pwa-org.netlify.app/guide/inject-manifest.html)
- [Apple: Web Push for PWAs (iOS 16.4+)](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/)

---

## 12. Datei-Checkliste (neu/geändert)

```
server/
  prisma/schema.prisma          # PushSubscription, UserNotificationPrefs
  prisma/migrations/.../
  src/routes/push.ts            # neu
  src/services/pushNotify.ts    # neu
  src/routes/sync.ts            # Hook notifyOwner
  src/index.ts                  # Router mount
  package.json                  # web-push

client/
  src/sw.ts                     # neu (injectManifest)
  vite.config.ts                # strategies: injectManifest
  src/services/pushNotifications.ts  # neu
  src/components/PushNotificationSettings.tsx  # neu (optional)
  src/components/SettingsForm.tsx    # Integration
  src/i18n/locales/de.json, en.json
  .env.example                  # VITE_VAPID_PUBLIC_KEY
  src/services/api.ts           # apiFetch (credentials: include)

server/
  src/session.ts                # Session-Cookie signieren/verifizieren
  src/middleware/auth.ts        # requireUser, requireReauth

docs/
  push-notifications-plan.md    # dieses Dokument
README.md                       # Auth/Session, Env-Hinweise
```
