feat(#71,#72,#73): SSE push notifications, mention chips, deep-link fixes

- Add SseEmitterRegistry (ConcurrentHashMap, one emitter per user)
- Add GET /api/notifications/stream SSE endpoint and unread-count endpoint
- Push SSE event on every notifyReply / notifyMentions via saveAndPush()
- Collapse V18/V19 migrations into V16 (actor_name + annotation_id upfront)
- Add @Schema(requiredMode=REQUIRED) to NotificationDTO required fields
- Switch NotificationBell from polling to EventSource; seed unread count on open
- Fix MentionEditor: replace setTimeout with await tick(); div role=option
- Add aria-modal=true to NotificationBell dialog
- Tests: SseEmitterRegistryTest (3), NotificationServiceTest (+2), NotificationControllerTest (+5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-28 15:41:35 +01:00
parent 9900d0b54b
commit f568c0aeb7
14 changed files with 264 additions and 45 deletions

View File

@@ -7,13 +7,18 @@ import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.NotificationService;
import org.raddatz.familienarchiv.service.SseEmitterRegistry;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.UUID;
@@ -23,6 +28,17 @@ public class NotificationController {
private final NotificationService notificationService;
private final UserService userService;
private final SseEmitterRegistry sseEmitterRegistry;
// These endpoints are intentionally open to any authenticated user —
// they return and mutate only the current user's own notifications, scoped
// by the resolved user identity. No additional permission check is required.
@GetMapping(value = "/api/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(Authentication authentication) {
AppUser user = resolveUser(authentication);
return sseEmitterRegistry.register(user.getId());
}
@GetMapping("/api/notifications")
public Page<NotificationDTO> getNotifications(
@@ -34,6 +50,12 @@ public class NotificationController {
return notificationService.getNotifications(user.getId(), pageable);
}
@GetMapping("/api/notifications/unread-count")
public Map<String, Long> countUnread(Authentication authentication) {
AppUser user = resolveUser(authentication);
return Map.of("count", notificationService.countUnread(user.getId()));
}
@PostMapping("/api/notifications/read-all")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void markAllRead(Authentication authentication) {

View File

@@ -1,17 +1,18 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.model.NotificationType;
import java.time.LocalDateTime;
import java.util.UUID;
public record NotificationDTO(
UUID id,
NotificationType type,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) NotificationType type,
UUID documentId,
UUID referenceId,
UUID annotationId,
boolean read,
LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
String actorName
) {}

View File

@@ -33,6 +33,7 @@ public class NotificationService {
private final NotificationRepository notificationRepository;
private final UserService userService;
private final Optional<JavaMailSender> mailSender;
private final SseEmitterRegistry sseEmitterRegistry;
@Value("${app.mail.from:noreply@familienarchiv.local}")
private String mailFrom;
@@ -58,7 +59,7 @@ public class NotificationService {
.annotationId(reply.getAnnotationId())
.actorName(reply.getAuthorName())
.build();
notificationRepository.save(notification);
saveAndPush(notification);
if (recipient.isNotifyOnReply()) {
sendNotificationEmail(recipient, reply, NotificationType.REPLY);
@@ -84,7 +85,7 @@ public class NotificationService {
.annotationId(comment.getAnnotationId())
.actorName(comment.getAuthorName())
.build();
notificationRepository.save(notification);
saveAndPush(notification);
if (recipient.isNotifyOnMention()) {
sendNotificationEmail(recipient, comment, NotificationType.MENTION);
@@ -125,6 +126,11 @@ public class NotificationService {
// ─── private helpers ──────────────────────────────────────────────────────
private void saveAndPush(Notification notification) {
Notification saved = notificationRepository.save(notification);
sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved));
}
private NotificationDTO toDTO(Notification n) {
return new NotificationDTO(
n.getId(),

View File

@@ -0,0 +1,36 @@
package org.raddatz.familienarchiv.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
public class SseEmitterRegistry {
private final ConcurrentHashMap<UUID, SseEmitter> emitters = new ConcurrentHashMap<>();
public SseEmitter register(UUID userId) {
SseEmitter emitter = new SseEmitter(0L); // 0 = no timeout; EventSource reconnects automatically
emitters.put(userId, emitter);
emitter.onCompletion(() -> emitters.remove(userId, emitter));
emitter.onTimeout(() -> emitters.remove(userId, emitter));
emitter.onError(e -> emitters.remove(userId, emitter));
return emitter;
}
public void send(UUID userId, Object data) {
SseEmitter emitter = emitters.get(userId);
if (emitter == null) return;
try {
emitter.send(SseEmitter.event().name("notification").data(data));
} catch (IOException e) {
log.debug("SSE send failed for user {} — removing emitter", userId);
emitters.remove(userId, emitter);
}
}
}

View File

@@ -9,8 +9,10 @@ CREATE TABLE notifications (
type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION'
document_id UUID,
reference_id UUID, -- commentId that triggered this notification
annotation_id UUID,
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT now()
created_at TIMESTAMP NOT NULL DEFAULT now(),
actor_name VARCHAR(255)
);
CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);

View File

@@ -1 +0,0 @@
ALTER TABLE notifications ADD COLUMN actor_name VARCHAR(255);

View File

@@ -1 +0,0 @@
ALTER TABLE notifications ADD COLUMN annotation_id UUID;