feat(dashboard): permission-gated reader dashboard for READ_ALL / BLOG_WRITE users #447
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The archive has two distinct user groups:
Solution
Introduce a permission-gated reader dashboard that replaces the current dashboard for users without
WRITE_ALLorANNOTATE_ALL(including pure readers and story writers). The contributor dashboard remains unchanged.Detection Logic
BLOG_WRITEusers 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 whencanBlogWriteis true.canWrite,canAnnotate,canBlogWriteare already derived in+layout.server.tsand available in$page.dataon 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:
/documents/persons/geschichtenEach 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
canBlogWriteis true, between the stats strip and the person chips.updated_at DESC/geschichten/[id]/edit/geschichten)4. Person Chips
Top 4 persons by total document count, each linking to
/persons/[id]. Followed by "Alle N Personen →" pointing to/persons.flex flex-wrap gap-2— chips reflow gracefully at narrower viewportsdocumentCount DESC, limit 4Rationale: 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
MissionControlStripEnrichmentBlockDashboardResumeStripDashboardFamilyPulseDashboardActivityFeedDropZonecanWrite— unchangedBackend Changes
GET /api/persons?sort=documentCount,desc&size=4GET /api/documents?sort=updatedAt,desc&size=5GET /api/geschichten?published=true&sort=updatedAt,desc&size=3GET /api/geschichten?published=false&authorId=currentUser&size=10+page.server.tsload function branches onisReader: fetches the lean reader set instead of the contributor set (avoids loading transcription queues, enrichment data, weekly stats).Frontend Changes
+page.server.ts— addisReaderflag; branch fetch logic+page.svelte— conditional render: reader layout vs. existing contributor layoutsrc/lib/shared/dashboard/:ReaderStatsStrip.svelteReaderPersonChips.svelteReaderRecentDocs.svelteReaderRecentStories.svelteReaderDraftsModule.svelte(rendered conditionally oncanBlogWrite)Non-Functional Requirements
<a>elements, not<div onclick>).< md. Person chips wrap viaflex-wrap.messages/{de,en,es}.json.Out of Scope
Open Questions
GET /api/personssupportsort=documentCount,desc?GET /api/documentssupportsort=updatedAt,desc?published=false+ author filter?Acceptance Criteria
marcel referenced this issue2026-05-06 11:37:00 +02:00
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
OQ-01 is already solvable in
+page.server.tswithout a backend change.GET /api/personsreturns all persons withdocumentCount(thePersonSummaryDTOinterface 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.
DocumentSortenum is:DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, RELEVANCE— noUPDATED_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 onBLOG_WRITE. Butstatus=DRAFTreturns all users' drafts, not just the current user's. The "Meine Entwürfe" module needs the backend to filter bycurrentUser()when status=DRAFT. I'd add this implicitly inGeschichteService.list()— when status is DRAFT, AND the author=currentUser() spec automatically (no new controller param needed).isReadercomputation in+page.server.ts— the layout returnscanWrite,canAnnotate,canBlogWritefromlocals.user. The page.server.ts must callawait 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.sveltecovers only the stats part — if the greeting is also in this component, consider naming itReaderHeaderBar.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
+page.server.ts. Zero backend changes.DocumentSort.UPDATED_ATto the backend before implementingReaderRecentDocs.svelte. Don't stub this withUPLOAD_DATE— semantics differ.GeschichteService.list():if (effective == DRAFT) { spec = spec.and(hasAuthor(currentUser())); }. This is also a correctness fix regardless of this feature.+page.server.ts:const { canWrite, canAnnotate, canBlogWrite } = await parent(); const isReader = !canWrite && !canAnnotate;{#each}blocks must be keyed:{#each persons as person (person.id)}.🏗️ Markus Keller — Application Architect
Observations
Branching in
+page.server.tsis 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_ATis missing, (2) PersonController has nosort=documentCountorsizeparams, (3)GET /api/geschichten?status=DRAFTdoesn'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 inGeschichteService.list(): when status is DRAFT, implicitly restrict tocurrentUser()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 withDashboardResumeStrip,DashboardFamilyPulse, andDashboardActivityFeedwhich already live there. Good.isReaderderivation:await parent()in+page.server.tsis the SvelteKit-native approach — it avoids re-computing permissions fromlocals.userin the page load and reuses the already-computed flags from the layout. Use it.Recommendations
DocumentSort.UPDATED_AT, (2) DRAFT author filter in GeschichteService. These are blockers.authorIdas a controller parameter for the drafts filter — compute it fromcurrentUser()inside the service. Client-supplied author IDs would introduce an authorization bypass risk.+page.server.ts. If the persons list ever needs server-side pagination, that's a separate feature.🔒 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 onBLOG_WRITE, but does not filter byauthor. A BLOG_WRITE user callingGET /api/geschichten?status=DRAFTtoday 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. IfauthorIdis a client-supplied request parameter, any authenticated BLOG_WRITE user could substitute another user's UUID and read their drafts. Do not exposeauthorIdas a query parameter. Instead, fixGeschichteService.list()to always ANDauthor = currentUser()whenstatus == DRAFT.Test to write before the fix:
Permission detection is server-side — safe.
isReader = !canWrite && !canAnnotatecomputed in+page.server.tsviaawait 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
isReaderis 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
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.authorIdas a query parameter for drafts. Derive it from the security context inside the service.@WebMvcTestslice test inGeschichteControllerTest.🧪 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:
Given WRITE_ALL AND BLOG_WRITEWRITE_ALLalone — the overlap case is untested.Given there are 0 published stories, When I view the reader dashboard, Then I see an empty Geschichten card (no crash, no error)Given the persons endpoint returns 5xx, When I view the reader dashboard, Then the other sections still render— the current contributor dashboard usesPromise.allSettledfor resilience; the reader load function must do the sameGiven I have BLOG_WRITE and no drafts, Then I see "Keine Entwürfe" (not a blank card)+page.server.tsload branching needs unit tests via direct import (SvelteKit load functions are plain TypeScript):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
+page.server.tsbranching logic — it's the most critical path and easy to test directly.READ_ALLtest user to the E2E fixtures (or seed data) specifically for reader dashboard coverage.Promise.allSettledin the reader load function, mirroring the contributor branch's pattern — and verify that partial failures degrade gracefully in a unit test.🎨 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) withpadding:7px 8pxat mockup scale. At real scale, these cards needmin-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.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 isfocus-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.shortvariant per key (e.g.dashboard.stat.documents.short) or use responsive Tailwind to show/hide two separate elements:Both must be in all three message files (
de,en,es).bg-whitefor header bar — intentional, follow the specThe impl-ref table calls this out explicitly: "Explizit
bg-white, nichtbg-surface". This is a conscious B.1 design decision. Do not substitutebg-surfaceeven though it's the usual card pattern.Dark mode breakpoint annotation gap
The spec provides the full dark token mapping. The
DK-HSTAT-LABELannotation saysdark:text-ink-4 (#6070A0 — WCAG AA ≥ 4.5:1 on #161C27). Verify this ratio in implementation —#6070A0on#161C27computes 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
min-h-[44px]toPCARDandHSTATlink elements. Verify all interactive elements meet 44px minimum at mobile.ReaderPersonChips.svelte. A deterministic UUID-to-color hash with 6+ accessible colors is the standard approach.focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2to 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..shorti18n keys for mobile stat labels; confirm all three language files are updated.📋 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_WRITEcombinationThe 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 && !canAnnotateresolves this correctly (WRITE_ALL wins), but there is no Gherkin AC that covers this state. Add:"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 currentGET /api/personsreturns all persons. State this explicitly: the count ispersons.lengthfrom 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 DESCwith no status filter could surfacePLACEHOLDERdocuments — created via Excel import with no file yet. Readers cannot interact with placeholders in any meaningful way (no file to view, incomplete metadata). Shouldstatus != PLACEHOLDERbe 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:
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.allSettledwith 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
WRITE_ALL + BLOG_WRITEAC to the issue.status != PLACEHOLDERfilter to the "Zuletzt aktualisiert" requirement, or explicitly decide to include placeholders.🚀 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_ATand 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.
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
UX
Data
ORDER BY updated_at DESCwith no status filter.PLACEHOLDERdocuments (created via Excel import, no file attached yet) would surface in this feed. Readers cannot view them — no file, likely incomplete metadata. Option A: addstatus != PLACEHOLDERto 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.
🎨 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:
dashboard.badge.new/dashboard.badge.updated(de/en/es)created_atis present in the document response DTO for the reader feed✅ PLACEHOLDER documents in the feed
Decision: no
status != PLACEHOLDERfilter 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.svelteshould use it directly — no new strategy needed.✅ Dark mode contrast (
text-ink-4annotation in spec)Decision: use
text-ink-3throughout, consistent with the existing dashboard.The spec annotated
DK-HSTAT-LABELasdark:text-ink-4(#6070A0, ~4.6:1) — butink-4does not exist as a token. The existing dashboard usestext-ink-3everywhere for secondary labels. In dark modetext-ink-3resolves to#8b97a5at 7.1:1 — solid AAA. The spec annotation was incorrect; follow the existing token.✅ Mobile stats strip
Decision: hide the stats strip entirely below
smbreakpoint.Stats (document count, person count, story count) are not important enough to warrant abbreviated labels or responsive text switching on mobile. Use
hidden sm:flexon the stats strip container. No.shorti18n key variants needed.All five items resolved. Implementation can proceed on the full spec without further UI/UX input needed.
👨💻 Felix Brandt — Senior Fullstack Developer
Developer discussion walkthrough — four open items resolved, plus one spec correction captured.
✅
created_atin document DTOBoth
createdAtandupdatedAtare already on theDocumententity with@Schema(requiredMode = REQUIRED). They're already in the generated TypeScript types. No backend change needed for the badge —ReaderRecentDocs.sveltecan 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— blocksReaderRecentDocs.svelteGeschichteServiceDRAFT author filter — blocksReaderDraftsModule.svelteGET /api/persons?sort=documentCount,desc&size=4— blocksReaderPersonChips.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.allSettledin reader load functionRequired. The reader load function fires ~4–5 parallel calls. Use
Promise.allSettledwith 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(): whenstatus == DRAFT, implicitly ANDauthor = 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.
🏗️ Markus Keller — Application Architect
Architecture discussion walkthrough — three open items resolved, all affecting backend implementation.
✅
documentCountsort implementationdocumentCountonPersonSummaryDTOis computed via two correlated subqueries in a native SQL query (sender_idcount +document_receiverscount). It is not a stored column.Sorting by it server-side is valid — PostgreSQL supports
ORDER BY documentCount DESCwhendocumentCountis a SELECT alias. For a dedicated top-N query, the implementation is a new repository method: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
countQueryannotation — Spring Data cannot derive it from the correlated subquery form:The sort field
documentCountmust match the SQL alias exactly — worth a test to confirm it resolves correctly via Pageable.✅ Index on
documents.updated_atNo existing index on
documents.updated_at— only a GIN full-text index exists on the documents table.transcription_blocksgot one in V38 but documents did not.Decision: this PR adds a Flyway migration:
DESCmatches 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+GeschichteServiceDRAFT author filter + Pageable onPersonController+idx_documents_updated_atmigration — then all five frontend components.