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,88 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Notification;
import de.cannamanage.domain.enums.NotificationType;
import de.cannamanage.service.repository.NotificationRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Notification service — persists notifications and delivers in real-time via WebSocket.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
private final SimpMessagingTemplate messagingTemplate;
/**
* Send a notification: persist to DB + push via WebSocket.
*/
@Transactional
public Notification sendNotification(UUID userId, NotificationType type, String title, String message, String link) {
var notification = new Notification(userId, type, title, message, link);
notification = notificationRepository.save(notification);
// Push to user's WebSocket queue
messagingTemplate.convertAndSendToUser(
userId.toString(),
"/queue/notifications",
Map.of(
"id", notification.getId(),
"type", notification.getType().name(),
"title", notification.getTitle(),
"message", notification.getMessage(),
"link", notification.getLink() != null ? notification.getLink() : "",
"read", notification.isRead(),
"createdAt", notification.getCreatedAt().toString()
)
);
log.debug("Notification sent to user {}: {} - {}", userId, type, title);
return notification;
}
/**
* Get the last 10 notifications for a user (unread first).
*/
@Transactional(readOnly = true)
public List<Notification> getRecentNotifications(UUID userId) {
return notificationRepository.findTop10ByUserIdOrderByReadAscCreatedAtDesc(userId);
}
/**
* Get unread count for badge display.
*/
@Transactional(readOnly = true)
public long getUnreadCount(UUID userId) {
return notificationRepository.countByUserIdAndReadFalse(userId);
}
/**
* Mark a single notification as read.
*/
@Transactional
public void markAsRead(UUID notificationId) {
notificationRepository.findById(notificationId).ifPresent(n -> {
n.setRead(true);
notificationRepository.save(n);
});
}
/**
* Mark all notifications as read for a user.
*/
@Transactional
public int markAllAsRead(UUID userId) {
return notificationRepository.markAllAsRead(userId);
}
}
@@ -0,0 +1,25 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Notification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
List<Notification> findByUserIdOrderByReadAscCreatedAtDesc(UUID userId);
List<Notification> findTop10ByUserIdOrderByReadAscCreatedAtDesc(UUID userId);
long countByUserIdAndReadFalse(UUID userId);
@Modifying
@Query("UPDATE Notification n SET n.read = true WHERE n.userId = :userId AND n.read = false")
int markAllAsRead(@Param("userId") UUID userId);
}