feat(#447): permission-gated reader dashboard #477
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Geschichte> spec = Specification.allOf(
|
||||
GeschichteSpecifications.hasStatus(effective),
|
||||
GeschichteSpecifications.hasAuthor(authorId),
|
||||
GeschichteSpecifications.hasAllPersons(personIds),
|
||||
GeschichteSpecifications.hasDocument(documentId),
|
||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||
|
||||
@@ -42,6 +42,12 @@ public final class GeschichteSpecifications {
|
||||
};
|
||||
}
|
||||
|
||||
// null authorId → no restriction (PUBLISHED path passes null; Spring Data skips null predicates)
|
||||
public static Specification<Geschichte> hasAuthor(UUID authorId) {
|
||||
return (root, query, cb) ->
|
||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||
}
|
||||
|
||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||
return (root, query, cb) -> {
|
||||
if (documentId == null) return null;
|
||||
|
||||
@@ -35,7 +35,14 @@ public class PersonController {
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||
public ResponseEntity<List<PersonSummaryDTO>> 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,22 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
nativeQuery = true)
|
||||
List<PersonSummaryDTO> 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<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
|
||||
@@ -41,6 +41,10 @@ public class PersonService {
|
||||
return personRepository.searchWithDocumentCount(q.trim());
|
||||
}
|
||||
|
||||
public List<PersonSummaryDTO> 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));
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC);
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Pageable> 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
|
||||
|
||||
@@ -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("<p>private</p>");
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle(title);
|
||||
|
||||
@@ -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<Integer> 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(); }
|
||||
|
||||
@@ -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`.
|
||||
|
||||
52
docs/adr/007-reader-dashboard-permission-discriminant.md
Normal file
52
docs/adr/007-reader-dashboard-permission-discriminant.md
Normal file
@@ -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`.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
38
frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
Normal file
38
frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
interface Props {
|
||||
drafts: Geschichte[];
|
||||
}
|
||||
|
||||
const { drafts }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_drafts_heading()}
|
||||
</h2>
|
||||
{#if drafts.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#each drafts as draft (draft.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/geschichten/{draft.id}/edit"
|
||||
class="flex min-h-[44px] items-center justify-between gap-4 rounded-sm py-2 transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="text-ink-1 truncate font-serif text-sm">{draft.title}</span>
|
||||
<span class="shrink-0 font-sans text-xs text-ink-3">
|
||||
{relativeTimeDe(new Date(draft.updatedAt))}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
63
frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
Normal file
63
frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const AVATAR_PALETTE = ['#012851', '#5A3080', '#005F74', '#2A6040', '#803020'] as const;
|
||||
function djb2(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
return Math.abs(hash);
|
||||
}
|
||||
function personAvatarColor(id: string): string {
|
||||
return AVATAR_PALETTE[djb2(id) % AVATAR_PALETTE.length];
|
||||
}
|
||||
function getInitials(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '';
|
||||
if (words.length === 1) return words[0].charAt(0).toUpperCase();
|
||||
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
|
||||
interface Props {
|
||||
persons: PersonSummaryDTO[];
|
||||
}
|
||||
|
||||
const { persons }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_person_chips_heading()}
|
||||
</h2>
|
||||
{#if persons.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_no_persons()}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each persons as p (p.id)}
|
||||
<a
|
||||
href="/persons/{p.id}"
|
||||
class="flex min-h-[44px] items-center gap-2 rounded-sm border border-line bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
style="background-color: {personAvatarColor(p.id ?? '')}"
|
||||
>
|
||||
{getInitials(p.displayName ?? p.lastName ?? '')}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="text-ink-1 truncate font-serif text-sm">{p.displayName ?? p.lastName}</span>
|
||||
<span class="font-sans text-xs text-ink-3"
|
||||
>{p.documentCount ?? 0} {m.dashboard_reader_doc_count_suffix()}</span
|
||||
>
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex min-h-[44px] items-center self-end rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>{m.dashboard_reader_all_persons()}</a
|
||||
>
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
65
frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
Normal file
65
frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
interface Props {
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
const { documents }: Props = $props();
|
||||
|
||||
function isNew(doc: Document): boolean {
|
||||
return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_recent_docs_heading()}
|
||||
</h2>
|
||||
<ul class="flex flex-col divide-y divide-line">
|
||||
{#each documents as doc (doc.id)}
|
||||
<li class="py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="text-ink-1 truncate rounded-sm font-serif text-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
{#if isNew(doc)}
|
||||
<span
|
||||
class="rounded bg-brand-mint/20 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide text-brand-navy uppercase"
|
||||
>
|
||||
{m.dashboard_badge_new()}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="text-ink-1 rounded bg-ink-3/10 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide uppercase"
|
||||
>
|
||||
{m.dashboard_badge_updated()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/persons/{doc.sender.id}"
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-brand-mint"
|
||||
>
|
||||
{doc.sender.displayName ?? doc.sender.lastName}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="shrink-0 font-sans text-xs text-ink-3">
|
||||
{relativeTimeDe(new Date(doc.updatedAt))}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
56
frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
Normal file
56
frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
interface Props {
|
||||
stories: Geschichte[];
|
||||
}
|
||||
|
||||
const { stories }: Props = $props();
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
function excerpt(body: string | undefined): string {
|
||||
if (!body) return '';
|
||||
const text = stripHtml(body);
|
||||
if (text.length <= 150) return text;
|
||||
return text.slice(0, 150) + '…';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stories.length > 0}
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_recent_stories_heading()}
|
||||
</h2>
|
||||
<ul class="flex flex-col divide-y divide-line">
|
||||
{#each stories as story (story.id)}
|
||||
<li class="py-4 first:pt-0 last:pb-0">
|
||||
<a
|
||||
href="/geschichten/{story.id}"
|
||||
class="flex flex-col gap-1 rounded-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="text-ink-1 font-serif text-base italic">{story.title}</span>
|
||||
{#if story.body}
|
||||
<p class="line-clamp-2 font-sans text-xs text-ink-3">{excerpt(story.body)}</p>
|
||||
{/if}
|
||||
<span class="font-sans text-xs text-ink-3">
|
||||
{relativeTimeDe(new Date(story.publishedAt ?? story.updatedAt))}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a
|
||||
href="/geschichten"
|
||||
class="mt-4 inline-flex min-h-[44px] items-center rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
{m.dashboard_reader_all_stories()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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: '<p>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.</p>',
|
||||
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: '<p>' + 'A'.repeat(200) + '</p>',
|
||||
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\]/);
|
||||
});
|
||||
});
|
||||
43
frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
Normal file
43
frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
documents: number | null;
|
||||
persons: number | null;
|
||||
stories: number | null;
|
||||
}
|
||||
|
||||
const { documents, persons, stories }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="hidden gap-4 sm:flex">
|
||||
<a
|
||||
href="/documents"
|
||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="font-serif text-2xl font-bold text-brand-navy">{documents ?? '—'}</span>
|
||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
||||
>{m.dashboard_reader_stats_documents()}</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="font-serif text-2xl font-bold text-brand-navy">{persons ?? '—'}</span>
|
||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
||||
>{m.dashboard_reader_stats_persons()}</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/geschichten"
|
||||
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span class="font-serif text-2xl font-bold text-brand-navy">{stories ?? '—'}</span>
|
||||
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
|
||||
>{m.dashboard_reader_stats_stories()}</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
@@ -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('—');
|
||||
});
|
||||
});
|
||||
@@ -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<T>(res: PromiseSettledResult<unknown> | 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<unknown>[] = [
|
||||
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<StatsDTO>(statsRes);
|
||||
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
|
||||
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
|
||||
const recentDocs = searchData?.items.map((i) => i.document) ?? [];
|
||||
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
|
||||
const drafts = settled<Geschichte[]>(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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
{#if data.isReader}
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||
|
||||
<EnrichmentBlock
|
||||
topDocs={data.incompleteDocs ?? []}
|
||||
totalCount={data.incompleteTotal ?? 0}
|
||||
bannerCount={bannerCount}
|
||||
onBannerClose={() => (bannerCount = 0)}
|
||||
<ReaderStatsStrip
|
||||
documents={data.readerStats?.totalDocuments ?? null}
|
||||
persons={data.readerStats?.totalPersons ?? null}
|
||||
stories={data.readerStats?.totalStories ?? null}
|
||||
/>
|
||||
|
||||
<section aria-label={m.dashboard_mission_caption()}>
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_mission_caption()}
|
||||
</h2>
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
||||
{#if data.canBlogWrite}
|
||||
<ReaderDraftsModule drafts={data.drafts ?? []} />
|
||||
{/if}
|
||||
|
||||
<ReaderPersonChips persons={data.topPersons ?? []} />
|
||||
|
||||
<div class="flex flex-col gap-5 md:flex-row">
|
||||
<div class="flex-[3]">
|
||||
<ReaderRecentDocs documents={data.recentDocs ?? []} />
|
||||
</div>
|
||||
<div class="flex-[2]">
|
||||
<ReaderRecentStories stories={data.recentStories ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||
|
||||
<EnrichmentBlock
|
||||
topDocs={data.incompleteDocs ?? []}
|
||||
totalCount={data.incompleteTotal ?? 0}
|
||||
bannerCount={bannerCount}
|
||||
onBannerClose={() => (bannerCount = 0)}
|
||||
/>
|
||||
|
||||
<section aria-label={m.dashboard_mission_caption()}>
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_mission_caption()}
|
||||
</h2>
|
||||
<MissionControlStrip
|
||||
segmentationDocs={data.segmentationDocs ?? []}
|
||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||
readyDocs={data.readyDocs ?? []}
|
||||
weeklyStats={data.weeklyStats ?? null}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -19,6 +19,10 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[0]);
|
||||
|
||||
expect(result.activityFeed).toEqual([]);
|
||||
});
|
||||
@@ -201,7 +230,11 @@ describe('home page load — auth redirect', () => {
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
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<typeof load>[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<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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[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<typeof load>[0]);
|
||||
|
||||
expect(result.isReader).toBe(true);
|
||||
if (result.isReader) {
|
||||
expect(result.topPersons).toEqual([]);
|
||||
expect(result.readerStats?.totalDocuments).toBe(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user