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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})()
|
||||
)
|
||||
})
|
||||
Vendored
+9
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 2–5 Minuten, **oder**
|
||||
- Redis/DB-Tabelle `NotificationThrottle` mit `lastSentAt`.
|
||||
|
||||
Verhindert Push-Spam bei großen Offline-Queues.
|
||||
|
||||
### 5.4 Hook in `sync.ts`
|
||||
|
||||
Nach der Schleife über `items` (oder innerhalb, mit Sammellogik):
|
||||
|
||||
```ts
|
||||
// Pro Request sammeln:
|
||||
const ownerNotifications = new Map<string, { logbookId: string; count: number }>()
|
||||
|
||||
// Bei jedem erfolgreichen Item:
|
||||
if (res.status === 'success' && !isOwner && isCollaborator) {
|
||||
if (action === 'create' || action === 'update') {
|
||||
const ownerId = logbook.userId
|
||||
const key = `${ownerId}:${logbookId}`
|
||||
const prev = ownerNotifications.get(key) ?? { logbookId, count: 0 }
|
||||
prev.count++
|
||||
ownerNotifications.set(key, prev)
|
||||
}
|
||||
}
|
||||
|
||||
// Nach der Schleife, async fire-and-forget:
|
||||
for (const [key, { logbookId, count }] of ownerNotifications) {
|
||||
const ownerId = key.split(':')[0]
|
||||
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtig:** Owner, der selbst als „Crew“ irrtümlich synct, ist `isOwner` — kein Push.
|
||||
|
||||
**Optional später:** auch `delete`-Aktionen einbeziehen (gleiche Logik).
|
||||
|
||||
### 5.5 `index.ts`
|
||||
|
||||
```ts
|
||||
import pushRouter from './routes/push.js'
|
||||
app.use('/api/push', pushRouter)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Client-Implementierung
|
||||
|
||||
### 6.1 Service Worker (Custom `injectManifest`)
|
||||
|
||||
`vite.config.ts` anpassen:
|
||||
|
||||
```ts
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
injectRegister: 'auto',
|
||||
// manifest unverändert
|
||||
})
|
||||
```
|
||||
|
||||
**Datei:** `client/src/sw.ts`
|
||||
|
||||
- `precacheAndRoute` von Workbox importieren (wie vite-plugin-pwa-Doku).
|
||||
- `self.addEventListener('push', …)`:
|
||||
- `event.data.json()` parsen
|
||||
- `self.registration.showNotification(title, { body, tag, data, icon: '/logo.png' })`
|
||||
- `notificationclick`:
|
||||
- `event.notification.close()`
|
||||
- `clients.openWindow(data.url || '/')` — absolute URL mit `self.location.origin`
|
||||
|
||||
**i18n im SW:** MVP mit serverseitigem `locale` in Subscription; alternativ nur EN/DE-Body vom Server senden.
|
||||
|
||||
### 6.2 Client-Service `pushNotifications.ts`
|
||||
|
||||
| Funktion | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `isPushSupported()` | `'serviceWorker' in navigator && 'PushManager' in window` |
|
||||
| `getPermissionState()` | `Notification.permission` |
|
||||
| `subscribeToPush()` | SW ready → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })` → `PUT /api/push/subscription` |
|
||||
| `unsubscribeFromPush()` | `subscription.unsubscribe()` + `DELETE` API |
|
||||
| `syncPrefs(enabled)` | `PUT /api/push/prefs` |
|
||||
| `ensureSubscriptionOnLogin()` | Wenn Prefs an und Permission granted, Subscription erneuern (Key-Rotation) |
|
||||
|
||||
`applicationServerKey`: VAPID Public Key von `GET /api/push/vapid-public-key` oder Build-Time `import.meta.env.VITE_VAPID_PUBLIC_KEY`.
|
||||
|
||||
### 6.3 UI (Settings)
|
||||
|
||||
**Ort:** `SettingsForm.tsx` (nur für Owner sichtbar, nicht bei `readOnly` / Crew-Logbuch).
|
||||
|
||||
Ablauf beim Einschalten:
|
||||
|
||||
1. `Notification.requestPermission()` — bei `denied` Hinweis + Link zu Browser-Einstellungen.
|
||||
2. `subscribeToPush()` + `syncPrefs(true)`.
|
||||
3. Bei Erfolg: grüner Status „Push aktiv“.
|
||||
|
||||
Beim Ausschalten:
|
||||
|
||||
1. `syncPrefs(false)` + optional `unsubscribeFromPush()` auf diesem Gerät.
|
||||
|
||||
**Hinweis-Banner** wenn `!isPushSupported()` oder iOS & nicht installiert → Verweis auf `PwaInstallPrompt`.
|
||||
|
||||
### 6.4 Deep-Link beim Öffnen
|
||||
|
||||
In `App.tsx` oder Router: beim Start `url` aus `notificationclick` via `clients.matchAll` nicht nötig — SW öffnet direkt.
|
||||
|
||||
Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route) existiert und Auth-Gate passiert.
|
||||
|
||||
### 6.5 Bestehenden SW-Update-Flow
|
||||
|
||||
`usePwaUpdate.ts` bleibt kompatibel mit `injectManifest`, sofern `virtual:pwa-register` weiter registriert wird — vite-plugin-pwa-Doku für `injectManifest` + React beachten.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherheit
|
||||
|
||||
| Risiko | Maßnahme |
|
||||
|--------|----------|
|
||||
| Fremde subscriben mit fremder `userId` | 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
|
||||
```
|
||||
Generated
+155
-1
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user