feat(sprint-6): Phase 6 — Notifications (WebSocket) + PWA
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- WebSocket: Spring STOMP + SockJS, NotificationService, persistent notifications table
- NotificationController: GET/PUT endpoints for notification management
- Frontend: notification bell with unread badge, dropdown panel, real-time via STOMP
- PWA: manifest.json, service worker (manual sw.js), offline page, install prompt
- PWA icons (192+512), dark theme colors, standalone display
- Full i18n (de/en) for notifications and PWA
- Flyway V10 migration for notifications table
- spring-boot-starter-websocket dependency added
This commit is contained in:
Patrick Plate
2026-06-12 23:02:44 +02:00
parent 076fd6f9b3
commit 599514c0db
39 changed files with 6684 additions and 3217 deletions
@@ -0,0 +1,102 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
export interface WsNotification {
id: string
type: string
title: string
message: string
link: string
read: boolean
createdAt: string
}
interface UseNotificationsOptions {
userId?: string
enabled?: boolean
}
/**
* WebSocket hook for real-time notifications via STOMP over SockJS.
* Connects to /ws, subscribes to /user/queue/notifications.
* Falls back gracefully if WebSocket is unavailable.
*/
export function useNotifications({
userId,
enabled = true,
}: UseNotificationsOptions) {
const [connected, setConnected] = useState(false)
const [lastNotification, setLastNotification] =
useState<WsNotification | null>(null)
const stompClientRef = useRef<unknown>(null)
const queryClient = useQueryClient()
const connect = useCallback(async () => {
if (!userId || !enabled) return
try {
// Dynamic import to avoid SSR issues
const { Client } = await import("@stomp/stompjs")
const SockJS = (await import("sockjs-client")).default
const backendUrl =
process.env.NEXT_PUBLIC_WS_URL || "http://localhost:8080"
const client = new Client({
webSocketFactory: () => new SockJS(`${backendUrl}/ws`),
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
onConnect: () => {
setConnected(true)
client.subscribe(`/user/${userId}/queue/notifications`, (message) => {
try {
const notification: WsNotification = JSON.parse(message.body)
setLastNotification(notification)
// Invalidate notifications query to refresh badge count
queryClient.invalidateQueries({ queryKey: ["notifications"] })
} catch {
console.error("Failed to parse notification message")
}
})
},
onDisconnect: () => {
setConnected(false)
},
onStompError: (frame) => {
console.error("STOMP error:", frame.headers["message"])
setConnected(false)
},
})
client.activate()
stompClientRef.current = client
} catch {
// WebSocket libraries not available (SSR or missing deps) — fail silently
console.warn("WebSocket connection unavailable, falling back to polling")
}
}, [userId, enabled, queryClient])
const disconnect = useCallback(() => {
const client = stompClientRef.current as { deactivate?: () => void } | null
if (client?.deactivate) {
client.deactivate()
}
stompClientRef.current = null
setConnected(false)
}, [])
useEffect(() => {
connect()
return () => disconnect()
}, [connect, disconnect])
return {
connected,
lastNotification,
disconnect,
}
}