Files
cannamanage/cannamanage-service/src/main/java/de/cannamanage/service/NotificationService.java
T
Patrick Plate 706a6e257b 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
2026-06-13 19:25:19 +02:00

168 lines
6.3 KiB
Java

package de.cannamanage.service;
import de.cannamanage.domain.entity.Notification;
import de.cannamanage.domain.entity.NotificationSend;
import de.cannamanage.domain.entity.NotificationSendRecipient;
import de.cannamanage.domain.enums.NotificationType;
import de.cannamanage.domain.enums.TargetType;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.NotificationRepository;
import de.cannamanage.service.repository.NotificationSendRepository;
import de.cannamanage.service.repository.NotificationSendRecipientRepository;
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.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Notification service — persists notifications and delivers in real-time via WebSocket.
* Extended in Sprint 7 with broadcast and targeted send capabilities.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
private final NotificationSendRepository notificationSendRepository;
private final NotificationSendRecipientRepository sendRecipientRepository;
private final MemberRepository memberRepository;
private final SimpMessagingTemplate messagingTemplate;
private final NotificationDispatchService dispatchService;
/**
* 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
pushToWebSocket(notification);
// Dispatch to additional channels (Web Push, FCM, Email) asynchronously
dispatchService.dispatch(notification, userId);
log.debug("Notification sent to user {}: {} - {}", userId, type, title);
return notification;
}
/**
* Send a broadcast notification to ALL active members in the current tenant.
*/
@Transactional
public NotificationSend sendBroadcast(String title, String message, String link, UUID authorId) {
// Get all active member user IDs in the current tenant
var memberUserIds = memberRepository.findAllActiveUserIds();
return sendToRecipients(title, message, link, authorId, memberUserIds, TargetType.ALL);
}
/**
* Send a targeted notification to selected members.
*/
@Transactional
public NotificationSend sendToSelected(String title, String message, String link, UUID authorId, List<UUID> recipientIds) {
return sendToRecipients(title, message, link, authorId, recipientIds, TargetType.SELECTED);
}
private NotificationSend sendToRecipients(String title, String message, String link, UUID authorId,
List<UUID> recipientIds, TargetType targetType) {
// Create the send record
var send = NotificationSend.builder()
.title(title)
.message(message)
.link(link)
.authorId(authorId)
.targetType(targetType)
.targetCount(recipientIds.size())
.readCount(0)
.sentAt(Instant.now())
.build();
send = notificationSendRepository.save(send);
// Create individual notifications for each recipient
var recipients = new ArrayList<NotificationSendRecipient>();
for (UUID userId : recipientIds) {
var notification = new Notification(userId, NotificationType.ADMIN_MESSAGE, title, message, link);
notification = notificationRepository.save(notification);
recipients.add(NotificationSendRecipient.builder()
.sendId(send.getId())
.userId(userId)
.notificationId(notification.getId())
.build());
// Push via WebSocket
pushToWebSocket(notification);
// Dispatch to push channels asynchronously
dispatchService.dispatch(notification, userId);
}
sendRecipientRepository.saveAll(recipients);
log.info("Broadcast sent: {} recipients, type={}, title='{}'", recipientIds.size(), targetType, title);
return send;
}
private void pushToWebSocket(Notification notification) {
messagingTemplate.convertAndSendToUser(
notification.getUserId().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()
)
);
}
/**
* 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);
}
}