feat(tickets): Web-Push-Benachrichtigungen (PWA)

- Die Züchterin kann auf der Tickets-Seite Push-Benachrichtigungen aktivieren
  (🔔-Schalter). Service Worker ist BEWUSST push-only (kein fetch/Caching), damit das
  Laden der App nie beeinträchtigt wird.
- Backend: WebPush-Bibliothek + VAPID; PushNotifier sendet an alle Abos, räumt veraltete
  (404/410) auf. Endpoints: GET /push/vapid-public-key, POST /push/subscribe|unsubscribe.
- Ausgelöst über ein notify-Flag auf PUT /feedback: NUR die KI setzt es (z. B. neue
  Rückfrage / Ticket gelöst) → keine Selbst-Pushes durch Aktionen der Züchterin. Text wird
  aus dem Status abgeleitet, Klick öffnet das Ticket (?focus=).
- Schalter/Logik blenden sich aus, wenn das Gerät kein Push kann oder der Server keine
  VAPID-Schlüssel hat (z. B. e2e-Mock).

Migration WebPushSubscriptions. VAPID-Schlüssel in appsettings.json (lokale Single-User-App
im Heimnetz — bewusste, vertretbare Vereinfachung). Tests: 264 Backend grün (+Push-Endpoints),
e2e Tickets Desktop grün, vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:32:26 +02:00
parent 750619d3d7
commit f7a1a7724b
19 changed files with 2405 additions and 3 deletions

View File

@@ -447,6 +447,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
}
return json(route, 405)
}
// WEB-PUSH: im Mock deaktiviert (keine VAPID-Schlüssel) → PushToggle blendet sich aus.
if (path === '/push/vapid-public-key' && method === 'GET') {
return json(route, 200, { enabled: false, publicKey: null })
}
if ((path === '/push/subscribe' || path === '/push/unsubscribe') && method === 'POST') {
return json(route, 200, {})
}
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
if (attUpload && method === 'POST') {

View File

@@ -0,0 +1,49 @@
/*
* Service Worker — NUR für Web-Push-Benachrichtigungen.
* BEWUSST OHNE fetch-Handler/Caching, damit das Laden der App niemals beeinflusst wird.
*/
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
self.addEventListener('push', (event) => {
let data = {}
try {
data = event.data ? event.data.json() : {}
} catch {
data = {}
}
const title = data.title || 'Zucht der kleinen Chaoten'
const body = data.body || ''
const url = data.url || '/hilfe/tickets'
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { url },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = (event.notification.data && event.notification.data.url) || '/hilfe/tickets'
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ('focus' in client) {
if ('navigate' in client) {
try {
client.navigate(url)
} catch {
/* ignorieren */
}
}
return client.focus()
}
}
if (self.clients.openWindow) return self.clients.openWindow(url)
return undefined
}),
)
})

View File

@@ -0,0 +1,64 @@
/**
* Schalter „Benachrichtigungen aktivieren" (Web-Push / PWA).
* Rendert nichts, wenn Push nicht unterstützt wird ODER der Server keine VAPID-Schlüssel hat
* (z. B. im e2e-Mock) — so bleibt die UI dort unverändert.
*/
import { useEffect, useState } from 'react'
import { de } from '../strings/de'
import { useToast } from './toast'
import { disablePush, enablePush, fetchPushConfig, isPushSupported, isSubscribed } from '../push'
export default function PushToggle() {
const t = de.feedback.tickets
const toast = useToast()
const [available, setAvailable] = useState(false)
const [subscribed, setSubscribed] = useState(false)
const [busy, setBusy] = useState(false)
useEffect(() => {
let cancelled = false
;(async () => {
if (!isPushSupported()) return
const cfg = await fetchPushConfig()
if (cancelled || !cfg.enabled) return
setAvailable(true)
setSubscribed(await isSubscribed())
})()
return () => {
cancelled = true
}
}, [])
if (!available) return null
async function toggle() {
setBusy(true)
try {
if (subscribed) {
await disablePush()
setSubscribed(false)
toast.success(t.pushDisabledToast)
} else {
const res = await enablePush()
if (res === 'enabled') {
setSubscribed(true)
toast.success(t.pushEnabledToast)
} else if (res === 'denied') {
toast.error(t.pushDenied)
} else {
toast.error(t.pushUnavailable)
}
}
} catch {
toast.error(t.pushUnavailable)
} finally {
setBusy(false)
}
}
return (
<button type="button" className="btn tickets-push-toggle" onClick={toggle} disabled={busy}>
{subscribed ? `🔕 ${t.pushDisable}` : `🔔 ${t.pushEnable}`}
</button>
)
}

View File

@@ -30,6 +30,7 @@ import { getGerbil } from '../api/gerbils'
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
import { useApi } from '../hooks/useApi'
import { useToast } from '../components/toast'
import PushToggle from '../components/PushToggle'
import './tickets.css'
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
@@ -568,6 +569,7 @@ export default function TicketsPage() {
</nav>
<h2>{t.title}</h2>
<p className="tickets-subtitle">{t.subtitle}</p>
<PushToggle />
{tickets.error && <p className="tickets-error">{t.loadError}</p>}

View File

@@ -0,0 +1,87 @@
/**
* WEB-PUSH (PWA-Benachrichtigungen) — Client-Seite.
* Registriert den Service Worker (push-only) und verwaltet das Abo gegen /push/*.
*/
import { API_BASE_URL } from './api/client'
export function isPushSupported(): boolean {
return (
typeof navigator !== 'undefined' &&
'serviceWorker' in navigator &&
typeof window !== 'undefined' &&
'PushManager' in window &&
'Notification' in window
)
}
/** Server-Status: ist Push konfiguriert + welcher VAPID-Public-Key. */
export async function fetchPushConfig(): Promise<{ enabled: boolean; publicKey: string | null }> {
try {
const r = await fetch(`${API_BASE_URL}/push/vapid-public-key`)
if (!r.ok) return { enabled: false, publicKey: null }
return await r.json()
} catch {
return { enabled: false, publicKey: null }
}
}
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
const arr = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
return arr
}
export async function isSubscribed(): Promise<boolean> {
if (!isPushSupported()) return false
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
return !!sub
}
export type EnableResult = 'enabled' | 'denied' | 'unavailable'
export async function enablePush(): Promise<EnableResult> {
if (!isPushSupported()) return 'unavailable'
const cfg = await fetchPushConfig()
if (!cfg.enabled || !cfg.publicKey) return 'unavailable'
const permission = await Notification.requestPermission()
if (permission !== 'granted') return 'denied'
const reg = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(cfg.publicKey),
})
const json = sub.toJSON()
await fetch(`${API_BASE_URL}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: sub.endpoint,
p256dh: json.keys?.p256dh ?? '',
auth: json.keys?.auth ?? '',
}),
})
return 'enabled'
}
export async function disablePush(): Promise<void> {
if (!isPushSupported()) return
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
if (!sub) return
try {
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
})
} catch {
/* trotzdem lokal abmelden */
}
await sub.unsubscribe()
}

View File

@@ -1300,6 +1300,13 @@ export const de = {
attachmentAdded: 'Anhang hinzugefügt.',
attachmentRemoved: 'Anhang entfernt.',
attachmentError: 'Anhang konnte nicht verarbeitet werden.',
/** Push-Benachrichtigungen (PWA). */
pushEnable: 'Benachrichtigungen aktivieren',
pushDisable: 'Benachrichtigungen aus',
pushEnabledToast: 'Benachrichtigungen aktiviert — du wirst informiert, sobald ich antworte.',
pushDisabledToast: 'Benachrichtigungen deaktiviert.',
pushDenied: 'Benachrichtigungen wurden im Browser blockiert. Bitte in den Browser-Einstellungen erlauben.',
pushUnavailable: 'Benachrichtigungen sind auf diesem Gerät nicht verfügbar.',
/** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */