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:
@@ -7,13 +7,18 @@ import org.raddatz.familienarchiv.model.AppUser;
|
|||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.NotificationService;
|
import org.raddatz.familienarchiv.service.NotificationService;
|
||||||
|
import org.raddatz.familienarchiv.service.SseEmitterRegistry;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -23,6 +28,17 @@ public class NotificationController {
|
|||||||
|
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final UserService userService;
|
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")
|
@GetMapping("/api/notifications")
|
||||||
public Page<NotificationDTO> getNotifications(
|
public Page<NotificationDTO> getNotifications(
|
||||||
@@ -34,6 +50,12 @@ public class NotificationController {
|
|||||||
return notificationService.getNotifications(user.getId(), pageable);
|
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")
|
@PostMapping("/api/notifications/read-all")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void markAllRead(Authentication authentication) {
|
public void markAllRead(Authentication authentication) {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.raddatz.familienarchiv.model.NotificationType;
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record NotificationDTO(
|
public record NotificationDTO(
|
||||||
UUID id,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
NotificationType type,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) NotificationType type,
|
||||||
UUID documentId,
|
UUID documentId,
|
||||||
UUID referenceId,
|
UUID referenceId,
|
||||||
UUID annotationId,
|
UUID annotationId,
|
||||||
boolean read,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read,
|
||||||
LocalDateTime createdAt,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
||||||
String actorName
|
String actorName
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public class NotificationService {
|
|||||||
private final NotificationRepository notificationRepository;
|
private final NotificationRepository notificationRepository;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final Optional<JavaMailSender> mailSender;
|
private final Optional<JavaMailSender> mailSender;
|
||||||
|
private final SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
|
||||||
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
||||||
private String mailFrom;
|
private String mailFrom;
|
||||||
@@ -58,7 +59,7 @@ public class NotificationService {
|
|||||||
.annotationId(reply.getAnnotationId())
|
.annotationId(reply.getAnnotationId())
|
||||||
.actorName(reply.getAuthorName())
|
.actorName(reply.getAuthorName())
|
||||||
.build();
|
.build();
|
||||||
notificationRepository.save(notification);
|
saveAndPush(notification);
|
||||||
|
|
||||||
if (recipient.isNotifyOnReply()) {
|
if (recipient.isNotifyOnReply()) {
|
||||||
sendNotificationEmail(recipient, reply, NotificationType.REPLY);
|
sendNotificationEmail(recipient, reply, NotificationType.REPLY);
|
||||||
@@ -84,7 +85,7 @@ public class NotificationService {
|
|||||||
.annotationId(comment.getAnnotationId())
|
.annotationId(comment.getAnnotationId())
|
||||||
.actorName(comment.getAuthorName())
|
.actorName(comment.getAuthorName())
|
||||||
.build();
|
.build();
|
||||||
notificationRepository.save(notification);
|
saveAndPush(notification);
|
||||||
|
|
||||||
if (recipient.isNotifyOnMention()) {
|
if (recipient.isNotifyOnMention()) {
|
||||||
sendNotificationEmail(recipient, comment, NotificationType.MENTION);
|
sendNotificationEmail(recipient, comment, NotificationType.MENTION);
|
||||||
@@ -125,6 +126,11 @@ public class NotificationService {
|
|||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void saveAndPush(Notification notification) {
|
||||||
|
Notification saved = notificationRepository.save(notification);
|
||||||
|
sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved));
|
||||||
|
}
|
||||||
|
|
||||||
private NotificationDTO toDTO(Notification n) {
|
private NotificationDTO toDTO(Notification n) {
|
||||||
return new NotificationDTO(
|
return new NotificationDTO(
|
||||||
n.getId(),
|
n.getId(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ CREATE TABLE notifications (
|
|||||||
type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION'
|
type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION'
|
||||||
document_id UUID,
|
document_id UUID,
|
||||||
reference_id UUID, -- commentId that triggered this notification
|
reference_id UUID, -- commentId that triggered this notification
|
||||||
|
annotation_id UUID,
|
||||||
read BOOLEAN NOT NULL DEFAULT false,
|
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);
|
CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE notifications ADD COLUMN actor_name VARCHAR(255);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE notifications ADD COLUMN annotation_id UUID;
|
|
||||||
@@ -3,11 +3,14 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.NotificationType;
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.NotificationService;
|
import org.raddatz.familienarchiv.service.NotificationService;
|
||||||
|
import org.raddatz.familienarchiv.service.SseEmitterRegistry;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
@@ -26,8 +29,10 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@MockitoBean NotificationService notificationService;
|
@MockitoBean NotificationService notificationService;
|
||||||
@MockitoBean UserService userService;
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean SseEmitterRegistry sseEmitterRegistry;
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
private static final UUID USER_ID = UUID.randomUUID();
|
private static final UUID USER_ID = UUID.randomUUID();
|
||||||
@@ -238,4 +244,63 @@ class NotificationControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.notifyOnReply").value(true));
|
.andExpect(jsonPath("$.notifyOnReply").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/notifications/unread-count ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countUnread_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications/unread-count"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void countUnread_returns200WithCount_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.countUnread(USER_ID)).thenReturn(3L);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications/unread-count"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/notifications/{id}/read — additional cases ───────────────
|
||||||
|
|
||||||
|
// ─── GET /api/notifications/stream ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stream_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications/stream")
|
||||||
|
.accept(TEXT_EVENT_STREAM_VALUE))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void stream_returns200_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(sseEmitterRegistry.register(USER_ID)).thenReturn(new org.springframework.web.servlet.mvc.method.annotation.SseEmitter());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications/stream")
|
||||||
|
.accept(TEXT_EVENT_STREAM_VALUE))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/notifications/{id}/read — additional cases ───────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void markOneRead_returns404_whenNotificationDoesNotExist() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
UUID notifId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
||||||
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -29,6 +30,7 @@ class NotificationServiceTest {
|
|||||||
@Mock NotificationRepository notificationRepository;
|
@Mock NotificationRepository notificationRepository;
|
||||||
@Mock UserService userService;
|
@Mock UserService userService;
|
||||||
@Mock JavaMailSender mailSender;
|
@Mock JavaMailSender mailSender;
|
||||||
|
@Mock SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
|
||||||
NotificationService notificationService;
|
NotificationService notificationService;
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ class NotificationServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender));
|
notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender), sseEmitterRegistry);
|
||||||
|
|
||||||
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
||||||
.firstName("Anna").lastName("Smith").email("a@test.com")
|
.firstName("Anna").lastName("Smith").email("a@test.com")
|
||||||
@@ -140,6 +142,34 @@ class NotificationServiceTest {
|
|||||||
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── SSE push ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_pushesEventToRegistry_forEachRecipient() {
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
|
||||||
|
|
||||||
|
verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class));
|
||||||
|
verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_pushesEventToRegistry_forEachMentionedUser() {
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||||
|
|
||||||
|
verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class));
|
||||||
|
verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── markRead ─────────────────────────────────────────────────────────────
|
// ─── markRead ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
|
||||||
|
class SseEmitterRegistryTest {
|
||||||
|
|
||||||
|
private final SseEmitterRegistry registry = new SseEmitterRegistry();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_returnsEmitter() {
|
||||||
|
SseEmitter emitter = registry.register(UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(emitter).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void send_doesNothing_whenNoEmitterRegistered() {
|
||||||
|
assertThatCode(() -> registry.send(UUID.randomUUID(), "data"))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_replacesExistingEmitter_forSameUser() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
|
||||||
|
SseEmitter first = registry.register(userId);
|
||||||
|
SseEmitter second = registry.register(userId);
|
||||||
|
|
||||||
|
assertThat(first).isNotSameAs(second);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, tick } from 'svelte';
|
||||||
import { detectMention } from '$lib/utils/mention';
|
import { detectMention } from '$lib/utils/mention';
|
||||||
import type { MentionDTO } from '$lib/types';
|
import type { MentionDTO } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
@@ -80,7 +80,7 @@ function scheduleSearch(q: string) {
|
|||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectUser(user: MentionDTO) {
|
async function selectUser(user: MentionDTO) {
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
const displayName = `${user.firstName} ${user.lastName}`;
|
const displayName = `${user.firstName} ${user.lastName}`;
|
||||||
@@ -99,13 +99,12 @@ function selectUser(user: MentionDTO) {
|
|||||||
closePopup();
|
closePopup();
|
||||||
|
|
||||||
// Reposition cursor after the inserted mention
|
// Reposition cursor after the inserted mention
|
||||||
setTimeout(() => {
|
await tick();
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
const pos = mentionStart + replacement.length;
|
const pos = mentionStart + replacement.length;
|
||||||
textarea.selectionStart = pos;
|
textarea.selectionStart = pos;
|
||||||
textarea.selectionEnd = pos;
|
textarea.selectionEnd = pos;
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePopup() {
|
function closePopup() {
|
||||||
@@ -153,7 +152,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAtButtonClick() {
|
async function handleAtButtonClick() {
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
const pos = textarea.selectionStart;
|
const pos = textarea.selectionStart;
|
||||||
const before = value.slice(0, pos);
|
const before = value.slice(0, pos);
|
||||||
@@ -163,22 +162,21 @@ function handleAtButtonClick() {
|
|||||||
const insertion = needsSpace ? ' @' : '@';
|
const insertion = needsSpace ? ' @' : '@';
|
||||||
value = before + insertion + after;
|
value = before + insertion + after;
|
||||||
|
|
||||||
setTimeout(() => {
|
await tick();
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
const newPos = pos + insertion.length;
|
const newPos = pos + insertion.length;
|
||||||
textarea.selectionStart = newPos;
|
textarea.selectionStart = newPos;
|
||||||
textarea.selectionEnd = newPos;
|
textarea.selectionEnd = newPos;
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
|
|
||||||
// Trigger mention detection after inserting @
|
// Trigger mention detection after inserting @
|
||||||
const detected = detectMention(value, newPos);
|
const detected = detectMention(value, newPos);
|
||||||
if (detected !== null) {
|
if (detected !== null) {
|
||||||
mentionStart = newPos - 1;
|
mentionStart = newPos - 1;
|
||||||
query = detected;
|
query = detected;
|
||||||
highlightedIndex = 0;
|
highlightedIndex = 0;
|
||||||
scheduleSearch(detected);
|
scheduleSearch(detected);
|
||||||
}
|
}
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => clearTimeout(debounceTimer));
|
onDestroy(() => clearTimeout(debounceTimer));
|
||||||
@@ -208,10 +206,11 @@ const popupOpen = $derived(query !== null);
|
|||||||
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
|
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as user, i (user.id)}
|
{#each results as user, i (user.id)}
|
||||||
<button
|
<div
|
||||||
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
|
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === highlightedIndex}
|
aria-selected={i === highlightedIndex}
|
||||||
|
tabindex="-1"
|
||||||
onmousedown={(e) => {
|
onmousedown={(e) => {
|
||||||
// Use mousedown to fire before textarea blur
|
// Use mousedown to fire before textarea blur
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -220,7 +219,7 @@ const popupOpen = $derived(query !== null);
|
|||||||
>
|
>
|
||||||
{user.firstName}
|
{user.firstName}
|
||||||
{user.lastName}
|
{user.lastName}
|
||||||
</button>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { PUBLIC_NOTIFICATION_POLL_MS } from '$env/static/public';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
type NotificationItem = {
|
type NotificationItem = {
|
||||||
@@ -16,15 +15,14 @@ type NotificationItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let notifications: NotificationItem[] = $state([]);
|
let notifications: NotificationItem[] = $state([]);
|
||||||
let unreadCount = $derived(notifications.filter((n) => !n.read).length);
|
let unreadCount: number = $state(0);
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
|
|
||||||
// DOM refs managed via attachments
|
// DOM refs managed via attachments
|
||||||
let bellButtonEl: HTMLButtonElement | null = null;
|
let bellButtonEl: HTMLButtonElement | null = null;
|
||||||
let firstFocusableEl: HTMLButtonElement | null = null;
|
let firstFocusableEl: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
const pollMs = Number(PUBLIC_NOTIFICATION_POLL_MS) || 60000;
|
let eventSource: EventSource | null = null;
|
||||||
let intervalId: ReturnType<typeof setInterval>;
|
|
||||||
|
|
||||||
async function fetchNotifications() {
|
async function fetchNotifications() {
|
||||||
try {
|
try {
|
||||||
@@ -38,6 +36,18 @@ async function fetchNotifications() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchUnreadCount() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/unread-count');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
unreadCount = data.count;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch unread count', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleDropdown() {
|
async function toggleDropdown() {
|
||||||
open = !open;
|
open = !open;
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -59,6 +69,7 @@ async function markRead(notification: NotificationItem) {
|
|||||||
try {
|
try {
|
||||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||||
notification.read = true;
|
notification.read = true;
|
||||||
|
unreadCount = Math.max(0, unreadCount - 1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to mark notification as read', e);
|
console.error('Failed to mark notification as read', e);
|
||||||
}
|
}
|
||||||
@@ -76,6 +87,7 @@ async function markAllRead() {
|
|||||||
for (const n of notifications) {
|
for (const n of notifications) {
|
||||||
n.read = true;
|
n.read = true;
|
||||||
}
|
}
|
||||||
|
unreadCount = 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to mark all notifications as read', e);
|
console.error('Failed to mark all notifications as read', e);
|
||||||
}
|
}
|
||||||
@@ -133,11 +145,17 @@ function relativeTime(isoString: string): string {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
intervalId = setInterval(fetchNotifications, pollMs);
|
fetchUnreadCount();
|
||||||
|
eventSource = new EventSource('/api/notifications/stream');
|
||||||
|
eventSource.addEventListener('notification', (e) => {
|
||||||
|
const notification = JSON.parse(e.data) as NotificationItem;
|
||||||
|
notifications = [notification, ...notifications];
|
||||||
|
if (!notification.read) unreadCount += 1;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearInterval(intervalId);
|
eventSource?.close();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -189,6 +207,7 @@ onDestroy(() => {
|
|||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
aria-label={m.notification_bell_label()}
|
aria-label={m.notification_bell_label()}
|
||||||
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ let {
|
|||||||
groups: { id: string; name: string }[];
|
groups: { id: string; name: string }[];
|
||||||
selectedGroupIds?: string[];
|
selectedGroupIds?: string[];
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let selected = $derived([...selectedGroupIds]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
@@ -15,7 +17,7 @@ let {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="groupIds"
|
name="groupIds"
|
||||||
value={group.id}
|
value={group.id}
|
||||||
checked={selectedGroupIds.includes(group.id)}
|
bind:group={selected}
|
||||||
class="rounded border-line text-ink focus:ring-accent"
|
class="rounded border-line text-ink focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
{group.name}
|
{group.name}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
return {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
||||||
canAnnotate: groups.some((g) => g.permissions.includes('ANNOTATE_ALL'))
|
canAnnotate: groups.some(
|
||||||
|
(g) => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')
|
||||||
|
)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user