feat(dashboard): redesign Dokumente dashboard as a document hub (Variant A) #271
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 current homepage (
/) is a wall of lists — nothing invites collaboration, nothing celebrates group progress, and there is no clear primary action. It feels institutional, not familial.Solution
Redesign the Dokumente dashboard as an action-led document hub (Variant A from the design exploration):
Design spec
docs/specs/dokumente-dashboard-spec.html— committed in the same issue.Design spec committed:
40db469→docs/specs/dokumente-dashboard-spec.htmlSections covered:
Ready to implement.
🏛️ Markus Keller — Senior Application Architect
Observations
Critical data gap: status change history.
GET /api/dashboard/pulse?period=weekneeds counts of documents transcribed / reviewed / uploaded this week. The currentDocumentmodel stores only the current status — not when it changed.createdAtcovers uploads, but transitions toTRANSCRIBED/REVIEWEDhave no timestamp. This blocks the Pulse endpoint entirely.GET /api/dashboard/resumeis derivable without a new table. Query the most recently updatedTranscriptionBlockwhereupdatedBy = currentUserand the parent document has statusUPLOADED.TranscriptionBlock.updatedByandupdatedAtalready exist — no migration needed for this endpoint.PDF thumbnail approach: generate-once is architecturally correct. Serving thumbnails on-the-fly per dashboard page load means one CPU-bound PDF render per concurrent user viewing the dashboard. That scales linearly and badly. The right design: async thumbnail generation on upload, stored in MinIO as
thumbnails/{documentId}_p1.jpg, served via presigned URL. The@Asyncpattern already used byFileServiceand the OCR pipeline applies directly.AppUserhas nocolorfield. The spec referencesperson.colorin 4 separate impl-ref tables (hero collaborator stack, mission control starter avatars, pulse contributor avatars, activity feed avatars). This field is load-bearing — avatars will have no background colour without it. It must be added before any frontend component work begins.Existing endpoint overlap.
GET /api/documents/recent-activitypartially overlaps the newGET /api/dashboard/activity. These must not coexist — delete the old endpoint in the same PR to prevent confusion and double-maintenance.DashboardControlleris a cross-cutting concern. It aggregates across documents, persons, and comments — it should live in its own package, not insidedocument/. InjectDocumentService,PersonService, and a thinActivityServicethat wraps comment + status-change queries.Recommendations
Add Flyway
V46migration addingstatus_changed_at TIMESTAMPTZ NOT NULL DEFAULT now()todocuments. Backfill withupdated_at. Going forward,DocumentServicesets this column wheneverdocument.statuschanges. This gives the Pulse endpoint "changed this week" data without event sourcing.Add
thumbnailPath StringtoDocumententity. Populate asynchronously via a newThumbnailServicethat calls the OCR service (POST /ocr/thumbnail— see Tobias's review) after upload completes. The presigned-URL logic inFileService.getPresignedUrl()already handles authenticated serving.Add
color StringtoAppUserwith@Builder.Defaultassigned deterministically:PALETTE[Math.abs(userId.hashCode()) % PALETTE.length]from a fixed 8-colour array matching the spec's colours. No user input required; consistent across sessions.Delete
/api/documents/incompleteand/api/documents/recent-activityin the same PR as the new dashboard endpoints. These are superseded and will cause confusion if left in.Do not implement the Pulse endpoint before V46 is in. Using
updated_atas a fallback produces wrong numbers becauseupdated_atchanges on any metadata edit, not just status transitions.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
Component decomposition in the spec is correct. Six clearly-named visual regions → six component files:
DashboardResumeStrip.svelte,MissionControlStrip.svelte,DashboardFamilyPulse.svelte,DashboardActivityFeed.svelte, plus changes to+page.svelteand+layout.svelte. Each maps to exactly one nameable area.TranscriptionQueueServicealready exists and delivers the three queue lists (Segmentierung / Transkription / Lesefertig). The spec's suggestion to use rawGET /api/documents?status=PLACEHOLDER&limit=5queries bypasses this. Route throughDashboardController→TranscriptionQueueServiceinstead — the logic is already tested.AppUserhas nocolorfield. Every avatar in this spec uses it. This field must exist beforeMissionControlStrip,DashboardFamilyPulse, andDashboardActivityFeedcan render correctly. It's a prerequisite, not a nice-to-have.Pull-quote for the hero card is derivable.
TranscriptionBlockhassortOrderfor ordering andtextfor content. First 200 chars of the first block (bysortOrder) gives the excerpt. No new field needed.Progress percentage is derivable.
TranscriptionQueueProjectionalready providesannotationCountandreviewedBlockCountvia native SQL.pct = reviewedBlockCount / annotationCount. Use this projection in the resume endpoint response rather than adding a denormalized field.$derivedapplies throughout. Every computed value in the new components (progressPct,isForYou,collaboratorNames, greeting time-of-day) should be$derived, not$state+$effect.Recommendations
Write
DashboardControllerTestfirst —@WebMvcTest(DashboardController.class)with three test methods:resume_returns_null_when_no_in_progress,pulse_returns_week_stats,activity_returns_activity_list. Run them red before creating the controller class.Add
color: StringtoAppUserwith@Builder.Defaultfrom a deterministic palette hash. Include@Schema(requiredMode = REQUIRED)so it propagates to the TypeScript types afternpm run generate:api. Without this, the TypeScriptAppUsertype will havecolor?: stringand every avatar will need a null-check fallback.Component test for
DashboardResumeStrip: two cases — empty state rendersm.dashboard_empty_title()heading; loaded state rendersdocument.titlein an<h2>and arole="progressbar"with correctaria-valuenow.Do not use
{#each queue as doc}without a key. All three mission control queues must be keyed:{#each queue as doc (doc.id)}.The greeting subtitle contains a document title (user-controlled content). Use
{subtitle}interpolation, never{@html subtitle}. This is already safe in Svelte by default — just flag it explicitly in the component so it's not "helpfully" changed to@htmllater.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
All three new dashboard endpoints expose aggregated data to authenticated users. This is fine — it's the same audience as the rest of the API. The risk is forgetting to add the permission annotation on a new controller. Annotate the class, not individual methods.
Thumbnail serving path needs care. If
thumbnailPathis returned as a raw MinIO path in the API response and the frontend constructs a direct MinIO URL, that URL is unauthenticated. UseFileService.getPresignedUrl(thumbnailPath)to generate a short-TTL signed URL server-side, exactly as document downloads currently work. Do not add a new unauthenticated/api/documents/{id}/thumbnailendpoint.youMentionedflag in the activity feed. This is computed by checkingDocumentComment.mentionsfor the current user's ID. The same logic already exists inNotificationController(SSE stream). Do not re-implement it independently — call the same service method or extract a sharedmentionsCurrentUser(comment, userId)helper. Two independent implementations of "does this mention me?" will diverge.Activity feed reveals who is working on what. For a family archive with
READ_ALLgating, this is acceptable — all authenticated users can see all documents already. No additional scoping is needed. Document this reasoning in a comment inDashboardControllerso a future reviewer doesn't "fix" it by adding per-user filtering.Greeting subtitle renders user-controlled content (document title:
"Klaus hat dich erwähnt in 'Brief an Frieda'"). Svelte's{variable}syntax escapes HTML automatically — no XSS risk. Would only become a risk if someone changes it to{@html ...}, which they shouldn't. Confirmed safe.No new CSRF surface. All three endpoints are
GET— they read, not write. No CSRF consideration needed.Recommendations
Annotate
DashboardControllerat class level:Class-level annotation covers all three methods. No risk of accidentally leaving one unprotected.
Return presigned thumbnail URL in
GET /api/dashboard/resumeresponse, not the rawthumbnailPath. The DTO should includethumbnailUrl: String(presigned, 15-minute TTL), not the internal S3 key. This is the same pattern asFileService.getPresignedUrl()used elsewhere.Add security tests to
DashboardControllerTest:One per endpoint variant. Standard table stakes for any new controller.
Do not expose
TranscriptionBlock.createdByorupdatedByUUIDs in the activity feed DTO. The spec needsPerson { name, initials, color }— strip internal IDs at the DTO projection layer before they reach the API response.🧪 Sara Holt — Senior QA Engineer
Observations
The spec's 10-step implementation sequence maps directly to test-driven delivery. Each step produces something independently testable: backend endpoints first, then TypeScript types, then components. This is the right order.
Family Pulse stats require
status_changed_atto be testable. Without Markus's V46 migration, any Pulse integration test that asserts "transcribed this week = 3" would be testing againstupdated_at, which changes on metadata edits. The test would be unreliable. This migration is a prerequisite for writing meaningful Pulse tests.GET /api/dashboard/resumehas three distinct cases that each need a test before implementation:Activity feed deduplication behaviour is unspecified. If a user transcribes the same document 3 times in one hour, does the feed show 3 entries or 1? The spec says "recent archive-wide activity, newest first" with
limit=7— but doesn't address collapse/dedup. This is a genuine open decision (flagged below). It must be decided before writing the feed tests.Mobile layout needs visual regression coverage. §7 of the spec includes a 320px mobile layout. Without tests at this breakpoint, layout regressions will slip through undetected on subsequent PRs.
colorfield onAppUsermust be present before component tests for any avatar-rendering component can assert correct visual output.Recommendations
Test plan for
DashboardControllerunit layer (@ExtendWith(MockitoExtension.class)):DashboardResumeServiceTest: 3 cases aboveDashboardPulseServiceTest: current-week counts correct, last-week documents excludedDashboardActivityServiceTest: returns newest first,youMentionedset correctly for current userIntegration test for Pulse stats using Testcontainers + real PostgreSQL 16: create two documents with
status_changed_atset to Monday of current week and one set to last Sunday; asserttranscribed = 2, not3.Vitest component tests:
DashboardResumeStrip: renders empty state whenresume === null; rendersrole="progressbar"with correctaria-valuenowwhen loadedDashboardActivityFeed: renders "für dich" badge whenactivity.youMentioned === true; does not render badge whenfalsePlaywright E2E: one smoke test at 1280px (dashboard loads, hero card visible, 3 mission columns visible), one at 320px (single-column stacked layout, no horizontal overflow). Total: 2 tests. Permutation coverage stays at the integration layer.
Add
@Test void pulse_counts_only_current_week_status_changes()as the first test written — before any Pulse SQL is written. It documents the exact boundary condition (status_changed_at >= start_of_current_iso_week) that the query must satisfy.🎨 Leonie Voss — UX Design Lead & Accessibility Strategist
Observations
The spec itself is well-structured. Two-layer format (scaled mockup + impl-ref table with real px values), explicit decision log, scale disclaimer. This is the correct template for this project. No changes needed to the spec format.
AppUser.coloris missing and visually load-bearing. Avatars appear in 4 distinct sections of the dashboard: hero collaborator stack, mission control row starters, pulse contributor stack, activity feed actors. Without this field, all avatars render as colorless circles. This is the single highest-priority data gap.Mission control task row touch targets fall short. The impl-ref specifies
py-2.5(10px vertical padding). At 16px font, that gives ≈36px total row height — below the WCAG 2.2 minimum of 44px for interactive elements. This is especially important given the 60+ audience.Progress bar lacks ARIA semantics. The hero card progress bar is a
<div>with a fill width. Withoutrole="progressbar"andaria-valuenow/aria-valuemax, screen readers announce nothing about transcription progress.Landmark structure is not specified. The spec mockup shows distinct page sections but doesn't call out HTML landmark roles. The mission control strip needs
<section aria-label="...">with a visible heading to be navigable by screen reader users.Empty mission control avatar has a
titleattribute noted in impl-ref ("noch niemand angefangen"). This is correct — it provides the accessible name for keyboard and screen reader users. Confirm this is implemented astitle, not just a visual tooltip via CSS.Font sizes throughout comply with the senior audience constraint. Greeting headline at 32px, pull-quote at 17px, feed text at 15px, body at 16px. No violations.
The spec documents a 320px mobile layout in §7. The grid collapses to single column. Confirmed via
grid-cols-[1fr_320px]with responsive override. Sidebar stacks below main content on mobile — this is the correct order for reading flow.Recommendations
Fix mission control row touch targets. Change
py-2.5topy-3.5(14px vertical padding). Total: 16px font + 28px padding = 44px. Exact WCAG 2.2 floor. Addmin-h-[44px] flex items-centeras belt-and-suspenders.Add ARIA to progress bar:
Add landmark structure to
+page.svelte:colorfield: recommend a fixed 8-colour curated palette matching the spec exactly:['#7a4f9a', '#5a8a6a', '#3060b0', '#a0522d', '#c0446e', '#c17a00', '#0e7490', '#1d4ed8']. Assign viahashCode(userId) % 8. These are the exact colours shown in the spec's avatar examples — consistent with the existing visual language.Run axe-core on the full dashboard page in the Playwright E2E suite. The multiple interactive sections and new ARIA roles make this a high-value automated check target.
⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
Thumbnail generation is the only infrastructure question here. Everything else (3 new API endpoints, 6 new Svelte components) runs on the existing stack with zero config changes.
Generate-once is the right approach. On-the-fly thumbnail rendering means one PDF-to-JPEG conversion per dashboard viewer. The OCR service already loads Surya/Kraken models at startup — adding a lightweight thumbnail render to that container is a 3-line change, but making it synchronous per-request is wrong. A concurrent-users spike would cause memory contention with the loaded OCR models.
The OCR service already has everything needed. It imports
pdf2imageandPillow(both used in the existing OCR pipeline). APOST /ocr/thumbnailendpoint that extracts page 1 as a 300×400px JPEG is a small addition — reusing_download_and_convert_pdf()which already handles MinIO fetching. Tobias checked: this function exists at line ~80 ofmain.py.Storage overhead is negligible. A 180×246px JPEG thumbnail is
15-30KB. 10,000 documents = 150-300MB. At Hetzner Object Storage pricing (€0.02/GB), that's under €1/year. No concern.No new volumes, no new services, no new compose entries needed. The thumbnail path is just another object in the existing
archive-documentsMinIO bucket. No structural changes todocker-compose.yml.One new async flow to document:
FileService.uploadDocument()→ fires@Async ThumbnailService.generateThumbnail(documentId)→ calls OCR servicePOST /ocr/thumbnail→ stores result in MinIO → setsdocument.thumbnailPath. This is the same pattern as the existing OCR trigger. Document it in a comment inThumbnailServiceso the next person understands the async boundary.Recommendations
Add
POST /ocr/thumbnailto the OCR service (Python/FastAPI). Request:{ pdfUrl: string }. Response: JPEG binary (Content-Type: image/jpeg). Implementation: call_download_and_convert_pdf(pdfUrl), takeimages[0], resize to 360×492px (2× retina density for 180×246px display size), return as JPEG with quality 85.Fire thumbnail generation asynchronously on document upload, not on first dashboard view. Use
@AsynconThumbnailService.generateAndStore(UUID documentId). If it fails, log and leavethumbnailPath = null— the hero card renders the SVG placeholder fallback per spec.Store thumbnails in the same MinIO bucket (
archive-documents) under athumbnails/prefix. Key:thumbnails/{documentId}_p1.jpg. Keeps backup and restore scope unified — one bucket, one backup policy.Cache-Control on presigned thumbnail URLs. Set
Cache-Control: max-age=86400on presigned URL responses fromFileService. Thumbnails don't change unless the document is replaced (rare). This prevents redundant signed URL regeneration on every dashboard reload.Add a Grafana panel tracking MinIO bucket size by prefix (
thumbnails/vsdocuments/). Cheap to add now, avoids surprises at scale. Alert threshold: >2GB total (generous ceiling for a family archive).🗳️ Decision Queue — Action Required
1 decision needs your input before implementation starts.
Activity Feed
Activity feed deduplication — If a user (or the same user) acts on the same document multiple times in quick succession (e.g. saves a transcription block 3 times in one hour), should the
GET /api/dashboard/activityfeed show 3 separate entries or collapse them into 1?GROUP BY actor, document, kind, time_bucket). The "vor 12 Min." timestamp would reflect the most recent action in the bucket.(Raised by: Sara)
🏛️ Markus Keller — Architecture follow-up: audit log prerequisite
Following the discussion about Family Pulse data sources, we concluded that
Document.updatedAtis too coarse to drive reliable stats — it fires on every field change with no actor context and no semantic distinction between "user saved transcription text" and "system updated status."Decision: build a dedicated audit log first, then the dashboard on top of it.
Created #274 — feat(audit): domain-level audit log for archive activity as a prerequisite for this issue.
Key decisions made in this discussion:
status_changed_atmigration (previously proposed V46) is dropped. The audit log supersedes it cleanly.Document.updatedAtaccepted as slightly imprecise for metadata events — the audit log will be the authoritative source going forward.audit_log, not from ad-hoc queries across multiple tables.Implementation sequence update: #274 must be merged before the dashboard backend endpoints in this issue can be implemented.
🏛️ Markus Keller — Architecture discussion summary
Follow-up discussion resolving all remaining open items for the dashboard backend design.
Resolved: Family Pulse stats
All stats and their data sources are now fully defined:
COUNT(DISTINCT (document_id, payload->>'pageNumber'))onANNOTATION_CREATED+TEXT_SAVEDevents inaudit_logCOUNT(*)onANNOTATION_CREATED— each annotation is created onceCOUNT(DISTINCT entity_id)onTEXT_SAVED— distinct blocks with text, not raw savesCOUNT(DISTINCT document_id)onFILE_UPLOADEDpageNumberis written into the audit eventpayloadat write time (see #274) so the headline query never needs a join.Resolved: Activity feed deduplication
Collapse per actor/document/kind per hour. Query uses
DISTINCT ON (actor_id, document_id, kind, date_trunc('hour', happened_at))ordered by most recent within each bucket. The raw events remain inaudit_logfor stats — only the feed view is collapsed.Resolved: DashboardController package
Lives in its own
dashboard/package —org.raddatz.familienarchiv.dashboard. It aggregates across documents, persons, and audit log; placing it insidedocument/would make that package a cross-domain god module.Updated i18n keys
pulse_uploadedchanges from "Seiten hochgeladen" to "Dokumente hochgeladen".pulse_headlinechanges from "Ihr habt zusammen {pages} Seiten bearbeitet" to "Ihr habt {pages} Seiten bearbeitet".Implementation sequence (updated)
#274 must be merged first. Dashboard backend endpoints depend entirely on
audit_logbeing populated. No dashboard endpoint can be implemented correctly without it.🏛️ Markus Keller — Audit Log Impact on Dashboard Architecture
PR #275 is the right foundation. Three of the four dashboard data problems are now solvable. Here's the precise accounting.
What #275 unblocks
Family Pulse — all three stats are now queryable from
audit_log:COUNT(*) WHERE kind = 'ANNOTATION_CREATED' AND happened_at >= week_startCOUNT(DISTINCT document_id) WHERE kind = 'FILE_UPLOADED' AND happened_at >= week_startCOUNT(DISTINCT (document_id, payload->>'pageNumber')) WHERE kind IN ('ANNOTATION_CREATED','TEXT_SAVED')Resume card →
SELECT document_id FROM audit_log WHERE kind = 'TEXT_SAVED' AND actor_id = :userId ORDER BY happened_at DESC LIMIT 1Activity feed core events →
TEXT_SAVED,FILE_UPLOADED,ANNOTATION_CREATED,BLOCK_REVIEWEDare all populated.Data gap:
TEXT_SAVEDpayload is missingblockIdMy own comment #3354 specified: "COUNT(DISTINCT entity_id) on TEXT_SAVED — distinct blocks with text, not raw saves." The current payload is
{pageNumber: N}— there is no block/entity ID. One page can have multiple blocks; two saves on different blocks on the same page are indistinguishable.Fix before data accumulates: Add
blockIdto theTEXT_SAVEDpayload in PR #275 or as a follow-up before merging. Retroactive fix is impossible. The change is minimal:Pulse stat 2 then becomes
COUNT(DISTINCT payload->>'blockId') WHERE kind = 'TEXT_SAVED'.Data gap: comment/mention activity is not in the audit log
The spec's activity feed shows two item types that #275 does not cover:
"Klaus hat dich erwähnt"and"Lotte hat geantwortet auf deinem Kommentar". These requireDocumentCommentevents. My comment #3354 said "activity feed draws fromaudit_log" — that statement assumed comment events would be added too. They weren't.Decision needed: Either (a) add
COMMENT_ADDEDandMENTION_CREATEDevents to the audit log (cleaner — one data source), or (b) letDashboardControllermerge audit events with a separate comment query (two sources, more complex JOIN at query time). Option A is architecturally cleaner. Flagging as an open decision below.Query layer design
AuditLogRepositorycurrently extendsJpaRepositorywith no custom queries. The dashboard queries require:DISTINCT ONfor activity dedup (JPQL cannot express this)payload->>'pageNumber'(JPQL cannot express this)date_trunc('hour', happened_at)for time bucketingAll three require
@Query(nativeQuery = true). I recommend a dedicatedAuditLogQueryService(not methods dumped intoAuditLogRepository) to keep the main repository clean and the dashboard queries in one place — consistent with howDashboardControllerlives in its owndashboard/package.Minor:
@Async log()is unreachable in practiceAuditService.log()is annotated@Async("auditExecutor")but every call site useslogAfterCommit()instead. TheauditExecutorthread pool is configured and running but executes zero tasks. Either document the intended use case or removelog()before the next PR adds a third call site and picks the wrong method.Open Decisions
COMMENT_ADDED/MENTION_CREATEDevents toaudit_log(one unified data source forDashboardController) vs. merge comment table query at controller layer (two sources). Option A requires expanding PR #275 or a follow-up before dashboard backend starts. (Raised by: Markus, impacts Felix's DashboardController design)👨💻 Felix Brandt — Audit Log Impact on Dashboard Implementation
Observations
The event guards are correct and audit-accurate.
TEXT_SAVEDonly fires on!text.equals(previousText)— rapid re-saves of identical text produce no events. Pulse stat 2 won't overcount.BLOCK_REVIEWEDonly fires onfalse → truetransitions — toggling a review off emits nothing. Activity feed won't show "un-review" noise.ANNOTATION_CREATEDfires for manual annotations only (createOcrAnnotationis excluded, confirmed bycreateOcrAnnotation_doesNotLogAuditEvent). Pulse stat 1 counts user intent, not OCR automation.requireUserId(Authentication)is duplicated verbatim.Both
DocumentControllerandTranscriptionBlockControllercontain this identical private method:The dashboard
DashboardControllerwill need actor resolution too. Before it lands, extract this to aSecurityUtils.requireUserId(Authentication auth, UserService userService)static helper or move it to a shared base controller class. Three copies is the breaking point.AuditLogRepositoryneeds native SQL from the start — plan for it.The dedup query for the activity feed is:
JPQL cannot express
DISTINCT ONordate_trunc. Write this as@Query(nativeQuery = true)— and write the failing test for it first, using a real Testcontainers Postgres so the PostgreSQL-specific syntax is actually validated.TEXT_SAVEDpayload is missingblockId— Pulse stat 2 is underspecified.Markus flagged this above. From an implementation standpoint: the block ID is available at the call site in
TranscriptionService.updateBlock()— it'ssaved.getId(). Add it to the payload now:The dashboard can then
COUNT(DISTINCT payload->>'blockId')for stat 2. Existing data from before this fix will be inaccurate — accept that and document it in the migration.@Async log()is unreachable.All six instrumented call sites use
logAfterCommit(). The@Async("auditExecutor")annotation onlog()does nothing in practice. BeforeDashboardControlleris built, clarify the contract: iflogAfterCommitis the standard, removelog()or add a prominent comment explaining when each is appropriate. An undocumented overload in a cross-cutting service will be misused.Recommendations
requireUserIdto a shared utility before the next controller adds it.blockIdtoTEXT_SAVEDpayload in the current PR or an immediate follow-up — do not wait until dashboard queries are being written.AuditLogQueryServicewith@Query(nativeQuery = true)methods as the first TDD step of the dashboard backend work: red tests against Testcontainers Postgres → green queries.AuditKind→ German action string is needed in the frontend:TEXT_SAVED→"hat transkribiert",FILE_UPLOADED→"hat hochgeladen",ANNOTATION_CREATED→"hat markiert",BLOCK_REVIEWED→"hat geprüft",METADATA_UPDATED→"hat bearbeitet". These keys should be added tomessages/de.jsonbefore any component renders a feed item.🔒 Nora "NullX" Steiner — Audit Log Security Impact on Dashboard
Observations
Append-only enforcement is solid.
REVOKE UPDATE, DELETE ON audit_log FROM app_userin V46 means the application DB role cannot tamper with the audit trail after the fact. This is the right control — database-layer enforcement that no application bug can bypass.GDPR
ON DELETE SET NULLis correct, but creates a live edge case for the dashboard.When a user exercises the right to erasure, their
actor_idbecomes null in existingaudit_logrows. TheDashboardControllerwill query actor IDs to join againstapp_usersfor display names and initials. A nullactor_idmust not cause a 500 error.Concrete risk:
GET /api/dashboard/activitymust handleactor_id IS NULLgracefully. If the query does an INNER JOIN toapp_users, rows from deleted users simply vanish from the feed — silent data loss. If it does a LEFT JOIN, the activity row appears with a null actor. The frontend component must handleactor: nullwithout crashing.Recommended response shape:
Dashboard must not expose
actor_idUUIDs in the API response.The spec defines activity feed actors as
{ name, initials, color }. The DTO projection at theDashboardControllerlayer must stripactor_idbefore the response leaves the server. An inadvertentactorIdfield in the response DTO would expose internal UUIDs that map to users — not a critical vulnerability givenREAD_ALLscoping, but unnecessary data exposure.requireUserIddoes a DB lookup on every write request.userService.findByEmail(authentication.getName())is called insiderequireUserId()on everyPUT /api/documents/{id},POST /api/documents/quick-upload, andPUT /api/documents/{id}/transcription-blocks/{blockId}/review. This is a new DB round-trip that didn't exist before #275. Not a security issue, but it's an attack surface in that a slow or lockedapp_userstable now blocks write operations. If Spring Security can be configured to store the user UUID in the principal (e.g., via a customUserDetailsimplementation), this lookup could be eliminated. Worth evaluating before the dashboard adds more write endpoints that need actor IDs.The
logAfterCommit()after-commit timing is safe from a security standpoint.The audit write happens synchronously after the main transaction commits. If the audit write itself fails (DB timeout, pool exhaustion), the
try-catchinwriteLog()logs and swallows the exception. The main operation succeeds, the audit row is silently lost. This is the right trade-off for a family archive — failing the user's transcription save because the audit log is unavailable would be wrong. But it means audit completeness is best-effort, not guaranteed. Document this explicitly inAuditServiceso a future reviewer doesn't "fix" the swallowed exception.Recommendations
@Nullableannotation toactorfield in the activity feed DTO — makes the contract explicit for TypeScript codegen.AuditService.writeLog()explaining the swallow: "Audit log is best-effort — failure must not block the domain operation."activity_feed_excludes_or_handles_deleted_user_gracefully()before the dashboard activity endpoint ships.🧪 Sara Holt — Audit Log Test Coverage and Dashboard Test Plan
Observations
The
logAfterCommittiming test is the standout in PR #275.logAfterCommit_registersCallback_andSavesOnlyAfterCommit_whenTransactionIsActivemanually captures theTransactionSynchronizationcallback, verifies the repo is not called before commit, then firesafterCommit()and verifies it is called. This is the right level of granularity — it proves the timing contract, not just the happy path.Integration tests for Pulse stats are now unblocked.
Previously (comment #3340) I flagged that
updated_atmade Pulse integration tests unreliable — any metadata edit would skew counts. Withaudit_log.happened_at, we can insert precise test fixtures. The Testcontainers integration test I outlined is now implementable:This test should be written before the Pulse query is implemented.
Missing test: null
actor_idin activity feed.When a user is deleted (GDPR erasure), their rows have
actor_id = NULL. NeitherAuditServiceTestnor the newDocumentServiceTestcases cover what happens when the dashboard queries encounter null-actor rows. This is an edge case that will silently fail in production on the first user deletion.Add before dashboard ships:
Deduplication boundary test is critical and currently unspecified.
The resolved dedup strategy is: collapse per actor/document/kind per hour. The boundary condition needs an explicit test:
The timestamp of the collapsed row must be the latest in the bucket (spec shows "vor 12 Min." — the most recent action). This needs an explicit assertion on the returned
happenedAt.Recommendations
DashboardServiceIntegrationTest, before any query code exists. It documents the exacthappened_atboundary condition.happenedAtbeingMAX()within the bucket.createOcrAnnotation_doesNotLogAuditEventtest should have a comment explaining why OCR annotations are excluded from audit — otherwise a future developer will "fix" this deliberate omission.🎨 Leonie Voss — Audit Log Impact on Dashboard UX
Observations
Comment and mention activity is not covered by PR #275 — the "für dich" badge is blocked.
The spec's activity feed mockup (§6) shows five item types:
TEXT_SAVED✓METADATA_UPDATED✓ (with an action label question, see below)FILE_UPLOADED✓Three of five item types render correctly from the audit log. The "für dich" badge — the most emotionally engaging element in the entire dashboard — depends on items 2 and 4, which require
DocumentCommentdata. The activity feed cannot be fully implemented from the audit log alone.Until comments are added to the audit log (or the feed queries both sources), the activity feed renders without any personalized badge items. This is still worth shipping — it's not empty — but the "für Dich" experience that makes the dashboard feel like a family space is absent.
METADATA_UPDATEDmaps to an ambiguous action label.The spec shows "Oskar hat zugeordnet" (assigned/segmented).
METADATA_UPDATEDfires on any metadata change — sender update, tag change, date change, receiver assignment. All of these look identical in the audit log. The feed would show "hat bearbeitet" for all of them, which is vague. Consider adding asubkindor more specific event kinds (DOCUMENT_ASSIGNED,TAG_UPDATED) if the feed needs to distinguish them. For now, "hat bearbeitet" is acceptable — but call this out in the i18n key so it's not a surprise.BLOCK_REVIEWEDis not in the spec's activity feed mockup.The spec shows no "hat geprüft" activity feed entry, yet
BLOCK_REVIEWEDevents will be in the audit log. Two options: (a) excludeBLOCK_REVIEWEDfrom the activity feed query entirely (simpler, matches the spec), or (b) show it with "hat geprüft" label. For Pulse headline "Seiten bearbeitet",BLOCK_REVIEWEDis also not included — onlyANNOTATION_CREATEDandTEXT_SAVEDevents contribute to page counts. Confirm this intent before writing the dashboard query.I18n action label mapping needs to be defined before frontend work starts.
The dashboard
DashboardActivityFeed.sveltecomponent will receivekind: AuditKindand must render a German sentence. These keys don't exist in Paraglide yet. Define the mapping before writing any component:AuditKindTEXT_SAVEDaudit_action_text_savedFILE_UPLOADEDaudit_action_file_uploadedANNOTATION_CREATEDaudit_action_annotation_createdBLOCK_REVIEWEDaudit_action_block_reviewedMETADATA_UPDATEDaudit_action_metadata_updatedSTATUS_CHANGEDPulse contributor avatars need actor names, not just IDs.
The Pulse sidebar shows "6 Mitwirkende" with an avatar stack. The query will return distinct
actor_idvalues from the week's audit events. The backend must JOIN toapp_usersto resolve initials and color.AppUser.coloris still missing (noted in prior review, comment #3341) — this blocks avatar rendering in both Mission Control and Pulse.Recommendations
audit_action_*i18n keys inde.json,en.json,es.jsonbefore writingDashboardActivityFeed.svelte.BLOCK_REVIEWEDappear in the activity feed? Update the spec annotation in §6 with the decision.ANNOTATION_CREATED+TEXT_SAVEDcontribute to "Seiten bearbeitet", the label is truthful; ifBLOCK_REVIEWEDis excluded from the count, make sure the headline still feels accurate to users who did review work that week.⚙️ Tobias Wendt — Audit Log Infrastructure Impact on Dashboard
Observations
Cleanest possible infrastructure impact: zero.
No new services, no new volumes, no compose changes. The audit log is a table in the existing PostgreSQL instance. Backup, restore, and monitoring all inherit automatically.
The four indexes match the expected query patterns.
idx_audit_log_happened_at DESC— period scoping (PulseWHERE happened_at >= week_start)idx_audit_log_actor_id— resume card (WHERE actor_id = :userId)idx_audit_log_kind— event-type filteringidx_audit_log_document_id— document-scoped activityFor a family archive generating <100 events/day, these are more than sufficient. No composite indexes needed at current scale.
The dedup query's
date_trunc('hour', happened_at)is not indexed — this is fine now, worth noting for later.The resolved activity dedup:
DISTINCT ON (actor_id, document_id, kind, date_trunc('hour', happened_at)). Thedate_truncexpression prevents index use onhappened_at. At 1,000 rows/month this is a non-issue — the index onhappened_at DESCbounds the scan to the relevant period, and the dedup is cheap on a small result set. If the archive grows to 10,000+ events, a partial index on(actor_id, document_id, kind, date_trunc('hour', happened_at))would help. File this as a future optimization, not a blocker.REVOKE UPDATE, DELETE in V46 — confirm migration role is separate from
app_user.The migration runs as the database owner (configured via
spring.datasource.usernameinapplication.yaml). If that username matches the role targeted byREVOKE ... FROM app_user, the REVOKE targets the correct role. But looking at the current Docker Compose,POSTGRES_USERis used for both Flyway migrations and the running application. If both are the same role,REVOKE UPDATE, DELETE ON audit_log FROM app_userREVOKES the running app's own write permission.Check
backend/src/main/resources/application.yaml:spring.datasource.username = app_userAND Flyway also runs asapp_user, then the migration user equals the revoked role. Flyway (running asapp_user) cannot grant or revoke its own permissions — theREVOKEwill fail.app_useris a separate application role, V46 is correct.Verify this before merging. The CI integration tests would catch a failing migration but only if Testcontainers uses the same role configuration as production.
auditExecutorthread pool is configured but executes zero tasks.All six
logAfterCommit()call sites run thewriteLog()synchronously in theafterCommit()callback — outside the main transaction, but in the same request thread. TheauditExecutorbean (1 core, 2 max, 50 queue,CallerRunsPolicy) allocates a thread pool that handles zero tasks. It costs ~1MB overhead. Either document the intended use case for@Async log()or remove both thelog()method and theauditExecutorbean before more code accumulates around this confusion.Audit log size projection.
At steady-state family-archive usage: ~20
TEXT_SAVED+ 5ANNOTATION_CREATED+ 3FILE_UPLOADEDper active day. Each row is ~200 bytes (UUID × 3 + timestamp + varchar + JSONB). 30 events/day × 365 days = ~10,950 rows/year = ~2.2MB/year. Including JSONB overhead and indexes: ~10MB/year. Negligible. No partitioning, no archival policy needed for the foreseeable future.Recommendations
app_userrole separation before merging V46. If they're the same role, theREVOKEneeds to run as a superuser in a separate init script.auditExecutorthread pool and@Async log()method, or add a code comment explaining the intended division: when should callers uselog()vs.logAfterCommit(). Right now there's no documented contract.🗳️ Decision Queue — Audit Log / Dashboard Impact Review
3 decisions need your input before dashboard backend implementation starts.
Architecture
Comment events in audit log — The activity feed's "für dich" @mention and comment-reply items (2 of 5 feed types in the spec) are not covered by PR #275. The dashboard controller needs a data source for them.
COMMENT_ADDED/MENTION_CREATEDtoaudit_log: One unified data source for all feed items.DashboardControllerruns a single query. Requires expanding PR #275 or a follow-up before dashboard backend starts.document_commentsseparately:DashboardControllermerges two result sets (audit events + comment rows). More complex join.youMentionedflag logic inNotificationControllerwould need to be shared.(Raised by: Markus, Leonie)
Null actor in activity feed — GDPR
ON DELETE SET NULLmeans deleted users'actor_idbecomes null. When the activity feed renders, null actors must be handled at the query level.actor: nullgracefully.(Raised by: Nora)
UX
BLOCK_REVIEWEDin activity feed — The spec's §6 mockup shows no "hat geprüft" feed entry, butBLOCK_REVIEWEDevents exist in the audit log. Does the activity feed query include or exclude this event kind? (It is also excluded from the Pulse headline page count per comment #3354.)TEXT_SAVED,FILE_UPLOADED,ANNOTATION_CREATED. Simpler query, matches the spec mockup as drawn.(Raised by: Leonie)
Also flagged for verification (not a decision, but needs checking before PR #275 merges): Tobias identified that the
REVOKE UPDATE, DELETE ... FROM app_userin V46 may target the wrong role if Flyway and the application share the same DB user. Verifyspring.datasource.usernamevs. theapp_userrole indocker-compose.ymlbefore merging.🗳️ Decision Queue — Resolved
All three open decisions from the last review cycle are settled. Summary below.
✅ Comment events in audit log → Option A
Add
COMMENT_ADDEDandMENTION_CREATEDtoaudit_logbefore dashboard backend work starts. One unified data source for all feed items. Details and implementation notes posted directly on PR #275.✅ Null actor in activity feed → Option B (LEFT JOIN)
Use a LEFT JOIN to
app_users. Rows from deleted users remain in the feed — the activity happened and the record should reflect it. Actor is nullable in the DTO; frontend renders a grey anonymous avatar whenactor === null. Nora's recommended DTO shape stands:✅
BLOCK_REVIEWEDin activity feed → Option A (exclude)Activity feed only shows
TEXT_SAVED,FILE_UPLOADED,ANNOTATION_CREATED,COMMENT_ADDED,MENTION_CREATED. Review work is high-frequency and fine-grained; including it would make the feed noisy. Consistent with its exclusion from the Pulse headline count. No new Paraglide key needed.🐛 Migration bug in V46 — fix before merging PR #275
Tobias flagged that
REVOKE UPDATE, DELETE ON audit_log FROM app_usermay target the wrong role if Flyway and the application share the same DB user. Verified: the actual DB role isarchive_user(set viaPOSTGRES_USERin.env). The migration hardcodesapp_user, which does not exist — theREVOKEwill fail at migration time.Fix: replace the hardcoded role name with
CURRENT_USER:CURRENT_USERresolves to whichever role runs the migration (same role the application uses), making the append-only enforcement role-agnostic and correct regardless of environment.Implementation complete ✅
Branch:
feat/issue-271-dashboard-redesignWhat was implemented
Backend (8 commits):
V47__add_user_color.sql— addscolorcolumn touserstable; also fixes V46's brokenREFERENCES app_users→REFERENCES usersandREVOKE FROM app_user→FROM CURRENT_USERAppUser.color— deterministic palette color derived from user ID via@PrePersist/@PostLoadTranscriptionService—TEXT_SAVEDaudit payload now includesblockIdSecurityUtils.requireUserId()— extracted shared helper; bothDocumentControllerandTranscriptionBlockControllernow delegate to itdashboard/package —DashboardController(3 endpoints),DashboardService,AuditLogQueryService,AuditLogQueryRepository(native PostgreSQL queries withDISTINCT ON, JSONB operators), all DTOs (DashboardResumeDTO,DashboardPulseDTO,ActivityFeedItemDTO,ActivityActorDTO)GET /api/documents/incompleteandGET /api/documents/recent-activityremovedFrontend (2 commits):
/documents/new+page.server.ts— calls/api/dashboard/resume,/api/dashboard/pulse,/api/dashboard/activity; removes deprecated endpoints+page.svelte— 2-column layout (1fr 320px), personalised greeting (morning/day/evening),DashboardResumeStrip+MissionControlStripin main column;DashboardFamilyPulse+DashboardActivityFeed+DropZonein sticky sidebarDashboardResumeStrip— full rewrite: SVG parchment thumbnail, pull-quote, ARIA progress bar, collaborator avatar stack, empty stateDashboardFamilyPulse— new component: weekly page count headline, contributor avatar stack, 3-stat gridDashboardActivityFeed— new component: activity feed with "für dich" badge for@mentionsTests: 1159 backend tests green · 932 frontend tests green