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:
@@ -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