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
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

+24
View File
@@ -0,0 +1,24 @@
{
"name": "CannaManage",
"short_name": "CannaManage",
"description": "Cannabis-Club Verwaltung",
"start_url": "/",
"display": "standalone",
"background_color": "#0D1117",
"theme_color": "#2ECC71",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+72
View File
@@ -0,0 +1,72 @@
/// <reference lib="webworker" />
const CACHE_NAME = "cannamanage-v1"
const OFFLINE_URL = "/offline"
// Assets to pre-cache
const PRECACHE_ASSETS = [
"/offline",
"/manifest.json",
"/icons/icon-192.png",
"/icons/icon-512.png",
]
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(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
})
)
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
})
)
}
})