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
@@ -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 }),
}
)
}