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
+46 -1
View File
@@ -499,7 +499,52 @@
"QUOTA_WARNING": "Kontingent-Warnung",
"BATCH_RECALLED": "Chargen-Rückruf",
"DISTRIBUTION_RECORDED": "Ausgabe erfasst",
"SUBSCRIPTION_EXPIRING": "Abo läuft bald ab"
"SUBSCRIPTION_EXPIRING": "Abo läuft bald ab",
"ADMIN_MESSAGE": "Vereins-Nachricht",
"INFO_BOARD_POST": "Neuer Aushang",
"FORUM_REPLY": "Forum-Antwort",
"FORUM_MENTION": "Forum-Erwähnung"
},
"compose": {
"title": "Benachrichtigung verfassen",
"titleField": "Titel",
"messageField": "Nachricht",
"linkField": "Link (optional)",
"targetAll": "Alle Mitglieder",
"targetSelected": "Ausgewählte Mitglieder",
"selectMembers": "Mitglieder auswählen",
"send": "Senden",
"sending": "Wird gesendet...",
"sent": "Benachrichtigung gesendet an {count} Mitglieder",
"history": "Verlauf",
"sentAt": "Gesendet am",
"recipients": "Empfänger",
"readCount": "Gelesen"
},
"preferences": {
"title": "Benachrichtigungs-Einstellungen",
"inApp": "In-App (immer aktiv)",
"email": "E-Mail",
"webPush": "Browser Push-Benachrichtigungen",
"mobilePush": "Mobile Push-Benachrichtigungen",
"enabled": "Aktiviert",
"disabled": "Deaktiviert"
},
"push": {
"promptTitle": "Push-Benachrichtigungen aktivieren?",
"promptBody": "Erhalte Benachrichtigungen über Ausgaben, Events und Vereins-News direkt auf dein Gerät.",
"accept": "Aktivieren",
"decline": "Nicht jetzt",
"granted": "Push-Benachrichtigungen aktiviert",
"denied": "Push-Benachrichtigungen abgelehnt"
},
"devices": {
"title": "Registrierte Geräte",
"noDevices": "Keine Geräte registriert",
"remove": "Entfernen",
"web": "Web-Browser",
"ios": "iPhone/iPad",
"android": "Android-Gerät"
}
},
"pwa": {
+46 -1
View File
@@ -499,7 +499,52 @@
"QUOTA_WARNING": "Quota Warning",
"BATCH_RECALLED": "Batch Recalled",
"DISTRIBUTION_RECORDED": "Distribution Recorded",
"SUBSCRIPTION_EXPIRING": "Subscription Expiring"
"SUBSCRIPTION_EXPIRING": "Subscription Expiring",
"ADMIN_MESSAGE": "Club Message",
"INFO_BOARD_POST": "New Announcement",
"FORUM_REPLY": "Forum Reply",
"FORUM_MENTION": "Forum Mention"
},
"compose": {
"title": "Compose Notification",
"titleField": "Title",
"messageField": "Message",
"linkField": "Link (optional)",
"targetAll": "All Members",
"targetSelected": "Selected Members",
"selectMembers": "Select members",
"send": "Send",
"sending": "Sending...",
"sent": "Notification sent to {count} members",
"history": "History",
"sentAt": "Sent at",
"recipients": "Recipients",
"readCount": "Read"
},
"preferences": {
"title": "Notification Settings",
"inApp": "In-App (always active)",
"email": "Email",
"webPush": "Browser Push Notifications",
"mobilePush": "Mobile Push Notifications",
"enabled": "Enabled",
"disabled": "Disabled"
},
"push": {
"promptTitle": "Enable Push Notifications?",
"promptBody": "Get notified about distributions, events, and club news directly on your device.",
"accept": "Enable",
"decline": "Not now",
"granted": "Push notifications enabled",
"denied": "Push notifications denied"
},
"devices": {
"title": "Registered Devices",
"noDevices": "No devices registered",
"remove": "Remove",
"web": "Web Browser",
"ios": "iPhone/iPad",
"android": "Android Device"
}
},
"pwa": {
+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
})
)
}
})
@@ -0,0 +1,99 @@
import { getVapidKey, registerDevice } from "@/services/notification-compose"
/**
* Convert a base64 URL-safe string to a Uint8Array for VAPID key usage.
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
/**
* Subscribe the current browser to Web Push notifications.
* Returns the PushSubscription on success, null on failure/denial.
*/
export async function subscribeToPush(): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.warn("Push notifications not supported in this browser")
return null
}
try {
// Get VAPID public key from backend
const { publicKey, configured } = await getVapidKey()
if (configured !== "true" || !publicKey) {
console.warn("Web Push not configured on server")
return null
}
// Wait for service worker to be ready
const registration = await navigator.serviceWorker.ready
// Request permission
const permission = await Notification.requestPermission()
if (permission !== "granted") {
console.info("Push notification permission denied")
return null
}
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
// Send subscription to backend
await registerDevice({
platform: "WEB",
token: JSON.stringify(subscription),
deviceName: navigator.userAgent.includes("Mobile")
? "Mobile Browser"
: "Desktop Browser",
})
return subscription
} catch (error) {
console.error("Failed to subscribe to push notifications:", error)
return null
}
}
/**
* Check if the user has already granted push permission.
*/
export function isPushPermissionGranted(): boolean {
return "Notification" in window && Notification.permission === "granted"
}
/**
* Check if push notifications are supported in this browser.
*/
export function isPushSupported(): boolean {
return "serviceWorker" in navigator && "PushManager" in window
}
/**
* Get the current push subscription (if any).
*/
export async function getCurrentSubscription(): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator)) return null
const registration = await navigator.serviceWorker.ready
return registration.pushManager.getSubscription()
}
/**
* Unsubscribe from push notifications.
*/
export async function unsubscribeFromPush(): Promise<boolean> {
const subscription = await getCurrentSubscription()
if (subscription) {
return subscription.unsubscribe()
}
return false
}
@@ -0,0 +1,118 @@
import { apiClient } from "@/lib/api-client"
export interface NotificationSend {
id: string
title: string
targetType: string
targetCount: number
readCount: number
sentAt: string
}
export interface ComposeNotificationRequest {
title: string
message: string
link?: string
targetType: "ALL" | "SELECTED"
recipientIds?: string[]
}
export interface ComposeNotificationResponse {
id: string
targetType: string
targetCount: number
sentAt: string
}
export interface NotificationSendsResponse {
sends: NotificationSend[]
totalElements: number
totalPages: number
}
export async function composeNotification(
request: ComposeNotificationRequest
): Promise<ComposeNotificationResponse> {
return apiClient<ComposeNotificationResponse>("/notifications/compose", {
method: "POST",
body: JSON.stringify(request),
})
}
export async function getNotificationSends(
page = 0,
size = 20
): Promise<NotificationSendsResponse> {
return apiClient<NotificationSendsResponse>(
`/notifications/sends?page=${page}&size=${size}`
)
}
// Device registration
export interface DeviceTokenResponse {
id: string
platform: string
deviceName: string
lastUsedAt: string
createdAt: string
}
export interface RegisterDeviceRequest {
platform: "WEB" | "IOS" | "ANDROID"
token: string
deviceName?: string
}
export async function registerDevice(
request: RegisterDeviceRequest
): Promise<DeviceTokenResponse> {
return apiClient<DeviceTokenResponse>("/notifications/devices", {
method: "POST",
body: JSON.stringify(request),
})
}
export async function getDevices(): Promise<{ devices: DeviceTokenResponse[] }> {
return apiClient<{ devices: DeviceTokenResponse[] }>("/notifications/devices")
}
export async function unregisterDevice(id: string): Promise<void> {
await apiClient<void>(`/notifications/devices/${id}`, { method: "DELETE" })
}
export async function getVapidKey(): Promise<{
publicKey: string
configured: string
}> {
return apiClient<{ publicKey: string; configured: string }>(
"/notifications/devices/vapid-key"
)
}
// Notification preferences
export interface NotificationPreferences {
IN_APP: boolean
EMAIL: boolean
WEB_PUSH: boolean
MOBILE_PUSH: boolean
}
export async function getNotificationPreferences(): Promise<{
preferences: NotificationPreferences
}> {
return apiClient<{ preferences: NotificationPreferences }>(
"/notifications/preferences"
)
}
export async function updateNotificationPreferences(
preferences: Partial<NotificationPreferences>
): Promise<{ preferences: NotificationPreferences }> {
return apiClient<{ preferences: NotificationPreferences }>(
"/notifications/preferences",
{
method: "PUT",
body: JSON.stringify({ preferences }),
}
)
}