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:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user