feat(dashboard): permission-gated reader dashboard for READ_ALL / BLOG_WRITE users #447

Closed
opened 2026-05-06 10:10:46 +02:00 by marcel · 11 comments
Owner

Problem

The archive has two distinct user groups:

  • Contributors — transcribe, annotate, upload. Comfortable with the current dashboard (MissionControlStrip, EnrichmentBlock, enrichment queue, activity feed).
  • Readers — browse and consume finished content. Older, less technical. Currently overwhelmed by contribution-focused UI they cannot use and do not need.

Solution

Introduce a permission-gated reader dashboard that replaces the current dashboard for users without WRITE_ALL or ANNOTATE_ALL (including pure readers and story writers). The contributor dashboard remains unchanged.


Detection Logic

isReader   = !canWrite && !canAnnotate
showDrafts = canBlogWrite   // overlays on reader dashboard only

BLOG_WRITE users land on the reader dashboard (not the contributor dashboard), because story writers are conceptually closer to readers than to transcribers. The drafts module appears on top of the reader layout when canBlogWrite is true.

canWrite, canAnnotate, canBlogWrite are already derived in +layout.server.ts and available in $page.data on all routes. No new backend work required for detection.


Reader Dashboard Layout

Five zones, rendered top-to-bottom:

1. Greeting

Unchanged from current dashboard — personalized, time-based greeting.

2. Stats Strip

Three linked stat tiles in a single row:

Tile Value Link
Dokumente Total document count /documents
Personen Total person count /persons
Geschichten Total published story count /geschichten

Each tile is a full-area <a>. Values come from the existing stats endpoint already called by the current dashboard.

3. Drafts Module (conditional — BLOG_WRITE only)

Shown only when canBlogWrite is true, between the stats strip and the person chips.

  • Section heading: "Meine Entwürfe"
  • Lists all draft stories authored by the current user, sorted by updated_at DESC
  • Each entry: story title + "Entwurf · zuletzt bearbeitet vor X" relative timestamp
  • Each entry links to /geschichten/[id]/edit
  • Empty state: "Keine Entwürfe" (no CTA needed — they can create from /geschichten)

4. Person Chips

Top 4 persons by total document count, each linking to /persons/[id]. Followed by "Alle N Personen →" pointing to /persons.

  • Chip content: display name + document count (e.g. "Käthe Raddatz · 23 Dok.")
  • Layout: flex flex-wrap gap-2 — chips reflow gracefully at narrower viewports
  • Data source: persons list endpoint, sorted by documentCount DESC, limit 4

Rationale: readers most often browse by person. Surfacing the 4 most-documented family members answers "where do I start?"

5. Two-Column Content Row

Side-by-side at desktop; stacks to single column below md (768 px).

Left — "Zuletzt aktualisiert" (flex: 3)

5 most recently updated documents, ORDER BY updated_at DESC, no status filter.

Each row: thumbnail placeholder · document title (→ /documents/[id]) · sender name (→ /persons/[id], omitted if no sender) · relative timestamp.

Right — "Geschichten" (flex: 2)

3 most recently published stories.

Each entry: title (italic serif, → /geschichten/[id]) · first ~150 chars as excerpt · relative publication timestamp.


What Is Hidden from Readers

Component Reason
MissionControlStrip Transcription queue — contributor-only
EnrichmentBlock Incomplete-document workflow — contributor-only
DashboardResumeStrip Contribution resume metric — meaningless to readers
DashboardFamilyPulse Contribution-focused activity metrics
DashboardActivityFeed Replaced by the simpler "Zuletzt aktualisiert" feed
DropZone Already gated on canWrite — unchanged

Backend Changes

Data Endpoint Notes
Stats Existing stats endpoint Already fetched today
Top 4 persons GET /api/persons?sort=documentCount,desc&size=4 Verify sort param; add if missing
Recent 5 docs GET /api/documents?sort=updatedAt,desc&size=5 Verify sort param; add if missing
Recent 3 stories GET /api/geschichten?published=true&sort=updatedAt,desc&size=3 Verify filter + sort
Draft stories GET /api/geschichten?published=false&authorId=currentUser&size=10 Verify author filter; add if missing

+page.server.ts load function branches on isReader: fetches the lean reader set instead of the contributor set (avoids loading transcription queues, enrichment data, weekly stats).


Frontend Changes

  • +page.server.ts — add isReader flag; branch fetch logic
  • +page.svelte — conditional render: reader layout vs. existing contributor layout
  • New components in src/lib/shared/dashboard/:
    • ReaderStatsStrip.svelte
    • ReaderPersonChips.svelte
    • ReaderRecentDocs.svelte
    • ReaderRecentStories.svelte
    • ReaderDraftsModule.svelte (rendered conditionally on canBlogWrite)

Non-Functional Requirements

  • PERF: Reader dashboard loads in ≤ 2 s on broadband — achieved by fetching ~4 endpoints instead of 10.
  • A11Y: All stat tiles and person chips are keyboard-navigable (<a> elements, not <div onclick>).
  • RESP: Two-column row stacks to single column at < md. Person chips wrap via flex-wrap.
  • I18N: All new labels have keys in messages/{de,en,es}.json.

Out of Scope

  • Mobile-specific reader dashboard (responsive reflow is sufficient)
  • Admin dashboard variant
  • Any change to the contributor dashboard
  • Personalization / "favourite persons" feature

Open Questions

ID Question Blocks
OQ-01 Does GET /api/persons support sort=documentCount,desc? ReaderPersonChips
OQ-02 Does GET /api/documents support sort=updatedAt,desc? ReaderRecentDocs
OQ-03 Does the stories endpoint support published=false + author filter? ReaderDraftsModule
OQ-04 Should "Zuletzt aktualisiert" distinguish uploads from transcription updates with a badge? ReaderRecentDocs UX

Acceptance Criteria

Given I am logged in with only READ_ALL permission
When I navigate to the dashboard
Then I see the stats strip, person chips, recent docs, and recent stories
And I do NOT see MissionControlStrip, EnrichmentBlock, DashboardResumeStrip, or DashboardActivityFeed

Given I am logged in with READ_ALL + BLOG_WRITE
When I navigate to the dashboard
Then I see the reader layout
And I see the "Meine Entwürfe" module between the stats strip and the person chips

Given I am logged in with WRITE_ALL or ANNOTATE_ALL
When I navigate to the dashboard
Then I see the existing contributor dashboard unchanged

Given there are more than 4 persons in the archive
When I view the reader dashboard
Then I see exactly 4 person chips (top by document count) and an "Alle N Personen →" link
## Problem The archive has two distinct user groups: - **Contributors** — transcribe, annotate, upload. Comfortable with the current dashboard (MissionControlStrip, EnrichmentBlock, enrichment queue, activity feed). - **Readers** — browse and consume finished content. Older, less technical. Currently overwhelmed by contribution-focused UI they cannot use and do not need. ## Solution Introduce a **permission-gated reader dashboard** that replaces the current dashboard for users without `WRITE_ALL` or `ANNOTATE_ALL` (including pure readers and story writers). The contributor dashboard remains unchanged. --- ## Detection Logic ``` isReader = !canWrite && !canAnnotate showDrafts = canBlogWrite // overlays on reader dashboard only ``` `BLOG_WRITE` users land on the **reader dashboard** (not the contributor dashboard), because story writers are conceptually closer to readers than to transcribers. The drafts module appears on top of the reader layout when `canBlogWrite` is true. `canWrite`, `canAnnotate`, `canBlogWrite` are already derived in `+layout.server.ts` and available in `$page.data` on all routes. No new backend work required for detection. --- ## Reader Dashboard Layout Five zones, rendered top-to-bottom: ### 1. Greeting Unchanged from current dashboard — personalized, time-based greeting. ### 2. Stats Strip Three linked stat tiles in a single row: | Tile | Value | Link | |---|---|---| | Dokumente | Total document count | `/documents` | | Personen | Total person count | `/persons` | | Geschichten | Total published story count | `/geschichten` | Each tile is a full-area `<a>`. Values come from the existing stats endpoint already called by the current dashboard. ### 3. Drafts Module *(conditional — BLOG_WRITE only)* Shown only when `canBlogWrite` is true, between the stats strip and the person chips. - Section heading: "Meine Entwürfe" - Lists all draft stories authored by the current user, sorted by `updated_at DESC` - Each entry: story title + "Entwurf · zuletzt bearbeitet vor X" relative timestamp - Each entry links to `/geschichten/[id]/edit` - Empty state: "Keine Entwürfe" (no CTA needed — they can create from `/geschichten`) ### 4. Person Chips Top **4** persons by total document count, each linking to `/persons/[id]`. Followed by "Alle N Personen →" pointing to `/persons`. - Chip content: display name + document count (e.g. "Käthe Raddatz · 23 Dok.") - Layout: `flex flex-wrap gap-2` — chips reflow gracefully at narrower viewports - Data source: persons list endpoint, sorted by `documentCount DESC`, limit 4 Rationale: readers most often browse by person. Surfacing the 4 most-documented family members answers "where do I start?" ### 5. Two-Column Content Row Side-by-side at desktop; stacks to single column below `md` (768 px). **Left — "Zuletzt aktualisiert" (flex: 3)** 5 most recently updated documents, `ORDER BY updated_at DESC`, no status filter. Each row: thumbnail placeholder · document title (→ `/documents/[id]`) · sender name (→ `/persons/[id]`, omitted if no sender) · relative timestamp. **Right — "Geschichten" (flex: 2)** 3 most recently published stories. Each entry: title (italic serif, → `/geschichten/[id]`) · first ~150 chars as excerpt · relative publication timestamp. --- ## What Is Hidden from Readers | Component | Reason | |---|---| | `MissionControlStrip` | Transcription queue — contributor-only | | `EnrichmentBlock` | Incomplete-document workflow — contributor-only | | `DashboardResumeStrip` | Contribution resume metric — meaningless to readers | | `DashboardFamilyPulse` | Contribution-focused activity metrics | | `DashboardActivityFeed` | Replaced by the simpler "Zuletzt aktualisiert" feed | | `DropZone` | Already gated on `canWrite` — unchanged | --- ## Backend Changes | Data | Endpoint | Notes | |---|---|---| | Stats | Existing stats endpoint | Already fetched today | | Top 4 persons | `GET /api/persons?sort=documentCount,desc&size=4` | Verify sort param; add if missing | | Recent 5 docs | `GET /api/documents?sort=updatedAt,desc&size=5` | Verify sort param; add if missing | | Recent 3 stories | `GET /api/geschichten?published=true&sort=updatedAt,desc&size=3` | Verify filter + sort | | Draft stories | `GET /api/geschichten?published=false&authorId=currentUser&size=10` | Verify author filter; add if missing | `+page.server.ts` load function branches on `isReader`: fetches the lean reader set instead of the contributor set (avoids loading transcription queues, enrichment data, weekly stats). --- ## Frontend Changes - `+page.server.ts` — add `isReader` flag; branch fetch logic - `+page.svelte` — conditional render: reader layout vs. existing contributor layout - New components in `src/lib/shared/dashboard/`: - `ReaderStatsStrip.svelte` - `ReaderPersonChips.svelte` - `ReaderRecentDocs.svelte` - `ReaderRecentStories.svelte` - `ReaderDraftsModule.svelte` (rendered conditionally on `canBlogWrite`) --- ## Non-Functional Requirements - **PERF**: Reader dashboard loads in ≤ 2 s on broadband — achieved by fetching ~4 endpoints instead of 10. - **A11Y**: All stat tiles and person chips are keyboard-navigable (`<a>` elements, not `<div onclick>`). - **RESP**: Two-column row stacks to single column at `< md`. Person chips wrap via `flex-wrap`. - **I18N**: All new labels have keys in `messages/{de,en,es}.json`. --- ## Out of Scope - Mobile-specific reader dashboard (responsive reflow is sufficient) - Admin dashboard variant - Any change to the contributor dashboard - Personalization / "favourite persons" feature --- ## Open Questions | ID | Question | Blocks | |---|---|---| | OQ-01 | Does `GET /api/persons` support `sort=documentCount,desc`? | ReaderPersonChips | | OQ-02 | Does `GET /api/documents` support `sort=updatedAt,desc`? | ReaderRecentDocs | | OQ-03 | Does the stories endpoint support `published=false` + author filter? | ReaderDraftsModule | | OQ-04 | Should "Zuletzt aktualisiert" distinguish uploads from transcription updates with a badge? | ReaderRecentDocs UX | ## Acceptance Criteria ```gherkin Given I am logged in with only READ_ALL permission When I navigate to the dashboard Then I see the stats strip, person chips, recent docs, and recent stories And I do NOT see MissionControlStrip, EnrichmentBlock, DashboardResumeStrip, or DashboardActivityFeed Given I am logged in with READ_ALL + BLOG_WRITE When I navigate to the dashboard Then I see the reader layout And I see the "Meine Entwürfe" module between the stats strip and the person chips Given I am logged in with WRITE_ALL or ANNOTATE_ALL When I navigate to the dashboard Then I see the existing contributor dashboard unchanged Given there are more than 4 persons in the archive When I view the reader dashboard Then I see exactly 4 person chips (top by document count) and an "Alle N Personen →" link ```
marcel added the P1-highfeatureui labels 2026-05-06 10:10:52 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • OQ-01 is already solvable in +page.server.ts without a backend change. GET /api/persons returns all persons with documentCount (the PersonSummaryDTO interface exposes it). After fetching, sort and slice in the load function: persons.sort((a, b) => b.documentCount - a.documentCount).slice(0, 4). No new backend endpoint needed.

  • OQ-02 requires a backend change. DocumentSort enum is: DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, RELEVANCE — no UPDATED_AT. Add it to the enum and wire it in the service. Until this lands, the "Zuletzt aktualisiert" feed cannot be implemented correctly.

  • OQ-03 is only half-resolved. GeschichteController.list() accepts ?status=DRAFT, and the service gates it on BLOG_WRITE. But status=DRAFT returns all users' drafts, not just the current user's. The "Meine Entwürfe" module needs the backend to filter by currentUser() when status=DRAFT. I'd add this implicitly in GeschichteService.list() — when status is DRAFT, AND the author=currentUser() spec automatically (no new controller param needed).

  • isReader computation in +page.server.ts — the layout returns canWrite, canAnnotate, canBlogWrite from locals.user. The page.server.ts must call await parent() to access those flags before branching fetch logic. This is the correct SvelteKit pattern.

  • Component naming note: The spec shows that the header bar combines Zones 1+2 (greeting + stats in one <div>). ReaderStatsStrip.svelte covers only the stats part — if the greeting is also in this component, consider naming it ReaderHeaderBar.svelte. If they're separate, the current naming is fine.

  • Issue body says "flex: 3 / flex: 2" for the content row but the impl-ref table says grid grid-cols-2 (equal columns). Follow the impl-ref table — that's the authoritative implementation reference.

Recommendations

  • Implement OQ-01 with client-side sort/slice in +page.server.ts. Zero backend changes.
  • Add DocumentSort.UPDATED_AT to the backend before implementing ReaderRecentDocs.svelte. Don't stub this with UPLOAD_DATE — semantics differ.
  • Fix OQ-03 in GeschichteService.list(): if (effective == DRAFT) { spec = spec.and(hasAuthor(currentUser())); }. This is also a correctness fix regardless of this feature.
  • In +page.server.ts: const { canWrite, canAnnotate, canBlogWrite } = await parent(); const isReader = !canWrite && !canAnnotate;
  • All {#each} blocks must be keyed: {#each persons as person (person.id)}.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - **OQ-01 is already solvable in `+page.server.ts`** without a backend change. `GET /api/persons` returns all persons with `documentCount` (the `PersonSummaryDTO` interface exposes it). After fetching, sort and slice in the load function: `persons.sort((a, b) => b.documentCount - a.documentCount).slice(0, 4)`. No new backend endpoint needed. - **OQ-02 requires a backend change.** `DocumentSort` enum is: `DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, RELEVANCE` — no `UPDATED_AT`. Add it to the enum and wire it in the service. Until this lands, the "Zuletzt aktualisiert" feed cannot be implemented correctly. - **OQ-03 is only half-resolved.** `GeschichteController.list()` accepts `?status=DRAFT`, and the service gates it on `BLOG_WRITE`. But `status=DRAFT` returns **all** users' drafts, not just the current user's. The "Meine Entwürfe" module needs the backend to filter by `currentUser()` when status=DRAFT. I'd add this implicitly in `GeschichteService.list()` — when status is DRAFT, AND the author=currentUser() spec automatically (no new controller param needed). - **`isReader` computation in `+page.server.ts`** — the layout returns `canWrite`, `canAnnotate`, `canBlogWrite` from `locals.user`. The page.server.ts must call `await parent()` to access those flags before branching fetch logic. This is the correct SvelteKit pattern. - **Component naming note**: The spec shows that the header bar combines Zones 1+2 (greeting + stats in one `<div>`). `ReaderStatsStrip.svelte` covers only the stats part — if the greeting is also in this component, consider naming it `ReaderHeaderBar.svelte`. If they're separate, the current naming is fine. - **Issue body says "flex: 3 / flex: 2" for the content row** but the impl-ref table says `grid grid-cols-2` (equal columns). Follow the impl-ref table — that's the authoritative implementation reference. ### Recommendations - Implement OQ-01 with client-side sort/slice in `+page.server.ts`. Zero backend changes. - Add `DocumentSort.UPDATED_AT` to the backend before implementing `ReaderRecentDocs.svelte`. Don't stub this with `UPLOAD_DATE` — semantics differ. - Fix OQ-03 in `GeschichteService.list()`: `if (effective == DRAFT) { spec = spec.and(hasAuthor(currentUser())); }`. This is also a correctness fix regardless of this feature. - In `+page.server.ts`: `const { canWrite, canAnnotate, canBlogWrite } = await parent(); const isReader = !canWrite && !canAnnotate;` - All `{#each}` blocks must be keyed: `{#each persons as person (person.id)}`.
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

  • Branching in +page.server.ts is the right architectural call. No new routes, no new layouts. The conditional fetch logic lives in one place, and the page component conditionally renders reader vs. contributor zones. This avoids route duplication and keeps the permission evaluation co-located with the data fetch.

  • Three of four required backend endpoints need changes — the issue frames this as "verify and add if missing" but it's worth being explicit: (1) DocumentSort.UPDATED_AT is missing, (2) PersonController has no sort=documentCount or size params, (3) GET /api/geschichten?status=DRAFT doesn't filter by current user. All three require backend work before the frontend can be completed.

  • OQ-03 has an architectural gap beyond UX. GeschichteService.list(status=DRAFT) returns all BLOG_WRITE users' drafts in the absence of an author filter. This is a cross-domain data leak that should be fixed in the service layer regardless of this issue. The fix belongs in GeschichteService.list(): when status is DRAFT, implicitly restrict to currentUser() as author. No new controller parameter is needed — the current user is already available via the security context.

  • PersonController sorting: The cleanest approach is to handle in +page.server.ts (all persons are returned anyway). If PersonController later needs size/sort for the persons list page, add it then. YAGNI applies here.

  • Component placement: New components in src/lib/shared/dashboard/ is consistent with DashboardResumeStrip, DashboardFamilyPulse, and DashboardActivityFeed which already live there. Good.

  • isReader derivation: await parent() in +page.server.ts is the SvelteKit-native approach — it avoids re-computing permissions from locals.user in the page load and reuses the already-computed flags from the layout. Use it.

Recommendations

  • Split the backend work from the frontend ticket, or add explicit sub-tasks: (1) DocumentSort.UPDATED_AT, (2) DRAFT author filter in GeschichteService. These are blockers.
  • Do not add authorId as a controller parameter for the drafts filter — compute it from currentUser() inside the service. Client-supplied author IDs would introduce an authorization bypass risk.
  • OQ-01: Sort/slice persons in +page.server.ts. If the persons list ever needs server-side pagination, that's a separate feature.
## 🏗️ Markus Keller — Application Architect ### Observations - **Branching in `+page.server.ts` is the right architectural call.** No new routes, no new layouts. The conditional fetch logic lives in one place, and the page component conditionally renders reader vs. contributor zones. This avoids route duplication and keeps the permission evaluation co-located with the data fetch. - **Three of four required backend endpoints need changes** — the issue frames this as "verify and add if missing" but it's worth being explicit: (1) `DocumentSort.UPDATED_AT` is missing, (2) PersonController has no `sort=documentCount` or `size` params, (3) `GET /api/geschichten?status=DRAFT` doesn't filter by current user. All three require backend work before the frontend can be completed. - **OQ-03 has an architectural gap beyond UX.** `GeschichteService.list(status=DRAFT)` returns all BLOG_WRITE users' drafts in the absence of an author filter. This is a cross-domain data leak that should be fixed in the service layer regardless of this issue. The fix belongs in `GeschichteService.list()`: when status is DRAFT, implicitly restrict to `currentUser()` as author. No new controller parameter is needed — the current user is already available via the security context. - **PersonController sorting**: The cleanest approach is to handle in `+page.server.ts` (all persons are returned anyway). If PersonController later needs size/sort for the persons list page, add it then. YAGNI applies here. - **Component placement**: New components in `src/lib/shared/dashboard/` is consistent with `DashboardResumeStrip`, `DashboardFamilyPulse`, and `DashboardActivityFeed` which already live there. Good. - **`isReader` derivation**: `await parent()` in `+page.server.ts` is the SvelteKit-native approach — it avoids re-computing permissions from `locals.user` in the page load and reuses the already-computed flags from the layout. Use it. ### Recommendations - Split the backend work from the frontend ticket, or add explicit sub-tasks: (1) `DocumentSort.UPDATED_AT`, (2) DRAFT author filter in GeschichteService. These are blockers. - Do **not** add `authorId` as a controller parameter for the drafts filter — compute it from `currentUser()` inside the service. Client-supplied author IDs would introduce an authorization bypass risk. - OQ-01: Sort/slice persons in `+page.server.ts`. If the persons list ever needs server-side pagination, that's a separate feature.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Observations

  • OQ-03 is a confirmed cross-user data leak. GeschichteController.list() accepts ?status=DRAFT. GeschichteService.list() gates this on BLOG_WRITE, but does not filter by author. A BLOG_WRITE user calling GET /api/geschichten?status=DRAFT today receives every other BLOG_WRITE user's unpublished drafts — including draft titles and body content.

    The issue proposes GET /api/geschichten?published=false&authorId=currentUser. If authorId is a client-supplied request parameter, any authenticated BLOG_WRITE user could substitute another user's UUID and read their drafts. Do not expose authorId as a query parameter. Instead, fix GeschichteService.list() to always AND author = currentUser() when status == DRAFT.

    Test to write before the fix:

    @Test
    void list_drafts_does_not_return_other_users_drafts() {
        // user A creates a draft; authenticated as user B (both have BLOG_WRITE)
        // GET /api/geschichten?status=DRAFT should return empty list for user B
    }
    
  • Permission detection is server-side — safe. isReader = !canWrite && !canAnnotate computed in +page.server.ts via await parent() runs on the SvelteKit Node server, not in the browser. A user cannot forge these flags.

  • Reader fetch path defense-in-depth looks correct. When isReader is true, the load function should never call the transcription queue, enrichment, or weekly stats endpoints. No draft data is fetched for pure readers (only BLOG_WRITE users get that branch). This is the correct data-minimization approach.

  • Stats endpoint exposes aggregate counts (847 docs, 94 persons, 12 stories) — these are non-sensitive totals. No concern.

  • "Zuletzt aktualisiert" feed exposes documents to READ_ALL users, which is already the minimum required permission. No additional guard needed here.

Recommendations

  • Fix the DRAFT author leak in GeschichteService.list() before shipping this feature (or any feature that calls the DRAFT filter). This is not a new vulnerability introduced by this issue — it exists today — but this issue would make it visible in the UI.
  • Never add authorId as a query parameter for drafts. Derive it from the security context inside the service.
  • Add the regression test above as a @WebMvcTest slice test in GeschichteControllerTest.
## 🔒 Nora "NullX" Steiner — Application Security Engineer ### Observations - **OQ-03 is a confirmed cross-user data leak.** `GeschichteController.list()` accepts `?status=DRAFT`. `GeschichteService.list()` gates this on `BLOG_WRITE`, but does **not** filter by `author`. A BLOG_WRITE user calling `GET /api/geschichten?status=DRAFT` today receives every other BLOG_WRITE user's unpublished drafts — including draft titles and body content. The issue proposes `GET /api/geschichten?published=false&authorId=currentUser`. If `authorId` is a client-supplied request parameter, any authenticated BLOG_WRITE user could substitute another user's UUID and read their drafts. **Do not expose `authorId` as a query parameter.** Instead, fix `GeschichteService.list()` to always AND `author = currentUser()` when `status == DRAFT`. Test to write before the fix: ```java @Test void list_drafts_does_not_return_other_users_drafts() { // user A creates a draft; authenticated as user B (both have BLOG_WRITE) // GET /api/geschichten?status=DRAFT should return empty list for user B } ``` - **Permission detection is server-side — safe.** `isReader = !canWrite && !canAnnotate` computed in `+page.server.ts` via `await parent()` runs on the SvelteKit Node server, not in the browser. A user cannot forge these flags. - **Reader fetch path defense-in-depth looks correct.** When `isReader` is true, the load function should never call the transcription queue, enrichment, or weekly stats endpoints. No draft data is fetched for pure readers (only BLOG_WRITE users get that branch). This is the correct data-minimization approach. - **Stats endpoint exposes aggregate counts (847 docs, 94 persons, 12 stories)** — these are non-sensitive totals. No concern. - **"Zuletzt aktualisiert" feed** exposes documents to READ_ALL users, which is already the minimum required permission. No additional guard needed here. ### Recommendations - **Fix the DRAFT author leak in `GeschichteService.list()` before shipping this feature** (or any feature that calls the DRAFT filter). This is not a new vulnerability introduced by this issue — it exists today — but this issue would make it visible in the UI. - **Never add `authorId` as a query parameter for drafts.** Derive it from the security context inside the service. - Add the regression test above as a `@WebMvcTest` slice test in `GeschichteControllerTest`.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Observations

  • The 4 Gherkin ACs are well-formed. They cover the three permission states and the "Alle N Personen" chip count constraint. Good baseline.

  • Missing acceptance criteria:

    Gap Why it matters
    Given WRITE_ALL AND BLOG_WRITE A user with both should see contributor dashboard. The AC only covers WRITE_ALL alone — the overlap case is untested.
    Empty states Given there are 0 published stories, When I view the reader dashboard, Then I see an empty Geschichten card (no crash, no error)
    API error handling Given the persons endpoint returns 5xx, When I view the reader dashboard, Then the other sections still render — the current contributor dashboard uses Promise.allSettled for resilience; the reader load function must do the same
    Draft empty state Given I have BLOG_WRITE and no drafts, Then I see "Keine Entwürfe" (not a blank card)
  • +page.server.ts load branching needs unit tests via direct import (SvelteKit load functions are plain TypeScript):

    it('reader branch: does not call transcription endpoints', async () => {
        const mockParent = vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false });
        // verify api.GET is never called for /api/transcription/*
    });
    
  • Permission boundary test (WebMvcTest): The OQ-03 fix (DRAFT author filter) needs an explicit 403/empty test. Without it, the fix is untested and could regress.

  • NFR-PERF "≤ 2 s on broadband" is stated but has no corresponding test. A k6 smoke test or Playwright performance mark assertion would make this verifiable in CI.

  • Playwright E2E coverage: The existing E2E tests likely cover contributor flow. A new Playwright fixture with a READ_ALL-only user account is needed to cover the reader dashboard critical path.

Recommendations

  • Add the 4 missing ACs to the issue before implementation starts.
  • Write a Vitest unit test for the +page.server.ts branching logic — it's the most critical path and easy to test directly.
  • Add a READ_ALL test user to the E2E fixtures (or seed data) specifically for reader dashboard coverage.
  • Use Promise.allSettled in the reader load function, mirroring the contributor branch's pattern — and verify that partial failures degrade gracefully in a unit test.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Observations - **The 4 Gherkin ACs are well-formed.** They cover the three permission states and the "Alle N Personen" chip count constraint. Good baseline. - **Missing acceptance criteria:** | Gap | Why it matters | |---|---| | `Given WRITE_ALL AND BLOG_WRITE` | A user with both should see contributor dashboard. The AC only covers `WRITE_ALL` alone — the overlap case is untested. | | Empty states | `Given there are 0 published stories, When I view the reader dashboard, Then I see an empty Geschichten card (no crash, no error)` | | API error handling | `Given the persons endpoint returns 5xx, When I view the reader dashboard, Then the other sections still render` — the current contributor dashboard uses `Promise.allSettled` for resilience; the reader load function must do the same | | Draft empty state | `Given I have BLOG_WRITE and no drafts, Then I see "Keine Entwürfe" (not a blank card)` | - **`+page.server.ts` load branching** needs unit tests via direct import (SvelteKit load functions are plain TypeScript): ```typescript it('reader branch: does not call transcription endpoints', async () => { const mockParent = vi.fn().mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }); // verify api.GET is never called for /api/transcription/* }); ``` - **Permission boundary test (WebMvcTest)**: The OQ-03 fix (DRAFT author filter) needs an explicit 403/empty test. Without it, the fix is untested and could regress. - **NFR-PERF "≤ 2 s on broadband"** is stated but has no corresponding test. A k6 smoke test or Playwright performance mark assertion would make this verifiable in CI. - **Playwright E2E coverage**: The existing E2E tests likely cover contributor flow. A new Playwright fixture with a `READ_ALL`-only user account is needed to cover the reader dashboard critical path. ### Recommendations - Add the 4 missing ACs to the issue before implementation starts. - Write a Vitest unit test for the `+page.server.ts` branching logic — it's the most critical path and easy to test directly. - Add a `READ_ALL` test user to the E2E fixtures (or seed data) specifically for reader dashboard coverage. - Use `Promise.allSettled` in the reader load function, mirroring the contributor branch's pattern — and verify that partial failures degrade gracefully in a unit test.
Author
Owner

🎨 Leonie Voss — UX Design Lead & Accessibility Strategist

Observations

The spec is thorough and the Final designation resolves all visual decisions cleanly. A few implementation gaps to call out:

Touch targets on mobile — Critical for this audience
The spec shows person cards (M-PCARD) with padding:7px 8px at mockup scale. At real scale, these cards need min-h-[44px] explicitly added. The 60+ audience this targets requires 44px minimum, 48px preferred. Person chip cards are the primary navigation entry — this is the highest-risk touch target on the page.

<!-- Implementation must add -->
<a class="... min-h-[44px]" href="/persons/{person.id}">

Stat tiles in the header also need to verify minimum tap area, especially on mobile where the stats stack vertically.

Avatar color assignment — not specified
The spec hardcodes colors per person (#002850, #1A4A6B, etc.) but the real implementation needs a deterministic color strategy. Recommendation: hash the person's UUID into one of 6–8 pre-selected brand-adjacent colors. This must be consistent (same person = same color across sessions) and accessible (all avatar colors must pass AA on white text).

Focus rings — not specified in the spec
Every <a> tile (stat tiles, person chips, doc rows, story rows) needs a visible focus ring. The pattern elsewhere in the app is focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 outline-none rounded-sm. Add this to all interactive elements in the new components.

Mobile stat label i18n
The spec lists dashboard.stat.documents → "Dokumente" / mobile: "Dok." — but these are two different strings. Either add a .short variant per key (e.g. dashboard.stat.documents.short) or use responsive Tailwind to show/hide two separate elements:

<span class="hidden sm:block">{m.dashboard_stat_documents()}</span>
<span class="sm:hidden">{m.dashboard_stat_documents_short()}</span>

Both must be in all three message files (de, en, es).

bg-white for header bar — intentional, follow the spec
The impl-ref table calls this out explicitly: "Explizit bg-white, nicht bg-surface". This is a conscious B.1 design decision. Do not substitute bg-surface even though it's the usual card pattern.

Dark mode breakpoint annotation gap
The spec provides the full dark token mapping. The DK-HSTAT-LABEL annotation says dark:text-ink-4 (#6070A0 — WCAG AA ≥ 4.5:1 on #161C27). Verify this ratio in implementation — #6070A0 on #161C27 computes to ~4.6:1 which just barely passes AA. Don't reduce this further.

OQ-04 (badge for upload vs transcription update) — the spec shows document type icons (file icon vs image/photo icon) which implicitly communicate the document type. This answers the spirit of OQ-04 without requiring a new badge concept. Recommend resolving OQ-04 as "document type icon is sufficient."

Recommendations

  • Add min-h-[44px] to PCARD and HSTAT link elements. Verify all interactive elements meet 44px minimum at mobile.
  • Define avatar color strategy before implementing ReaderPersonChips.svelte. A deterministic UUID-to-color hash with 6+ accessible colors is the standard approach.
  • Add focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 to every <a> element in the new components — spec doesn't call it out but it's required for accessibility and consistency with the rest of the app.
  • Add .short i18n keys for mobile stat labels; confirm all three language files are updated.
## 🎨 Leonie Voss — UX Design Lead & Accessibility Strategist ### Observations The spec is thorough and the Final designation resolves all visual decisions cleanly. A few implementation gaps to call out: **Touch targets on mobile — Critical for this audience** The spec shows person cards (`M-PCARD`) with `padding:7px 8px` at mockup scale. At real scale, these cards need `min-h-[44px]` explicitly added. The 60+ audience this targets requires 44px minimum, 48px preferred. Person chip cards are the primary navigation entry — this is the highest-risk touch target on the page. ```svelte <!-- Implementation must add --> <a class="... min-h-[44px]" href="/persons/{person.id}"> ``` Stat tiles in the header also need to verify minimum tap area, especially on mobile where the stats stack vertically. **Avatar color assignment — not specified** The spec hardcodes colors per person (`#002850`, `#1A4A6B`, etc.) but the real implementation needs a deterministic color strategy. Recommendation: hash the person's UUID into one of 6–8 pre-selected brand-adjacent colors. This must be consistent (same person = same color across sessions) and accessible (all avatar colors must pass AA on white text). **Focus rings — not specified in the spec** Every `<a>` tile (stat tiles, person chips, doc rows, story rows) needs a visible focus ring. The pattern elsewhere in the app is `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 outline-none rounded-sm`. Add this to all interactive elements in the new components. **Mobile stat label i18n** The spec lists `dashboard.stat.documents` → "Dokumente" / mobile: "Dok." — but these are two different strings. Either add a `.short` variant per key (e.g. `dashboard.stat.documents.short`) or use responsive Tailwind to show/hide two separate elements: ```svelte <span class="hidden sm:block">{m.dashboard_stat_documents()}</span> <span class="sm:hidden">{m.dashboard_stat_documents_short()}</span> ``` Both must be in all three message files (`de`, `en`, `es`). **`bg-white` for header bar — intentional, follow the spec** The impl-ref table calls this out explicitly: "Explizit `bg-white`, nicht `bg-surface`". This is a conscious B.1 design decision. Do not substitute `bg-surface` even though it's the usual card pattern. **Dark mode breakpoint annotation gap** The spec provides the full dark token mapping. The `DK-HSTAT-LABEL` annotation says `dark:text-ink-4 (#6070A0 — WCAG AA ≥ 4.5:1 on #161C27)`. Verify this ratio in implementation — `#6070A0` on `#161C27` computes to ~4.6:1 which just barely passes AA. Don't reduce this further. **OQ-04 (badge for upload vs transcription update)** — the spec shows document type icons (file icon vs image/photo icon) which implicitly communicate the document type. This answers the spirit of OQ-04 without requiring a new badge concept. Recommend resolving OQ-04 as "document type icon is sufficient." ### Recommendations - Add `min-h-[44px]` to `PCARD` and `HSTAT` link elements. Verify all interactive elements meet 44px minimum at mobile. - Define avatar color strategy before implementing `ReaderPersonChips.svelte`. A deterministic UUID-to-color hash with 6+ accessible colors is the standard approach. - Add `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2` to every `<a>` element in the new components — spec doesn't call it out but it's required for accessibility and consistency with the rest of the app. - Add `.short` i18n keys for mobile stat labels; confirm all three language files are updated.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

The spec is unusually complete for a feature of this scope. The five-zone layout, permission detection logic, and component list are all precise and testable. A few gaps:

OQ-04 is unresolved and should be decided before implementation
The open question "Should Zuletzt aktualisiert distinguish uploads from transcription updates with a badge?" is listed but not answered. The spec already shows document-type icons (file vs photo icon in the mockup) which implicitly distinguish document types — but this is different from distinguishing what changed (new upload vs. transcription edited). If OQ-04 is a "no badge needed" decision, close it explicitly. If it's still open, it blocks the final design of ReaderRecentDocs.svelte.

Missing edge case: WRITE_ALL + BLOG_WRITE combination
The issue says: "BLOG_WRITE users land on the reader dashboard." But also: "WRITE_ALL → contributor dashboard." What happens if a user has both? The detection logic isReader = !canWrite && !canAnnotate resolves this correctly (WRITE_ALL wins), but there is no Gherkin AC that covers this state. Add:

Given I am logged in with WRITE_ALL AND BLOG_WRITE, When I navigate to the dashboard, Then I see the contributor dashboard (not the reader dashboard).

"Alle N Personen →" — count source is implicit
The label shows "Alle 94 Personen →". The count comes from persons.length (all persons are fetched). This is correct given the current GET /api/persons returns all persons. State this explicitly: the count is persons.length from the fetch result, not a separate API call. This avoids an implementor making a second stats request.

"Zuletzt aktualisiert" has no document status filter
ORDER BY updated_at DESC with no status filter could surface PLACEHOLDER documents — created via Excel import with no file yet. Readers cannot interact with placeholders in any meaningful way (no file to view, incomplete metadata). Should status != PLACEHOLDER be added? This is a requirement gap worth deciding before implementation.

Dark mode is an implied NFR, not a stated one
The spec provides complete dark token mappings. However, the NFR table in the issue doesn't list dark mode as a requirement. Given the rest of the app supports dark mode, add:

DARK: All reader dashboard components support dark mode via Tailwind dark: variants per the spec token mappings.

Missing error state specification
No AC covers: what does the reader dashboard render if one of the four API calls fails? The contributor dashboard uses Promise.allSettled with null fallbacks and the components render empty states. The reader load function should do the same — specify this explicitly so the implementation doesn't accidentally crash on partial failures.

Recommendations

  • Close OQ-04 with a concrete decision (spec icons sufficient, or add badge) before implementation.
  • Add the WRITE_ALL + BLOG_WRITE AC to the issue.
  • Add a status != PLACEHOLDER filter to the "Zuletzt aktualisiert" requirement, or explicitly decide to include placeholders.
  • Add dark mode as an NFR.
  • Add an error-state AC for partial API failures.
## 📋 Elicit — Requirements Engineer ### Observations The spec is unusually complete for a feature of this scope. The five-zone layout, permission detection logic, and component list are all precise and testable. A few gaps: **OQ-04 is unresolved and should be decided before implementation** The open question "Should Zuletzt aktualisiert distinguish uploads from transcription updates with a badge?" is listed but not answered. The spec already shows document-type icons (file vs photo icon in the mockup) which implicitly distinguish document types — but this is different from distinguishing *what changed* (new upload vs. transcription edited). If OQ-04 is a "no badge needed" decision, close it explicitly. If it's still open, it blocks the final design of `ReaderRecentDocs.svelte`. **Missing edge case: `WRITE_ALL + BLOG_WRITE` combination** The issue says: "BLOG_WRITE users land on the reader dashboard." But also: "WRITE_ALL → contributor dashboard." What happens if a user has both? The detection logic `isReader = !canWrite && !canAnnotate` resolves this correctly (WRITE_ALL wins), but there is no Gherkin AC that covers this state. Add: > Given I am logged in with WRITE_ALL AND BLOG_WRITE, When I navigate to the dashboard, Then I see the contributor dashboard (not the reader dashboard). **"Alle N Personen →" — count source is implicit** The label shows "Alle 94 Personen →". The count comes from `persons.length` (all persons are fetched). This is correct given the current `GET /api/persons` returns all persons. State this explicitly: the count is `persons.length` from the fetch result, not a separate API call. This avoids an implementor making a second stats request. **"Zuletzt aktualisiert" has no document status filter** `ORDER BY updated_at DESC` with no status filter could surface `PLACEHOLDER` documents — created via Excel import with no file yet. Readers cannot interact with placeholders in any meaningful way (no file to view, incomplete metadata). Should `status != PLACEHOLDER` be added? This is a requirement gap worth deciding before implementation. **Dark mode is an implied NFR, not a stated one** The spec provides complete dark token mappings. However, the NFR table in the issue doesn't list dark mode as a requirement. Given the rest of the app supports dark mode, add: > **DARK**: All reader dashboard components support dark mode via Tailwind `dark:` variants per the spec token mappings. **Missing error state specification** No AC covers: what does the reader dashboard render if one of the four API calls fails? The contributor dashboard uses `Promise.allSettled` with null fallbacks and the components render empty states. The reader load function should do the same — specify this explicitly so the implementation doesn't accidentally crash on partial failures. ### Recommendations - Close OQ-04 with a concrete decision (spec icons sufficient, or add badge) before implementation. - Add the `WRITE_ALL + BLOG_WRITE` AC to the issue. - Add a `status != PLACEHOLDER` filter to the "Zuletzt aktualisiert" requirement, or explicitly decide to include placeholders. - Add dark mode as an NFR. - Add an error-state AC for partial API failures.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Observations

This is a pure frontend/backend code change with no infrastructure implications. No new Docker services, volumes, or config.

PERF NFR is measurable but currently unverified
The NFR "reader dashboard loads in ≤ 2 s on broadband" is a real, stated requirement. If Prometheus is scraping endpoint latencies (which it should be for /api/stats, /api/persons, /api/documents, /api/geschichten), the p95 response time for each of these endpoints can be read from Grafana before and after the change. The ~4-call reader load should measurably outperform the current 10-call contributor load.

If a k6 smoke test exists for the dashboard endpoint, add a reader-role variant. If not, a simple Playwright performance.now() assertion in the E2E test would confirm the NFR.

Reduction from 10 → 4 API calls is infrastructure-friendly
The current contributor dashboard fires 10 parallel API calls on every page load. The reader branch fires ~4. On the CX32 VPS (4 vCPU, 8GB RAM), this reduces database query pressure for the majority of users (readers outnumber contributors in most families). No sizing concern — this is a net positive.

No new backend endpoints means no new routes to expose or lock down
DocumentSort.UPDATED_AT and the GeschichteService DRAFT author fix are additive changes to existing controllers. No new actuator endpoints, no new path patterns to add to the Caddy config or security config.

CI pipeline impact: none
No new Docker images, no new environment variables, no compose changes. Existing test suite coverage + the new unit/E2E tests from Sara's recommendations are the only CI additions.

No concerns from my angle — clean change from an infra perspective. Verify the PERF NFR against Grafana after deployment.

## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Observations This is a pure frontend/backend code change with no infrastructure implications. No new Docker services, volumes, or config. **PERF NFR is measurable but currently unverified** The NFR "reader dashboard loads in ≤ 2 s on broadband" is a real, stated requirement. If Prometheus is scraping endpoint latencies (which it should be for `/api/stats`, `/api/persons`, `/api/documents`, `/api/geschichten`), the p95 response time for each of these endpoints can be read from Grafana before and after the change. The ~4-call reader load should measurably outperform the current 10-call contributor load. If a k6 smoke test exists for the dashboard endpoint, add a reader-role variant. If not, a simple Playwright `performance.now()` assertion in the E2E test would confirm the NFR. **Reduction from 10 → 4 API calls is infrastructure-friendly** The current contributor dashboard fires 10 parallel API calls on every page load. The reader branch fires ~4. On the CX32 VPS (4 vCPU, 8GB RAM), this reduces database query pressure for the majority of users (readers outnumber contributors in most families). No sizing concern — this is a net positive. **No new backend endpoints means no new routes to expose or lock down** `DocumentSort.UPDATED_AT` and the GeschichteService DRAFT author fix are additive changes to existing controllers. No new actuator endpoints, no new path patterns to add to the Caddy config or security config. **CI pipeline impact: none** No new Docker images, no new environment variables, no compose changes. Existing test suite coverage + the new unit/E2E tests from Sara's recommendations are the only CI additions. No concerns from my angle — clean change from an infra perspective. Verify the PERF NFR against Grafana after deployment.
Author
Owner

🗳️ Decision Queue — Action Required

2 decisions need your input before implementation starts.


UX

  • OQ-04: Badge for "Zuletzt aktualisiert" or not? — The issue leaves this open. Leonie observes that the spec already shows document-type icons (file icon vs. photo icon), which implicitly communicate document type. Option A: close as "no badge — doc-type icon is sufficient." Option B: add a small badge distinguishing "upload" from "transcription updated." Option A is simpler and avoids scope creep; Option B adds context that might help readers understand why a document they already know appears in the feed again. (Raised by: Elicit, Leonie)

Data

  • PLACEHOLDER documents in "Zuletzt aktualisiert": The feed uses ORDER BY updated_at DESC with no status filter. PLACEHOLDER documents (created via Excel import, no file attached yet) would surface in this feed. Readers cannot view them — no file, likely incomplete metadata. Option A: add status != PLACEHOLDER to the documents query — readers get a cleaner, actionable feed. Option B: include them — simpler implementation, no new query param. If the archive rarely has long-lived PLACEHOLDERs this may not matter in practice, but it needs an explicit decision. (Raised by: Elicit)

All other findings (OQ-01, OQ-02, OQ-03 fixes, missing ACs, touch targets, focus rings, i18n keys) have concrete recommended solutions and do not require product input — implementation can proceed on those once the two items above are decided.

## 🗳️ Decision Queue — Action Required _2 decisions need your input before implementation starts._ --- ### UX - **OQ-04: Badge for "Zuletzt aktualisiert" or not?** — The issue leaves this open. Leonie observes that the spec already shows document-type icons (file icon vs. photo icon), which implicitly communicate document type. Option A: close as "no badge — doc-type icon is sufficient." Option B: add a small badge distinguishing "upload" from "transcription updated." Option A is simpler and avoids scope creep; Option B adds context that might help readers understand why a document they already know appears in the feed again. _(Raised by: Elicit, Leonie)_ ### Data - **PLACEHOLDER documents in "Zuletzt aktualisiert":** The feed uses `ORDER BY updated_at DESC` with no status filter. `PLACEHOLDER` documents (created via Excel import, no file attached yet) would surface in this feed. Readers cannot view them — no file, likely incomplete metadata. Option A: add `status != PLACEHOLDER` to the documents query — readers get a cleaner, actionable feed. Option B: include them — simpler implementation, no new query param. If the archive rarely has long-lived PLACEHOLDERs this may not matter in practice, but it needs an explicit decision. _(Raised by: Elicit)_ --- _All other findings (OQ-01, OQ-02, OQ-03 fixes, missing ACs, touch targets, focus rings, i18n keys) have concrete recommended solutions and do not require product input — implementation can proceed on those once the two items above are decided._
Author
Owner

🎨 Leonie Voss — UX Design Lead & Accessibility Strategist

UI discussion walkthrough — five open items resolved, no skipped items.


OQ-04: Badge in "Zuletzt aktualisiert"

Decision: show "Neu" / "Aktualisiert" badge based on created_at == updated_at.

There is only one document type, so a doc-type icon adds no value. What is meaningful to readers: knowing whether a document is new to them or was recently updated (sender changed, transcription improved, etc.).

Implementation notes:

  • Two i18n keys needed: dashboard.badge.new / dashboard.badge.updated (de/en/es)
  • Badge must not rely on color alone — text label is sufficient
  • Verify created_at is present in the document response DTO for the reader feed

PLACEHOLDER documents in the feed

Decision: no status != PLACEHOLDER filter needed.

PLACEHOLDERs are transient — the import process attaches the PDF immediately. They won't realistically surface in the feed.


Avatar color strategy for person chips

Decision: reuse existing avatar color logic.

The project already has a deterministic color assignment mechanism. ReaderPersonChips.svelte should use it directly — no new strategy needed.


Dark mode contrast (text-ink-4 annotation in spec)

Decision: use text-ink-3 throughout, consistent with the existing dashboard.

The spec annotated DK-HSTAT-LABEL as dark:text-ink-4 (#6070A0, ~4.6:1) — but ink-4 does not exist as a token. The existing dashboard uses text-ink-3 everywhere for secondary labels. In dark mode text-ink-3 resolves to #8b97a5 at 7.1:1 — solid AAA. The spec annotation was incorrect; follow the existing token.


Mobile stats strip

Decision: hide the stats strip entirely below sm breakpoint.

Stats (document count, person count, story count) are not important enough to warrant abbreviated labels or responsive text switching on mobile. Use hidden sm:flex on the stats strip container. No .short i18n key variants needed.


All five items resolved. Implementation can proceed on the full spec without further UI/UX input needed.

## 🎨 Leonie Voss — UX Design Lead & Accessibility Strategist UI discussion walkthrough — five open items resolved, no skipped items. --- ### ✅ OQ-04: Badge in "Zuletzt aktualisiert" **Decision: show "Neu" / "Aktualisiert" badge based on `created_at == updated_at`.** There is only one document type, so a doc-type icon adds no value. What _is_ meaningful to readers: knowing whether a document is new to them or was recently updated (sender changed, transcription improved, etc.). Implementation notes: - Two i18n keys needed: `dashboard.badge.new` / `dashboard.badge.updated` (de/en/es) - Badge must not rely on color alone — text label is sufficient - Verify `created_at` is present in the document response DTO for the reader feed --- ### ✅ PLACEHOLDER documents in the feed **Decision: no `status != PLACEHOLDER` filter needed.** PLACEHOLDERs are transient — the import process attaches the PDF immediately. They won't realistically surface in the feed. --- ### ✅ Avatar color strategy for person chips **Decision: reuse existing avatar color logic.** The project already has a deterministic color assignment mechanism. `ReaderPersonChips.svelte` should use it directly — no new strategy needed. --- ### ✅ Dark mode contrast (`text-ink-4` annotation in spec) **Decision: use `text-ink-3` throughout, consistent with the existing dashboard.** The spec annotated `DK-HSTAT-LABEL` as `dark:text-ink-4` (`#6070A0`, ~4.6:1) — but `ink-4` does not exist as a token. The existing dashboard uses `text-ink-3` everywhere for secondary labels. In dark mode `text-ink-3` resolves to `#8b97a5` at 7.1:1 — solid AAA. The spec annotation was incorrect; follow the existing token. --- ### ✅ Mobile stats strip **Decision: hide the stats strip entirely below `sm` breakpoint.** Stats (document count, person count, story count) are not important enough to warrant abbreviated labels or responsive text switching on mobile. Use `hidden sm:flex` on the stats strip container. No `.short` i18n key variants needed. --- All five items resolved. Implementation can proceed on the full spec without further UI/UX input needed.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Developer discussion walkthrough — four open items resolved, plus one spec correction captured.


created_at in document DTO

Both createdAt and updatedAt are already on the Document entity with @Schema(requiredMode = REQUIRED). They're already in the generated TypeScript types. No backend change needed for the badge — ReaderRecentDocs.svelte can use both fields directly.


Implementation order — backend blockers first

Decision: Option A — single PR, backend changes land before frontend components.

Three confirmed backend changes are needed before their corresponding frontend components can be built:

  • DocumentSort.UPDATED_AT — blocks ReaderRecentDocs.svelte
  • GeschichteService DRAFT author filter — blocks ReaderDraftsModule.svelte
  • GET /api/persons?sort=documentCount,desc&size=4 — blocks ReaderPersonChips.svelte (see spec correction below)

The unblocked components (greeting, ReaderStatsStrip, badge logic) can be built in parallel. All ship together in one PR — no half-finished intermediate state.


Promise.allSettled in reader load function

Required. The reader load function fires ~4–5 parallel calls. Use Promise.allSettled with null fallbacks, mirroring the contributor branch. A single failing endpoint renders an empty section — not a blank page. Critical for the older audience this dashboard targets.


GeschichteService DRAFT fix — this PR

Decision: fix lands in this PR, not a separate one. The fix is small (GeschichteService.list(): when status == DRAFT, implicitly AND author = currentUser()). Bundling it here is fine for a solo project — no separate security PR needed.


📝 Spec correction: OQ-01 is now a backend change

The original spec said "Alle N Personen →" with a count. To get that count, Felix's earlier comment proposed fetching all persons and sorting/slicing in +page.server.ts.

Decision: drop the count — link becomes "Alle Personen →" with no number. This means we don't need to fetch all persons, but we still need the top 4 by documentCount. That requires server-side support: GET /api/persons?sort=documentCount,desc&size=4. OQ-01 is now a backend change, not a frontend-only fix.


All four backend changes confirmed before frontend implementation starts. The feature is self-contained in one PR.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer Developer discussion walkthrough — four open items resolved, plus one spec correction captured. --- ### ✅ `created_at` in document DTO Both `createdAt` and `updatedAt` are already on the `Document` entity with `@Schema(requiredMode = REQUIRED)`. They're already in the generated TypeScript types. No backend change needed for the badge — `ReaderRecentDocs.svelte` can use both fields directly. --- ### ✅ Implementation order — backend blockers first **Decision: Option A — single PR, backend changes land before frontend components.** Three confirmed backend changes are needed before their corresponding frontend components can be built: - `DocumentSort.UPDATED_AT` — blocks `ReaderRecentDocs.svelte` - `GeschichteService` DRAFT author filter — blocks `ReaderDraftsModule.svelte` - `GET /api/persons?sort=documentCount,desc&size=4` — blocks `ReaderPersonChips.svelte` (see spec correction below) The unblocked components (greeting, `ReaderStatsStrip`, badge logic) can be built in parallel. All ship together in one PR — no half-finished intermediate state. --- ### ✅ `Promise.allSettled` in reader load function **Required.** The reader load function fires ~4–5 parallel calls. Use `Promise.allSettled` with null fallbacks, mirroring the contributor branch. A single failing endpoint renders an empty section — not a blank page. Critical for the older audience this dashboard targets. --- ### ✅ GeschichteService DRAFT fix — this PR **Decision: fix lands in this PR, not a separate one.** The fix is small (`GeschichteService.list()`: when `status == DRAFT`, implicitly AND `author = currentUser()`). Bundling it here is fine for a solo project — no separate security PR needed. --- ### 📝 Spec correction: OQ-01 is now a backend change The original spec said "Alle N Personen →" with a count. To get that count, Felix's earlier comment proposed fetching all persons and sorting/slicing in `+page.server.ts`. **Decision: drop the count — link becomes "Alle Personen →" with no number.** This means we don't need to fetch all persons, but we still need the top 4 by `documentCount`. That requires server-side support: `GET /api/persons?sort=documentCount,desc&size=4`. OQ-01 is now a backend change, not a frontend-only fix. --- All four backend changes confirmed before frontend implementation starts. The feature is self-contained in one PR.
Author
Owner

🏗️ Markus Keller — Application Architect

Architecture discussion walkthrough — three open items resolved, all affecting backend implementation.


documentCount sort implementation

documentCount on PersonSummaryDTO is computed via two correlated subqueries in a native SQL query (sender_id count + document_receivers count). It is not a stored column.

Sorting by it server-side is valid — PostgreSQL supports ORDER BY documentCount DESC when documentCount is a SELECT alias. For a dedicated top-N query, the implementation is a new repository method:

@Query(value = "SELECT ... AS documentCount FROM persons p ORDER BY documentCount DESC LIMIT :limit",
       nativeQuery = true)
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);

No DB index needed at family archive scale.


Pageable on PersonController

Decision: use Spring Data Pageable, aligned with the existing persons ordering ticket.

The dashboard calls GET /api/persons?page=0&size=4&sort=documentCount,desc. This keeps PersonController consistent with the rest of the API.

Implementation note: native queries with Pageable require an explicit countQuery annotation — Spring Data cannot derive it from the correlated subquery form:

@Query(value = "SELECT ... AS documentCount FROM persons p",
       countQuery = "SELECT COUNT(*) FROM persons",
       nativeQuery = true)
Page<PersonSummaryDTO> findAllWithDocumentCount(Pageable pageable);

The sort field documentCount must match the SQL alias exactly — worth a test to confirm it resolves correctly via Pageable.


Index on documents.updated_at

No existing index on documents.updated_at — only a GIN full-text index exists on the documents table. transcription_blocks got one in V38 but documents did not.

Decision: this PR adds a Flyway migration:

CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC);

DESC matches the query direction (ORDER BY updated_at DESC) and eliminates the sort step. Additive, zero downtime risk, cheap to add now before the table grows further.


All backend changes are now fully specified. The PR scope is: DocumentSort.UPDATED_AT + GeschichteService DRAFT author filter + Pageable on PersonController + idx_documents_updated_at migration — then all five frontend components.

## 🏗️ Markus Keller — Application Architect Architecture discussion walkthrough — three open items resolved, all affecting backend implementation. --- ### ✅ `documentCount` sort implementation `documentCount` on `PersonSummaryDTO` is computed via two correlated subqueries in a native SQL query (`sender_id` count + `document_receivers` count). It is **not** a stored column. Sorting by it server-side is valid — PostgreSQL supports `ORDER BY documentCount DESC` when `documentCount` is a SELECT alias. For a dedicated top-N query, the implementation is a new repository method: ```java @Query(value = "SELECT ... AS documentCount FROM persons p ORDER BY documentCount DESC LIMIT :limit", nativeQuery = true) List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit); ``` No DB index needed at family archive scale. --- ### ✅ Pageable on PersonController **Decision: use Spring Data Pageable, aligned with the existing persons ordering ticket.** The dashboard calls `GET /api/persons?page=0&size=4&sort=documentCount,desc`. This keeps PersonController consistent with the rest of the API. Implementation note: native queries with Pageable require an explicit `countQuery` annotation — Spring Data cannot derive it from the correlated subquery form: ```java @Query(value = "SELECT ... AS documentCount FROM persons p", countQuery = "SELECT COUNT(*) FROM persons", nativeQuery = true) Page<PersonSummaryDTO> findAllWithDocumentCount(Pageable pageable); ``` The sort field `documentCount` must match the SQL alias exactly — worth a test to confirm it resolves correctly via Pageable. --- ### ✅ Index on `documents.updated_at` No existing index on `documents.updated_at` — only a GIN full-text index exists on the documents table. `transcription_blocks` got one in V38 but documents did not. **Decision: this PR adds a Flyway migration:** ```sql CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC); ``` `DESC` matches the query direction (`ORDER BY updated_at DESC`) and eliminates the sort step. Additive, zero downtime risk, cheap to add now before the table grows further. --- All backend changes are now fully specified. The PR scope is: `DocumentSort.UPDATED_AT` + `GeschichteService` DRAFT author filter + Pageable on `PersonController` + `idx_documents_updated_at` migration — then all five frontend components.
Sign in to join this conversation.
No Label P1-high feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#447