chore: merge main into feat/persons-redesign-concept-a
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
Resolved conflicts in messages/de.json, en.json, es.json by keeping both the persons-redesign keys (feature branch) and the notification keys (main) in all three locale files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #159.
This commit is contained in:
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -32,6 +33,14 @@ public class GlobalExceptionHandler {
|
||||
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)
|
||||
public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
||||
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.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
@@ -25,6 +29,7 @@ import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Validated
|
||||
public class NotificationController {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
@@ -44,9 +49,9 @@ public class NotificationController {
|
||||
@GetMapping("/api/notifications")
|
||||
public Page<NotificationDTO> getNotifications(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) NotificationType type,
|
||||
@RequestParam(required = false) Boolean read,
|
||||
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size,
|
||||
@Parameter(description = "Filter by notification type") @RequestParam(required = false) NotificationType type,
|
||||
@Parameter(description = "Filter by read status") @RequestParam(required = false) Boolean read,
|
||||
Authentication authentication) {
|
||||
AppUser user = resolveUser(authentication);
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||
|
||||
@@ -14,5 +14,6 @@ public record NotificationDTO(
|
||||
UUID annotationId,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read,
|
||||
@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 java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -44,6 +46,9 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
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();
|
||||
|
||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||
|
||||
@@ -21,6 +21,9 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
|
||||
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||
UUID recipientId, NotificationType type, Pageable pageable);
|
||||
|
||||
Page<Notification> findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
|
||||
UUID recipientId, Pageable pageable);
|
||||
|
||||
long countByRecipientIdAndReadFalse(UUID recipientId);
|
||||
|
||||
@Modifying
|
||||
|
||||
@@ -25,8 +25,11 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -46,6 +49,15 @@ public class DocumentService {
|
||||
|
||||
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.
|
||||
* - 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.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -32,6 +35,7 @@ public class NotificationService {
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final UserService userService;
|
||||
private final DocumentService documentService;
|
||||
private final Optional<JavaMailSender> mailSender;
|
||||
private final SseEmitterRegistry sseEmitterRegistry;
|
||||
|
||||
@@ -94,16 +98,26 @@ public class NotificationService {
|
||||
}
|
||||
|
||||
public Page<NotificationDTO> getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) {
|
||||
Page<Notification> page;
|
||||
if (type != null && Boolean.FALSE.equals(read)) {
|
||||
return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable)
|
||||
.map(this::toDTO);
|
||||
page = notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable);
|
||||
} 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 notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable)
|
||||
.map(this::toDTO);
|
||||
}
|
||||
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
|
||||
.map(this::toDTO);
|
||||
return mapWithDocumentTitles(page);
|
||||
}
|
||||
|
||||
private Page<NotificationDTO> mapWithDocumentTitles(Page<Notification> page) {
|
||||
Set<UUID> documentIds = page.getContent().stream()
|
||||
.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) {
|
||||
@@ -124,7 +138,7 @@ public class NotificationService {
|
||||
throw DomainException.forbidden("Notification belongs to a different user");
|
||||
}
|
||||
notification.setRead(true);
|
||||
return toDTO(notificationRepository.save(notification));
|
||||
return toDTO(notificationRepository.save(notification), Map.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -136,10 +150,10 @@ public class NotificationService {
|
||||
|
||||
private void saveAndPush(Notification 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(
|
||||
n.getId(),
|
||||
n.getType(),
|
||||
@@ -148,7 +162,8 @@ public class NotificationService {
|
||||
n.getAnnotationId(),
|
||||
n.isRead(),
|
||||
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();
|
||||
NotificationDTO dto = new NotificationDTO(
|
||||
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(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||
@@ -123,6 +123,20 @@ class NotificationControllerTest {
|
||||
.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 ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.*;
|
||||
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.mail.MailException;
|
||||
import org.springframework.mail.MailSendException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
@@ -19,6 +20,7 @@ import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -34,6 +36,7 @@ class NotificationServiceTest {
|
||||
|
||||
@Mock NotificationRepository notificationRepository;
|
||||
@Mock UserService userService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock JavaMailSender mailSender;
|
||||
@Mock SseEmitterRegistry sseEmitterRegistry;
|
||||
|
||||
@@ -45,7 +48,7 @@ class NotificationServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
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")
|
||||
.firstName("Anna").lastName("Smith").email("a@test.com")
|
||||
@@ -258,7 +261,7 @@ class NotificationServiceTest {
|
||||
@Test
|
||||
void notifyReply_skipsEmail_whenMailSenderIsAbsent() {
|
||||
NotificationService serviceWithoutMail = new NotificationService(
|
||||
notificationRepository, userService, Optional.empty(), sseEmitterRegistry);
|
||||
notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry);
|
||||
|
||||
userA.setNotifyOnReply(true);
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||
@@ -274,7 +277,7 @@ class NotificationServiceTest {
|
||||
@Test
|
||||
void notifyMentions_skipsEmail_whenMailSenderIsAbsent() {
|
||||
NotificationService serviceWithoutMail = new NotificationService(
|
||||
notificationRepository, userService, Optional.empty(), sseEmitterRegistry);
|
||||
notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry);
|
||||
|
||||
userA.setNotifyOnMention(true);
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||
@@ -401,6 +404,63 @@ class NotificationServiceTest {
|
||||
.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
|
||||
void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() {
|
||||
// read=true with a type filter falls through to the type-only branch —
|
||||
|
||||
Reference in New Issue
Block a user