From 2428313a22668990ad2e7e5674c23d06c165ae3f Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 11:36:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Web=20Push=20f=C3=BCr=20Logbuch-Eigner?= =?UTF-8?q?=20bei=20Crew-Sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benachrichtigt Owner optional per VAPID/Web Push, wenn Collaborators Änderungen synchronisieren — ohne Klartext-Inhalte, mit Opt-in in den Einstellungen, Custom Service Worker und Deep-Link zum Logbuch. Co-authored-by: Cursor --- .env.example | 8 +- README.md | 5 + client/src/App.tsx | 62 +++ .../components/PushNotificationSettings.tsx | 135 ++++++ client/src/components/SettingsForm.tsx | 2 + client/src/i18n/locales/de.json | 8 + client/src/i18n/locales/en.json | 8 + client/src/services/analytics.ts | 4 +- client/src/services/pushNotifications.ts | 182 ++++++++ client/src/sw.ts | 73 +++ client/src/vite-env.d.ts | 9 + client/vite.config.ts | 6 +- docs/plausible-events.md | 2 + docs/push-notifications-plan.md | 417 ++++++++++++++++++ server/package-lock.json | 156 ++++++- server/package.json | 4 +- server/prisma/schema.prisma | 26 ++ server/src/index.ts | 2 + server/src/routes/push.ts | 139 ++++++ server/src/routes/sync.ts | 34 ++ server/src/services/pushNotify.ts | 105 +++++ 21 files changed, 1381 insertions(+), 6 deletions(-) create mode 100644 client/src/components/PushNotificationSettings.tsx create mode 100644 client/src/services/pushNotifications.ts create mode 100644 client/src/sw.ts create mode 100644 docs/push-notifications-plan.md create mode 100644 server/src/routes/push.ts create mode 100644 server/src/services/pushNotify.ts diff --git a/.env.example b/.env.example index 959b650..f1ca415 100755 --- a/.env.example +++ b/.env.example @@ -4,4 +4,10 @@ OpenWeatherMapAPIKey= # For local dev: localhost and http://localhost # For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu RP_ID=localhost -ORIGIN=http://localhost \ No newline at end of file +ORIGIN=http://localhost + +# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys +# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu \ No newline at end of file diff --git a/README.md b/README.md index fc27a81..4dd7732 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API). - **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder) - **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit) - **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff) +- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner werden bei Crew-Sync per Web Push informiert (ohne Klartext-Inhalte) - **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte - **Export** — PDF pro Reisetag, CSV-Download/-Teilen - **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account @@ -116,6 +117,10 @@ Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.: DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public" RP_ID=localhost ORIGIN=http://localhost:5173 +# Optional — Web Push (npx web-push generate-vapid-keys) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu ``` ### 3. Datenbank & Schema diff --git a/client/src/App.tsx b/client/src/App.tsx index b587503..2ba66a5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -39,6 +39,10 @@ import { getStoredDemoFirstEntryId, seedDemoLogbookIfNeeded } from './services/demoLogbook.js' +import { fetchLogbooks } from './services/logbook.js' +import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js' + +const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id' function App() { const { t, i18n } = useTranslation() @@ -169,6 +173,18 @@ function App() { setIsAcceptingInvite(false) + const openLogbookId = params.get('logbook') + if (openLogbookId) { + sessionStorage.setItem(PENDING_PUSH_LOGBOOK_KEY, openLogbookId) + const cleanUrl = new URL(window.location.href) + cleanUrl.searchParams.delete('logbook') + window.history.replaceState( + {}, + document.title, + `${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}` + ) + } + const savedUser = localStorage.getItem('active_username') const key = getActiveMasterKey() if (savedUser && key) { @@ -217,9 +233,53 @@ function App() { localStorage.setItem('active_logbook_title', title) } + const openLogbookById = useCallback( + async (logbookId: string) => { + try { + const books = await fetchLogbooks() + const match = books.find((b) => b.id === logbookId) + if (match) { + selectLogbook(match.id, match.title) + return + } + } catch (err) { + console.error('Failed to resolve logbook from push:', err) + } + selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`) + }, + [] + ) + + const consumePendingPushLogbook = useCallback(() => { + const pending = sessionStorage.getItem(PENDING_PUSH_LOGBOOK_KEY) + if (!pending) return + sessionStorage.removeItem(PENDING_PUSH_LOGBOOK_KEY) + void openLogbookById(pending) + }, [openLogbookById]) + + useEffect(() => { + if (isAuthenticated) { + consumePendingPushLogbook() + } + }, [isAuthenticated, consumePendingPushLogbook]) + + useEffect(() => { + if (!isAuthenticated || !('serviceWorker' in navigator)) return + + const onSwMessage = (event: MessageEvent) => { + if (event.data?.type === 'OPEN_LOGBOOK' && typeof event.data.logbookId === 'string') { + void openLogbookById(event.data.logbookId) + } + } + + navigator.serviceWorker.addEventListener('message', onSwMessage) + return () => navigator.serviceWorker.removeEventListener('message', onSwMessage) + }, [isAuthenticated, openLogbookById]) + const handleAuthenticated = async () => { setIsAuthenticated(true) trackPlausibleEvent(PlausibleEvents.LOGGED_IN) + void ensurePushSubscriptionIfEnabled() try { const demo = await seedDemoLogbookIfNeeded() @@ -229,6 +289,7 @@ function App() { setDemoHighlightEntryId(demo.firstEntryId) } requestStartAfterLogin() + consumePendingPushLogbook() return } } catch (err) { @@ -241,6 +302,7 @@ function App() { setActiveLogbookId(savedLogbookId) setActiveLogbookTitle(savedLogbookTitle) } + consumePendingPushLogbook() } const handleLogout = () => { diff --git a/client/src/components/PushNotificationSettings.tsx b/client/src/components/PushNotificationSettings.tsx new file mode 100644 index 0000000..dc58804 --- /dev/null +++ b/client/src/components/PushNotificationSettings.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Bell, BellOff } from 'lucide-react' +import { + disableCollaboratorChangePush, + enableCollaboratorChangePush, + fetchPushPrefs, + getNotificationPermission, + isPushSupported +} from '../services/pushNotifications.js' +import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js' +import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' +import { useDialog } from './ModalDialog.tsx' + +export default function PushNotificationSettings() { + const { t } = useTranslation() + const { showAlert } = useDialog() + const [enabled, setEnabled] = useState(false) + const [loading, setLoading] = useState(true) + const [toggling, setToggling] = useState(false) + + const supported = isPushSupported() + const permission = getNotificationPermission() + const iosNeedsInstall = isIosDevice() && !isRunningStandalone() + + const loadPrefs = useCallback(async () => { + if (!supported) { + setLoading(false) + return + } + try { + const prefs = await fetchPushPrefs() + setEnabled(prefs.collaboratorChangesEnabled) + } catch (err) { + console.error('Failed to load push prefs:', err) + } finally { + setLoading(false) + } + }, [supported]) + + useEffect(() => { + void loadPrefs() + }, [loadPrefs]) + + const handleToggle = async (e: React.ChangeEvent) => { + const next = e.target.checked + setToggling(true) + try { + if (next) { + await enableCollaboratorChangePush() + setEnabled(true) + trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED) + } else { + await disableCollaboratorChangePush() + setEnabled(false) + trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED) + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('settings.push_error') + showAlert(message) + void loadPrefs() + } finally { + setToggling(false) + } + } + + if (!supported) { + return ( +
+
+ +

{t('settings.push_title')}

+
+

+ {t('settings.push_unsupported')} +

+
+ ) + } + + return ( +
+
+ +

+ {t('settings.push_title')} +

+
+ +

+ {t('settings.push_desc')} +

+ + {iosNeedsInstall && ( +

+ {t('settings.push_ios_install_hint')} +

+ )} + + {permission === 'denied' && ( +

+ {t('settings.push_denied_hint')} +

+ )} + + + + {enabled && permission === 'granted' && ( +

+ {t('settings.push_active')} +

+ )} +
+ ) +} diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index 1902b33..2b56fed 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -5,6 +5,7 @@ import { ensureLogbookKey } from '../services/logbookKeys.js' import LogbookBackupPanel from './LogbookBackupPanel.tsx' import AccountDangerZone from './AccountDangerZone.tsx' import PwaInstallPrompt from './PwaInstallPrompt.tsx' +import PushNotificationSettings from './PushNotificationSettings.tsx' import { useDialog } from './ModalDialog.tsx' import { notifyAppearanceChanged } from '../services/appearance.js' import ThemedSelect from './ThemedSelect.tsx' @@ -297,6 +298,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
+ {/* Weather Integration card */}
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 7a737d2..4e387bb 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -334,6 +334,14 @@ "tour_title": "App-Tour", "tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.", "tour_restart": "Tour erneut starten", + "push_title": "Push-Benachrichtigungen", + "push_desc": "Als Logbuch-Eigner werden Sie benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.", + "push_enable": "Bei Crew-Änderungen benachrichtigen", + "push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.", + "push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.", + "push_denied_hint": "Benachrichtigungen sind blockiert. Erlauben Sie sie in den Browser- oder Geräteeinstellungen.", + "push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.", + "push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.", "backup_title": "Backup & Wiederherstellung", "backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.", "backup_export_title": "Backup erstellen", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 61db70c..93fa3a9 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -334,6 +334,14 @@ "tour_title": "App tour", "tour_desc": "Take a guided walkthrough of the main areas of the app again.", "tour_restart": "Restart tour", + "push_title": "Push notifications", + "push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.", + "push_enable": "Notify on crew changes", + "push_active": "Push notifications are active on this device.", + "push_unsupported": "Push notifications are not supported in this browser.", + "push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.", + "push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.", + "push_error": "Could not enable push notifications.", "backup_title": "Backup & restore", "backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.", "backup_export_title": "Create backup", diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts index 5e59797..10525d7 100644 --- a/client/src/services/analytics.ts +++ b/client/src/services/analytics.ts @@ -20,7 +20,9 @@ export const PlausibleEvents = { PHOTO_UPLOADED: 'Photo Uploaded', BACKUP_EXPORTED: 'Backup Exported', BACKUP_RESTORED: 'Backup Restored', - DEMO_OPENED: 'Demo Opened' + DEMO_OPENED: 'Demo Opened', + PUSH_ENABLED: 'Push Enabled', + PUSH_DISABLED: 'Push Disabled' } as const export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] diff --git a/client/src/services/pushNotifications.ts b/client/src/services/pushNotifications.ts new file mode 100644 index 0000000..1eca890 --- /dev/null +++ b/client/src/services/pushNotifications.ts @@ -0,0 +1,182 @@ +const API_BASE = '/api/push' + +function getUserId(): string | null { + return localStorage.getItem('active_userid') +} + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const raw = atob(base64) + const output = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) { + output[i] = raw.charCodeAt(i) + } + return output +} + +export function isPushSupported(): boolean { + return ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ) +} + +export function getNotificationPermission(): NotificationPermission | 'unsupported' { + if (!isPushSupported()) return 'unsupported' + return Notification.permission +} + +async function fetchVapidPublicKey(): Promise { + const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY + if (typeof envKey === 'string' && envKey.trim()) { + return envKey.trim() + } + + try { + const res = await fetch(`${API_BASE}/vapid-public-key`) + if (!res.ok) return null + const data = await res.json() + return typeof data.publicKey === 'string' ? data.publicKey : null + } catch { + return null + } +} + +export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> { + const userId = getUserId() + if (!userId) return { collaboratorChangesEnabled: false } + + const res = await fetch(`${API_BASE}/prefs`, { + headers: { 'X-User-Id': userId } + }) + if (!res.ok) { + throw new Error('Failed to load push notification preferences') + } + return res.json() +} + +export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise { + const userId = getUserId() + if (!userId) throw new Error('Not authenticated') + + const res = await fetch(`${API_BASE}/prefs`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': userId + }, + body: JSON.stringify({ collaboratorChangesEnabled }) + }) + if (!res.ok) { + throw new Error('Failed to save push notification preferences') + } +} + +async function saveSubscriptionToServer(subscription: PushSubscription): Promise { + const userId = getUserId() + if (!userId) throw new Error('Not authenticated') + + const json = subscription.toJSON() + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { + throw new Error('Invalid push subscription') + } + + const locale = document.documentElement.lang?.startsWith('en') ? 'en' : 'de' + + const res = await fetch(`${API_BASE}/subscription`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': userId + }, + body: JSON.stringify({ + endpoint: json.endpoint, + keys: json.keys, + locale, + userAgent: navigator.userAgent + }) + }) + if (!res.ok) { + throw new Error('Failed to register push subscription on server') + } +} + +export async function subscribeToPush(): Promise { + if (!isPushSupported()) { + throw new Error('Push notifications are not supported on this device') + } + + const permission = await Notification.requestPermission() + if (permission !== 'granted') { + throw new Error('Notification permission denied') + } + + const publicKey = await fetchVapidPublicKey() + if (!publicKey) { + throw new Error('Push notifications are not configured on this server') + } + + const registration = await navigator.serviceWorker.ready + let subscription = await registration.pushManager.getSubscription() + + if (!subscription) { + const keyBytes = urlBase64ToUint8Array(publicKey) + const applicationServerKey = new Uint8Array(keyBytes) + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey + }) + } + + await saveSubscriptionToServer(subscription) +} + +export async function unsubscribeFromPush(): Promise { + if (!isPushSupported()) return + + const userId = getUserId() + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() + if (!subscription) return + + const endpoint = subscription.endpoint + await subscription.unsubscribe() + + if (userId && endpoint) { + await fetch(`${API_BASE}/subscription`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': userId + }, + body: JSON.stringify({ endpoint }) + }).catch(() => {}) + } +} + +/** Re-register subscription when prefs are on and permission already granted. */ +export async function ensurePushSubscriptionIfEnabled(): Promise { + if (!isPushSupported() || Notification.permission !== 'granted') return + + const prefs = await fetchPushPrefs() + if (!prefs.collaboratorChangesEnabled) return + + try { + await subscribeToPush() + } catch (err) { + console.warn('Could not refresh push subscription:', err) + } +} + +export async function enableCollaboratorChangePush(): Promise { + await subscribeToPush() + await savePushPrefs(true) +} + +export async function disableCollaboratorChangePush(): Promise { + await savePushPrefs(false) + await unsubscribeFromPush() +} diff --git a/client/src/sw.ts b/client/src/sw.ts new file mode 100644 index 0000000..571cd5b --- /dev/null +++ b/client/src/sw.ts @@ -0,0 +1,73 @@ +/// +import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' + +declare let self: ServiceWorkerGlobalScope + +precacheAndRoute(self.__WB_MANIFEST) +cleanupOutdatedCaches() + +interface PushPayload { + title?: string + body?: string + tag?: string + renotify?: boolean + data?: { + url?: string + logbookId?: string + changeCount?: number + } +} + +self.addEventListener('push', (event) => { + event.waitUntil( + (async () => { + let payload: PushPayload = {} + try { + payload = event.data?.json() ?? {} + } catch { + payload = { body: event.data?.text() ?? '' } + } + + const title = payload.title ?? 'Kapteins Daagbok' + const body = payload.body ?? '' + const data = payload.data ?? {} + + await self.registration.showNotification(title, { + body, + tag: payload.tag, + icon: '/logo.png', + badge: '/logo.png', + data + }) + })() + ) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + const data = (event.notification.data ?? {}) as PushPayload['data'] + const targetPath = data?.url ?? '/' + const targetUrl = new URL(targetPath, self.location.origin).href + + event.waitUntil( + (async () => { + const windowClients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }) + + for (const client of windowClients) { + if ('focus' in client) { + await client.focus() + client.postMessage({ + type: 'OPEN_LOGBOOK', + logbookId: data?.logbookId + }) + return + } + } + + await self.clients.openWindow(targetUrl) + })() + ) +}) diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index ab2eddb..8344d32 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -1,5 +1,14 @@ /// /// +/// + +interface ImportMetaEnv { + readonly VITE_VAPID_PUBLIC_KEY?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} declare module '*?raw' { const content: string diff --git a/client/vite.config.ts b/client/vite.config.ts index d22b4ec..ef7e390 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -38,10 +38,12 @@ export default defineConfig({ plugins: [ react(), VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', registerType: 'prompt', includeAssets: ['favicon.ico', 'logo.png'], - workbox: { - cleanupOutdatedCaches: true, + injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}'] }, manifest: { diff --git a/docs/plausible-events.md b/docs/plausible-events.md index ac154ea..f504476 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -35,6 +35,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri | Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` | | Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) | | Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` | +| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — | +| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — | ## Bewusst nicht getrackt diff --git a/docs/push-notifications-plan.md b/docs/push-notifications-plan.md new file mode 100644 index 0000000..1f7cb1e --- /dev/null +++ b/docs/push-notifications-plan.md @@ -0,0 +1,417 @@ +# 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:** Service Worker nur für PWA-Caching/Updates (`vite-plugin-pwa`). Sync läuft per `setInterval` im Tab (~30 s). Kein `web-push`, keine Push-Subscriptions in der DB. + +--- + +## 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 (X-User-Id: crew) + 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 +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` | `X-User-Id` | Upsert Subscription (endpoint + keys) | +| `DELETE` | `/subscription` | `X-User-Id` | Body: `{ endpoint }` — Gerät abmelden | +| `GET` | `/prefs` | `X-User-Id` | Liest `collaboratorChangesEnabled` | +| `PUT` | `/prefs` | `X-User-Id` | Body: `{ collaboratorChangesEnabled: boolean }` | + +`requireUser`-Middleware wie in `sync.ts` / `collaboration.ts` wiederverwenden. + +### 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 +``` + +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() + +// 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` | Nur authentifizierte Requests (`X-User-Id` wie heute — langfristig Session/JWT erwägen). | +| 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:** Push-Routen jetzt mit `X-User-Id` wie Rest der API; Roadmap-Item: echte Session. + +--- + +## 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 + +docs/ + push-notifications-plan.md # dieses Dokument +README.md # Feature-Zeile + Env-Hinweis +``` diff --git a/server/package-lock.json b/server/package-lock.json index f2487f3..4adcee2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,12 +13,14 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", - "prisma": "^5.10.2" + "prisma": "^5.10.2", + "web-push": "^3.6.7" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.11.24", + "@types/web-push": "^3.6.4", "tsx": "^4.7.1", "typescript": "^5.3.3" } @@ -762,6 +764,16 @@ "@types/node": "*" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -775,12 +787,33 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -795,6 +828,12 @@ "node": ">=12.0.0" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -819,6 +858,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -973,6 +1018,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1253,6 +1307,15 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1273,6 +1336,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1300,6 +1399,27 @@ "node": ">= 0.10" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1369,6 +1489,21 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1800,6 +1935,25 @@ "node": ">= 0.8" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/server/package.json b/server/package.json index 1a1ca22..92e7ee7 100644 --- a/server/package.json +++ b/server/package.json @@ -15,12 +15,14 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", - "prisma": "^5.10.2" + "prisma": "^5.10.2", + "web-push": "^3.6.7" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.11.24", + "@types/web-push": "^3.6.4", "tsx": "^4.7.1", "typescript": "^5.3.3" } diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index f35900d..731d232 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -20,6 +20,32 @@ model User { credentials Credential[] logbooks Logbook[] collaborations Collaboration[] + pushSubscriptions PushSubscription[] + notificationPrefs UserNotificationPrefs? +} + +model PushSubscription { + id String @id @default(uuid()) + userId String + endpoint String @unique + p256dh String + auth String + userAgent String? + locale String? + 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) } model Credential { diff --git a/server/src/index.ts b/server/src/index.ts index 9615f89..dffbe04 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import logbooksRouter from './routes/logbooks.js' import syncRouter from './routes/sync.js' import collaborationRouter from './routes/collaboration.js' import signRouter from './routes/sign.js' +import pushRouter from './routes/push.js' import { prisma } from './db.js' dotenv.config() @@ -22,6 +23,7 @@ app.use('/api/logbooks', logbooksRouter) app.use('/api/sync', syncRouter) app.use('/api/collaboration', collaborationRouter) app.use('/api/sign', signRouter) +app.use('/api/push', pushRouter) // Health check endpoint app.get('/api/health', async (req, res) => { diff --git a/server/src/routes/push.ts b/server/src/routes/push.ts new file mode 100644 index 0000000..c120ba8 --- /dev/null +++ b/server/src/routes/push.ts @@ -0,0 +1,139 @@ +import { Router } from 'express' +import { prisma } from '../db.js' + +const router = Router() + +const requireUser = (req: any, res: any, next: any) => { + const userId = req.headers['x-user-id'] + if (!userId) { + return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) + } + req.userId = userId + next() +} + +function isValidHttpsEndpoint(endpoint: unknown): endpoint is string { + if (typeof endpoint !== 'string' || endpoint.length > 2048) return false + try { + const url = new URL(endpoint) + return url.protocol === 'https:' + } catch { + return false + } +} + +router.get('/vapid-public-key', (_req, res) => { + const publicKey = process.env.VAPID_PUBLIC_KEY + if (!publicKey) { + return res.status(503).json({ error: 'Push notifications are not configured on this server' }) + } + return res.json({ publicKey }) +}) + +router.use(requireUser) + +router.get('/prefs', async (req: any, res) => { + try { + const prefs = await prisma.userNotificationPrefs.findUnique({ + where: { userId: req.userId } + }) + return res.json({ + collaboratorChangesEnabled: prefs?.collaboratorChangesEnabled ?? false + }) + } catch (error: any) { + console.error('Error reading push prefs:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +router.put('/prefs', async (req: any, res) => { + try { + const { collaboratorChangesEnabled } = req.body + if (typeof collaboratorChangesEnabled !== 'boolean') { + return res.status(400).json({ error: 'collaboratorChangesEnabled must be a boolean' }) + } + + const prefs = await prisma.userNotificationPrefs.upsert({ + where: { userId: req.userId }, + create: { + userId: req.userId, + collaboratorChangesEnabled, + updatedAt: new Date() + }, + update: { + collaboratorChangesEnabled, + updatedAt: new Date() + } + }) + + return res.json({ + collaboratorChangesEnabled: prefs.collaboratorChangesEnabled + }) + } catch (error: any) { + console.error('Error updating push prefs:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +router.put('/subscription', async (req: any, res) => { + try { + const { endpoint, keys, locale, userAgent } = req.body + if (!isValidHttpsEndpoint(endpoint)) { + return res.status(400).json({ error: 'Invalid push subscription endpoint' }) + } + if (!keys?.p256dh || !keys?.auth || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') { + return res.status(400).json({ error: 'Invalid subscription keys' }) + } + + const normalizedLocale = + typeof locale === 'string' && (locale === 'de' || locale === 'en') ? locale : null + + await prisma.pushSubscription.upsert({ + where: { endpoint }, + create: { + userId: req.userId, + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + locale: normalizedLocale, + userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null + }, + update: { + userId: req.userId, + p256dh: keys.p256dh, + auth: keys.auth, + locale: normalizedLocale, + userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null, + updatedAt: new Date() + } + }) + + return res.json({ success: true }) + } catch (error: any) { + console.error('Error saving push subscription:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +router.delete('/subscription', async (req: any, res) => { + try { + const { endpoint } = req.body + if (!isValidHttpsEndpoint(endpoint)) { + return res.status(400).json({ error: 'Invalid push subscription endpoint' }) + } + + await prisma.pushSubscription.deleteMany({ + where: { + endpoint, + userId: req.userId + } + }) + + return res.json({ success: true }) + } catch (error: any) { + console.error('Error deleting push subscription:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +export default router diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index 24856f5..a1c42eb 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -1,5 +1,6 @@ import { Router } from 'express' import { prisma } from '../db.js' +import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js' const router = Router() @@ -24,6 +25,27 @@ router.post('/push', async (req: any, res) => { } const results = [] + const ownerNotifications = new Map< + string, + { ownerId: string; logbookId: string; count: number } + >() + + const recordCollaboratorChange = ( + ownerId: string, + logbookId: string, + isOwner: boolean, + isCollaborator: unknown, + action: string, + type: string + ) => { + if (isOwner || !isCollaborator) return + if (action !== 'create' && action !== 'update') return + if (type === 'logbook') return + const key = `${ownerId}:${logbookId}` + const entry = ownerNotifications.get(key) ?? { ownerId, logbookId, count: 0 } + entry.count += 1 + ownerNotifications.set(key, entry) + } for (const item of items) { const { action, type, payloadId, logbookId, data, updatedAt } = item @@ -218,6 +240,14 @@ router.post('/push', async (req: any, res) => { } } + recordCollaboratorChange( + logbook.userId, + logbookId, + isOwner, + isCollaborator, + action, + type + ) results.push({ payloadId, status: 'success' }) } catch (err: any) { console.error(`Error processing sync item ${payloadId}:`, err) @@ -225,6 +255,10 @@ router.post('/push', async (req: any, res) => { } } + for (const { ownerId, logbookId, count } of ownerNotifications.values()) { + void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count) + } + return res.json({ results }) } catch (error: any) { console.error('Error during sync push:', error) diff --git a/server/src/services/pushNotify.ts b/server/src/services/pushNotify.ts new file mode 100644 index 0000000..31da9ea --- /dev/null +++ b/server/src/services/pushNotify.ts @@ -0,0 +1,105 @@ +import webpush from 'web-push' +import { prisma } from '../db.js' + +const THROTTLE_MS = 3 * 60 * 1000 +const lastSentByLogbook = new Map() + +let vapidConfigured = false + +function ensureVapid(): boolean { + if (vapidConfigured) return true + const publicKey = process.env.VAPID_PUBLIC_KEY + const privateKey = process.env.VAPID_PRIVATE_KEY + const subject = process.env.VAPID_SUBJECT + if (!publicKey || !privateKey || !subject) { + return false + } + webpush.setVapidDetails(subject, publicKey, privateKey) + vapidConfigured = true + return true +} + +function isThrottled(ownerUserId: string, logbookId: string): boolean { + const key = `${ownerUserId}:${logbookId}` + const last = lastSentByLogbook.get(key) ?? 0 + return Date.now() - last < THROTTLE_MS +} + +function markSent(ownerUserId: string, logbookId: string): void { + lastSentByLogbook.set(`${ownerUserId}:${logbookId}`, Date.now()) +} + +function notificationCopy(locale: string | null | undefined, changeCount: number): { title: string; body: string } { + const isDe = !locale || locale.startsWith('de') + const title = 'Kapteins Daagbok' + if (isDe) { + const body = + changeCount > 1 + ? `${changeCount} neue Änderungen in einem Ihrer Logbücher.` + : 'Neue Änderung in einem Ihrer Logbücher.' + return { title, body } + } + const body = + changeCount > 1 + ? `${changeCount} new changes in one of your logbooks.` + : 'New change in one of your logbooks.' + return { title, body } +} + +export async function notifyOwnerOfCollaboratorChanges( + logbookId: string, + ownerUserId: string, + _actorUserId: string, + changeCount: number +): Promise { + if (!ensureVapid() || changeCount < 1) return + if (isThrottled(ownerUserId, logbookId)) return + + const prefs = await prisma.userNotificationPrefs.findUnique({ + where: { userId: ownerUserId } + }) + if (!prefs?.collaboratorChangesEnabled) return + + const subscriptions = await prisma.pushSubscription.findMany({ + where: { userId: ownerUserId } + }) + if (subscriptions.length === 0) return + + markSent(ownerUserId, logbookId) + + const payloadBase = { + tag: `logbook-change-${logbookId}`, + renotify: false, + data: { + url: `/?logbook=${encodeURIComponent(logbookId)}`, + logbookId, + changeCount + } + } + + await Promise.allSettled( + subscriptions.map(async (sub) => { + const { title, body } = notificationCopy(sub.locale, changeCount) + const payload = JSON.stringify({ title, body, ...payloadBase }) + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { p256dh: sub.p256dh, auth: sub.auth } + }, + payload + ) + } catch (err: unknown) { + const statusCode = + err && typeof err === 'object' && 'statusCode' in err + ? (err as { statusCode: number }).statusCode + : undefined + if (statusCode === 404 || statusCode === 410) { + await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => {}) + } else { + console.warn('[push] Failed to send notification:', err) + } + } + }) + ) +}