feat(#153): notification history page (/notifications) #155
@@ -34,6 +34,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -32,6 +33,14 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ConstraintViolationException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
|
||||||
|
String message = ex.getConstraintViolations().stream()
|
||||||
|
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||||
String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'";
|
String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'";
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
@@ -16,6 +19,7 @@ import org.springframework.data.domain.Sort;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
@@ -25,6 +29,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Validated
|
||||||
public class NotificationController {
|
public class NotificationController {
|
||||||
|
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
@@ -44,9 +49,9 @@ public class NotificationController {
|
|||||||
@GetMapping("/api/notifications")
|
@GetMapping("/api/notifications")
|
||||||
public Page<NotificationDTO> getNotifications(
|
public Page<NotificationDTO> getNotifications(
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "10") int size,
|
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size,
|
||||||
@RequestParam(required = false) NotificationType type,
|
@Parameter(description = "Filter by notification type") @RequestParam(required = false) NotificationType type,
|
||||||
@RequestParam(required = false) Boolean read,
|
@Parameter(description = "Filter by read status") @RequestParam(required = false) Boolean read,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser user = resolveUser(authentication);
|
AppUser user = resolveUser(authentication);
|
||||||
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ public record NotificationDTO(
|
|||||||
UUID annotationId,
|
UUID annotationId,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
||||||
String actorName
|
String actorName,
|
||||||
|
String documentTitle
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import org.springframework.data.repository.query.Param;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -44,6 +46,9 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|
||||||
|
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
|
||||||
|
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);
|
||||||
|
|
||||||
long countByMetadataCompleteFalse();
|
long countByMetadataCompleteFalse();
|
||||||
|
|
||||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
|
|||||||
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||||
UUID recipientId, NotificationType type, Pageable pageable);
|
UUID recipientId, NotificationType type, Pageable pageable);
|
||||||
|
|
||||||
|
Page<Notification> findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
UUID recipientId, Pageable pageable);
|
||||||
|
|
||||||
long countByRecipientIdAndReadFalse(UUID recipientId);
|
long countByRecipientIdAndReadFalse(UUID recipientId);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -46,6 +49,15 @@ public class DocumentService {
|
|||||||
|
|
||||||
public record StoreResult(Document document, boolean isNew) {}
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
|
public Map<UUID, String> findTitlesByIds(Collection<UUID> ids) {
|
||||||
|
if (ids.isEmpty()) return Map.of();
|
||||||
|
Map<UUID, String> titles = new HashMap<>();
|
||||||
|
for (Object[] row : documentRepository.findIdAndTitleByIdIn(ids)) {
|
||||||
|
titles.put((UUID) row[0], (String) row[1]);
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Datei hoch.
|
* Lädt eine Datei hoch.
|
||||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Propagation;
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -32,6 +35,7 @@ public class NotificationService {
|
|||||||
|
|
||||||
private final NotificationRepository notificationRepository;
|
private final NotificationRepository notificationRepository;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final DocumentService documentService;
|
||||||
private final Optional<JavaMailSender> mailSender;
|
private final Optional<JavaMailSender> mailSender;
|
||||||
private final SseEmitterRegistry sseEmitterRegistry;
|
private final SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
|
||||||
@@ -94,16 +98,26 @@ public class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Page<NotificationDTO> getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) {
|
public Page<NotificationDTO> getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) {
|
||||||
|
Page<Notification> page;
|
||||||
if (type != null && Boolean.FALSE.equals(read)) {
|
if (type != null && Boolean.FALSE.equals(read)) {
|
||||||
return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable)
|
page = notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable);
|
||||||
.map(this::toDTO);
|
} else if (type != null) {
|
||||||
|
page = notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable);
|
||||||
|
} else if (Boolean.FALSE.equals(read)) {
|
||||||
|
page = notificationRepository.findByRecipientIdAndReadFalseOrderByCreatedAtDesc(userId, pageable);
|
||||||
|
} else {
|
||||||
|
page = notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable);
|
||||||
}
|
}
|
||||||
if (type != null) {
|
return mapWithDocumentTitles(page);
|
||||||
return notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable)
|
}
|
||||||
.map(this::toDTO);
|
|
||||||
}
|
private Page<NotificationDTO> mapWithDocumentTitles(Page<Notification> page) {
|
||||||
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
|
Set<UUID> documentIds = page.getContent().stream()
|
||||||
.map(this::toDTO);
|
.map(Notification::getDocumentId)
|
||||||
|
.filter(id -> id != null)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Map<UUID, String> titles = documentService.findTitlesByIds(documentIds);
|
||||||
|
return page.map(n -> toDTO(n, titles));
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countUnread(UUID userId) {
|
public long countUnread(UUID userId) {
|
||||||
@@ -124,7 +138,7 @@ public class NotificationService {
|
|||||||
throw DomainException.forbidden("Notification belongs to a different user");
|
throw DomainException.forbidden("Notification belongs to a different user");
|
||||||
}
|
}
|
||||||
notification.setRead(true);
|
notification.setRead(true);
|
||||||
return toDTO(notificationRepository.save(notification));
|
return toDTO(notificationRepository.save(notification), Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -136,10 +150,10 @@ public class NotificationService {
|
|||||||
|
|
||||||
private void saveAndPush(Notification notification) {
|
private void saveAndPush(Notification notification) {
|
||||||
Notification saved = notificationRepository.save(notification);
|
Notification saved = notificationRepository.save(notification);
|
||||||
sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved));
|
sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved, Map.of()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private NotificationDTO toDTO(Notification n) {
|
private NotificationDTO toDTO(Notification n, Map<UUID, String> titles) {
|
||||||
return new NotificationDTO(
|
return new NotificationDTO(
|
||||||
n.getId(),
|
n.getId(),
|
||||||
n.getType(),
|
n.getType(),
|
||||||
@@ -148,7 +162,8 @@ public class NotificationService {
|
|||||||
n.getAnnotationId(),
|
n.getAnnotationId(),
|
||||||
n.isRead(),
|
n.isRead(),
|
||||||
n.getCreatedAt(),
|
n.getCreatedAt(),
|
||||||
n.getActorName()
|
n.getActorName(),
|
||||||
|
n.getDocumentId() != null ? titles.get(n.getDocumentId()) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class NotificationControllerTest {
|
|||||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
NotificationDTO dto = new NotificationDTO(
|
NotificationDTO dto = new NotificationDTO(
|
||||||
UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
|
UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
|
||||||
UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith");
|
UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith", "Testdokument");
|
||||||
|
|
||||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||||
@@ -123,6 +123,20 @@ class NotificationControllerTest {
|
|||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNotifications_returns400_whenSizeExceedsMaximum() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications").param("size", "200"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNotifications_returns400_whenSizeIsZero() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications").param("size", "0"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/notifications/read-all ────────────────────────────────────
|
// ─── POST /api/notifications/read-all ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.NotificationDTO;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.*;
|
import org.raddatz.familienarchiv.model.*;
|
||||||
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.mail.MailException;
|
import org.springframework.mail.MailException;
|
||||||
import org.springframework.mail.MailSendException;
|
import org.springframework.mail.MailSendException;
|
||||||
import org.springframework.mail.SimpleMailMessage;
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
@@ -19,6 +20,7 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -34,6 +36,7 @@ class NotificationServiceTest {
|
|||||||
|
|
||||||
@Mock NotificationRepository notificationRepository;
|
@Mock NotificationRepository notificationRepository;
|
||||||
@Mock UserService userService;
|
@Mock UserService userService;
|
||||||
|
@Mock DocumentService documentService;
|
||||||
@Mock JavaMailSender mailSender;
|
@Mock JavaMailSender mailSender;
|
||||||
@Mock SseEmitterRegistry sseEmitterRegistry;
|
@Mock SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
|
||||||
@@ -45,7 +48,7 @@ class NotificationServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender), sseEmitterRegistry);
|
notificationService = new NotificationService(notificationRepository, userService, documentService, 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")
|
||||||
@@ -258,7 +261,7 @@ class NotificationServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void notifyReply_skipsEmail_whenMailSenderIsAbsent() {
|
void notifyReply_skipsEmail_whenMailSenderIsAbsent() {
|
||||||
NotificationService serviceWithoutMail = new NotificationService(
|
NotificationService serviceWithoutMail = new NotificationService(
|
||||||
notificationRepository, userService, Optional.empty(), sseEmitterRegistry);
|
notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry);
|
||||||
|
|
||||||
userA.setNotifyOnReply(true);
|
userA.setNotifyOnReply(true);
|
||||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
@@ -274,7 +277,7 @@ class NotificationServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void notifyMentions_skipsEmail_whenMailSenderIsAbsent() {
|
void notifyMentions_skipsEmail_whenMailSenderIsAbsent() {
|
||||||
NotificationService serviceWithoutMail = new NotificationService(
|
NotificationService serviceWithoutMail = new NotificationService(
|
||||||
notificationRepository, userService, Optional.empty(), sseEmitterRegistry);
|
notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry);
|
||||||
|
|
||||||
userA.setNotifyOnMention(true);
|
userA.setNotifyOnMention(true);
|
||||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
@@ -401,6 +404,63 @@ class NotificationServiceTest {
|
|||||||
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
|
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_withReadFalseAndNoType_usesUnreadOnlyRepoMethod() {
|
||||||
|
when(notificationRepository.findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), any()))
|
||||||
|
.thenReturn(Page.empty());
|
||||||
|
|
||||||
|
notificationService.getNotifications(userA.getId(), null, false, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
verify(notificationRepository).findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), any());
|
||||||
|
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_mapsDocumentTitleFromDocumentService() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.recipient(userA)
|
||||||
|
.type(NotificationType.REPLY)
|
||||||
|
.documentId(docId)
|
||||||
|
.referenceId(UUID.randomUUID())
|
||||||
|
.actorName("Clara Doe")
|
||||||
|
.build();
|
||||||
|
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(notification)));
|
||||||
|
when(documentService.findTitlesByIds(Set.of(docId)))
|
||||||
|
.thenReturn(Map.of(docId, "Geburtsurkunde Opa Karl"));
|
||||||
|
|
||||||
|
Page<NotificationDTO> result = notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
assertThat(result.getContent().getFirst().documentTitle()).isEqualTo("Geburtsurkunde Opa Karl");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_mapsDocumentTitleAsNull_whenDocumentDoesNotExist() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.recipient(userA)
|
||||||
|
.type(NotificationType.MENTION)
|
||||||
|
.documentId(docId)
|
||||||
|
.referenceId(UUID.randomUUID())
|
||||||
|
.actorName("Bob Jones")
|
||||||
|
.build();
|
||||||
|
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(notification)));
|
||||||
|
when(documentService.findTitlesByIds(Set.of(docId)))
|
||||||
|
.thenReturn(Map.of());
|
||||||
|
|
||||||
|
Page<NotificationDTO> result = notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
assertThat(result.getContent().getFirst().documentTitle()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() {
|
void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() {
|
||||||
// read=true with a type filter falls through to the type-only branch —
|
// read=true with a type filter falls through to the type-only branch —
|
||||||
|
|||||||
@@ -320,5 +320,19 @@
|
|||||||
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
||||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||||
"dashboard_resume_fallback": "Unbekanntes Dokument"
|
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||||
|
"notification_view_all": "Alle anzeigen →",
|
||||||
|
"notification_history_heading": "Benachrichtigungen",
|
||||||
|
"notification_history_view_link": "Benachrichtigungsverlauf ansehen →",
|
||||||
|
"notification_filter_all": "Alle",
|
||||||
|
"notification_filter_unread": "Ungelesen",
|
||||||
|
"notification_filter_mention": "Erwähnung",
|
||||||
|
"notification_filter_reply": "Antwort",
|
||||||
|
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
||||||
|
"notification_load_more": "Ältere laden",
|
||||||
|
"notification_empty_history": "Keine Benachrichtigungen",
|
||||||
|
"notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.",
|
||||||
|
"notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}",
|
||||||
|
"notification_read_state_read": "gelesen",
|
||||||
|
"notification_read_state_unread": "ungelesen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,5 +320,19 @@
|
|||||||
"dashboard_needs_metadata_show_all": "Show all",
|
"dashboard_needs_metadata_show_all": "Show all",
|
||||||
"dashboard_recent_heading": "Recent Activity",
|
"dashboard_recent_heading": "Recent Activity",
|
||||||
"dashboard_resume_label": "Last opened:",
|
"dashboard_resume_label": "Last opened:",
|
||||||
"dashboard_resume_fallback": "Unknown document"
|
"dashboard_resume_fallback": "Unknown document",
|
||||||
|
"notification_view_all": "View all →",
|
||||||
|
"notification_history_heading": "Notifications",
|
||||||
|
"notification_history_view_link": "View notification history →",
|
||||||
|
"notification_filter_all": "All",
|
||||||
|
"notification_filter_unread": "Unread",
|
||||||
|
"notification_filter_mention": "Mention",
|
||||||
|
"notification_filter_reply": "Reply",
|
||||||
|
"notification_mark_all_read_aria": "Mark all notifications as read",
|
||||||
|
"notification_load_more": "Load older",
|
||||||
|
"notification_empty_history": "No notifications",
|
||||||
|
"notification_empty_history_body": "Mentions and replies to your comments will appear here.",
|
||||||
|
"notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}",
|
||||||
|
"notification_read_state_read": "read",
|
||||||
|
"notification_read_state_unread": "unread"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,5 +320,19 @@
|
|||||||
"dashboard_needs_metadata_show_all": "Ver todos",
|
"dashboard_needs_metadata_show_all": "Ver todos",
|
||||||
"dashboard_recent_heading": "Actividad reciente",
|
"dashboard_recent_heading": "Actividad reciente",
|
||||||
"dashboard_resume_label": "Último abierto:",
|
"dashboard_resume_label": "Último abierto:",
|
||||||
"dashboard_resume_fallback": "Documento desconocido"
|
"dashboard_resume_fallback": "Documento desconocido",
|
||||||
|
"notification_view_all": "Ver todas →",
|
||||||
|
"notification_history_heading": "Notificaciones",
|
||||||
|
"notification_history_view_link": "Ver historial de notificaciones →",
|
||||||
|
"notification_filter_all": "Todas",
|
||||||
|
"notification_filter_unread": "No leídas",
|
||||||
|
"notification_filter_mention": "Mención",
|
||||||
|
"notification_filter_reply": "Respuesta",
|
||||||
|
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
|
||||||
|
"notification_load_more": "Cargar anteriores",
|
||||||
|
"notification_empty_history": "Sin notificaciones",
|
||||||
|
"notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.",
|
||||||
|
"notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}",
|
||||||
|
"notification_read_state_read": "leído",
|
||||||
|
"notification_read_state_unread": "no leído"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,25 +24,34 @@ let { mentions }: Props = $props();
|
|||||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
{m.dashboard_notifications_heading()}
|
{m.dashboard_notifications_heading()}
|
||||||
</h2>
|
</h2>
|
||||||
{#each mentions as mention (mention.id)}
|
<div>
|
||||||
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
|
{#each mentions as mention (mention.id)}
|
||||||
{#if mention.documentId}
|
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
|
||||||
<a
|
{#if mention.documentId}
|
||||||
href={mention.annotationId
|
<a
|
||||||
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
|
href={mention.annotationId
|
||||||
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
|
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
|
||||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
|
||||||
>{mention.actorName ?? ''}</a
|
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||||
>
|
>{mention.actorName ?? ''}</a
|
||||||
<span class="font-sans text-xs text-gray-400">
|
>
|
||||||
{mention.type === 'MENTION'
|
<span class="font-sans text-xs text-gray-400">
|
||||||
? m.dashboard_notification_mentioned()
|
{mention.type === 'MENTION'
|
||||||
: m.dashboard_notification_replied()}
|
? m.dashboard_notification_mentioned()
|
||||||
</span>
|
: m.dashboard_notification_replied()}
|
||||||
{:else}
|
</span>
|
||||||
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
|
{:else}
|
||||||
{/if}
|
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
|
||||||
</div>
|
{/if}
|
||||||
{/each}
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 border-t border-line pt-4">
|
||||||
|
<a
|
||||||
|
href="/notifications"
|
||||||
|
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>{m.notification_history_view_link()}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,17 +2,11 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import {
|
||||||
type NotificationItem = {
|
type NotificationItem,
|
||||||
id: string;
|
relativeTime,
|
||||||
type: 'REPLY' | 'MENTION';
|
parseNotificationEvent
|
||||||
documentId: string;
|
} from '$lib/utils/notifications';
|
||||||
referenceId: string;
|
|
||||||
annotationId: string | null;
|
|
||||||
read: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
actorName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let notifications: NotificationItem[] = $state([]);
|
let notifications: NotificationItem[] = $state([]);
|
||||||
let unreadCount: number = $state(0);
|
let unreadCount: number = $state(0);
|
||||||
@@ -131,23 +125,12 @@ function attachClickOutside(node: HTMLElement) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(isoString: string): string {
|
|
||||||
const diffMs = Date.now() - new Date(isoString).getTime();
|
|
||||||
const diffMin = Math.floor(diffMs / 60000);
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
|
||||||
if (diffMin < 1) return rtf.format(0, 'minute');
|
|
||||||
if (diffMin < 60) return rtf.format(-diffMin, 'minute');
|
|
||||||
const diffH = Math.floor(diffMin / 60);
|
|
||||||
if (diffH < 24) return rtf.format(-diffH, 'hour');
|
|
||||||
const diffD = Math.floor(diffH / 24);
|
|
||||||
return rtf.format(-diffD, 'day');
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchUnreadCount();
|
fetchUnreadCount();
|
||||||
eventSource = new EventSource('/api/notifications/stream');
|
eventSource = new EventSource('/api/notifications/stream');
|
||||||
eventSource.addEventListener('notification', (e) => {
|
eventSource.addEventListener('notification', (e) => {
|
||||||
const notification = JSON.parse(e.data) as NotificationItem;
|
const notification = parseNotificationEvent(e.data);
|
||||||
|
if (!notification) return;
|
||||||
notifications = [notification, ...notifications];
|
notifications = [notification, ...notifications];
|
||||||
if (!notification.read) unreadCount += 1;
|
if (!notification.read) unreadCount += 1;
|
||||||
});
|
});
|
||||||
@@ -317,6 +300,16 @@ onDestroy(() => {
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="border-t border-line px-4 py-2">
|
||||||
|
<a
|
||||||
|
href="/notifications"
|
||||||
|
onclick={closeDropdown}
|
||||||
|
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.notification_view_all()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1002,6 +1002,7 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
actorName?: string;
|
actorName?: string;
|
||||||
|
documentTitle?: string;
|
||||||
};
|
};
|
||||||
PageNotificationDTO: {
|
PageNotificationDTO: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
|
|||||||
106
frontend/src/lib/utils/notifications.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { relativeTime, parseNotificationEvent } from '$lib/utils/notifications';
|
||||||
|
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||||
|
|
||||||
|
function msAgo(ms: number, now: Date): string {
|
||||||
|
return new Date(now.getTime() - ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('relativeTime', () => {
|
||||||
|
const now = new Date('2024-06-15T12:00:00.000Z');
|
||||||
|
|
||||||
|
it('should use minute bucket for timestamps under 60 seconds ago', () => {
|
||||||
|
const ts = msAgo(30_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(rtf.format(0, 'minute'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use minute bucket for exactly 59 minutes ago', () => {
|
||||||
|
const ts = msAgo(59 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(rtf.format(-59, 'minute'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use minute bucket for exactly 1 minute ago', () => {
|
||||||
|
const ts = msAgo(60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'minute'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use hour bucket for exactly 1 hour ago', () => {
|
||||||
|
const ts = msAgo(60 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'hour'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use hour bucket for 23 hours ago', () => {
|
||||||
|
const ts = msAgo(23 * 60 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(rtf.format(-23, 'hour'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use day bucket for exactly 24 hours ago', () => {
|
||||||
|
const ts = msAgo(24 * 60 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'day'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use day bucket for 6 days ago', () => {
|
||||||
|
const ts = msAgo(6 * 24 * 60 * 60_000, now);
|
||||||
|
expect(relativeTime(ts, now)).toBe(rtf.format(-6, 'day'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default now to current time when omitted', () => {
|
||||||
|
// Just verify it returns a non-empty string — exact value depends on runtime clock
|
||||||
|
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||||
|
expect(relativeTime(ts)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseNotificationEvent', () => {
|
||||||
|
const valid = {
|
||||||
|
id: '00000000-0000-0000-0000-000000000001',
|
||||||
|
documentId: '00000000-0000-0000-0000-000000000002',
|
||||||
|
actorName: 'Anna Müller',
|
||||||
|
type: 'MENTION',
|
||||||
|
referenceId: '00000000-0000-0000-0000-000000000003',
|
||||||
|
annotationId: null,
|
||||||
|
read: false,
|
||||||
|
createdAt: '2024-06-15T10:00:00',
|
||||||
|
documentTitle: 'Geburtsurkunde Opa Karl'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return parsed object for a valid payload', () => {
|
||||||
|
const result = parseNotificationEvent(JSON.stringify(valid));
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.id).toBe(valid.id);
|
||||||
|
expect(result?.actorName).toBe('Anna Müller');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid JSON', () => {
|
||||||
|
expect(parseNotificationEvent('not-json')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when id is missing', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { id, ...noId } = valid;
|
||||||
|
expect(parseNotificationEvent(JSON.stringify(noId))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when documentId is missing', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { documentId, ...noDocId } = valid;
|
||||||
|
expect(parseNotificationEvent(JSON.stringify(noDocId))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when actorName is missing', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { actorName, ...noActor } = valid;
|
||||||
|
expect(parseNotificationEvent(JSON.stringify(noActor))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for unknown notification type', () => {
|
||||||
|
expect(parseNotificationEvent(JSON.stringify({ ...valid, type: 'UNKNOWN' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept REPLY as a valid type', () => {
|
||||||
|
const result = parseNotificationEvent(JSON.stringify({ ...valid, type: 'REPLY' }));
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.type).toBe('REPLY');
|
||||||
|
});
|
||||||
|
});
|
||||||
42
frontend/src/lib/utils/notifications.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export type NotificationItem = {
|
||||||
|
id: string;
|
||||||
|
type: 'REPLY' | 'MENTION';
|
||||||
|
documentId: string;
|
||||||
|
referenceId: string;
|
||||||
|
annotationId: string | null;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
actorName: string;
|
||||||
|
documentTitle: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||||
|
|
||||||
|
export function relativeTime(isoString: string, now: Date = new Date()): string {
|
||||||
|
const diffMs = now.getTime() - new Date(isoString).getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60_000);
|
||||||
|
if (diffMin < 1) return rtf.format(0, 'minute');
|
||||||
|
if (diffMin < 60) return rtf.format(-diffMin, 'minute');
|
||||||
|
const diffH = Math.floor(diffMin / 60);
|
||||||
|
if (diffH < 24) return rtf.format(-diffH, 'hour');
|
||||||
|
const diffD = Math.floor(diffH / 24);
|
||||||
|
return rtf.format(-diffD, 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNotificationEvent(raw: string): NotificationItem | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
typeof parsed.id !== 'string' ||
|
||||||
|
typeof parsed.documentId !== 'string' ||
|
||||||
|
typeof parsed.actorName !== 'string' ||
|
||||||
|
!['REPLY', 'MENTION'].includes(parsed.type)
|
||||||
|
) {
|
||||||
|
console.warn('Unexpected SSE payload shape:', parsed);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed as NotificationItem;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/src/routes/notifications/+page.server.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
|
const type = url.searchParams.get('type') ?? undefined;
|
||||||
|
const readParam = url.searchParams.get('read');
|
||||||
|
const read = readParam !== null ? readParam === 'true' : undefined;
|
||||||
|
|
||||||
|
const result = await api.GET('/api/notifications', {
|
||||||
|
params: { query: { type: type as 'MENTION' | 'REPLY' | undefined, read, page: 0, size: 20 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = result.data!;
|
||||||
|
const notifications = page.content ?? [];
|
||||||
|
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
|
return { notifications, unreadCount, totalPages: page.totalPages ?? 1 };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
'mark-all': async ({ fetch }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
await api.POST('/api/notifications/read-all');
|
||||||
|
redirect(303, '/notifications');
|
||||||
|
}
|
||||||
|
};
|
||||||
279
frontend/src/routes/notifications/+page.svelte
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { relativeTime, type NotificationItem } from '$lib/utils/notifications';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let additionalNotifications = $state<NotificationItem[]>([]);
|
||||||
|
let loadMorePage = $state(1);
|
||||||
|
let isLoadingMore = $state(false);
|
||||||
|
|
||||||
|
const allNotifications = $derived([...data.notifications, ...additionalNotifications]);
|
||||||
|
const activeType = $derived(page.url.searchParams.get('type'));
|
||||||
|
const activeReadFilter = $derived(page.url.searchParams.get('read'));
|
||||||
|
const hasMore = $derived(loadMorePage < (data.totalPages ?? 1));
|
||||||
|
|
||||||
|
function setFilter(params: Record<string, string | null>) {
|
||||||
|
additionalNotifications = [];
|
||||||
|
loadMorePage = 1;
|
||||||
|
const url = new URL(page.url);
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === null) url.searchParams.delete(k);
|
||||||
|
else url.searchParams.set(k, v);
|
||||||
|
}
|
||||||
|
goto(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
isLoadingMore = true;
|
||||||
|
try {
|
||||||
|
const typeParam = page.url.searchParams.get('type');
|
||||||
|
const readParam = page.url.searchParams.get('read');
|
||||||
|
let query = `page=${loadMorePage}&size=20`;
|
||||||
|
if (typeParam) query += `&type=${typeParam}`;
|
||||||
|
if (readParam !== null) query += `&read=${readParam}`;
|
||||||
|
const res = await fetch(`/api/notifications?${query}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
additionalNotifications = [...additionalNotifications, ...(json.content ?? [])];
|
||||||
|
loadMorePage += 1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToNotification(n: NotificationItem) {
|
||||||
|
if (!n.read) {
|
||||||
|
await fetch(`/api/notifications/${n.id}/read`, { method: 'PATCH' });
|
||||||
|
}
|
||||||
|
const url = n.annotationId
|
||||||
|
? `/documents/${n.documentId}?commentId=${n.referenceId}&annotationId=${n.annotationId}`
|
||||||
|
: `/documents/${n.documentId}?commentId=${n.referenceId}`;
|
||||||
|
goto(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadgeLabel(type: NotificationItem['type']): string {
|
||||||
|
return type === 'MENTION' ? m.notification_filter_mention() : m.notification_filter_reply();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{m.notification_history_heading()}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-canvas">
|
||||||
|
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<!-- Back link -->
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
{m.btn_back_to_overview()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Page header -->
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h1 class="font-serif text-2xl font-medium text-ink">
|
||||||
|
{m.notification_history_heading()}
|
||||||
|
</h1>
|
||||||
|
{#if data.unreadCount > 0}
|
||||||
|
<form method="POST" action="?/mark-all">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
aria-label={m.notification_mark_all_read_aria()}
|
||||||
|
>
|
||||||
|
{m.notification_mark_all_read()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter pills -->
|
||||||
|
<div role="radiogroup" aria-label="Filter" class="mb-6 flex flex-wrap gap-2">
|
||||||
|
<!-- All -->
|
||||||
|
<button
|
||||||
|
role="radio"
|
||||||
|
aria-checked={activeType === null && activeReadFilter === null}
|
||||||
|
onclick={() => setFilter({ type: null, read: null })}
|
||||||
|
class={[
|
||||||
|
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||||
|
activeType === null && activeReadFilter === null
|
||||||
|
? 'bg-primary text-primary-fg'
|
||||||
|
: 'bg-muted text-ink'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{m.notification_filter_all()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Unread -->
|
||||||
|
<button
|
||||||
|
role="radio"
|
||||||
|
aria-checked={activeReadFilter === 'false'}
|
||||||
|
onclick={() => setFilter({ read: 'false', type: null })}
|
||||||
|
class={[
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||||
|
activeReadFilter === 'false'
|
||||||
|
? 'bg-primary text-primary-fg'
|
||||||
|
: 'bg-muted text-ink'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{m.notification_filter_unread()}
|
||||||
|
{#if data.unreadCount > 0 && activeType === null && activeReadFilter === null}
|
||||||
|
<span
|
||||||
|
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 font-sans text-xs font-bold text-ink"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{data.unreadCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Mention -->
|
||||||
|
<button
|
||||||
|
role="radio"
|
||||||
|
aria-checked={activeType === 'MENTION'}
|
||||||
|
onclick={() => setFilter({ type: 'MENTION', read: null })}
|
||||||
|
class={[
|
||||||
|
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||||
|
activeType === 'MENTION'
|
||||||
|
? 'bg-primary text-primary-fg'
|
||||||
|
: 'bg-muted text-ink'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{m.notification_filter_mention()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Reply -->
|
||||||
|
<button
|
||||||
|
role="radio"
|
||||||
|
aria-checked={activeType === 'REPLY'}
|
||||||
|
onclick={() => setFilter({ type: 'REPLY', read: null })}
|
||||||
|
class={[
|
||||||
|
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||||
|
activeType === 'REPLY'
|
||||||
|
? 'bg-primary text-primary-fg'
|
||||||
|
: 'bg-muted text-ink'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{m.notification_filter_reply()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification list or empty state -->
|
||||||
|
{#if allNotifications.length === 0}
|
||||||
|
<div class="flex flex-col items-center gap-3 py-20 text-center">
|
||||||
|
<svg
|
||||||
|
class="h-10 w-10 text-ink-3"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h2 class="font-serif text-lg font-semibold text-ink">
|
||||||
|
{m.notification_empty_history()}
|
||||||
|
</h2>
|
||||||
|
<p class="max-w-xs font-sans text-sm text-ink-2">
|
||||||
|
{m.notification_empty_history_body()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul
|
||||||
|
role="list"
|
||||||
|
class="divide-y divide-line rounded-sm border border-line bg-canvas shadow-sm"
|
||||||
|
>
|
||||||
|
{#each allNotifications as n (n.id)}
|
||||||
|
<li class="relative bg-surface">
|
||||||
|
<a
|
||||||
|
href="/documents/{n.documentId}"
|
||||||
|
role="row"
|
||||||
|
class={[
|
||||||
|
'flex min-h-14 flex-col justify-center border-l-[3px] px-4 py-4 md:px-6 md:py-5',
|
||||||
|
'transition-colors hover:bg-accent-bg',
|
||||||
|
n.read
|
||||||
|
? 'border-l-transparent'
|
||||||
|
: 'border-l-accent'
|
||||||
|
].join(' ')}
|
||||||
|
aria-label={m.notification_row_aria({
|
||||||
|
actor: n.actorName,
|
||||||
|
type: typeBadgeLabel(n.type),
|
||||||
|
title: n.documentTitle ?? '',
|
||||||
|
time: relativeTime(n.createdAt),
|
||||||
|
readState: n.read ? m.notification_read_state_read() : m.notification_read_state_unread()
|
||||||
|
})}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateToNotification(n);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- Unread dot indicator -->
|
||||||
|
{#if !n.read}
|
||||||
|
<span
|
||||||
|
class="absolute top-4 right-4 h-2 w-2 rounded-full bg-accent md:right-6"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Line 1: actor name + type badge -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-serif font-semibold text-ink">{n.actorName}</span>
|
||||||
|
<span
|
||||||
|
class="rounded-sm bg-muted px-2 py-0.5 font-sans text-xs tracking-wide text-ink-2 uppercase"
|
||||||
|
>
|
||||||
|
{typeBadgeLabel(n.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line 2: document title -->
|
||||||
|
{#if n.documentTitle}
|
||||||
|
<p
|
||||||
|
class="mt-0.5 font-serif text-sm text-ink hover:underline hover:decoration-accent"
|
||||||
|
>
|
||||||
|
{n.documentTitle}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Line 3: relative time -->
|
||||||
|
<p class="mt-1 font-sans text-sm text-ink-3">
|
||||||
|
{relativeTime(n.createdAt)}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
{#if hasMore}
|
||||||
|
<button
|
||||||
|
onclick={loadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
class="mt-6 w-full rounded-sm border border-line py-3 text-sm font-medium text-ink-2 transition-colors hover:bg-canvas disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoadingMore ? '…' : m.notification_load_more()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
136
frontend/src/routes/notifications/page.server.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
|
|
||||||
|
import { load, actions } from './+page.server';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
function makeUrl(params: Record<string, string> = {}) {
|
||||||
|
const url = new URL('http://localhost/notifications');
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── load ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('notifications page load', () => {
|
||||||
|
it('returns notifications and unreadCount from API response', async () => {
|
||||||
|
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||||
|
response: { ok: true },
|
||||||
|
data: {
|
||||||
|
content: [
|
||||||
|
{ id: 'n1', read: false },
|
||||||
|
{ id: 'n2', read: true },
|
||||||
|
{ id: 'n3', read: false }
|
||||||
|
],
|
||||||
|
totalElements: 3,
|
||||||
|
totalPages: 1,
|
||||||
|
number: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(result.notifications).toHaveLength(3);
|
||||||
|
expect(result.unreadCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes type param to API when ?type=MENTION is in URL', async () => {
|
||||||
|
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||||
|
response: { ok: true },
|
||||||
|
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||||
|
});
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
await load({ url: makeUrl({ type: 'MENTION' }), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
|
|
||||||
|
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||||
|
expect(queryParams.type).toBe('MENTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes read=false to API when ?read=false is in URL', async () => {
|
||||||
|
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||||
|
response: { ok: true },
|
||||||
|
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||||
|
});
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
await load({ url: makeUrl({ read: 'false' }), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
|
|
||||||
|
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||||
|
expect(queryParams.read).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes no filter params when no search params present', async () => {
|
||||||
|
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||||
|
response: { ok: true },
|
||||||
|
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||||
|
});
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
|
|
||||||
|
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||||
|
expect(queryParams.type).toBeUndefined();
|
||||||
|
expect(queryParams.read).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls the API exactly once — no separate round-trip for unreadCount', async () => {
|
||||||
|
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||||
|
response: { ok: true },
|
||||||
|
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||||
|
});
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 401 error when API returns 401', async () => {
|
||||||
|
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||||
|
response: { ok: false, status: 401 },
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
|
||||||
|
).rejects.toMatchObject({ status: 401 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── mark-all action ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('notifications mark-all action', () => {
|
||||||
|
it('calls POST /api/notifications/read-all and redirects', async () => {
|
||||||
|
const mockPost = vi.fn().mockResolvedValueOnce({ response: { ok: true } });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ POST: mockPost } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
const markAll = actions['mark-all'] as (ctx: { fetch: typeof fetch }) => Promise<never>;
|
||||||
|
await expect(markAll({ fetch: vi.fn() as unknown as typeof fetch })).rejects.toMatchObject({
|
||||||
|
location: '/notifications'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -100,5 +100,14 @@ const hasEmail = $derived(!!data.user?.email);
|
|||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 border-t border-line pt-4">
|
||||||
|
<a
|
||||||
|
href="/notifications"
|
||||||
|
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.notification_history_view_link()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"branch": "feature/153-notification-history",
|
||||||
|
"commitSha": "edeff353931e9d625083eec983fe174c138c340d",
|
||||||
|
"startedAt": "2026-03-29T00:00:00.000Z",
|
||||||
|
"description": "Notification history page (/notifications): empty state, list view (20 items), load-more view (40 items) — 320/768/1440px, light/dark"
|
||||||
|
}
|
||||||