Compare commits

..

12 Commits

Author SHA1 Message Date
Marcel
1a849362a1 fix: replace hardcoded bg-white/border-brand-sand/text-brand-navy with semantic tokens in dashboard widgets
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
All four dashboard components (ResumeStrip, Mentions, NeedsMetadata, RecentDocuments)
used static brand colors that do not adapt to dark mode. Replace with bg-surface,
border-line, text-ink, text-ink-2 throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 09:36:28 +02:00
Marcel
b948c9a46c feat(#145): implement two-mode home page (dashboard vs search results)
- Dashboard mode (no active filters): shows DashboardResumeStrip,
  DropZone, DashboardMentions, DashboardNeedsMetadata, and
  DashboardRecentDocuments widgets
- Search mode (any filter active): shows DocumentList with results
- Removes the old incompleteCount banner in favour of the widget

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:43:54 +01:00
Marcel
df79eec5cc feat(#145): add DashboardRecentDocuments widget component
Shows recently reviewed documents as a dashboard widget with formatted
dates. Renders nothing when the list is empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:42:54 +01:00
Marcel
1d08522df8 feat(#145): add DashboardNeedsMetadata widget component
Shows documents with missing metadata as a dashboard widget with links
to the enrich workflow. Renders nothing when the list is empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:40:48 +01:00
Marcel
2ce95f2542 feat(#145): add DashboardMentions widget component
Shows unread mention notifications as a dashboard widget. Renders
nothing when the mentions list is empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:38:45 +01:00
Marcel
49f71e32ff feat(#145): add DashboardResumeStrip component
- Component reads familienarchiv.lastVisited from localStorage and
  shows a 'Zuletzt geöffnet' link to the last-visited document
- Renders nothing when no localStorage entry exists
- Document detail page writes id+title to localStorage on mount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:36:33 +01:00
Marcel
0610f0ee0f feat(#145): update home page server load for dashboard mode
- Add isDashboard flag (true when no search filters active)
- In dashboard mode: fetch mentions, incompleteDocs, recentDocs via
  Promise.allSettled so widget failures don't crash the page
- In search mode: skip widget fetches for performance
- Replace incomplete-count fetch with list fetch (derive count from
  list.length)
- Update enrich page to use IncompleteDocumentDTO (id + title only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:32:52 +01:00
Marcel
4aa3855936 chore(#145): regenerate API types with new filter params
Adds type, read (notifications) and status (documents/search),
size (documents/incomplete) to the generated TypeScript types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:30:44 +01:00
Marcel
0003b6d6ef chore(#145): regenerate API types from updated OpenAPI spec
Adds NotificationType filter params, IncompleteDocumentDTO, and status
param on document search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:23:03 +01:00
Marcel
147d1f2de5 feat(#145): add status filter to GET /api/documents/search
Dashboard "Recently Added" widget calls ?status=REVIEWED&size=5.
Null status is a no-op — existing callers without the param are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:21:48 +01:00
Marcel
968993c48e feat(#145): add IncompleteDocumentDTO and ?size= param to GET /api/documents/incomplete
Dashboard widget calls ?size=3 to cap the list. Response now returns
{id, title} DTO instead of full Document entity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:19:06 +01:00
Marcel
304359f67d feat(#145): add type and read filter params to GET /api/notifications
Dashboard widget uses ?type=MENTION&read=false to fetch unread mentions.
Also adds MethodArgumentTypeMismatchException → 400 handler so invalid
enum values in any @RequestParam return 400 instead of 500.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:16:04 +01:00
29 changed files with 1380 additions and 148 deletions

View File

@@ -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 ---

View File

@@ -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())

View File

@@ -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")

View File

@@ -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
) {}

View File

@@ -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 " +

View File

@@ -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) -> {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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"))

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View File

@@ -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) {

View 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}

View 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();
});
});

View 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}

View File

@@ -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');
});
});

View 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}

View File

@@ -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();
});
});

View 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}

View File

@@ -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');
});
});

View File

@@ -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: {

View File

@@ -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

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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