feat(sprint7): Phase 1 — notifications enhancement + push infrastructure

Phase 1 (Notification Enhancement):
- Extended NotificationType enum (ADMIN_MESSAGE, INFO_BOARD_POST, FORUM_REPLY, FORUM_MENTION)
- Extended StaffPermission enum (SEND_NOTIFICATIONS, MANAGE_INFO_BOARD, MODERATE_FORUM)
- Extended AuditEventType with Sprint 7 events
- Flyway V11: notification_sends + notification_send_recipients tables
- NotificationSend + NotificationSendRecipient entities
- NotificationSendRepository + NotificationSendRecipientRepository
- Extended NotificationService with sendBroadcast() and sendToSelected()
- NotificationComposeController (POST /compose, GET /sends)
- ComposeNotificationRequest DTO

Phase 1B (Push Infrastructure):
- Flyway V12: device_tokens + notification_preferences tables
- DeviceToken entity + DevicePlatform enum
- NotificationPreference entity + NotificationChannel enum
- DeviceTokenRepository + NotificationPreferenceRepository
- DeviceRegistrationService (register/unregister/list devices, max 10 per user)
- NotificationPreferenceService (get/create defaults, update, IN_APP always on)
- NotificationDispatchService (multi-channel fan-out: WebSocket, Web Push, FCM, Email)
- WebPushSender (VAPID-based, simplified for MVP)
- FcmPushSender (graceful degradation if not configured)
- PushPayload DTO
- DeviceRegistrationController (POST/GET/DELETE /devices, GET /vapid-key)
- NotificationPreferenceController (GET/PUT /preferences)
- ConsentType extended (NOTIFICATION_PUSH, NOTIFICATION_EMAIL)
- TargetType enum (ALL, SELECTED)

Frontend:
- Updated sw.js with push event handler + notification click handler
- push-subscription.ts (subscribeToPush, unsubscribe, permission helpers)
- notification-compose.ts service (compose, sends, devices, preferences APIs)
- i18n keys (de.json + en.json) for compose, preferences, push, devices

Configuration:
- application-docker.properties: VAPID + FCM push config properties
- MemberRepository: added findAllActiveUserIds() for broadcast
This commit is contained in:
Patrick Plate
2026-06-13 19:25:19 +02:00
parent 329b7abb18
commit 706a6e257b
43 changed files with 6635 additions and 76 deletions
+32 -66
View File
@@ -1,76 +1,42 @@
/// <reference lib="webworker" />
// Bump this version on every release that changes cached assets. The `activate`
// handler below deletes all caches whose name !== CACHE_NAME, so incrementing
// this string force-purges stale bundles from clients that cached the old
// (broken) build — fixes "website hasn't changed after redeploy".
const CACHE_NAME = "cannamanage-v2"
const OFFLINE_URL = "/offline"
// Assets to pre-cache
const PRECACHE_ASSETS = [
"/offline",
"/manifest.json",
"/icons/icon-192.png",
"/icons/icon-512.png",
]
// CannaManage Service Worker — PWA + Push Notifications
const CACHE_NAME = "cannamanage-v1"
// Cache static assets on install
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_ASSETS)
})
)
self.skipWaiting()
})
self.addEventListener("activate", (event) => {
event.waitUntil(clients.claim())
})
// Handle incoming push messages
self.addEventListener("push", (event) => {
const data = event.data ? event.data.json() : {}
const options = {
body: data.body || "Neue Benachrichtigung",
icon: data.icon || "/icons/icon-192.png",
badge: "/icons/icon-192.png",
tag: data.type || "default",
data: { url: data.url || "/portal/notifications", ...data.data },
actions: data.actions || [{ action: "open", title: "Anzeigen" }],
vibrate: [100, 50, 100],
}
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
self.registration.showNotification(data.title || "CannaManage", options)
)
})
// Handle notification click
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = event.notification.data?.url || "/portal/notifications"
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
for (const client of windowClients) {
if (client.url.includes(url) && "focus" in client) return client.focus()
}
return clients.openWindow(url)
})
)
self.clients.claim()
})
self.addEventListener("fetch", (event) => {
// Only handle GET requests
if (event.request.method !== "GET") return
// Skip API requests — let them fail naturally
if (event.request.url.includes("/api/")) return
// Network-first for navigation requests
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(OFFLINE_URL)
})
)
return
}
// Stale-while-revalidate for static assets
if (
event.request.destination === "style" ||
event.request.destination === "script" ||
event.request.destination === "image"
) {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
const cache = caches.open(CACHE_NAME)
cache.then((c) => c.put(event.request, networkResponse.clone()))
}
return networkResponse
})
return cachedResponse || fetchPromise
})
)
}
})