feat: Web Push für Logbuch-Eigner bei Crew-Sync

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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 11:36:03 +02:00
parent 0e61bc5dad
commit 2428313a22
21 changed files with 1381 additions and 6 deletions
+6
View File
@@ -5,3 +5,9 @@ OpenWeatherMapAPIKey=<owm_api_key>
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost
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
+5
View File
@@ -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
+62
View File
@@ -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 = () => {
@@ -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<HTMLInputElement>) => {
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 (
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<BellOff size={20} style={{ color: '#94a3b8' }} />
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('settings.push_title')}</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
{t('settings.push_unsupported')}
</p>
</div>
)
}
return (
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Bell size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.push_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.push_desc')}
</p>
{iosNeedsInstall && (
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
{t('settings.push_ios_install_hint')}
</p>
)}
{permission === 'denied' && (
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
{t('settings.push_denied_hint')}
</p>
)}
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: loading || toggling || iosNeedsInstall ? 'not-allowed' : 'pointer',
fontSize: '14px',
color: '#f1f5f9',
opacity: loading || iosNeedsInstall ? 0.6 : 1
}}
>
<input
type="checkbox"
checked={enabled}
onChange={handleToggle}
disabled={loading || toggling || iosNeedsInstall}
style={{ width: '18px', height: '18px', cursor: 'inherit' }}
/>
<span>{t('settings.push_enable')}</span>
</label>
{enabled && permission === 'granted' && (
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
{t('settings.push_active')}
</p>
)}
</div>
)
}
+2
View File
@@ -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
<form onSubmit={handleSubmit} className="vessel-form mt-6">
<PwaInstallPrompt variant="inline" />
<PushNotificationSettings />
{/* Weather Integration card */}
<div className="member-editor-card glass">
+8
View File
@@ -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",
+8
View File
@@ -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",
+3 -1
View File
@@ -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]
+182
View File
@@ -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<string | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await subscribeToPush()
await savePushPrefs(true)
}
export async function disableCollaboratorChangePush(): Promise<void> {
await savePushPrefs(false)
await unsubscribeFromPush()
}
+73
View File
@@ -0,0 +1,73 @@
/// <reference lib="webworker" />
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)
})()
)
})
+9
View File
@@ -1,5 +1,14 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
/// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_VAPID_PUBLIC_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*?raw' {
const content: string
+4 -2
View File
@@ -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: {
+2
View File
@@ -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
+417
View File
@@ -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<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 25 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` | 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 (12 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 (12 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:** 46 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
```
+155 -1
View File
@@ -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",
+3 -1
View File
@@ -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"
}
+26
View File
@@ -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 {
+2
View File
@@ -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) => {
+139
View File
@@ -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
+34
View File
@@ -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)
+105
View File
@@ -0,0 +1,105 @@
import webpush from 'web-push'
import { prisma } from '../db.js'
const THROTTLE_MS = 3 * 60 * 1000
const lastSentByLogbook = new Map<string, number>()
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<void> {
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)
}
}
})
)
}