diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsDTO.java index 37ac36d1..52ff3836 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsDTO.java @@ -1,7 +1,12 @@ package org.raddatz.familienarchiv.dashboard; +import io.swagger.v3.oas.annotations.media.Schema; + /** * Aggregate counts for the dashboard/persons stats bar. */ -public record StatsDTO(long totalPersons, long totalDocuments) { +public record StatsDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalPersons, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalDocuments, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalStories) { } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsService.java index 3c42855c..cac398fe 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/StatsService.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.dashboard; import lombok.RequiredArgsConstructor; import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.geschichte.GeschichteService; import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.dashboard.StatsDTO; import org.springframework.stereotype.Service; @@ -12,8 +13,9 @@ public class StatsService { private final PersonService personService; private final DocumentService documentService; + private final GeschichteService geschichteService; public StatsDTO getStats() { - return new StatsDTO(personService.count(), documentService.count()); + return new StatsDTO(personService.count(), documentService.count(), geschichteService.countPublished()); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 8cef03a5..567fe0cc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -658,6 +658,7 @@ public class DocumentService { return switch (sort) { case TITLE -> Sort.by(direction, "title"); case UPLOAD_DATE -> Sort.by(direction, "createdAt"); + case UPDATED_AT -> Sort.by(direction, "updatedAt"); default -> Sort.by(direction, "documentDate"); }; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSort.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSort.java index 03a616b0..9334d53a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSort.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSort.java @@ -1,5 +1,5 @@ package org.raddatz.familienarchiv.document; public enum DocumentSort { - DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, RELEVANCE + DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, UPDATED_AT, RELEVANCE } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index d6d38048..53443cf4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -56,6 +56,10 @@ public class GeschichteService { // ─── Read API ──────────────────────────────────────────────────────────── + public long countPublished() { + return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED)); + } + public Geschichte getById(UUID id) { Geschichte g = geschichteRepository.findById(id) .orElseThrow(() -> DomainException.notFound( @@ -77,8 +81,10 @@ public class GeschichteService { GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); + UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null; Specification spec = Specification.allOf( GeschichteSpecifications.hasStatus(effective), + GeschichteSpecifications.hasAuthor(authorId), GeschichteSpecifications.hasAllPersons(personIds), GeschichteSpecifications.hasDocument(documentId), GeschichteSpecifications.orderByDisplayDateDesc() diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java index 695c3315..42797ffe 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java @@ -42,6 +42,12 @@ public final class GeschichteSpecifications { }; } + // null authorId → no restriction (PUBLISHED path passes null; Spring Data skips null predicates) + public static Specification hasAuthor(UUID authorId) { + return (root, query, cb) -> + authorId == null ? null : cb.equal(root.get("author").get("id"), authorId); + } + public static Specification hasDocument(UUID documentId) { return (root, query, cb) -> { if (documentId == null) return null; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java index b59fa759..5c47cbde 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonController.java @@ -35,7 +35,14 @@ public class PersonController { @GetMapping @RequirePermission(Permission.READ_ALL) - public ResponseEntity> getPersons(@RequestParam(required = false) String q) { + public ResponseEntity> getPersons( + @RequestParam(required = false) String q, + @RequestParam(required = false, defaultValue = "0") int size, + @RequestParam(required = false) String sort) { + if ("documentCount".equals(sort) && size > 0 && q == null) { + int safeSize = Math.min(size, 50); + return ResponseEntity.ok(personService.findTopByDocumentCount(safeSize)); + } return ResponseEntity.ok(personService.findAll(q)); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 8b7b524d..6f431b74 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -69,6 +69,22 @@ public interface PersonRepository extends JpaRepository { nativeQuery = true) List searchWithDocumentCount(@Param("query") String query); + // ORDER BY uses the computed alias "documentCount" — valid PostgreSQL (aliases allowed in ORDER BY, + // unlike WHERE/HAVING). This is intentional; it would silently fail on MySQL or H2. + @Query(value = """ + SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, + p.person_type AS personType, + p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + p.family_member AS familyMember, + (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + FROM persons p + ORDER BY documentCount DESC + LIMIT :limit + """, + nativeQuery = true) + List findTopByDocumentCount(@Param("limit") int limit); + // --- Correspondent queries --- @Query(value = """ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index 007008b1..89b11ef3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -41,6 +41,10 @@ public class PersonService { return personRepository.searchWithDocumentCount(q.trim()); } + public List findTopByDocumentCount(int limit) { + return personRepository.findTopByDocumentCount(limit); + } + public Person getById(UUID id) { return personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); diff --git a/backend/src/main/resources/db/migration/V61__add_idx_documents_updated_at.sql b/backend/src/main/resources/db/migration/V61__add_idx_documents_updated_at.sql new file mode 100644 index 00000000..bfc9899e --- /dev/null +++ b/backend/src/main/resources/db/migration/V61__add_idx_documents_updated_at.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsControllerTest.java index db18630d..bd6767cc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsControllerTest.java @@ -44,7 +44,7 @@ class StatsControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void getStats_returns200_withCorrectCounts() throws Exception { - when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L)); + when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L, 2L)); mockMvc.perform(get("/api/stats")) .andExpect(status().isOk()) @@ -55,7 +55,7 @@ class StatsControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void getStats_returns200_withZeroCounts() throws Exception { - when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L)); + when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L, 0L)); mockMvc.perform(get("/api/stats")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsServiceTest.java index f462f43e..52489515 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/StatsServiceTest.java @@ -7,6 +7,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.dashboard.StatsDTO; +import org.raddatz.familienarchiv.geschichte.GeschichteService; import org.raddatz.familienarchiv.person.PersonService; import static org.assertj.core.api.Assertions.assertThat; @@ -17,6 +18,7 @@ class StatsServiceTest { @Mock PersonService personService; @Mock DocumentService documentService; + @Mock GeschichteService geschichteService; @InjectMocks StatsService statsService; @Test @@ -30,6 +32,17 @@ class StatsServiceTest { assertThat(stats.totalDocuments()).isEqualTo(12L); } + @Test + void getStats_includes_totalStories() { + when(personService.count()).thenReturn(3L); + when(documentService.count()).thenReturn(7L); + when(geschichteService.countPublished()).thenReturn(5L); + + StatsDTO stats = statsService.getStats(); + + assertThat(stats.totalStories()).isEqualTo(5L); + } + @Test void getStats_returnsZero_whenNoEntities() { when(personService.count()).thenReturn(0L); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index e3390024..e8acb667 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -1402,6 +1402,21 @@ class DocumentServiceTest { assertThat(result.items()).hasSize(1); // only the slice is enriched } + @Test + void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + documentService.searchDocuments(null, null, null, null, null, null, null, null, + DocumentSort.UPDATED_AT, "DESC", null, + org.springframework.data.domain.PageRequest.of(0, 5)); + + verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture()); + assertThat(captor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt")); + } + @Test void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() { // Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index e0cb9f27..55eaaa4c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -159,6 +159,26 @@ class GeschichteServiceIntegrationTest { .isEmpty(); } + @Test + void list_DRAFT_does_not_return_other_users_drafts() { + // writer creates a draft; writer2 (also BLOG_WRITE) should not see it + AppUser writer2 = appUserRepository.save(AppUser.builder() + .email("writer2-int@test") + .password("hash") + .build()); + + authenticateAs(writer, Permission.BLOG_WRITE); + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("Writer 1 draft"); + dto.setBody("

private

"); + geschichteService.create(dto); + + authenticateAs(writer2, Permission.BLOG_WRITE); + List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50); + + assertThat(result).isEmpty(); + } + private UUID publishedStoryWithPersons(String title, List personIds) { GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); dto.setTitle(title); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java index 45454794..c7800da1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonControllerTest.java @@ -81,6 +81,29 @@ class PersonControllerTest { .andExpect(jsonPath("$[0].firstName").value("Hans")); } + @Test + @WithMockUser(authorities = "READ_ALL") + void getPersons_delegatesTopByDocumentCount_whenSortAndSizeGiven() throws Exception { + PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz"); + when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top)); + + mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].firstName").value("Käthe")); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getPersons_capsTopByDocumentCount_atFifty() throws Exception { + ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Integer.class); + when(personService.findTopByDocumentCount(sizeCaptor.capture())).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "999")) + .andExpect(status().isOk()); + + assertThat(sizeCaptor.getValue()).isEqualTo(50); + } + private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) { return new PersonSummaryDTO() { public java.util.UUID getId() { return UUID.randomUUID(); } diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 2b7ebe42..f1c75053 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -13,6 +13,9 @@ For domain package structure see [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) _(com **AppUser** (`AppUser`) — a real person who can log into the system (a family member or administrator). `AppUser` records carry login credentials, group memberships, and notification history. _Not to be confused with [Person](#person-person)_ — an AppUser is never recorded as a document sender, receiver, or historical individual. +**Reader** — an `AppUser` whose effective permissions include `READ_ALL` but neither `WRITE_ALL` nor `ANNOTATE_ALL`. Readers see a dedicated dashboard (`isReader = !canWrite && !canAnnotate`) focused on browsing documents, persons, and stories rather than contribution tasks. A user who also holds `BLOG_WRITE` is still classified as a Reader and additionally sees a drafts module. +_Not to be confused with [AppUser](#appuser-appuser)_ — Reader is a permission-derived role, not an entity. + **Permission** — a discrete capability string assigned to a `UserGroup` (e.g. `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`). Enforced via the `@RequirePermission` AOP annotation on controller methods, checked at runtime by `PermissionAspect`; not via Spring Security's `@PreAuthorize`. **Person** (`Person`) — a historical individual in the family archive (sender, receiver of letters, person mentioned in transcriptions). NEVER has a login account and NEVER appears as an `AppUser`. diff --git a/docs/adr/007-reader-dashboard-permission-discriminant.md b/docs/adr/007-reader-dashboard-permission-discriminant.md new file mode 100644 index 00000000..0d7e2aa8 --- /dev/null +++ b/docs/adr/007-reader-dashboard-permission-discriminant.md @@ -0,0 +1,52 @@ +# ADR-007: Reader-dashboard permission discriminant + +## Status + +Accepted + +## Context + +Issue #447 introduced two distinct user cohorts on the home page: + +- **Contributors** — transcribe, annotate, upload. The existing `MissionControlStrip`, `EnrichmentBlock`, `DashboardResumeStrip`, `DashboardFamilyPulse`, `DashboardActivityFeed`, and `DropZone` are aimed at them. +- **Readers** — browse and consume finished content. Older, less technical, on smaller devices. The contribution-focused widgets are noise to them. + +`AppUser` permissions are already derived in `+layout.server.ts` and exposed via `$page.data` as `canWrite`, `canAnnotate`, and `canBlogWrite`. The home route needs a single boolean to switch its layout and its data fetch set, and that boolean has to be load-bearing — every future permission introduced has to be classified against it. + +## Decision + +```ts +const isReader = !canWrite && !canAnnotate; +``` + +Computed at the start of `+page.server.ts` `load()`. When true, the loader fetches a lean reader set (stats / top-4 persons / recent docs / recent stories — and drafts when `canBlogWrite`) via `Promise.allSettled` and returns a discriminated-union shape the page distinguishes via `data.isReader`. + +`BLOG_WRITE` is **not** part of the discriminant. A `READ_ALL + BLOG_WRITE` user is still a reader and additionally sees the `ReaderDraftsModule`. Story writers are conceptually closer to readers than to transcribers: they consume the archive, occasionally publish narrative on top of it, and have no business with the transcription queue. + +A `BLOG_WRITE`-only user (no `READ_ALL`) is also classified as a reader by this formula. Because every reader API requires `READ_ALL`, all four content tiles degrade to empty via `Promise.allSettled`. They see the empty reader shell plus the drafts module — acceptable behaviour, since this permission combination is degenerate by configuration. Documented in `docs/GLOSSARY.md`. + +## Alternatives Considered + +| Alternative | Why rejected | +|---|---| +| New `/reader-home` route with a server-side redirect from `/` | Two routes that mostly answer the same product question (home page). Bookmarks split, breadcrumbs split, header `home` link has to know which to use. The conditional-render keeps a single canonical URL and lets the auth state drive the layout, matching how `canWrite` already gates the upload zone in the contributor branch. | +| `AppUser.dashboardVariant` column persisted in the DB | Permissions already encode the relevant signal; a separate field has to be kept in sync with permission changes. Drift is a feature foot-gun: a user gets `WRITE_ALL` granted but their `dashboardVariant` field still says `reader` and they keep seeing the wrong UI. | +| Middleware/handle hook redirecting based on permissions | Same logical issue as the dedicated route plus a network round-trip on every dashboard hit. The discriminant runs once inside the same `load()` that's already fetching the user. | +| `isReader = !canWrite && !canAnnotate && !canBlogWrite` (exclude `BLOG_WRITE` from readers) | Treats blog writers as contributors. They would land on the `MissionControlStrip` they cannot meaningfully use (no `WRITE_ALL`, no `ANNOTATE_ALL`) and would have to scroll past the transcription queue to find their own drafts. The reader shell + drafts module fits their actual workflow. | + +## Consequences + +**Easier:** +- Reader and contributor views share one canonical home URL — no redirect, no routing fork. +- Adding a new content tile to the reader dashboard is a single-file change inside the `if (isReader)` branch of `load()` plus a new component import in `+page.svelte`. +- Backend `@RequirePermission(READ_ALL)` on every reader API call remains the load-bearing security gate. `isReader` is purely a UX flag — manipulating it client-side serves a different layout to the same authenticated user with the same permissions. + +**Harder:** +- Every future `Permission` value has to be explicitly classified against this formula. Adding a permission that grants contribution rights but not `WRITE_ALL`/`ANNOTATE_ALL` would silently leave its bearers on the reader dashboard. Mitigation: keep this ADR linked from `+page.server.ts` and from the `Permission` enum's Javadoc. +- The discriminated-union return type of `load()` (`{isReader: true} | {isReader: false}`) requires every consumer to narrow on `data.isReader` before accessing branch-specific fields. The current `+page.svelte` already does this with the top-level `{#if data.isReader}`; new consumers of the home loader must follow suit. + +## Future Direction + +If a third cohort emerges (e.g. an admin home with system-health tiles), promote the discriminant to a tagged-union: `dashboard: 'reader' | 'contributor' | 'admin'`. The discriminant computation moves from `+page.server.ts` into a small helper in `lib/shared/server/`, callable from any route that needs the same classification (e.g. a future `/welcome` onboarding flow). + +If `BLOG_WRITE`-only access becomes a real product mode (rather than the degenerate combination it is today), revisit whether the formula should add a `canRead` precondition: `isReader = canRead && !canWrite && !canAnnotate`. diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2ab6edc6..39c77daa 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -448,6 +448,20 @@ "dashboard_recent_heading": "Zuletzt aktiv", "dashboard_stats_documents": "Dokumente", "dashboard_stats_persons": "Personen", + "dashboard_reader_stats_documents": "Dokumente", + "dashboard_reader_stats_persons": "Personen", + "dashboard_reader_stats_stories": "Geschichten", + "dashboard_reader_person_chips_heading": "Personen", + "dashboard_reader_no_persons": "Noch keine Personen im Archiv.", + "dashboard_reader_all_persons": "Alle Personen →", + "dashboard_reader_drafts_heading": "Meine Entwürfe", + "dashboard_reader_drafts_empty": "Keine Entwürfe", + "dashboard_reader_recent_docs_heading": "Zuletzt aktualisiert", + "dashboard_reader_recent_stories_heading": "Neue Geschichten", + "dashboard_badge_new": "Neu", + "dashboard_badge_updated": "Aktualisiert", + "dashboard_reader_all_stories": "Alle Geschichten →", + "dashboard_reader_doc_count_suffix": "Dok.", "dashboard_resume_label": "Zuletzt geöffnet:", "dashboard_resume_fallback": "Unbekanntes Dokument", "doc_status_placeholder": "Platzhalter", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0a39a394..6929736c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -448,6 +448,20 @@ "dashboard_recent_heading": "Recent Activity", "dashboard_stats_documents": "Documents", "dashboard_stats_persons": "Persons", + "dashboard_reader_stats_documents": "Documents", + "dashboard_reader_stats_persons": "Persons", + "dashboard_reader_stats_stories": "Stories", + "dashboard_reader_person_chips_heading": "Persons", + "dashboard_reader_no_persons": "No persons in the archive yet.", + "dashboard_reader_all_persons": "All Persons →", + "dashboard_reader_drafts_heading": "My Drafts", + "dashboard_reader_drafts_empty": "No drafts", + "dashboard_reader_recent_docs_heading": "Recently Updated", + "dashboard_reader_recent_stories_heading": "New Stories", + "dashboard_badge_new": "New", + "dashboard_badge_updated": "Updated", + "dashboard_reader_all_stories": "All Stories →", + "dashboard_reader_doc_count_suffix": "docs.", "dashboard_resume_label": "Last opened:", "dashboard_resume_fallback": "Unknown document", "doc_status_placeholder": "Placeholder", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 29e50e9f..fbdec2aa 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -448,6 +448,20 @@ "dashboard_recent_heading": "Actividad reciente", "dashboard_stats_documents": "Documentos", "dashboard_stats_persons": "Personas", + "dashboard_reader_stats_documents": "Documentos", + "dashboard_reader_stats_persons": "Personas", + "dashboard_reader_stats_stories": "Historias", + "dashboard_reader_person_chips_heading": "Personas", + "dashboard_reader_no_persons": "Todavía no hay personas en el archivo.", + "dashboard_reader_all_persons": "Todas las personas →", + "dashboard_reader_drafts_heading": "Mis borradores", + "dashboard_reader_drafts_empty": "Sin borradores", + "dashboard_reader_recent_docs_heading": "Actualizados recientemente", + "dashboard_reader_recent_stories_heading": "Nuevas historias", + "dashboard_badge_new": "Nuevo", + "dashboard_badge_updated": "Actualizado", + "dashboard_reader_all_stories": "Todas las historias →", + "dashboard_reader_doc_count_suffix": "docs.", "dashboard_resume_label": "Último abierto:", "dashboard_resume_fallback": "Documento desconocido", "doc_status_placeholder": "Marcador", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 32c1c2e6..091c5ce4 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2122,9 +2122,11 @@ export interface components { }; StatsDTO: { /** Format: int64 */ - totalPersons?: number; + totalPersons: number; /** Format: int64 */ - totalDocuments?: number; + totalDocuments: number; + /** Format: int64 */ + totalStories: number; }; PersonSummaryDTO: { title?: string; @@ -2973,6 +2975,8 @@ export interface operations { parameters: { query?: { q?: string; + size?: number; + sort?: string; }; header?: never; path?: never; @@ -4801,7 +4805,7 @@ export interface operations { /** @description Filter by document status */ status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; /** @description Sort field */ - sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "RELEVANCE"; + sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE" | "UPDATED_AT" | "RELEVANCE"; /** @description Sort direction: ASC or DESC */ dir?: string; /** @description Tag operator: AND (default) or OR */ diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte new file mode 100644 index 00000000..a03899e1 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte @@ -0,0 +1,38 @@ + + +
+

+ {m.dashboard_reader_drafts_heading()} +

+ {#if drafts.length === 0} +

{m.dashboard_reader_drafts_empty()}

+ {:else} + + {/if} +
diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts new file mode 100644 index 00000000..9d4beab8 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderDraftsModule from './ReaderDraftsModule.svelte'; +import type { components } from '$lib/generated/api'; + +type Geschichte = components['schemas']['Geschichte']; + +afterEach(() => { + cleanup(); +}); + +const draft1: Geschichte = { + id: 'g1', + title: 'Mein erster Entwurf', + status: 'DRAFT', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-02T00:00:00Z' +}; + +const draft2: Geschichte = { + id: 'g2', + title: 'Zweiter Entwurf', + status: 'DRAFT', + createdAt: '2025-02-01T00:00:00Z', + updatedAt: '2025-02-01T00:00:00Z' +}; + +describe('ReaderDraftsModule', () => { + it('renders a link to /geschichten/{id}/edit for each draft', async () => { + render(ReaderDraftsModule, { drafts: [draft1, draft2] }); + const link1 = page.getByRole('link', { name: /Mein erster Entwurf/ }); + await expect.element(link1).toHaveAttribute('href', '/geschichten/g1/edit'); + const link2 = page.getByRole('link', { name: /Zweiter Entwurf/ }); + await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit'); + }); + + it('shows heading "Meine Entwürfe"', async () => { + render(ReaderDraftsModule, { drafts: [draft1] }); + const heading = page.getByRole('heading', { name: /Meine Entwürfe/i }); + await expect.element(heading).toBeInTheDocument(); + }); + + it('shows empty state when drafts is empty', async () => { + render(ReaderDraftsModule, { drafts: [] }); + const emptyText = page.getByText(/Keine Entwürfe/i); + await expect.element(emptyText).toBeInTheDocument(); + }); + + it('does not show empty state when drafts are present', async () => { + render(ReaderDraftsModule, { drafts: [draft1] }); + const emptyText = page.getByText(/Keine Entwürfe/i); + await expect.element(emptyText).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte new file mode 100644 index 00000000..f78a9b6a --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -0,0 +1,63 @@ + + +
+

+ {m.dashboard_reader_person_chips_heading()} +

+ {#if persons.length === 0} +

{m.dashboard_reader_no_persons()}

+ {/if} + + {m.dashboard_reader_all_persons()} +
diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts new file mode 100644 index 00000000..4aebb735 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderPersonChips from './ReaderPersonChips.svelte'; +import type { components } from '$lib/generated/api'; + +type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; + +afterEach(() => { + cleanup(); +}); + +const person1: PersonSummaryDTO = { + id: 'aaaaaaaa-0000-0000-0000-000000000001', + firstName: 'Anna', + lastName: 'Müller', + displayName: 'Anna Müller', + documentCount: 23, + personType: 'PERSON', + familyMember: false +}; + +const person2: PersonSummaryDTO = { + id: 'aaaaaaaa-0000-0000-0000-000000000002', + firstName: 'Karl', + lastName: 'Schmidt', + displayName: 'Karl Schmidt', + documentCount: 5, + personType: 'PERSON', + familyMember: false +}; + +describe('ReaderPersonChips', () => { + it('renders a chip for each person with correct href', async () => { + render(ReaderPersonChips, { persons: [person1, person2] }); + const link1 = page.getByRole('link', { name: /Anna Müller/ }); + await expect + .element(link1) + .toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000001'); + const link2 = page.getByRole('link', { name: /Karl Schmidt/ }); + await expect + .element(link2) + .toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002'); + }); + + it('shows document count in each chip', async () => { + render(ReaderPersonChips, { persons: [person1] }); + const chip = page.getByRole('link', { name: /Anna Müller/ }); + await expect.element(chip).toBeInTheDocument(); + const text = ((await chip.element()) as HTMLElement).textContent; + expect(text).toContain('23'); + }); + + it('renders an "Alle Personen" link to /persons', async () => { + render(ReaderPersonChips, { persons: [person1] }); + const allLink = page.getByRole('link', { name: /Alle Personen/i }); + await expect.element(allLink).toHaveAttribute('href', '/persons'); + }); + + it('exposes a focus-visible ring on the "Alle Personen" link', async () => { + render(ReaderPersonChips, { persons: [person1] }); + const allLink = page.getByRole('link', { name: /Alle Personen/i }); + const cls = ((await allLink.element()) as HTMLElement).className; + expect(cls).toMatch(/focus-visible:ring-2/); + expect(cls).toMatch(/focus-visible:ring-brand-navy/); + }); + + it('meets the 44px touch target on the "Alle Personen" link', async () => { + render(ReaderPersonChips, { persons: [person1] }); + const allLink = page.getByRole('link', { name: /Alle Personen/i }); + const cls = ((await allLink.element()) as HTMLElement).className; + expect(cls).toMatch(/min-h-\[44px\]/); + }); + + it('renders empty state without chips when persons array is empty', async () => { + render(ReaderPersonChips, { persons: [] }); + const chips = page.getByRole('link', { name: /Müller|Schmidt/ }); + await expect.element(chips).not.toBeInTheDocument(); + }); + + it('renders an empty-state message when persons array is empty', async () => { + render(ReaderPersonChips, { persons: [] }); + const message = page.getByText(/Noch keine Personen im Archiv/i); + await expect.element(message).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte new file mode 100644 index 00000000..d569b57c --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -0,0 +1,65 @@ + + +
+

+ {m.dashboard_reader_recent_docs_heading()} +

+ +
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts new file mode 100644 index 00000000..8960a5fa --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderRecentDocs from './ReaderRecentDocs.svelte'; +import type { components } from '$lib/generated/api'; + +type Document = components['schemas']['Document']; + +afterEach(() => { + cleanup(); +}); + +const baseDoc: Document = { + id: 'doc1', + title: 'Brief an Hans', + originalFilename: 'brief.pdf', + status: 'UPLOADED', + metadataComplete: true, + scriptType: 'HANDWRITING_KURRENT', + createdAt: '2025-01-01T12:00:00Z', + updatedAt: '2025-01-01T12:00:00Z' +}; + +const updatedDoc: Document = { + ...baseDoc, + id: 'doc2', + title: 'Urkunde 1920', + createdAt: '2025-01-01T12:00:00Z', + updatedAt: '2025-03-01T12:00:00Z' +}; + +describe('ReaderRecentDocs', () => { + it('renders a link to /documents/{id} for each document', async () => { + render(ReaderRecentDocs, { documents: [baseDoc] }); + const link = page.getByRole('link', { name: /Brief an Hans/ }); + await expect.element(link).toHaveAttribute('href', '/documents/doc1'); + }); + + it('shows "Neu" badge when createdAt equals updatedAt', async () => { + render(ReaderRecentDocs, { documents: [baseDoc] }); + const badge = page.getByText(/^Neu$/i); + await expect.element(badge).toBeInTheDocument(); + }); + + it('shows "Aktualisiert" badge when updatedAt differs from createdAt', async () => { + render(ReaderRecentDocs, { documents: [updatedDoc] }); + const badge = page.getByText(/^Aktualisiert$/i); + await expect.element(badge).toBeInTheDocument(); + }); + + it('renders the "Aktualisiert" badge with high-contrast text-ink-1', async () => { + render(ReaderRecentDocs, { documents: [updatedDoc] }); + const badge = page.getByText(/^Aktualisiert$/i); + const cls = ((await badge.element()) as HTMLElement).className; + expect(cls).toMatch(/text-ink-1/); + expect(cls).not.toMatch(/text-ink-3(?!\/)/); + }); + + it('does not show "Neu" badge when updatedAt differs from createdAt', async () => { + render(ReaderRecentDocs, { documents: [updatedDoc] }); + const badge = page.getByText(/^Neu$/i); + await expect.element(badge).not.toBeInTheDocument(); + }); + + it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => { + const sameInstantDoc: Document = { + ...baseDoc, + id: 'doc-same-instant', + createdAt: '2025-01-01T12:00:00Z', + updatedAt: '2025-01-01T12:00:00.000Z' + }; + render(ReaderRecentDocs, { documents: [sameInstantDoc] }); + const badge = page.getByText(/^Neu$/i); + await expect.element(badge).toBeInTheDocument(); + }); + + it('renders sender link when sender is present', async () => { + const docWithSender: Document = { + ...baseDoc, + sender: { + id: 'p1', + lastName: 'Müller', + firstName: 'Anna', + displayName: 'Anna Müller', + personType: 'PERSON' as const, + familyMember: false + } + }; + render(ReaderRecentDocs, { documents: [docWithSender] }); + const senderLink = page.getByRole('link', { name: /Anna Müller/ }); + await expect.element(senderLink).toHaveAttribute('href', '/persons/p1'); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte new file mode 100644 index 00000000..ab5b0a30 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte @@ -0,0 +1,56 @@ + + +{#if stories.length > 0} + +{/if} diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts new file mode 100644 index 00000000..c749858d --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderRecentStories from './ReaderRecentStories.svelte'; +import type { components } from '$lib/generated/api'; + +type Geschichte = components['schemas']['Geschichte']; + +afterEach(() => { + cleanup(); +}); + +const story1: Geschichte = { + id: 'g1', + title: 'Die Familie Müller', + body: '

Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.

', + status: 'PUBLISHED', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + publishedAt: '2025-01-01T00:00:00Z' +}; + +const longBodyStory: Geschichte = { + id: 'g2', + title: 'Sehr lange Geschichte', + body: '

' + 'A'.repeat(200) + '

', + status: 'PUBLISHED', + createdAt: '2025-02-01T00:00:00Z', + updatedAt: '2025-02-01T00:00:00Z', + publishedAt: '2025-02-01T00:00:00Z' +}; + +describe('ReaderRecentStories', () => { + it('renders a link to /geschichten/{id} for each story', async () => { + render(ReaderRecentStories, { stories: [story1] }); + const link = page.getByRole('link', { name: /Die Familie Müller/ }); + await expect.element(link).toHaveAttribute('href', '/geschichten/g1'); + }); + + it('truncates body excerpt to 150 characters and strips HTML', async () => { + render(ReaderRecentStories, { stories: [longBodyStory] }); + const excerpt = page.getByText(/A{100,150}/); + await expect.element(excerpt).toBeInTheDocument(); + const text = ((await excerpt.element()) as HTMLElement).textContent; + expect(text!.replace(/…$/, '').length).toBeLessThanOrEqual(150); + }); + + it('shows empty state when stories array is empty', async () => { + render(ReaderRecentStories, { stories: [] }); + const links = page.getByRole('link'); + await expect.element(links).not.toBeInTheDocument(); + }); + + it('renders "Alle Geschichten" link', async () => { + render(ReaderRecentStories, { stories: [story1] }); + const allLink = page.getByRole('link', { name: /Alle Geschichten/i }); + await expect.element(allLink).toHaveAttribute('href', '/geschichten'); + }); + + it('exposes a focus-visible ring on the "Alle Geschichten" link', async () => { + render(ReaderRecentStories, { stories: [story1] }); + const allLink = page.getByRole('link', { name: /Alle Geschichten/i }); + const cls = ((await allLink.element()) as HTMLElement).className; + expect(cls).toMatch(/focus-visible:ring-2/); + expect(cls).toMatch(/focus-visible:ring-brand-navy/); + }); + + it('meets the 44px touch target on the "Alle Geschichten" link', async () => { + render(ReaderRecentStories, { stories: [story1] }); + const allLink = page.getByRole('link', { name: /Alle Geschichten/i }); + const cls = ((await allLink.element()) as HTMLElement).className; + expect(cls).toMatch(/min-h-\[44px\]/); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte new file mode 100644 index 00000000..8129b03c --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts new file mode 100644 index 00000000..b33ddfc7 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ReaderStatsStrip from './ReaderStatsStrip.svelte'; + +afterEach(() => { + cleanup(); +}); + +describe('ReaderStatsStrip', () => { + it('renders a link to /documents', async () => { + render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 }); + const link = page.getByRole('link', { name: /42/ }); + await expect.element(link).toHaveAttribute('href', '/documents'); + }); + + it('renders a link to /persons', async () => { + render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 }); + const link = page.getByRole('link', { name: /7/ }); + await expect.element(link).toHaveAttribute('href', '/persons'); + }); + + it('renders a link to /geschichten', async () => { + render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 }); + const link = page.getByRole('link', { name: /3/ }); + await expect.element(link).toHaveAttribute('href', '/geschichten'); + }); + + it('shows "—" when documents count is null', async () => { + render(ReaderStatsStrip, { documents: null, persons: null, stories: null }); + const links = page.getByRole('link'); + await expect.element(links.first()).toBeInTheDocument(); + const text = ((await links.first().element()) as HTMLElement).textContent; + expect(text).toContain('—'); + }); +}); diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index ae356311..b217452f 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -9,8 +9,21 @@ type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; type DashboardPulseDTO = components['schemas']['DashboardPulseDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; +type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; +type Document = components['schemas']['Document']; +type Geschichte = components['schemas']['Geschichte']; -export async function load({ fetch }) { +function settled(res: PromiseSettledResult | undefined): T | null { + if (res?.status !== 'fulfilled') return null; + const v = res.value as { response: Response; data: unknown }; + return v.response.ok ? ((v.data as T) ?? null) : null; +} + +export async function load({ fetch, parent }) { + const { canWrite, canAnnotate, canBlogWrite } = await parent(); + // READ_ALL without WRITE_ALL or ANNOTATE_ALL — see ADR-007. + // BLOG_WRITE-only users land here too and see the drafts module on top. + const isReader = !canWrite && !canAnnotate; const api = createApiClient(fetch); try { @@ -20,6 +33,43 @@ export async function load({ fetch }) { throw redirect(302, '/login'); } + if (isReader) { + const readerFetches: Promise[] = [ + api.GET('/api/stats'), + api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }), + api.GET('/api/documents/search', { + params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } } + }), + api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }) + ]; + if (canBlogWrite) { + readerFetches.push( + api.GET('/api/geschichten', { params: { query: { status: 'DRAFT', limit: 10 } } }) + ); + } + + const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] = + await Promise.allSettled(readerFetches); + + const readerStats = settled(statsRes); + const topPersons = settled(topPersonsRes) ?? []; + const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); + const recentDocs = searchData?.items.map((i) => i.document) ?? []; + const recentStories = settled(recentStoriesRes) ?? []; + const drafts = settled(draftsRes) ?? []; + + return { + isReader: true as const, + canBlogWrite, + readerStats, + topPersons, + recentDocs, + recentStories, + drafts, + error: null as string | null + }; + } + const [ statsResult, resumeResult, @@ -87,6 +137,7 @@ export async function load({ fetch }) { } return { + isReader: false as const, stats, resumeDoc, pulse, @@ -103,6 +154,7 @@ export async function load({ fetch }) { if ((e as { status?: number }).status) throw e; console.error('Error loading data:', e); return { + isReader, stats: null, resumeDoc: null, pulse: null, @@ -113,6 +165,11 @@ export async function load({ fetch }) { weeklyStats: null, incompleteDocs: [] as IncompleteDocumentDTO[], incompleteTotal: 0, + readerStats: null, + topPersons: [] as PersonSummaryDTO[], + recentDocs: [] as Document[], + recentStories: [] as Geschichte[], + drafts: [] as Geschichte[], error: 'Daten konnten nicht geladen werden.' as string | null }; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2512be89..20c05ebf 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -5,6 +5,11 @@ import MissionControlStrip from '$lib/document/MissionControlStrip.svelte'; import DashboardFamilyPulse from '$lib/shared/dashboard/DashboardFamilyPulse.svelte'; import DashboardActivityFeed from '$lib/activity/DashboardActivityFeed.svelte'; import EnrichmentBlock from '$lib/document/EnrichmentBlock.svelte'; +import ReaderStatsStrip from '$lib/shared/dashboard/ReaderStatsStrip.svelte'; +import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte'; +import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte'; +import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte'; +import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte'; import { m } from '$lib/paraglide/messages.js'; let { data } = $props(); @@ -31,36 +36,61 @@ const greetingText = $derived.by(() => { {/if} -
+ {#if data.isReader}
- - - (bannerCount = 0)} + -
-

- {m.dashboard_mission_caption()} -

- -
-
- -
- - - {#if data.canWrite} - (bannerCount = count)} /> + {#if data.canBlogWrite} + {/if} + + + +
+
+ +
+
+ +
+
-
+ {:else} +
+
+ + + (bannerCount = 0)} + /> + +
+

+ {m.dashboard_mission_caption()} +

+ +
+
+ +
+ + + {#if data.canWrite} + (bannerCount = count)} /> + {/if} +
+
+ {/if} diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 8832bde2..b51540c3 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -19,6 +19,10 @@ function makeUrl(params: Record = {}) { return url; } +function contributorParent() { + return vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false }); +} + // ─── always-dashboard behaviour ─────────────────────────────────────────────── it('never calls /api/documents/search regardless of URL params', async () => { @@ -29,8 +33,9 @@ it('never calls /api/documents/search regardless of URL params', async () => { await load({ url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }), - fetch: vi.fn() as unknown as typeof fetch - }); + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]); expect(calledEndpoints).not.toContain('/api/documents/search'); @@ -42,7 +47,11 @@ it('always fetches dashboard data regardless of URL params', async () => { typeof createApiClient >); - await load({ url: makeUrl({ q: 'Urlaub' }), fetch: vi.fn() as unknown as typeof fetch }); + await load({ + url: makeUrl({ q: 'Urlaub' }), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]); expect(calledEndpoints).toContain('/api/stats'); @@ -99,7 +108,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 }); expect(result.resumeDoc).not.toBeNull(); @@ -132,7 +145,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats?.totalDocuments).toBe(248); expect(result.stats?.totalPersons).toBe(34); @@ -149,7 +166,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.stats).toBeNull(); }); @@ -166,7 +187,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.resumeDoc).toBeNull(); }); @@ -186,7 +211,11 @@ describe('home page load — dashboard', () => { typeof createApiClient >); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.activityFeed).toEqual([]); }); @@ -201,7 +230,11 @@ describe('home page load — auth redirect', () => { } as ReturnType); await expect( - load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]) ).rejects.toMatchObject({ location: '/login' }); }); }); @@ -214,8 +247,167 @@ describe('home page load — network error fallback', () => { GET: vi.fn().mockRejectedValue(new Error('Network failure')) } as ReturnType); - const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: contributorParent() + } as Parameters[0]); expect(result.error).toBe('Daten konnten nicht geladen werden.'); }); }); + +// ─── reader branch ───────────────────────────────────────────────────────────── + +describe('home page load — reader branch (isReader = !canWrite && !canAnnotate)', () => { + it('does not call /api/transcription/* endpoints for a read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string); + const transcriptionCalls = calledEndpoints.filter((ep: string) => + ep.startsWith('/api/transcription') + ); + expect(transcriptionCalls).toHaveLength(0); + }); + + it('calls /api/stats, /api/persons, /api/documents/search, /api/geschichten for a read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string); + expect(calledEndpoints).toContain('/api/stats'); + expect(calledEndpoints).toContain('/api/persons'); + expect(calledEndpoints).toContain('/api/documents/search'); + expect(calledEndpoints).toContain('/api/geschichten'); + }); + + it('does not call /api/geschichten with status=DRAFT when canBlogWrite is false', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + const draftCalls = mockGet.mock.calls.filter( + (c: unknown[]) => + c[0] === '/api/geschichten' && + (c[1] as { params?: { query?: { status?: string } } })?.params?.query?.status === 'DRAFT' + ); + expect(draftCalls).toHaveLength(0); + }); + + it('calls /api/geschichten with status=DRAFT when canBlogWrite is true', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: true }) + } as Parameters[0]); + + const draftCalls = mockGet.mock.calls.filter( + (c: unknown[]) => + c[0] === '/api/geschichten' && + (c[1] as { params?: { query?: { status?: string } } })?.params?.query?.status === 'DRAFT' + ); + expect(draftCalls).toHaveLength(1); + }); + + it('returns isReader: true for read-only user', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(true); + }); + + it('returns isReader: false for contributor with WRITE_ALL', async () => { + const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi.fn().mockResolvedValue({ canWrite: true, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(false); + }); + + it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => { + const okStats = { + response: { ok: true, status: 200 }, + data: { totalDocuments: 5, totalPersons: 2, totalStories: 1 } + }; + const failPersons = Promise.reject(new Error('timeout')); + const okSearch = { response: { ok: true, status: 200 }, data: { items: [] } }; + const okStories = { response: { ok: true, status: 200 }, data: [] }; + + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // initial persons check + .mockResolvedValueOnce(okStats) + .mockReturnValueOnce(failPersons) + .mockResolvedValueOnce(okSearch) + .mockResolvedValueOnce(okStories); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(true); + if (result.isReader) { + expect(result.topPersons).toEqual([]); + expect(result.readerStats?.totalDocuments).toBe(5); + } + }); +}); diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index 59d9a1c0..e1d82648 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -10,16 +10,19 @@ afterEach(cleanup); vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() })); +const baseUser: User = { + id: 'u1', + email: 'max@example.com', + firstName: 'Max', + lastName: '', + groups: [], + enabled: true, + createdAt: '2024-01-01T00:00:00Z' +}; + const baseData = { - user: { - id: 'u1', - email: 'max@example.com', - firstName: 'Max', - lastName: '', - groups: [], - enabled: true, - createdAt: '2024-01-01T00:00:00Z' - } as User, + user: baseUser, + isReader: false as const, canWrite: true, canAnnotate: false, canBlogWrite: false, @@ -31,6 +34,22 @@ const baseData = { transcriptionDocs: [], readyDocs: [], weeklyStats: null, + incompleteDocs: [], + incompleteTotal: 0, + error: null +}; + +const readerData = { + user: baseUser, + isReader: true as const, + canWrite: false, + canAnnotate: false, + canBlogWrite: false, + readerStats: { totalPersons: 12, totalDocuments: 34, totalStories: 5 }, + topPersons: [], + recentDocs: [], + recentStories: [], + drafts: [], error: null }; @@ -79,3 +98,34 @@ describe('Home page – dashboard layout', () => { await expect.element(page.getByText(/Dateien auf einmal hochladen/i)).not.toBeInTheDocument(); }); }); + +// ─── Reader dashboard layout ────────────────────────────────────────────────── + +describe('Home page – reader dashboard layout', () => { + it('renders ReaderStatsStrip totals when isReader is true', async () => { + render(Page, { data: readerData }); + await expect.element(page.getByText('34')).toBeInTheDocument(); + await expect.element(page.getByText('12')).toBeInTheDocument(); + await expect.element(page.getByText('5')).toBeInTheDocument(); + }); + + it('renders the recent-docs heading when isReader is true', async () => { + render(Page, { data: readerData }); + await expect.element(page.getByText('Zuletzt aktualisiert')).toBeInTheDocument(); + }); + + it('hides the contributor mission control caption when isReader is true', async () => { + render(Page, { data: readerData }); + await expect.element(page.getByText('Offene Aufgaben')).not.toBeInTheDocument(); + }); + + it('renders the drafts module when canBlogWrite is true', async () => { + render(Page, { data: { ...readerData, canBlogWrite: true } }); + await expect.element(page.getByText('Meine Entwürfe')).toBeInTheDocument(); + }); + + it('hides the drafts module when canBlogWrite is false', async () => { + render(Page, { data: readerData }); + await expect.element(page.getByText('Meine Entwürfe')).not.toBeInTheDocument(); + }); +});