Compare commits
12 Commits
bf46fe6d8b
...
1a849362a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a849362a1 | ||
|
|
b948c9a46c | ||
|
|
df79eec5cc | ||
|
|
1d08522df8 | ||
|
|
2ce95f2542 | ||
|
|
49f71e32ff | ||
|
|
0610f0ee0f | ||
|
|
4aa3855936 | ||
|
|
0003b6d6ef | ||
|
|
147d1f2de5 | ||
|
|
968993c48e | ||
|
|
304359f67d |
@@ -13,9 +13,11 @@ import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
@@ -164,8 +166,9 @@ public class DocumentController {
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete")
|
||||
public List<Document> getIncomplete() {
|
||||
return documentService.findIncompleteDocuments();
|
||||
public List<IncompleteDocumentDTO> getIncomplete(
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return documentService.findIncompleteDocuments(size);
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete/next")
|
||||
@@ -182,8 +185,9 @@ public class DocumentController {
|
||||
@RequestParam(required = false) LocalDate to,
|
||||
@RequestParam(required = false) UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false, name = "tag") List<String> tags) {
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags));
|
||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||
@RequestParam(required = false) DocumentStatus status) {
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
|
||||
}
|
||||
|
||||
// --- VERSIONS ---
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -31,6 +32,12 @@ public class GlobalExceptionHandler {
|
||||
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() + "'";
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
|
||||
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.NotificationType;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.NotificationService;
|
||||
@@ -44,10 +45,12 @@ public class NotificationController {
|
||||
public Page<NotificationDTO> getNotifications(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) NotificationType type,
|
||||
@RequestParam(required = false) Boolean read,
|
||||
Authentication authentication) {
|
||||
AppUser user = resolveUser(authentication);
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||
return notificationService.getNotifications(user.getId(), pageable);
|
||||
return notificationService.getNotifications(user.getId(), type, read, pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/api/notifications/unread-count")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IncompleteDocumentDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
|
||||
) {}
|
||||
@@ -2,6 +2,8 @@ package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
@@ -46,6 +48,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||
|
||||
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
|
||||
|
||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
|
||||
@@ -7,6 +7,7 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -55,6 +56,11 @@ public class DocumentSpecifications {
|
||||
};
|
||||
}
|
||||
|
||||
// Filtert nach Status
|
||||
public static Specification<Document> hasStatus(DocumentStatus status) {
|
||||
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
||||
}
|
||||
|
||||
// Filtert nach Schlagworten (UND-Verknüpfung)
|
||||
public static Specification<Document> hasTags(List<String> tags) {
|
||||
return (root, query, cb) -> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.Notification;
|
||||
import org.raddatz.familienarchiv.model.NotificationType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -14,6 +15,9 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
|
||||
|
||||
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
|
||||
|
||||
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||
UUID recipientId, NotificationType type, Pageable pageable);
|
||||
|
||||
long countByRecipientIdAndReadFalse(UUID recipientId);
|
||||
|
||||
@Modifying
|
||||
|
||||
@@ -4,11 +4,13 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -259,12 +261,13 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) {
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
|
||||
Specification<Document> spec = Specification.where(hasText(text))
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(tags));
|
||||
.and(hasTags(tags))
|
||||
.and(hasStatus(status));
|
||||
|
||||
// Neueste zuerst (nach Erstellungsdatum)
|
||||
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
@@ -313,8 +316,12 @@ public class DocumentService {
|
||||
return documentRepository.countByMetadataCompleteFalse();
|
||||
}
|
||||
|
||||
public List<Document> findIncompleteDocuments() {
|
||||
return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
public List<IncompleteDocumentDTO> findIncompleteDocuments(int size) {
|
||||
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
return documentRepository.findByMetadataCompleteFalse(pageable)
|
||||
.stream()
|
||||
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
|
||||
|
||||
@@ -93,7 +93,11 @@ public class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
public Page<NotificationDTO> getNotifications(UUID userId, Pageable pageable) {
|
||||
public Page<NotificationDTO> getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) {
|
||||
if (type != null && Boolean.FALSE.equals(read)) {
|
||||
return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable)
|
||||
.map(this::toDTO);
|
||||
}
|
||||
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
|
||||
.map(this::toDTO);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
@@ -25,6 +27,9 @@ import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
@@ -53,13 +58,32 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_withStatusParam_passesItToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED)))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_withInvalidStatus_returns400() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search").param("status", "INVALID"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -315,16 +339,39 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_returns200_withList() throws Exception {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build();
|
||||
when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc));
|
||||
void getIncomplete_returns200_withDTOList() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig");
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_withSizeParam_passesItToService() throws Exception {
|
||||
when(documentService.findIncompleteDocuments(5)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete").param("size", "5"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIncompleteDocuments(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception {
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIncompleteDocuments(10);
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -62,7 +62,7 @@ class NotificationControllerTest {
|
||||
void getNotifications_returns200_whenAuthenticatedWithNoPermissions() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
@@ -78,7 +78,7 @@ class NotificationControllerTest {
|
||||
UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith");
|
||||
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||
.thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
@@ -91,13 +91,36 @@ class NotificationControllerTest {
|
||||
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(notificationService).getNotifications(eq(USER_ID), any());
|
||||
verify(notificationService).getNotifications(eq(USER_ID), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getNotifications_withTypeAndReadFalse_passesFiltersToService() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any()))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/notifications")
|
||||
.param("type", "MENTION")
|
||||
.param("read", "false"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(notificationService).getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getNotifications_withInvalidType_returns400() throws Exception {
|
||||
mockMvc.perform(get("/api/notifications").param("type", "INVALID_TYPE"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── POST /api/notifications/read-all ────────────────────────────────────
|
||||
@@ -199,7 +222,7 @@ class NotificationControllerTest {
|
||||
void getNotifications_returns200_whenUserHasOnlyWriteAll() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
|
||||
@@ -11,6 +11,10 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -147,4 +151,25 @@ class DocumentRepositoryTest {
|
||||
|
||||
assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ─── findByMetadataCompleteFalse (Pageable) ───────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByMetadataCompleteFalse_withPageable_returnsOnlyIncompleteAndRespectsSizeCap() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Incomplete " + i).originalFilename("inc" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED).metadataComplete(false).build());
|
||||
}
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Complete").originalFilename("complete.pdf")
|
||||
.status(DocumentStatus.REVIEWED).metadataComplete(true).build());
|
||||
|
||||
Page<Document> result = documentRepository.findByMetadataCompleteFalse(
|
||||
PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdAt")));
|
||||
|
||||
assertThat(result.getContent()).hasSize(3);
|
||||
assertThat(result.getTotalElements()).isEqualTo(5);
|
||||
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,4 +249,24 @@ class DocumentSpecificationsTest {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt"))));
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── hasStatus ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void hasStatus_returnsAllDocuments_whenStatusIsNull() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(null)));
|
||||
assertThat(result).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasStatus_returnsOnlyMatchingDocuments_whenStatusIsSet() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.PLACEHOLDER)));
|
||||
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasStatus_returnsEmpty_whenNoDocumentMatchesStatus() {
|
||||
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED)));
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Notification;
|
||||
import org.raddatz.familienarchiv.model.NotificationType;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class NotificationRepositoryTest {
|
||||
|
||||
@Autowired NotificationRepository notificationRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
|
||||
private AppUser userA;
|
||||
private AppUser userB;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
notificationRepository.deleteAll();
|
||||
appUserRepository.deleteAll();
|
||||
userA = appUserRepository.save(AppUser.builder().username("userA").password("pw").build());
|
||||
userB = appUserRepository.save(AppUser.builder().username("userB").password("pw").build());
|
||||
}
|
||||
|
||||
// ─── findByRecipientIdAndTypeAndReadFalse ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void returnsOnlyUnreadMentions_forTargetUser() {
|
||||
notificationRepository.save(mention(userA, false)); // ✓ match
|
||||
notificationRepository.save(mention(userA, true)); // read — excluded
|
||||
notificationRepository.save(reply(userA, false)); // REPLY — excluded
|
||||
notificationRepository.save(mention(userB, false)); // different user — excluded
|
||||
|
||||
Page<Notification> result = notificationRepository
|
||||
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
|
||||
|
||||
assertThat(result.getContent()).hasSize(1);
|
||||
assertThat(result.getContent().get(0).getRecipient().getId()).isEqualTo(userA.getId());
|
||||
assertThat(result.getContent().get(0).getType()).isEqualTo(NotificationType.MENTION);
|
||||
assertThat(result.getContent().get(0).isRead()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmpty_whenAllMentionsAreRead() {
|
||||
notificationRepository.save(mention(userA, true));
|
||||
|
||||
Page<Notification> result = notificationRepository
|
||||
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
|
||||
|
||||
assertThat(result.getContent()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void respectsSizeLimit() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
notificationRepository.save(mention(userA, false));
|
||||
}
|
||||
|
||||
Page<Notification> result = notificationRepository
|
||||
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||
userA.getId(), NotificationType.MENTION, Pageable.ofSize(3));
|
||||
|
||||
assertThat(result.getContent()).hasSize(3);
|
||||
assertThat(result.getTotalElements()).isEqualTo(5);
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Notification mention(AppUser recipient, boolean read) {
|
||||
return Notification.builder()
|
||||
.recipient(recipient)
|
||||
.type(NotificationType.MENTION)
|
||||
.actorName("Tester")
|
||||
.read(read)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Notification reply(AppUser recipient, boolean read) {
|
||||
return Notification.builder()
|
||||
.recipient(recipient)
|
||||
.type(NotificationType.REPLY)
|
||||
.actorName("Tester")
|
||||
.read(read)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,16 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
@@ -360,12 +364,30 @@ class DocumentServiceTest {
|
||||
// ─── findIncompleteDocuments ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
|
||||
void findIncompleteDocuments_returnsDTOsWithIdAndTitle() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("Unvollständig").build();
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(doc)));
|
||||
|
||||
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
|
||||
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).id()).isEqualTo(id);
|
||||
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIncompleteDocuments_passesSizeToPageable() {
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||
.thenReturn(Page.empty());
|
||||
|
||||
documentService.findIncompleteDocuments(3);
|
||||
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
verify(documentRepository).findByMetadataCompleteFalse(captor.capture());
|
||||
assertThat(captor.getValue().getPageSize()).isEqualTo(3);
|
||||
assertThat(captor.getValue().getSort()).isEqualTo(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
}
|
||||
|
||||
// ─── findNextIncompleteDocument ───────────────────────────────────────────
|
||||
@@ -1169,4 +1191,26 @@ class DocumentServiceTest {
|
||||
Object result = method.invoke(null, (String) null);
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, DocumentStatus.REVIEWED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import org.springframework.mail.MailSendException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -356,6 +359,33 @@ class NotificationServiceTest {
|
||||
assertThat(captor.getValue().getText()).contains("annotationId=" + annotationId);
|
||||
}
|
||||
|
||||
// ─── getNotifications — filter dispatch ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getNotifications_withNoFilters_usesUnfilteredRepoMethod() {
|
||||
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
|
||||
.thenReturn(Page.empty());
|
||||
|
||||
notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
|
||||
|
||||
verify(notificationRepository).findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any());
|
||||
verify(notificationRepository, never())
|
||||
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNotifications_withTypeAndReadFalse_usesFilteredRepoMethod() {
|
||||
when(notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||
eq(userA.getId()), eq(NotificationType.MENTION), any()))
|
||||
.thenReturn(Page.empty());
|
||||
|
||||
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, false, Pageable.ofSize(3));
|
||||
|
||||
verify(notificationRepository).findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||
eq(userA.getId()), eq(NotificationType.MENTION), any());
|
||||
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
|
||||
|
||||
38
frontend/src/lib/components/DashboardMentions.svelte
Normal file
38
frontend/src/lib/components/DashboardMentions.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
mentions: NotificationDTO[];
|
||||
}
|
||||
|
||||
let { mentions }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if mentions.length > 0}
|
||||
<div data-testid="dashboard-mentions" class="rounded-sm border border-line bg-surface p-6">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
Erwähnungen
|
||||
</h2>
|
||||
{#each mentions as mention (mention.id)}
|
||||
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
|
||||
{#if mention.documentId}
|
||||
<a
|
||||
href="/documents/{mention.documentId}"
|
||||
class="font-serif text-sm text-ink hover:text-ink-2"
|
||||
>
|
||||
{mention.actorName ?? ''}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-ink">{mention.actorName ?? ''}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
58
frontend/src/lib/components/DashboardMentions.svelte.spec.ts
Normal file
58
frontend/src/lib/components/DashboardMentions.svelte.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardMentions from './DashboardMentions.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
|
||||
function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO {
|
||||
return {
|
||||
id: 'notif-1',
|
||||
type: 'MENTION',
|
||||
documentId: 'doc-abc',
|
||||
read: false,
|
||||
createdAt: '2026-01-15T10:00:00Z',
|
||||
actorName: 'Anna Schmidt',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('DashboardMentions', () => {
|
||||
it('renders nothing when mentions list is empty', async () => {
|
||||
render(DashboardMentions, { mentions: [] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a heading when mentions are present', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention()] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one row per mention with link to document', async () => {
|
||||
const mentions = [
|
||||
makeMention({ id: 'n1', documentId: 'doc-1', actorName: 'Anna' }),
|
||||
makeMention({ id: 'n2', documentId: 'doc-2', actorName: 'Bob' })
|
||||
];
|
||||
render(DashboardMentions, { mentions });
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.nth(0)).toHaveAttribute('href', '/documents/doc-1');
|
||||
await expect.element(links.nth(1)).toHaveAttribute('href', '/documents/doc-2');
|
||||
});
|
||||
|
||||
it('shows actor name in each row', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
|
||||
await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
33
frontend/src/lib/components/DashboardNeedsMetadata.svelte
Normal file
33
frontend/src/lib/components/DashboardNeedsMetadata.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
type IncompleteDocumentDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
incompleteDocs: IncompleteDocumentDTO[];
|
||||
}
|
||||
|
||||
let { incompleteDocs }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if incompleteDocs.length > 0}
|
||||
<div data-testid="dashboard-needs-metadata" class="rounded-sm border border-line bg-surface p-6">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
Metadaten fehlen
|
||||
</h2>
|
||||
{#each incompleteDocs as doc (doc.id)}
|
||||
<div class="flex items-center border-b border-line py-2 last:border-0">
|
||||
<a
|
||||
href="/enrich/{doc.id}"
|
||||
class="font-serif text-sm text-ink hover:text-ink-2 hover:underline"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="mt-4">
|
||||
<a href="/enrich" class="font-sans text-xs text-ink-2 hover:text-ink"> Alle anzeigen </a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type IncompleteDocumentDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function makeDoc(id: string, title: string): IncompleteDocumentDTO {
|
||||
return { id, title };
|
||||
}
|
||||
|
||||
describe('DashboardNeedsMetadata', () => {
|
||||
it('renders nothing when incompleteDocs is empty', async () => {
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: [] });
|
||||
const widget = page.getByTestId('dashboard-needs-metadata');
|
||||
await expect.element(widget).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the widget when incompleteDocs are present', async () => {
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Taufschein')] });
|
||||
const widget = page.getByTestId('dashboard-needs-metadata');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a link to /enrich/{id} for each document', async () => {
|
||||
const docs = [makeDoc('d1', 'Taufschein'), makeDoc('d2', 'Heiratsurkunde')];
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: docs });
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.nth(0)).toHaveAttribute('href', '/enrich/d1');
|
||||
await expect.element(links.nth(1)).toHaveAttribute('href', '/enrich/d2');
|
||||
});
|
||||
|
||||
it('shows the document title in each row', async () => {
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Sterbeurkunde 1930')] });
|
||||
await expect.element(page.getByText('Sterbeurkunde 1930')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a "Alle anzeigen" link to /enrich', async () => {
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Dok')] });
|
||||
const allLink = page.getByRole('link', { name: /Alle anzeigen/i });
|
||||
await expect.element(allLink).toHaveAttribute('href', '/enrich');
|
||||
});
|
||||
});
|
||||
48
frontend/src/lib/components/DashboardRecentDocuments.svelte
Normal file
48
frontend/src/lib/components/DashboardRecentDocuments.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
type Document = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
interface Props {
|
||||
recentDocs: Document[];
|
||||
}
|
||||
|
||||
let { recentDocs }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(new Date(dateStr + 'T12:00:00'));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if recentDocs.length > 0}
|
||||
<div data-testid="dashboard-recent-docs" class="rounded-sm border border-line bg-surface p-6">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
Zuletzt hinzugefügt
|
||||
</h2>
|
||||
{#each recentDocs as doc (doc.id)}
|
||||
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="font-serif text-sm text-ink hover:text-ink-2 hover:underline"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
{#if doc.documentDate}
|
||||
<span
|
||||
data-testid="doc-date-{doc.id}"
|
||||
class="ml-2 shrink-0 font-sans text-xs text-gray-400"
|
||||
>
|
||||
{formatDate(doc.documentDate)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardRecentDocuments from './DashboardRecentDocuments.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Document = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
function makeDoc(id: string, title: string, date?: string): Document {
|
||||
return { id, title, documentDate: date };
|
||||
}
|
||||
|
||||
describe('DashboardRecentDocuments', () => {
|
||||
it('renders nothing when recentDocs is empty', async () => {
|
||||
render(DashboardRecentDocuments, { recentDocs: [] });
|
||||
const widget = page.getByTestId('dashboard-recent-docs');
|
||||
await expect.element(widget).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the widget when recentDocs are present', async () => {
|
||||
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Taufschein')] });
|
||||
const widget = page.getByTestId('dashboard-recent-docs');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a link to /documents/{id} for each document', async () => {
|
||||
const docs = [makeDoc('d1', 'Taufschein'), makeDoc('d2', 'Heiratsurkunde')];
|
||||
render(DashboardRecentDocuments, { recentDocs: docs });
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.nth(0)).toHaveAttribute('href', '/documents/d1');
|
||||
await expect.element(links.nth(1)).toHaveAttribute('href', '/documents/d2');
|
||||
});
|
||||
|
||||
it('shows the document title in each row', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Sterbeurkunde 1930', '1930-05-12')]
|
||||
});
|
||||
await expect.element(page.getByText('Sterbeurkunde 1930')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats and displays the document date when present', async () => {
|
||||
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Dok', '1945-04-20')] });
|
||||
// The date should be visible in some formatted form
|
||||
const widget = page.getByTestId('dashboard-recent-docs');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
// Just verify the date element exists (not exact format due to locale)
|
||||
const dateEl = page.getByTestId('doc-date-d1');
|
||||
await expect.element(dateEl).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
36
frontend/src/lib/components/DashboardResumeStrip.svelte
Normal file
36
frontend/src/lib/components/DashboardResumeStrip.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface LastVisited {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
let lastVisited = $state<LastVisited | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('familienarchiv.lastVisited');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as LastVisited;
|
||||
if (parsed?.id) {
|
||||
lastVisited = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed JSON
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if lastVisited}
|
||||
<div
|
||||
data-testid="resume-strip"
|
||||
class="flex items-center gap-2 rounded-sm border border-line bg-surface px-4 py-3 font-sans text-sm"
|
||||
>
|
||||
<span class="text-ink-2">Zuletzt geöffnet:</span>
|
||||
<a href="/documents/{lastVisited.id}" class="font-medium text-ink hover:underline">
|
||||
{lastVisited.title || 'Zuletzt geöffnet'}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardResumeStrip from './DashboardResumeStrip.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('DashboardResumeStrip', () => {
|
||||
it('renders nothing when no last-visited document in localStorage', async () => {
|
||||
render(DashboardResumeStrip, {});
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the strip with link when localStorage has a document', async () => {
|
||||
localStorage.setItem(
|
||||
'familienarchiv.lastVisited',
|
||||
JSON.stringify({ id: 'doc-123', title: 'Geburtsurkunde 1920' })
|
||||
);
|
||||
render(DashboardResumeStrip, {});
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
const link = page.getByRole('link', { name: /Geburtsurkunde 1920/ });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-123');
|
||||
});
|
||||
|
||||
it('uses title fallback text when title is empty', async () => {
|
||||
localStorage.setItem(
|
||||
'familienarchiv.lastVisited',
|
||||
JSON.stringify({ id: 'doc-456', title: '' })
|
||||
);
|
||||
render(DashboardResumeStrip, {});
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-456');
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/me/notification-preferences": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getPreferences"];
|
||||
put: operations["updatePreferences"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -78,7 +94,7 @@ export interface paths {
|
||||
get: operations["getDocument"];
|
||||
put: operations["updateDocument"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
delete: operations["deleteDocument"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@@ -148,6 +164,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications/read-all": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["markAllRead"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/groups": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -260,6 +292,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/quick-upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["quickUpload"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/reset-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -324,6 +372,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-file-hashes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["backfillFileHashes"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications/{id}/read": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["markOneRead"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/groups/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -356,6 +436,22 @@ export interface paths {
|
||||
patch: operations["editComment"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/search": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["search"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -420,6 +516,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getNotifications"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications/unread-count": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["countUnread"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications/stream": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["stream"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -468,14 +612,14 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete-count": {
|
||||
"/api/documents/search": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getIncompleteCount"];
|
||||
get: operations["search_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -516,14 +660,14 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/search": {
|
||||
"/api/documents/incomplete-count": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["search"];
|
||||
get: operations["getIncompleteCount"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -548,6 +692,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/reset-token-for-test": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getResetTokenForTest"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/import-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -606,6 +766,8 @@ export interface components {
|
||||
email?: string;
|
||||
contact?: string;
|
||||
enabled: boolean;
|
||||
notifyOnReply: boolean;
|
||||
notifyOnMention: boolean;
|
||||
groups: components["schemas"]["UserGroup"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
@@ -624,6 +786,10 @@ export interface components {
|
||||
email?: string;
|
||||
contact?: string;
|
||||
};
|
||||
NotificationPreferenceDTO: {
|
||||
notifyOnReply?: boolean;
|
||||
notifyOnMention?: boolean;
|
||||
};
|
||||
Tag: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -663,6 +829,7 @@ export interface components {
|
||||
senderId?: string;
|
||||
receiverIds?: string[];
|
||||
tags?: string;
|
||||
metadataComplete?: boolean;
|
||||
};
|
||||
Document: {
|
||||
/** Format: uuid */
|
||||
@@ -686,6 +853,7 @@ export interface components {
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
metadataComplete: boolean;
|
||||
receivers?: components["schemas"]["Person"][];
|
||||
sender?: components["schemas"]["Person"];
|
||||
tags?: components["schemas"]["Tag"][];
|
||||
@@ -711,6 +879,7 @@ export interface components {
|
||||
};
|
||||
CreateCommentDTO: {
|
||||
content?: string;
|
||||
mentionedUserIds?: string[];
|
||||
};
|
||||
DocumentComment: {
|
||||
/** Format: uuid */
|
||||
@@ -730,6 +899,13 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
replies: components["schemas"]["DocumentComment"][];
|
||||
mentionDTOs: components["schemas"]["MentionDTO"][];
|
||||
};
|
||||
MentionDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
CreateAnnotationDTO: {
|
||||
/** Format: int32 */
|
||||
@@ -766,6 +942,15 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
QuickUploadResult: {
|
||||
created?: components["schemas"]["Document"][];
|
||||
updated?: components["schemas"]["Document"][];
|
||||
errors?: components["schemas"]["UploadError"][];
|
||||
};
|
||||
UploadError: {
|
||||
filename?: string;
|
||||
code?: string;
|
||||
};
|
||||
ResetPasswordRequest: {
|
||||
token?: string;
|
||||
newPassword?: string;
|
||||
@@ -786,6 +971,60 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
};
|
||||
NotificationDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
type: "REPLY" | "MENTION";
|
||||
/** Format: uuid */
|
||||
documentId?: string;
|
||||
/** Format: uuid */
|
||||
referenceId?: string;
|
||||
/** Format: uuid */
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
PageNotificationDTO: {
|
||||
/** Format: int64 */
|
||||
totalElements?: number;
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
/** Format: int32 */
|
||||
number?: number;
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
paged?: boolean;
|
||||
/** Format: int32 */
|
||||
pageNumber?: number;
|
||||
/** Format: int32 */
|
||||
pageSize?: number;
|
||||
/** Format: int64 */
|
||||
offset?: number;
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
unpaged?: boolean;
|
||||
};
|
||||
SortObject: {
|
||||
sorted?: boolean;
|
||||
empty?: boolean;
|
||||
unsorted?: boolean;
|
||||
};
|
||||
SseEmitter: {
|
||||
/** Format: int64 */
|
||||
timeout?: number;
|
||||
};
|
||||
DocumentVersionSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -807,6 +1046,11 @@ export interface components {
|
||||
snapshot: string;
|
||||
changedFields: string;
|
||||
};
|
||||
IncompleteDocumentDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@@ -928,6 +1172,50 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getPreferences: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["NotificationPreferenceDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updatePreferences: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["NotificationPreferenceDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["NotificationPreferenceDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateTag: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1072,6 +1360,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteDocument: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getAllUsers: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1212,6 +1520,24 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
markAllRead: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
getAllGroups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1479,6 +1805,32 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
quickUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
files?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["QuickUploadResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
resetPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1563,6 +1915,48 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillFileHashes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
markOneRead: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["NotificationDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteGroup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1657,6 +2051,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
search: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["MentionDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
searchTags: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -1747,6 +2163,75 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getNotifications: {
|
||||
parameters: {
|
||||
query?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
/** @description Filter by notification type */
|
||||
type?: "REPLY" | "MENTION";
|
||||
/** @description Filter by read status */
|
||||
read?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["PageNotificationDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
countUnread: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
stream: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"text/event-stream": components["schemas"]["SseEmitter"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getVersions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1814,7 +2299,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
search: {
|
||||
search_1: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
@@ -1823,6 +2308,8 @@ export interface operations {
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tag?: string[];
|
||||
/** @description Filter by document status */
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -1841,6 +2328,73 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncomplete: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Maximum number of results */
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["IncompleteDocumentDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getNextIncomplete: {
|
||||
parameters: {
|
||||
query: {
|
||||
excludeId: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncompleteCount: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getConversation: {
|
||||
parameters: {
|
||||
query: {
|
||||
@@ -1867,52 +2421,10 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncompleteCount: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncomplete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getNextIncomplete: {
|
||||
getResetTokenForTest: {
|
||||
parameters: {
|
||||
query: {
|
||||
excludeId: string;
|
||||
email: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -1926,16 +2438,9 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"];
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
importStatus: {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type NotificationDTO = components['schemas']['NotificationDTO'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
const q = url.searchParams.get('q') || '';
|
||||
@@ -9,44 +14,76 @@ export async function load({ url, fetch }) {
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
const tags = url.searchParams.getAll('tag');
|
||||
|
||||
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length;
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
try {
|
||||
const [docsResult, personsResult, incompleteCountResult] = await Promise.all([
|
||||
api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons'),
|
||||
api.GET('/api/documents/incomplete-count')
|
||||
const [docsResult, personsResult] = await Promise.all([
|
||||
isDashboard
|
||||
? Promise.resolve(null)
|
||||
: api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
api.GET('/api/persons')
|
||||
]);
|
||||
|
||||
if (docsResult.response.status === 401 || personsResult.response.status === 401) {
|
||||
if (personsResult.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
if (docsResult && docsResult.response.status === 401) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const documents = docsResult.data ?? [];
|
||||
const documents: Document[] = docsResult?.data ?? [];
|
||||
const allPersons: { id: string; firstName: string; lastName: string }[] =
|
||||
personsResult.data ?? [];
|
||||
|
||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
const incompleteCount = incompleteCountResult.response.ok
|
||||
? (incompleteCountResult.data?.count ?? 0)
|
||||
: 0;
|
||||
// Dashboard widgets — failures are isolated and don't crash the page
|
||||
let mentions: NotificationDTO[] = [];
|
||||
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
||||
let recentDocs: Document[] = [];
|
||||
|
||||
if (isDashboard) {
|
||||
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
||||
api.GET('/api/notifications', {
|
||||
params: { query: { type: 'MENTION', read: false, size: 5 } }
|
||||
}),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/search', {
|
||||
params: { query: { status: 'REVIEWED' } }
|
||||
})
|
||||
]);
|
||||
|
||||
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
|
||||
mentions = mentionsResult.value.data?.content ?? [];
|
||||
}
|
||||
if (incompleteResult.status === 'fulfilled' && incompleteResult.value.response.ok) {
|
||||
incompleteDocs = incompleteResult.value.data ?? [];
|
||||
}
|
||||
if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) {
|
||||
recentDocs = (recentResult.value.data ?? []).slice(0, 5);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDashboard,
|
||||
documents,
|
||||
incompleteCount,
|
||||
mentions,
|
||||
incompleteDocs,
|
||||
recentDocs,
|
||||
initialValues: {
|
||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||
@@ -58,8 +95,11 @@ export async function load({ url, fetch }) {
|
||||
if ((e as { status?: number }).status) throw e;
|
||||
console.error('Error loading data:', e);
|
||||
return {
|
||||
isDashboard,
|
||||
documents: [],
|
||||
incompleteCount: 0,
|
||||
mentions: [],
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { q, from, to, senderId, receiverId, tags },
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
|
||||
@@ -5,6 +5,10 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import SearchFilterBar from './SearchFilterBar.svelte';
|
||||
import DropZone from './DropZone.svelte';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
||||
import DashboardMentions from '$lib/components/DashboardMentions.svelte';
|
||||
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
|
||||
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -87,38 +91,23 @@ $effect(() => {
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
{#if data.canWrite}
|
||||
<DropZone />
|
||||
{/if}
|
||||
{#if data.isDashboard}
|
||||
<DashboardResumeStrip />
|
||||
|
||||
{#if data.incompleteCount > 0}
|
||||
<a
|
||||
href="/enrich"
|
||||
class="mb-6 flex items-center justify-between rounded-sm border border-accent/40 bg-accent-bg px-6 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Info/Block/Info-Block-Border-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6 opacity-60"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.enrich_needs_metadata_title()}
|
||||
</p>
|
||||
<p class="mt-0.5 font-serif text-sm text-ink-2">
|
||||
{m.enrich_needs_metadata_count({ count: data.incompleteCount })}
|
||||
</p>
|
||||
</div>
|
||||
{#if data.canWrite}
|
||||
<div class="mt-4">
|
||||
<DropZone />
|
||||
</div>
|
||||
<span
|
||||
class="font-sans text-xs font-bold tracking-widest text-ink uppercase transition-colors hover:text-ink-2"
|
||||
>
|
||||
{m.enrich_needs_metadata_cta()} →
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<DashboardMentions mentions={data.mentions ?? []} />
|
||||
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} />
|
||||
</div>
|
||||
{:else}
|
||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -113,6 +113,14 @@ onMount(() => {
|
||||
|
||||
localStorageRestored = true;
|
||||
|
||||
// Track last-visited document for the dashboard resume strip
|
||||
if (doc?.id) {
|
||||
localStorage.setItem(
|
||||
'familienarchiv.lastVisited',
|
||||
JSON.stringify({ id: doc.id, title: doc.title ?? '' })
|
||||
);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (activeAnnotationId) {
|
||||
|
||||
@@ -5,14 +5,6 @@ let { data } = $props();
|
||||
|
||||
const documents = $derived(data.documents);
|
||||
const count = $derived(documents.length);
|
||||
|
||||
function formatUploadDate(createdAt: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(new Date(createdAt));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||
@@ -85,10 +77,7 @@ function formatUploadDate(createdAt: string): string {
|
||||
<p
|
||||
class="font-serif text-lg font-medium text-ink decoration-accent decoration-2 underline-offset-4 group-hover:underline"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</p>
|
||||
<p class="mt-1 font-sans text-xs text-ink-3">
|
||||
{formatUploadDate(doc.createdAt)}
|
||||
{doc.title}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
|
||||
Reference in New Issue
Block a user