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
+5
View File
@@ -123,6 +123,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- WebSocket (STOMP + SockJS for notifications) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
<build>
@@ -0,0 +1,34 @@
package de.cannamanage.api.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket configuration — enables STOMP messaging over SockJS.
* Clients connect to /ws, subscribe to /user/queue/notifications for personal notifications.
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// Enable simple in-memory broker for /topic (broadcast) and /queue (user-specific)
config.enableSimpleBroker("/topic", "/queue");
// Prefix for @MessageMapping methods
config.setApplicationDestinationPrefixes("/app");
// User-specific destination prefix
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket endpoint with SockJS fallback
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
@@ -0,0 +1,68 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.Notification;
import de.cannamanage.service.NotificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* REST endpoints for notification management.
*/
@RestController
@RequestMapping("/api/v1/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
/**
* Get current user's notifications (last 10, unread first).
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getNotifications(@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
List<Notification> notifications = notificationService.getRecentNotifications(userId);
long unreadCount = notificationService.getUnreadCount(userId);
var items = notifications.stream().map(n -> Map.of(
"id", (Object) n.getId(),
"type", n.getType().name(),
"title", n.getTitle(),
"message", n.getMessage(),
"link", n.getLink() != null ? n.getLink() : "",
"read", n.isRead(),
"createdAt", n.getCreatedAt().toString()
)).toList();
return ResponseEntity.ok(Map.of(
"notifications", items,
"unreadCount", unreadCount
));
}
/**
* Mark a single notification as read.
*/
@PutMapping("/{id}/read")
public ResponseEntity<Void> markAsRead(@PathVariable UUID id) {
notificationService.markAsRead(id);
return ResponseEntity.noContent().build();
}
/**
* Mark all notifications as read.
*/
@PutMapping("/read-all")
public ResponseEntity<Map<String, Object>> markAllAsRead(@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
int updated = notificationService.markAllAsRead(userId);
return ResponseEntity.ok(Map.of("updated", updated));
}
}
@@ -0,0 +1,15 @@
-- V10: Notifications table for real-time + persistent notification system
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
link VARCHAR(500),
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL
);
CREATE INDEX idx_notifications_user ON notifications(user_id, read, created_at DESC);
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id);