From 6494b13147256328aa32297da89128a701f01f1d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 21:44:45 +0200 Subject: [PATCH 01/31] docs(spec): add /documents page design spec with mobile breakpoints Co-Authored-By: Claude Sonnet 4.6 --- docs/specs/documents-page-spec.html | 665 ++++++++++++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 docs/specs/documents-page-spec.html diff --git a/docs/specs/documents-page-spec.html b/docs/specs/documents-page-spec.html new file mode 100644 index 00000000..f69e2ef1 --- /dev/null +++ b/docs/specs/documents-page-spec.html @@ -0,0 +1,665 @@ + + + + + +Dokumente-Seite — Design Spec + + + +
+ + +
+
+ Neue Route + Frontend + Backend +
+

Dokumente-Seite — /documents

+

+ Dedicated search and browse page for all documents. Separates the document list from the dashboard hub. Uses per-year group cards with flat divide-y rows, a horizontal split row (content left · metadata right), a circular progress ring, and contributor avatars. +

+
Spec · Leonie Voss · 2026-04-19 · Issue TBD
+
+ +
+

Design decisions

+

The hub (/) becomes pure dashboard — no more dual-mode switching. The "Documents" nav tab points to /documents, a focused search/browse page.

+

Row layout: two-column split — title and snippet occupy the full left column for maximum scan width; date, sender, receiver, archive location, progress ring and contributor avatars live in a fixed 240px right panel. This keeps metadata consistently positioned across all rows.

+

List structure: one white card container per year group (matching the current border border-line bg-surface shadow-sm pattern), rows separated by divide-y dividers — no gaps, no individual row cards. The year label is an inset header row within each card.

+

Progress ring shows work completion as a percentage (0–100%). It is driven by a new completionPercentage field on the search result DTO, computed server-side from annotation block counts. Contributor avatars require a new contributors array (initials + color) on the search DTO.

+
+ +
+ + +
+
Section 1
+
Full page mockup — filter panel open, search active
+
Scaled at ~56%. Desktop 1200px concept width.
+ +
+ +
+ + +
Hochlader
MR
+
+ + + +
+ 31 documents + Sort +
Date ↓
+
+ + Filters 1 +
+
+ +
+
+ Date range +
From
To
+
+
+ Sender +
Search person…
+
+
+ Receiver +
Search person…
+
+
+ Tags + BriefFotoPostkarteUrkunde +
+
+ +
+ + +
+
1924
+
+
+
Demo: Ierlicher Brief — Belgern
+
… Hiermit übersende ich Ihnen den gewünschten Brief meines Vaters, welcher einige interessante Hinweise zur Familiengeschichte enthält …
+
Brief
Familie
+
+
+
+
Date 31. Mai 1924
+
From Louise Aon Boden
+
To Marcel Raddatz
+
Archive Box 3 · Folder A
+
+
+
+ +
100%
+
+
MR
LS
+
+
+
+
+ + +
+
1923
+
+
+
W-0614 – 8. September 1923 – Tölz
+
… Clara schreibt über die Ankunft in Tölz und erwähnt den letzten Brief von Fauld Rupley, der noch keine Antwort erhalten hat …
+
Brief
+
+
+
+
Date 8. Sept. 1923
+
From Clara Lam
+
To Fauld Rupley
+
Archive Box 1 · Folder C
+
+
+
+ +
75%
+
+
AK
+
+
+
+
+
+
W-0196 – 2. September 1923 – B. Lichterfelde
+
… Prediger's Haushaltung enthält einen Brief; Zusammen mit der Vollmacht aus dem Vorjahr ergibt sich folgendes Bild …
+
Brief
+
+
+
+
Date 2. Sept. 1923
+
From Müller de Gruym
+
To Herbert Cram
+
+
+
+ +
40%
+
+
MR
LS
AK
+
+
+
+
+
+
W-0397 – 2. September 1923 – B. Lichterfelde
+
… zum einleitend Kommentar hieraus, den Herrn, zum Brief az sechzig und weitere Passagen …
+
Brief
+
+
+
+
Date 2. Sept. 1923
+
From Müller de Gruym
+
+
+
+ +
0%
+
+ No contributors +
+
+
+
+ +
+
+ Fig 1 — /documents · 1200px · search: "brief" · filter panel open · sort: Date ↓ +
+ +
+ + +
+
Section 2
+
Page structure & zones
+ +
+
+

① Global search bar

+

Full-width row below the topbar. Contains the search input (flex-1), result count (right of input), and "+ New Document" button. Background white, bottom border border-line. Sticky — stays visible on scroll.

+

Same search bar pattern as the current homepage. Debounce 500 ms on text input; immediate on clear.

+
+
+

② Sort / count bar

+

Slim bar below search. Shows result count (left), sort dropdown (right), and Filters toggle button (far right). Background white, bottom border border-line. Sticky — stacks below search bar on scroll.

+

Filters button shows a mint badge with active filter count. When filters are open the button fills navy.

+
+
+

③ Collapsible filter panel

+

Drops open below the sort bar. Contains four groups: Date range (two inputs), Sender (PersonTypeahead), Receiver (PersonTypeahead), Tags (clickable pills). White background, bottom border border-line.

+

Closed by default on page load unless URL already has active filter params. Animate open/close with transition-all duration-200.

+
+
+ + +
+ +
+ + +
+
Section 2b
+
Mobile breakpoints
+
Three responsive tiers: <sm (mobile), sm–lg (tablet), lg+ (desktop).
+ +
+
+

< sm — < 640px (mobile)

+

Document row: single-column block. Left/right split collapses. Metadata (date, from, to, archive) moves below the tags row as a 2×2 compact grid. Progress ring and contributor stack appear in a bottom row directly below the grid.

+

Filter panel: single-column stack (flex-col). Sort bar wraps if needed.

+
+
+

sm – lg — 640–1023px

+

Document row: two-column split restored. Metadata column narrower: sm:w-48 (192px) instead of w-60 to fit tablet viewports.

+

Sticky bars span full width via negative margins. Filter panel: flex-row flex-wrap, groups can wrap.

+
+
+

lg+ — ≥ 1024px (desktop)

+

Full two-column split. Metadata column: lg:w-60 (240px). Filter panel: four groups in a single row. Max content width max-w-7xl (1280px) — from app layout container, no extra padding on list body.

+
+
+ + +
+
+
+
+ +
+ Dokumente +
MR
+
+
+ +
+ 31 documents + Sort +
Date ↓
+
Filters
+
+
+
+
1924
+ +
+
Demo: Ierlicher Brief — Belgern
+
… Hiermit übersende ich Ihnen den gewünschten Brief
+
Brief
+
+
Date 31. Mai 1924
+
From L. von Boden
+
Archive Box 3 · A
+
To M. Raddatz
+
+
+
+ +
100%
+
+
MR
LS
+
+
+ +
+
W-0614 – Sept. 1923 – Tölz
+
… Clara schreibt über den letzten Brief von Fauld Rupley …
+
Brief
+
+
Date 8. Sept. 1923
+
From Clara Lam
+
+
To F. Rupley
+
+
+
+ +
75%
+
+
AK
+
+
+
+
+
+ Fig 2 — /documents · 375px mobile · search "brief" · filter closed +
+ +
+

Mobile row — CSS-only approach

+

No JS needed. The <a> link is always block. On sm+ the inner element switches to flex items-stretch, showing the right metadata column (hidden sm:flex) and hiding the mobile compact grid (sm:hidden).

+

This means the DOM contains both layouts simultaneously — the metadata grid inside the left column (mobile only) and the right metadata panel (sm+ only). Both share the same data, just rendered differently.

+

Minimum touch target: the entire row is the <a>, guaranteed ≥44px on mobile given title + snippet + tags + metadata grid.

+
+
+
+ +
+ + +
+
Section 3
+
Year group card
+
One card per year group. Rows inside use divide-y — no gaps between rows.
+ +
+
+

Card container

+

Matches current DocumentList outer container exactly: border border-line bg-surface shadow-sm. No border-radius (keeps it flush). Margin between consecutive year cards: mb-4.

+

Rendered only when sort = DATE. For other sort modes (SENDER, RECEIVER, TITLE) the year header is replaced by the relevant group label using the same card pattern.

+
+
+

Year header row

+

First child of each card. Background bg-sand, text text-xs font-bold uppercase tracking-widest text-ink-3. Height py-1.5 px-5. Bottom border border-b border-line.

+

Not a standalone divider — it is part of the card so the top border of the card frames the year label on three sides.

+
+
+
+ +
+ + +
+
Section 4
+
Document row — two-column split
+ +
+
+

Left column — content

+

Flex-1, min-width 0. Padding p-4 pr-5. Right border border-r border-line-2.

+

Titlefont-serif text-base font-bold text-ink with search highlight underlines. mb-1.5.

+

Snippetfont-serif text-sm italic text-ink-2 line-clamp-2 mb-2 with highlight underlines. Only rendered when a match snippet is present.

+

Tags — existing tag pill pattern bg-muted text-ink text-[10px] font-bold uppercase tracking-widest rounded px-2 py-0.5. Gap gap-1.5 flex-wrap.

+
+
+

Right column — metadata panel

+

Fixed width w-60 (240px). Padding p-3.5. Flex column, justify-between.

+

Meta lines (top group) — font-sans text-[11px] text-ink-2 mb-1. Label: font-bold uppercase tracking-wide text-[10px] text-ink-3 mr-1.5. Lines: Date · From · To · Archive (Box · Folder). Archive only rendered when archiveBox is set.

+

Bottom row — flexbox, space-between. Left: progress ring. Right: ContributorStack.

+
+
+ +
+
Accessibility: The ring conveys progress by both percentage text and arc fill — not colour alone. Contributors show initials as text inside the avatar. Both pass the redundant-cue requirement from the Leonie Voss persona. Minimum touch target for the row link: the full row is the <a> element, always ≥44px tall given the content. Row hover: hover:bg-muted/50 transition-colors duration-200.
+
+
+ +
+ + +
+
Section 5
+
Progress ring
+ +
+
+

Anatomy

+

SVG donut ring, 36×36px. Track circle: stroke="#E4E2D7" (stroke-brand-sand) width 3px. Fill arc: stroke="#A6DAD8" (stroke-accent) width 3px, stroke-linecap="round". Rotated −90° so arc starts at 12 o'clock.

+

Centre label: percentage text font-sans text-[8px] font-bold. Colour: mint (text-accent-dark) when >0%, gray-400 when 0%.

+

Circumference of r=13: 2π×13 ≈ 81.7px. Stroke-dasharray: {pct * 81.7} 81.7.

+
+
+

Data source — new API field

+

New field completionPercentage: number (0–100, integer) on the document search result DTO. Computed server-side:

+

round((reviewedBlocks / max(totalBlocks, 1)) * 100)

+

If a document has no annotation blocks yet (no transcription started), returns 0. Backend change: new subquery in the document search repository to COUNT annotation blocks (all vs. reviewed) per document, joined into the search projection.

+
+
+
+ +
+ + +
+
Section 6
+
Contributor avatar stack
+ +
+
+

Anatomy

+

Reuse existing ContributorStack.svelte component (added in commit 031f6ea). Avatars 22×22px, -ml-1.5 overlap, white 2px border.

+

Show max 3 avatars. If more: +N text element in gray-400. When no contributors: render text-[9px] text-ink-3 uppercase tracking-wide label "No contributors".

+
+
+

Data source — new API field

+

New field contributors: ActivityActorDTO[] on the document search result DTO. ActivityActorDTO already exists (used in dashboard queue items): { initials: string, color: string, name?: string }.

+

Backend: join from document → annotation_blocks → created_by → users. Distinct by user. Order by most-recent contribution. Limit 4. New query in document search repository.

+
+
+
+ +
+ + +
+
Section 7
+
Backend changes required
+ +
+
New fields on document search DTO — Two new fields must be added to the object returned by GET /api/documents/search. These require a new projection or join in the repository layer. No schema migration needed — purely computed from existing annotation_block data.
+
+ +
+ + + + + + + + +
FieldTypeSourceNotes
completionPercentageint (0–100)COUNT(reviewed annotation blocks) / COUNT(all blocks)0 when no blocks exist
contributorsActivityActorDTO[]Distinct users with annotation_block contributions, ordered by recencyMax 4; reuse existing DTO
archiveBoxString?Already on Document entity — just not in search responseExpose existing field
archiveFolderString?Already on Document entity — just not in search responseExpose existing field
+
+
+ +
+ + +
+
Section 8 — Implementation Reference
+
Exact Tailwind classes & pixel values
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesPixels / valueNotes
Page chrome
Search bar wrapperbg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-3.5 flex items-center gap-3 sticky top-[65px] z-20padding 14px responsiveTopbar = 1px accent + 64px nav = 65px. Negative margins break out of container padding so bar spans full container width.
Search inputflex-1 h-9 border border-ink rounded-sm px-3 font-sans text-sm text-ink bg-whiteheight 36pxActive: navy border
Sort bar wrapperbg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-2.5 flex items-center gap-3 sticky top-[113px] z-20padding 10px responsiveStacks below search bar (65 + 48 = 113px)
Filters toggle (closed)h-7 px-3 border border-line rounded-sm font-sans text-[10px] font-bold uppercase tracking-wide text-ink flex items-center gap-1.5height 28px
Filters toggle (open)h-7 px-3 bg-ink text-white rounded-sm font-sans text-[10px] font-bold uppercase tracking-wide flex items-center gap-1.5height 28pxNavy fill when active
Filter panel wrapperbg-white border-b border-line -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 py-4 flex flex-col sm:flex-row sm:flex-wrap gap-4padding 16px responsiveUse Svelte slide transition; stacks vertically on mobile
List bodypy-5vertical padding onlyNo extra horizontal padding — app container handles it
Year group card
Card containerborder border-line bg-surface shadow-sm mb-4 overflow-hiddenMatches current DocumentList outer div exactly
Year headerbg-sand border-b border-line px-5 py-1.5 font-sans text-[10px] font-bold uppercase tracking-widest text-ink-3padding 6px 20px
Row listdivide-y divide-line-2Matches current <ul> pattern
Document row
Row wrapper <li>group transition-colors duration-200 hover:bg-muted/50Same hover pattern as current
Row inner (link)block sm:flex sm:items-stretchFull-row <a href="/documents/{id}">; flex only on sm+
Left columnp-4 sm:flex-1 sm:min-w-0 sm:pr-5 sm:border-r sm:border-line-2padding 16pxRight border only on sm+
Right column (sm+)hidden sm:flex sm:w-48 lg:w-60 flex-shrink-0 p-3.5 flex-col justify-between gap-2sm: 192px · lg: 240pxHidden on mobile; narrower on tablet
Mobile metadata gridsm:hidden border-t border-line-2 mt-3 pt-3 grid grid-cols-2 gap-x-4 gap-y-0.52×2 compact grid shown only on mobile, inside left col
Mobile meta bottom rowsm:hidden flex items-center justify-between mt-3Ring + contributors on mobile, shown only <sm
Document titlefont-serif text-base font-bold text-ink mb-1.5 leading-snug group-hover:underline16px / 700
Snippet textfont-serif text-sm italic text-ink-2 line-clamp-2 mb-214pxOnly when snippet present
Meta labelfont-sans text-[10px] font-bold uppercase tracking-wide text-ink-3 mr-1.510px / 700DATE · FROM · TO · ARCHIVE
Meta valuefont-sans text-[11px] text-ink-211px
Progress ring
SVG containerrelative w-9 h-9 flex-shrink-036×36px
Track circlestroke="var(--c-sand)" stroke-width="3"r=13, circumference 81.7px
Fill arcstroke="var(--c-accent)" stroke-width="3" stroke-linecap="round"dasharray = pct/100 × 81.7rotate(−90deg)
Percentage labelabsolute inset-0 flex items-center justify-center font-sans text-[8px] font-bold8px / 800Mint when >0, gray-400 when 0
New files
NEW frontend/src/routes/documents/+page.svelteDocument list page (extract from homepage)
NEW frontend/src/routes/documents/+page.server.tsLoads search results, same API call as current homepage
CHANGED frontend/src/routes/AppNav.svelteDocuments tab href: //documents
CHANGED frontend/src/routes/+page.svelteRemove dual-mode logic; always render dashboard
CHANGED frontend/src/routes/+page.server.tsRemove search branch; always fetch dashboard data
CHANGED frontend/src/routes/DocumentList.svelteRefactor to new two-column layout + year cards
NEW query backend/.../DocumentSearchRepositoryAdd completionPercentage + contributors to search projection
+
+
+ +
+ + From 74febd37f6d7899362e8c5ff5bc35d963e91d5be Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 16:33:27 +0200 Subject: [PATCH 02/31] feat(user): add deterministic avatar color to AppUser Adds color field assigned from an 8-colour palette keyed on the user's UUID hash (Math.abs(id.hashCode()) % 8). Fires via @PrePersist/@PreUpdate/@PostLoad so both new and existing users get the correct colour at runtime. V47 migration adds the column and fixes the V46 REVOKE bug that hardcoded role name 'app_user' instead of CURRENT_USER. Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/model/AppUser.java | 26 +++++++++++++ .../db/migration/V47__add_user_color.sql | 8 ++++ .../familienarchiv/model/AppUserTest.java | 38 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V47__add_user_color.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java index 3ac33625..6b2b8419 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java @@ -19,6 +19,10 @@ import java.util.HashSet; import java.util.Set; import java.util.UUID; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; + @Entity @Table(name = "users") @Data @@ -74,6 +78,28 @@ public class AppUser { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createdAt; + @Column(nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String color = ""; + + private static final String[] PALETTE = { + "#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8" + }; + + public static String computeColor(UUID id) { + return PALETTE[Math.abs(id.hashCode()) % PALETTE.length]; + } + + @PrePersist + @PreUpdate + @PostLoad + void deriveColor() { + if (id != null && (color == null || color.isEmpty())) { + this.color = computeColor(id); + } + } + public boolean hasPermission(String permission) { if (groups == null || groups.isEmpty()) { return false; diff --git a/backend/src/main/resources/db/migration/V47__add_user_color.sql b/backend/src/main/resources/db/migration/V47__add_user_color.sql new file mode 100644 index 00000000..cca60905 --- /dev/null +++ b/backend/src/main/resources/db/migration/V47__add_user_color.sql @@ -0,0 +1,8 @@ +-- Add deterministic avatar color to app_users. +-- Assigned at application layer (AppUser.java) from a fixed 8-colour palette. +-- Also corrects V46's REVOKE which hardcoded 'app_user' instead of CURRENT_USER. + +ALTER TABLE app_users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT ''; + +-- Fix V46 append-only enforcement for the actual application role. +REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java new file mode 100644 index 00000000..8fec24fd --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java @@ -0,0 +1,38 @@ +package org.raddatz.familienarchiv.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AppUserTest { + + private static final List EXPECTED_PALETTE = List.of( + "#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8" + ); + + @Test + void computeColor_returnsDeterministicPaletteColor() { + UUID id = UUID.fromString("12345678-1234-1234-1234-123456789abc"); + String color = AppUser.computeColor(id); + assertThat(EXPECTED_PALETTE).contains(color); + assertThat(AppUser.computeColor(id)).isEqualTo(color); + } + + @Test + void computeColor_isStableAcrossCalls() { + UUID id = UUID.randomUUID(); + assertThat(AppUser.computeColor(id)).isEqualTo(AppUser.computeColor(id)); + } + + @Test + void computeColor_variesAcrossDifferentIds() { + long distinct = java.util.stream.IntStream.range(0, 100) + .mapToObj(i -> AppUser.computeColor(UUID.randomUUID())) + .distinct() + .count(); + assertThat(distinct).isGreaterThan(1); + } +} From b3ae379be75d692a8f7a3e36be63e841770dd610 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 16:36:02 +0200 Subject: [PATCH 03/31] fix(audit): add blockId to TEXT_SAVED audit payload Required for dashboard Pulse stat 2 (COUNT DISTINCT blockId). Without it, two saves on different blocks on the same page were indistinguishable. Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/service/TranscriptionService.java | 3 ++- .../familienarchiv/service/TranscriptionServiceTest.java | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java index c52cf103..01bbff54 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -142,7 +142,8 @@ public class TranscriptionService { if (!text.equals(previousText)) { Optional annotation = annotationRepository.findById(block.getAnnotationId()); int pageNumber = annotation.map(DocumentAnnotation::getPageNumber).orElse(0); - auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId, Map.of("pageNumber", pageNumber)); + auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId, + Map.of("pageNumber", pageNumber, "blockId", saved.getId().toString())); } Document doc = documentService.getDocumentById(documentId); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java index e9d415a8..65584fe7 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -487,6 +487,7 @@ class TranscriptionServiceTest { org.mockito.ArgumentMatchers.eq(docId), payloadCaptor.capture()); assertThat(payloadCaptor.getValue()).containsEntry("pageNumber", 3); + assertThat(payloadCaptor.getValue()).containsEntry("blockId", blockId.toString()); } @Test From 56a44bcef9857091166d5f4f3a33b0c924cd4c80 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 16:39:41 +0200 Subject: [PATCH 04/31] refactor(security): extract requireUserId to SecurityUtils Both DocumentController and TranscriptionBlockController contained identical private requireUserId helpers. Extracted to a shared static utility in the security package ahead of DashboardController which also needs actor resolution. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 10 +--- .../TranscriptionBlockController.java | 12 +--- .../security/SecurityUtils.java | 24 ++++++++ .../security/SecurityUtilsTest.java | 58 +++++++++++++++++++ 4 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/security/SecurityUtilsTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index e29e164f..493fa046 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -28,6 +28,7 @@ import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.security.SecurityUtils; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.DocumentVersionService; import org.raddatz.familienarchiv.service.FileService; @@ -286,13 +287,6 @@ public class DocumentController { } private UUID requireUserId(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - throw DomainException.unauthorized("Authentication required"); - } - AppUser user = userService.findByEmail(authentication.getName()); - if (user == null) { - throw DomainException.unauthorized("User not found"); - } - return user.getId(); + return SecurityUtils.requireUserId(authentication, userService); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java index 7b36cd26..82338bc2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/TranscriptionBlockController.java @@ -5,12 +5,11 @@ import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO; import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO; import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO; -import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.security.SecurityUtils; import org.raddatz.familienarchiv.service.TranscriptionService; import org.raddatz.familienarchiv.service.UserService; import org.springframework.http.HttpStatus; @@ -100,13 +99,6 @@ public class TranscriptionBlockController { } private UUID requireUserId(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - throw DomainException.unauthorized("Authentication required"); - } - AppUser user = userService.findByEmail(authentication.getName()); - if (user == null) { - throw DomainException.unauthorized("User not found"); - } - return user.getId(); + return SecurityUtils.requireUserId(authentication, userService); } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java new file mode 100644 index 00000000..dd5dceb7 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/SecurityUtils.java @@ -0,0 +1,24 @@ +package org.raddatz.familienarchiv.security; + +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.security.core.Authentication; + +import java.util.UUID; + +public final class SecurityUtils { + + private SecurityUtils() {} + + public static UUID requireUserId(Authentication authentication, UserService userService) { + if (authentication == null || !authentication.isAuthenticated()) { + throw DomainException.unauthorized("Authentication required"); + } + AppUser user = userService.findByEmail(authentication.getName()); + if (user == null) { + throw DomainException.unauthorized("User not found"); + } + return user.getId(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/security/SecurityUtilsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/security/SecurityUtilsTest.java new file mode 100644 index 00000000..78f0dc24 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/security/SecurityUtilsTest.java @@ -0,0 +1,58 @@ +package org.raddatz.familienarchiv.security; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.security.core.Authentication; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityUtilsTest { + + @Mock Authentication authentication; + @Mock UserService userService; + + @Test + void requireUserId_throwsUnauthorized_whenAuthenticationIsNull() { + assertThatThrownBy(() -> SecurityUtils.requireUserId(null, userService)) + .isInstanceOf(DomainException.class); + } + + @Test + void requireUserId_throwsUnauthorized_whenNotAuthenticated() { + when(authentication.isAuthenticated()).thenReturn(false); + assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService)) + .isInstanceOf(DomainException.class); + } + + @Test + void requireUserId_throwsUnauthorized_whenUserNotFound() { + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("ghost@example.com"); + when(userService.findByEmail("ghost@example.com")).thenReturn(null); + assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService)) + .isInstanceOf(DomainException.class); + } + + @Test + void requireUserId_returnsUserId_whenAuthenticated() { + UUID userId = UUID.randomUUID(); + AppUser user = AppUser.builder().id(userId).email("user@example.com").password("pw").build(); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("user@example.com"); + when(userService.findByEmail("user@example.com")).thenReturn(user); + + UUID result = SecurityUtils.requireUserId(authentication, userService); + + assertThat(result).isEqualTo(userId); + } +} From 250a00ff3cfc39d155ed803e199ea677bae47ec5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 16:58:04 +0200 Subject: [PATCH 05/31] =?UTF-8?q?fix(migration):=20correct=20app=5Fusers?= =?UTF-8?q?=20=E2=86=92=20users=20table=20references=20in=20V46/V47?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AppUser entity is mapped to the 'users' table (not 'app_users'). V46 had a broken REFERENCES clause and hardcoded role in REVOKE; V47 and the native query in AuditLogQueryRepository had the same wrong table name. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/AuditLogQueryRepository.java | 88 +++++++++++++++++++ .../db/migration/V46__add_audit_log.sql | 4 +- .../db/migration/V47__add_user_color.sql | 2 +- 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java new file mode 100644 index 00000000..83d9a627 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java @@ -0,0 +1,88 @@ +package org.raddatz.familienarchiv.dashboard; + +import org.raddatz.familienarchiv.audit.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AuditLogQueryRepository extends JpaRepository { + + @Query(value = """ + SELECT a.document_id + FROM audit_log a + WHERE a.kind = 'TEXT_SAVED' + AND a.actor_id = :userId + AND a.document_id IS NOT NULL + ORDER BY a.happened_at DESC + LIMIT 1 + """, nativeQuery = true) + Optional findMostRecentDocumentIdByActor(@Param("userId") UUID userId); + + @Query(value = """ + SELECT * FROM ( + SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at)) + a.kind AS kind, + a.actor_id AS actorId, + CASE + WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL + THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1)) + WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1)) + WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1)) + ELSE '?' + END AS actorInitials, + COALESCE(u.color, '') AS actorColor, + CONCAT_WS(' ', u.first_name, u.last_name) AS actorName, + a.document_id AS documentId, + a.happened_at AS happenedAt, + (a.kind = 'MENTION_CREATED' + AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_id + WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED') + AND a.document_id IS NOT NULL + ORDER BY a.actor_id, a.document_id, a.kind, + date_trunc('hour', a.happened_at), a.happened_at DESC + ) deduped + ORDER BY happened_at DESC + LIMIT :limit + """, nativeQuery = true) + List findDedupedActivityFeed( + @Param("currentUserId") String currentUserId, + @Param("limit") int limit); + + @Query(value = """ + SELECT + COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) AS pages, + COUNT(*) FILTER (WHERE a.kind = 'ANNOTATION_CREATED') AS annotated, + COUNT(DISTINCT a.payload->>'blockId') FILTER (WHERE a.kind = 'TEXT_SAVED') AS transcribed, + COUNT(DISTINCT a.document_id) FILTER (WHERE a.kind = 'FILE_UPLOADED') AS uploaded, + COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) + FILTER (WHERE (a.kind = 'ANNOTATION_CREATED' OR a.kind = 'TEXT_SAVED') + AND a.actor_id::text = :userId) AS yourPages + FROM audit_log a + WHERE a.happened_at >= :weekStart + AND a.kind IN ('ANNOTATION_CREATED','TEXT_SAVED','FILE_UPLOADED') + """, nativeQuery = true) + PulseStatsRow getPulseStats( + @Param("weekStart") OffsetDateTime weekStart, + @Param("userId") String userId); + + @Query(value = """ + SELECT DISTINCT ON (a.document_id) + a.document_id AS documentId, + a.actor_id AS actorId + FROM audit_log a + WHERE a.kind = :kind + AND a.document_id IN :documentIds + AND a.actor_id IS NOT NULL + ORDER BY a.document_id, a.happened_at DESC + """, nativeQuery = true) + List findMostRecentActorPerDocument( + @Param("documentIds") List documentIds, + @Param("kind") String kind); +} diff --git a/backend/src/main/resources/db/migration/V46__add_audit_log.sql b/backend/src/main/resources/db/migration/V46__add_audit_log.sql index 2a01126a..645e1d43 100644 --- a/backend/src/main/resources/db/migration/V46__add_audit_log.sql +++ b/backend/src/main/resources/db/migration/V46__add_audit_log.sql @@ -6,7 +6,7 @@ CREATE TABLE audit_log ( happened_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- ON DELETE SET NULL is by design: GDPR right-to-erasure. Deleted users' events -- retain their timestamp and kind but lose actor attribution. - actor_id UUID REFERENCES app_users(id) ON DELETE SET NULL, + actor_id UUID REFERENCES users(id) ON DELETE SET NULL, kind VARCHAR(50) NOT NULL, document_id UUID REFERENCES documents(id) ON DELETE CASCADE, payload JSONB @@ -19,4 +19,4 @@ CREATE INDEX idx_audit_log_kind ON audit_log (kind); -- Enforce append-only at the database layer: the application role may INSERT -- but must not UPDATE or DELETE audit rows. -REVOKE UPDATE, DELETE ON audit_log FROM app_user; +REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER; diff --git a/backend/src/main/resources/db/migration/V47__add_user_color.sql b/backend/src/main/resources/db/migration/V47__add_user_color.sql index cca60905..ac6317f7 100644 --- a/backend/src/main/resources/db/migration/V47__add_user_color.sql +++ b/backend/src/main/resources/db/migration/V47__add_user_color.sql @@ -2,7 +2,7 @@ -- Assigned at application layer (AppUser.java) from a fixed 8-colour palette. -- Also corrects V46's REVOKE which hardcoded 'app_user' instead of CURRENT_USER. -ALTER TABLE app_users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT ''; -- Fix V46 append-only enforcement for the actual application role. REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER; From ddd811c634eef403968928eaa6a325a0c45d0ecc Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:05:14 +0200 Subject: [PATCH 06/31] feat(dashboard): remove deprecated /incomplete and /recent-activity endpoints GET /api/documents/incomplete and GET /api/documents/recent-activity are superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/DocumentController.java | 13 ---- .../controller/DocumentControllerTest.java | 72 +++---------------- 2 files changed, 8 insertions(+), 77 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index 493fa046..bbef336d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -17,7 +17,6 @@ import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.TagOperator; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; -import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; @@ -198,12 +197,6 @@ public class DocumentController { return Map.of("count", documentService.getIncompleteCount()); } - @GetMapping("/incomplete") - public List getIncomplete( - @Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) { - return documentService.findIncompleteDocuments(size); - } - @GetMapping("/incomplete/next") public ResponseEntity getNextIncomplete(@RequestParam UUID excludeId) { return documentService.findNextIncompleteDocument(excludeId) @@ -211,12 +204,6 @@ public class DocumentController { .orElse(ResponseEntity.noContent().build()); } - @GetMapping("/recent-activity") - public ResponseEntity> getRecentActivity( - @RequestParam(defaultValue = "5") int size) { - return ResponseEntity.ok(documentService.getRecentActivity(size)); - } - @GetMapping("/search") public ResponseEntity search( @RequestParam(required = false) String q, diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 36b86449..f8aaf5dd 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; -import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; @@ -390,47 +389,14 @@ class DocumentControllerTest { .andExpect(jsonPath("$.count").value(3)); } - // ─── GET /api/documents/incomplete ─────────────────────────────────────── - - @Test - void getIncomplete_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(get("/api/documents/incomplete")) - .andExpect(status().isUnauthorized()); - } + // ─── GET /api/documents/incomplete (removed — superseded by dashboard) ──── @Test @WithMockUser - void getIncomplete_returns200_withDTOList() throws Exception { - UUID id = UUID.randomUUID(); - IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig"); - when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto)); - + void getIncomplete_endpointRemoved() throws Exception { + // The path hits /{id} and fails UUID conversion — not a 200 anymore mockMvc.perform(get("/api/documents/incomplete")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(id.toString())) - .andExpect(jsonPath("$[0].title").value("Unvollständig")); - } - - @Test - @WithMockUser - void getIncomplete_withSizeParam_passesItToService() throws Exception { - when(documentService.findIncompleteDocuments(5)).thenReturn(List.of()); - - mockMvc.perform(get("/api/documents/incomplete").param("size", "5")) - .andExpect(status().isOk()); - - verify(documentService).findIncompleteDocuments(5); - } - - @Test - @WithMockUser - void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception { - when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of()); - - mockMvc.perform(get("/api/documents/incomplete")) - .andExpect(status().isOk()); - - verify(documentService).findIncompleteDocuments(10); + .andExpect(status().is4xxClientError()); } // ─── GET /api/documents/incomplete/next ────────────────────────────────── @@ -467,36 +433,14 @@ class DocumentControllerTest { .andExpect(status().isNoContent()); } - // ─── GET /api/documents/recent-activity ────────────────────────────────── - - @Test - void getRecentActivity_returns401_whenUnauthenticated() throws Exception { - mockMvc.perform(get("/api/documents/recent-activity")) - .andExpect(status().isUnauthorized()); - } + // ─── GET /api/documents/recent-activity (removed — superseded by dashboard) @Test @WithMockUser - void getRecentActivity_returnsOkWithDocuments() throws Exception { - Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build(); - Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build(); - when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2)); - - mockMvc.perform(get("/api/documents/recent-activity").param("size", "5")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("Alpha")) - .andExpect(jsonPath("$[1].title").value("Beta")); - } - - @Test - @WithMockUser - void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception { - when(documentService.getRecentActivity(5)).thenReturn(List.of()); - + void getRecentActivity_endpointRemoved() throws Exception { + // The path hits /{id} and fails UUID conversion — not a 200 anymore mockMvc.perform(get("/api/documents/recent-activity")) - .andExpect(status().isOk()); - - verify(documentService).getRecentActivity(5); + .andExpect(status().is4xxClientError()); } // ─── GET /api/documents/{id}/versions ──────────────────────────────────── From 06c75af96bb73edb71504c96e6023b2d7e478d1c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:10:50 +0200 Subject: [PATCH 07/31] chore(types): regenerate API types with dashboard endpoints Adds DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO, ActivityActorDTO and the three /api/dashboard/* paths. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 569 +++++++++++++++++++++++------- 1 file changed, 445 insertions(+), 124 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index ba57d088..cde59425 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -324,6 +324,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/invites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listInvites"]; + put?: never; + post: operations["createInvite"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/groups": { parameters: { query?: never; @@ -356,6 +372,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/{id}/file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getDocumentFile"]; + put?: never; + post: operations["attachFile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{documentId}/transcription-blocks": { parameters: { query?: never; @@ -532,6 +564,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/forgot-password": { parameters: { query?: never; @@ -1044,22 +1092,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/documents/{id}/file": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getDocumentFile"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/documents/{documentId}/transcription-blocks/{blockId}/history": { parameters: { query?: never; @@ -1108,38 +1140,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/documents/recent-activity": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getRecentActivity"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/documents/incomplete": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getIncomplete"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/documents/incomplete/next": { parameters: { query?: never; @@ -1188,6 +1188,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/dashboard/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getResume"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/dashboard/pulse": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPulse"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/dashboard/activity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getActivity"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/invite/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getInvitePrefill"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/import-status": { parameters: { query?: never; @@ -1236,6 +1300,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/invites/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["revokeInvite"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1253,12 +1333,13 @@ export interface components { AppUser: { /** Format: uuid */ id: string; + /** Format: email */ + email: string; password?: string; firstName?: string; lastName?: string; /** Format: date */ birthDate?: string; - email: string; contact?: string; enabled: boolean; notifyOnReply: boolean; @@ -1266,6 +1347,7 @@ export interface components { groups: components["schemas"]["UserGroup"][]; /** Format: date-time */ createdAt: string; + color: string; }; UserGroup: { /** Format: uuid */ @@ -1405,6 +1487,7 @@ export interface components { blockIds?: string[]; }; CreateUserRequest: { + /** Format: email */ email: string; initialPassword?: string; groupIds?: string[]; @@ -1470,11 +1553,40 @@ export interface components { }; TriggerSenderTrainingDTO: { /** Format: uuid */ - personId?: string; + personId: string; }; BatchOcrDTO: { documentIds: string[]; }; + CreateInviteRequest: { + label?: string; + /** Format: int32 */ + maxUses?: number; + prefillFirstName?: string; + prefillLastName?: string; + prefillEmail?: string; + groupIds?: string[]; + /** Format: date-time */ + expiresAt?: string; + }; + InviteListItemDTO: { + /** Format: uuid */ + id: string; + code: string; + displayCode: string; + label?: string; + /** Format: int32 */ + useCount: number; + /** Format: int32 */ + maxUses?: number; + /** Format: date-time */ + expiresAt?: string; + revoked: boolean; + status: string; + /** Format: date-time */ + createdAt: string; + shareableUrl?: string; + }; GroupDTO: { name?: string; permissions?: string[]; @@ -1580,6 +1692,15 @@ export interface components { token?: string; newPassword?: string; }; + RegisterRequest: { + code: string; + /** Format: email */ + email: string; + password: string; + firstName?: string; + lastName?: string; + notifyOnMention?: boolean; + }; ForgotPasswordRequest: { email?: string; }; @@ -1754,6 +1875,8 @@ export interface components { /** Format: int64 */ totalElements?: number; pageable?: components["schemas"]["PageableObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -1762,8 +1885,6 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; - first?: boolean; - last?: boolean; empty?: boolean; }; PageableObject: { @@ -1847,10 +1968,54 @@ export interface components { summarySnippet?: string; summaryOffsets: components["schemas"]["MatchOffset"][]; }; - IncompleteDocumentDTO: { + ActivityActorDTO: { + initials: string; + color: string; + name?: string; + }; + DashboardResumeDTO: { /** Format: uuid */ - id: string; + documentId: string; title: string; + caption: string; + excerpt: string; + /** Format: int32 */ + page: number; + /** Format: int32 */ + pages: number; + /** Format: int32 */ + pct: number; + thumbnailUrl?: string; + collaborators: components["schemas"]["ActivityActorDTO"][]; + }; + DashboardPulseDTO: { + /** Format: int32 */ + pages: number; + /** Format: int32 */ + annotated: number; + /** Format: int32 */ + transcribed: number; + /** Format: int32 */ + uploaded: number; + /** Format: int32 */ + yourPages: number; + contributors: components["schemas"]["ActivityActorDTO"][]; + }; + ActivityFeedItemDTO: { + /** @enum {string} */ + kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED"; + actor?: components["schemas"]["ActivityActorDTO"]; + /** Format: uuid */ + documentId: string; + documentTitle: string; + /** Format: date-time */ + happenedAt: string; + youMentioned: boolean; + }; + InvitePrefillDTO: { + firstName: string; + lastName: string; + email: string; }; }; responses: never; @@ -2619,6 +2784,52 @@ export interface operations { }; }; }; + listInvites: { + parameters: { + query?: { + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["InviteListItemDTO"][]; + }; + }; + }; + }; + createInvite: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateInviteRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["InviteListItemDTO"]; + }; + }; + }; + }; getAllGroups: { parameters: { query?: never; @@ -2687,6 +2898,57 @@ export interface operations { }; }; }; + getDocumentFile: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; + attachFile: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Document"]; + }; + }; + }; + }; listBlocks: { parameters: { query?: never; @@ -3086,6 +3348,30 @@ export interface operations { }; }; }; + register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AppUser"]; + }; + }; + }; + }; forgotPassword: { parameters: { query?: never; @@ -3603,9 +3889,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": { - [key: string]: unknown; - }; + "*/*": components["schemas"]["TrainingInfoResponse"]; }; }; }; @@ -3850,28 +4134,6 @@ export interface operations { }; }; }; - getDocumentFile: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": string; - }; - }; - }; - }; getBlockHistory: { parameters: { query?: never; @@ -3953,51 +4215,6 @@ export interface operations { }; }; }; - getRecentActivity: { - parameters: { - query?: { - size?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["Document"][]; - }; - }; - }; - }; - getIncomplete: { - parameters: { - query?: { - /** @description Maximum number of results */ - size?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["IncompleteDocumentDTO"][]; - }; - }; - }; - }; getNextIncomplete: { parameters: { query: { @@ -4068,6 +4285,90 @@ export interface operations { }; }; }; + getResume: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DashboardResumeDTO"]; + }; + }; + }; + }; + getPulse: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["DashboardPulseDTO"]; + }; + }; + }; + }; + getActivity: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ActivityFeedItemDTO"][]; + }; + }; + }; + }; + getInvitePrefill: { + parameters: { + query?: never; + header?: never; + path: { + code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["InvitePrefillDTO"]; + }; + }; + }; + }; importStatus: { parameters: { query?: never; @@ -4129,4 +4430,24 @@ export interface operations { }; }; }; + revokeInvite: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } From d34e8986afbecc0773da65265a3c1d10dbc52153 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:13:57 +0200 Subject: [PATCH 08/31] feat(i18n): add dashboard i18n keys (de/en/es) Greeting, resume card, mission control, family pulse, activity feed, audit action verbs, and dropzone keys for the Issue #271 dashboard. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 43 ++++++++++++++++++++++++++++++++++++++- frontend/messages/en.json | 43 ++++++++++++++++++++++++++++++++++++++- frontend/messages/es.json | 43 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 920886a2..fcaf1fc0 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -706,5 +706,46 @@ "admin_new_invite_expires": "Ablaufdatum (optional)", "admin_invite_created_title": "Einladung erstellt", "admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:", - "admin_invite_revoke_confirm": "Einladung wirklich widerrufen?" + "admin_invite_revoke_confirm": "Einladung wirklich widerrufen?", + + "greeting_morning": "Guten Morgen, {name}.", + "greeting_day": "Hallo, {name}.", + "greeting_evening": "Guten Abend, {name}.", + + "dashboard_resume_label": "Weiter, wo du aufgehört hast", + "dashboard_page_of": "Seite {page} von {pages}", + "dashboard_resume_cta": "Weitertranskribieren", + "dashboard_resume_other": "oder anderen Brief wählen", + "dashboard_empty_title": "Noch kein Dokument begonnen", + "dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.", + "dashboard_empty_cta": "Zum Archiv", + + "dashboard_mission_caption": "Offene Aufgaben", + "queue_segment": "Segmentieren", + "queue_segment_blurb": "Seiten aufteilen", + "queue_transcribe": "Transkribieren", + "queue_transcribe_blurb": "Text erfassen", + "queue_review": "Prüfen", + "queue_review_blurb": "Texte kontrollieren", + "queue_n_open": "{n} offen", + "queue_show_all": "Alle anzeigen →", + + "pulse_eyebrow": "Diese Woche", + "pulse_headline": "Ihr habt {pages} Seiten bearbeitet.", + "pulse_you": "Du selbst hast {pages} davon bearbeitet.", + "pulse_contributors": "Mitwirkende", + "pulse_transcribed": "Textstellen markiert", + "pulse_reviewed": "Textstellen transkribiert", + "pulse_uploaded": "Dokumente hochgeladen", + + "feed_caption": "Kommentare & Aktivität", + "feed_for_you": "für dich", + + "audit_action_text_saved": "hat Text gespeichert in", + "audit_action_file_uploaded": "hat eine Datei hochgeladen:", + "audit_action_annotation_created": "hat eine Markierung erstellt in", + "audit_action_comment_added": "hat kommentiert:", + "audit_action_mention_created": "hat dich erwähnt in", + + "dropzone_release": "Loslassen zum Hochladen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 18fda68a..43e213f3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -706,5 +706,46 @@ "admin_new_invite_expires": "Expiry date (optional)", "admin_invite_created_title": "Invite created", "admin_invite_created_desc": "Share this link with the person you are inviting:", - "admin_invite_revoke_confirm": "Really revoke this invite?" + "admin_invite_revoke_confirm": "Really revoke this invite?", + + "greeting_morning": "Good morning, {name}.", + "greeting_day": "Hello, {name}.", + "greeting_evening": "Good evening, {name}.", + + "dashboard_resume_label": "Continue where you left off", + "dashboard_page_of": "Page {page} of {pages}", + "dashboard_resume_cta": "Continue transcribing", + "dashboard_resume_other": "or choose another document", + "dashboard_empty_title": "No document started yet", + "dashboard_empty_body": "Choose a document from the archive to start transcribing.", + "dashboard_empty_cta": "To the archive", + + "dashboard_mission_caption": "Open tasks", + "queue_segment": "Segment", + "queue_segment_blurb": "Split pages", + "queue_transcribe": "Transcribe", + "queue_transcribe_blurb": "Capture text", + "queue_review": "Review", + "queue_review_blurb": "Check texts", + "queue_n_open": "{n} open", + "queue_show_all": "Show all →", + + "pulse_eyebrow": "This week", + "pulse_headline": "You have worked on {pages} pages.", + "pulse_you": "You personally worked on {pages} of them.", + "pulse_contributors": "Contributors", + "pulse_transcribed": "Passages annotated", + "pulse_reviewed": "Passages transcribed", + "pulse_uploaded": "Documents uploaded", + + "feed_caption": "Comments & activity", + "feed_for_you": "for you", + + "audit_action_text_saved": "saved text in", + "audit_action_file_uploaded": "uploaded a file:", + "audit_action_annotation_created": "created an annotation in", + "audit_action_comment_added": "commented:", + "audit_action_mention_created": "mentioned you in", + + "dropzone_release": "Release to upload" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1e59d1c6..254fae9a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -706,5 +706,46 @@ "admin_new_invite_expires": "Fecha de vencimiento (opcional)", "admin_invite_created_title": "Invitación creada", "admin_invite_created_desc": "Comparte este enlace con la persona invitada:", - "admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?" + "admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?", + + "greeting_morning": "Buenos días, {name}.", + "greeting_day": "Hola, {name}.", + "greeting_evening": "Buenas noches, {name}.", + + "dashboard_resume_label": "Continuar donde lo dejaste", + "dashboard_page_of": "Página {page} de {pages}", + "dashboard_resume_cta": "Continuar transcripción", + "dashboard_resume_other": "o elige otro documento", + "dashboard_empty_title": "Aún no has comenzado ningún documento", + "dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.", + "dashboard_empty_cta": "Al archivo", + + "dashboard_mission_caption": "Tareas pendientes", + "queue_segment": "Segmentar", + "queue_segment_blurb": "Dividir páginas", + "queue_transcribe": "Transcribir", + "queue_transcribe_blurb": "Capturar texto", + "queue_review": "Revisar", + "queue_review_blurb": "Controlar textos", + "queue_n_open": "{n} pendiente", + "queue_show_all": "Ver todo →", + + "pulse_eyebrow": "Esta semana", + "pulse_headline": "Habéis trabajado {pages} páginas.", + "pulse_you": "Tú mismo has trabajado {pages} de ellas.", + "pulse_contributors": "Colaboradores", + "pulse_transcribed": "Fragmentos anotados", + "pulse_reviewed": "Fragmentos transcritos", + "pulse_uploaded": "Documentos subidos", + + "feed_caption": "Comentarios y actividad", + "feed_for_you": "para ti", + + "audit_action_text_saved": "guardó texto en", + "audit_action_file_uploaded": "subió un archivo:", + "audit_action_annotation_created": "creó una anotación en", + "audit_action_comment_added": "comentó:", + "audit_action_mention_created": "te mencionó en", + + "dropzone_release": "Suelta para subir" } From 5a98edac86443a647b66ba2a212ceabb18e015e3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:44:08 +0200 Subject: [PATCH 09/31] feat(dashboard): complete frontend redesign for Issue #271 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - +layout.svelte: Upload button in header (authenticated users only) - +page.server.ts: call /api/dashboard/resume, /pulse, /activity; remove deprecated /api/documents/incomplete and /recent-activity - +page.svelte: 2-col grid layout (main + 320px sidebar), greeting, DashboardFamilyPulse + DashboardActivityFeed in sidebar - DashboardResumeStrip: refactored to use server data (resumeDoc prop), SVG thumbnail, progress bar with aria-*, empty state, CTA - DashboardFamilyPulse: new component — weekly stats from audit_log - DashboardActivityFeed: new component — activity feed with "für dich" badge - Update specs for new data shapes Co-Authored-By: Claude Sonnet 4.6 --- .../components/DashboardActivityFeed.svelte | 81 +++++++++++ .../DashboardActivityFeed.svelte.spec.ts | 42 ++++++ .../components/DashboardFamilyPulse.svelte | 70 ++++++++++ .../components/DashboardResumeStrip.svelte | 128 ++++++++++++++---- .../DashboardResumeStrip.svelte.spec.ts | 68 +++++----- frontend/src/routes/+layout.svelte | 28 +++- frontend/src/routes/+page.server.ts | 45 +++--- frontend/src/routes/+page.svelte | 72 +++++----- frontend/src/routes/page.server.spec.ts | 63 ++++++--- frontend/src/routes/page.svelte.spec.ts | 48 ++++--- 10 files changed, 488 insertions(+), 157 deletions(-) create mode 100644 frontend/src/lib/components/DashboardActivityFeed.svelte create mode 100644 frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts create mode 100644 frontend/src/lib/components/DashboardFamilyPulse.svelte diff --git a/frontend/src/lib/components/DashboardActivityFeed.svelte b/frontend/src/lib/components/DashboardActivityFeed.svelte new file mode 100644 index 00000000..f11691b0 --- /dev/null +++ b/frontend/src/lib/components/DashboardActivityFeed.svelte @@ -0,0 +1,81 @@ + + +
+
+

+ {m.feed_caption()} +

+ Alle → +
+ + {#if feed.length > 0} +
    + {#each feed as item (item.happenedAt + item.documentId + item.kind)} +
  • + {#if item.actor} + {item.actor.initials} + {:else} + ? + {/if} + +
    +

    + {#if item.actor} + {item.actor.name ?? item.actor.initials} + {/if} + {verb(item.kind)} + + {item.documentTitle} + + {#if item.youMentioned} + + {m.feed_for_you()} + + {/if} +

    +

    {formatDate(item.happenedAt)}

    +
    +
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts b/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts new file mode 100644 index 00000000..b12c682c --- /dev/null +++ b/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import DashboardActivityFeed from './DashboardActivityFeed.svelte'; +import type { components } from '$lib/generated/api'; + +type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; + +afterEach(() => { + cleanup(); +}); + +const baseItem: ActivityFeedItemDTO = { + kind: 'TEXT_SAVED', + actor: { initials: 'MR', color: '#7a4f9a', name: 'Max Raddatz' }, + documentId: 'doc-1', + documentTitle: 'Brief 1920', + happenedAt: '2026-04-19T10:00:00Z', + youMentioned: false +}; + +describe('DashboardActivityFeed', () => { + it('renders "für dich" badge when youMentioned is true', async () => { + const item: ActivityFeedItemDTO = { ...baseItem, kind: 'MENTION_CREATED', youMentioned: true }; + render(DashboardActivityFeed, { feed: [item] }); + const badge = page.getByText('für dich'); + await expect.element(badge).toBeInTheDocument(); + }); + + it('does not render "für dich" badge when youMentioned is false', async () => { + render(DashboardActivityFeed, { feed: [baseItem] }); + const badge = page.getByText('für dich'); + await expect.element(badge).not.toBeInTheDocument(); + }); + + it('renders empty state when feed is empty', async () => { + render(DashboardActivityFeed, { feed: [] }); + const section = page.getByText('Kommentare & Aktivität'); + await expect.element(section).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/DashboardFamilyPulse.svelte b/frontend/src/lib/components/DashboardFamilyPulse.svelte new file mode 100644 index 00000000..20c50268 --- /dev/null +++ b/frontend/src/lib/components/DashboardFamilyPulse.svelte @@ -0,0 +1,70 @@ + + +{#if pulse !== null} +
+

+ {m.pulse_eyebrow()} +

+ + {#if pulse.pages > 0} +

+ Ihr habt {pulse.pages} Seiten bearbeitet. +

+ {/if} + + {#if pulse.yourPages > 0} +

+ Du selbst hast {pulse.yourPages} davon bearbeitet. +

+ {/if} + + {#if pulse.contributors.length > 0} +
+

{m.pulse_contributors()}

+ {#each pulse.contributors as c (c.initials)} + {c.initials} + {/each} +
+ {/if} + +
+
+ {pulse.annotated} + + {m.pulse_transcribed()} + +
+
+ {pulse.transcribed} + + {m.pulse_reviewed()} + +
+
+ {pulse.uploaded} + + {m.pulse_uploaded()} + +
+
+
+{/if} diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte b/frontend/src/lib/components/DashboardResumeStrip.svelte index 1e7e9e31..ef18cb0d 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte @@ -1,37 +1,115 @@ -{#if lastVisited} +{#if resumeDoc === null} +{:else} +
+ + +
+

+ + {m.dashboard_resume_label()} + · + {m.dashboard_page_of({ page: resumeDoc.page, pages: resumeDoc.pages })} +

+ +

{resumeDoc.title}

+ +

{resumeDoc.caption}

+ +
+ {resumeDoc.excerpt} +
+ +
+ {resumeDoc.pct}% +
+
+
+ {#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials)} + {collab.initials} + {/each} +
+ + +
+
{/if} diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts index 2fac46b0..ec241bf5 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts @@ -3,48 +3,48 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import DashboardResumeStrip from './DashboardResumeStrip.svelte'; +import type { components } from '$lib/generated/api'; + +type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; afterEach(() => { cleanup(); - localStorage.clear(); }); +const mockResume: DashboardResumeDTO = { + documentId: 'doc-123', + title: 'Geburtsurkunde 1920', + caption: 'Max Mustermann · 1920-01-01', + excerpt: 'Hiermit wird beurkundet…', + page: 1, + pages: 4, + pct: 75, + collaborators: [] +}; + describe('DashboardResumeStrip', () => { - it('renders nothing when no last-visited document in localStorage', async () => { - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).not.toBeInTheDocument(); + it('renders empty state heading when resumeDoc is null', async () => { + render(DashboardResumeStrip, { resumeDoc: null }); + const heading = page.getByRole('heading', { name: /Noch kein Dokument begonnen/i }); + await expect.element(heading).toBeInTheDocument(); }); - it('shows the strip with link when localStorage has a document', async () => { - localStorage.setItem( - 'familienarchiv.lastVisited', - JSON.stringify({ id: 'doc-123', title: 'Geburtsurkunde 1920' }) - ); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).toBeInTheDocument(); - const link = page.getByRole('link', { name: /Geburtsurkunde 1920/ }); - await expect.element(link).toBeInTheDocument(); + it('renders progressbar with correct aria-valuenow when resumeDoc is provided', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const bar = page.getByRole('progressbar'); + await expect.element(bar).toBeInTheDocument(); + await expect.element(bar).toHaveAttribute('aria-valuenow', '75'); + }); + + it('shows document title when resumeDoc is provided', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const title = page.getByRole('heading', { name: /Geburtsurkunde 1920/i }); + await expect.element(title).toBeInTheDocument(); + }); + + it('links to the document for the CTA', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const link = page.getByRole('link', { name: /Weitertranskribieren/i }); await expect.element(link).toHaveAttribute('href', '/documents/doc-123'); }); - - it('uses title fallback text when title is empty', async () => { - localStorage.setItem( - 'familienarchiv.lastVisited', - JSON.stringify({ id: 'doc-456', title: '' }) - ); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).toBeInTheDocument(); - const link = page.getByRole('link'); - await expect.element(link).toHaveAttribute('href', '/documents/doc-456'); - }); - - it('renders nothing when localStorage contains malformed JSON', async () => { - localStorage.setItem('familienarchiv.lastVisited', '{not valid json'); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).not.toBeInTheDocument(); - }); }); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3aada446..0469053e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -28,7 +28,9 @@ onMount(() => { }); const isAuthPage = $derived( - ['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p)) + ['/login', '/register', '/forgot-password', '/reset-password'].some((p) => + page.url.pathname.startsWith(p) + ) ); const userInitials = $derived.by(() => { @@ -50,6 +52,30 @@ const userInitials = $derived.by(() => {
+ {#if data?.user} + + + Hochladen + + {/if} - {#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials)} + {#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials + collab.color)} {collab.initials} { const link = page.getByRole('link', { name: /Weitertranskribieren/i }); await expect.element(link).toHaveAttribute('href', '/documents/doc-123'); }); + + it('shows block count label', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const label = page.getByText(/4 Abschnitte/i); + await expect.element(label).toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 20918c9a..c16e40c3 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1982,9 +1982,7 @@ export interface components { caption: string; excerpt: string; /** Format: int32 */ - page: number; - /** Format: int32 */ - pages: number; + totalBlocks: number; /** Format: int32 */ pct: number; thumbnailUrl?: string; From 117044aad9cb13b101519be874cfd0fe4af40db7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 21:39:56 +0200 Subject: [PATCH 25/31] docs(spec): add /documents page design spec with mobile breakpoints Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/DashboardActivityFeed.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/DashboardActivityFeed.svelte b/frontend/src/lib/components/DashboardActivityFeed.svelte index f11691b0..d24fdd91 100644 --- a/frontend/src/lib/components/DashboardActivityFeed.svelte +++ b/frontend/src/lib/components/DashboardActivityFeed.svelte @@ -34,8 +34,10 @@ function formatDate(iso: string): string {

{m.feed_caption()}

- Alle →{m.feed_show_all()}
@@ -61,7 +63,7 @@ function formatDate(iso: string): string { {item.actor.name ?? item.actor.initials} {/if} {verb(item.kind)} - + {item.documentTitle} {#if item.youMentioned} From 3ede42503a2b81db9c0a21ec49b70a30095162fd Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 21:43:16 +0200 Subject: [PATCH 26/31] fix(dashboard): i18n, a11y, security, and type-safety fixes from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use @RequiredArgsConstructor in AuditLogQueryService; remove unused import - Add 401/403 tests for /activity endpoint - Add getPulseStats and findContributorsPerDocument integration tests - Use m.pulse_headline/pulse_you in FamilyPulse; composite avatar keys - Replace hover:text-accent with hover:text-ink in ActivityFeed (WCAG AA) - Localise "Alle →" link with feed_show_all key + aria-label - Gate DropZone behind {#if data.canWrite} - Export DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO from api.ts Co-Authored-By: Claude Sonnet 4.6 --- .../dashboard/AuditLogQueryService.java | 7 +-- ...uditLogQueryRepositoryIntegrationTest.java | 52 +++++++++++++++++++ .../dashboard/DashboardControllerTest.java | 13 +++++ .../components/DashboardFamilyPulse.svelte | 6 +-- frontend/src/lib/generated/api.ts | 4 ++ frontend/src/routes/+page.svelte | 4 +- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java index a7a45104..035bc2e4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java @@ -1,20 +1,17 @@ package org.raddatz.familienarchiv.dashboard; -import org.raddatz.familienarchiv.audit.AuditLogRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.OffsetDateTime; import java.util.*; @Service +@RequiredArgsConstructor public class AuditLogQueryService { private final AuditLogQueryRepository queryRepository; - public AuditLogQueryService(AuditLogQueryRepository queryRepository) { - this.queryRepository = queryRepository; - } - public Optional findMostRecentDocumentForUser(UUID userId) { return queryRepository.findMostRecentDocumentIdByActor(userId); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java index 0e0f7e6a..6121a856 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java @@ -9,6 +9,9 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas import org.springframework.context.annotation.Import; import org.springframework.test.context.jdbc.Sql; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -35,4 +38,53 @@ class AuditLogQueryRepositoryIntegrationTest { assertThat(result).contains(DOC_ID); } + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')" + }) + void findDedupedActivityFeed_returnsAnnotationEntry() { + List rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED"); + assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID); + assertThat(rows.get(0).getHappenedAt()).isNotNull(); + } + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')", + "INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"blockId\":\"ccc\",\"pageNumber\":1}')", + "INSERT INTO audit_log (kind, document_id) VALUES ('FILE_UPLOADED', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')" + }) + void getPulseStats_countsAnnotationsTranscriptionsAndUploads() { + OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC).minusDays(7); + + PulseStatsRow stats = auditLogQueryRepository.getPulseStats(weekStart, USER_ID.toString()); + + assertThat(stats.getAnnotated()).isEqualTo(1); + assertThat(stats.getTranscribed()).isEqualTo(1); + assertThat(stats.getUploaded()).isEqualTo(1); + assertThat(stats.getYourPages()).isGreaterThanOrEqualTo(1); + } + + @Test + @Sql(statements = { + "INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw', 'Anna', 'Meier', '#f00')", + "INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')", + "INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')" + }) + void findContributorsPerDocument_returnsContributorWithInitialsAndColor() { + List rows = auditLogQueryRepository.findContributorsPerDocument(List.of(DOC_ID)); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID); + assertThat(rows.get(0).getActorInitials()).isEqualTo("AM"); + assertThat(rows.get(0).getActorColor()).isEqualTo("#f00"); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java index 2c76d683..0f1e4922 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardControllerTest.java @@ -113,6 +113,19 @@ class DashboardControllerTest { .andExpect(jsonPath("$.annotated").value(23)); } + @Test + void activity_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/dashboard/activity")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void activity_returns403_whenUserHasNoPermissions() throws Exception { + mockMvc.perform(get("/api/dashboard/activity")) + .andExpect(status().isForbidden()); + } + // ─── GET /api/dashboard/activity ───────────────────────────────────────── @Test diff --git a/frontend/src/lib/components/DashboardFamilyPulse.svelte b/frontend/src/lib/components/DashboardFamilyPulse.svelte index 20c50268..44f89542 100644 --- a/frontend/src/lib/components/DashboardFamilyPulse.svelte +++ b/frontend/src/lib/components/DashboardFamilyPulse.svelte @@ -17,20 +17,20 @@ const { pulse }: Props = $props(); {#if pulse.pages > 0}

- Ihr habt {pulse.pages} Seiten bearbeitet. + {m.pulse_headline({ pages: pulse.pages })}

{/if} {#if pulse.yourPages > 0}

- Du selbst hast {pulse.yourPages} davon bearbeitet. + {m.pulse_you({ pages: pulse.yourPages })}

{/if} {#if pulse.contributors.length > 0}

{m.pulse_contributors()}

- {#each pulse.contributors as c (c.initials)} + {#each pulse.contributors as c (c.initials + c.color)} {
- + {#if data.canWrite} + + {/if}
{:else} From d9157b99dd8dc29e5b5a819cf6917726b1106baa Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:19:50 +0200 Subject: [PATCH 27/31] =?UTF-8?q?test(dashboard):=20fix=20stale=20resume?= =?UTF-8?q?=20mock=20=E2=80=94=20use=20totalBlocks=20instead=20of=20page/p?= =?UTF-8?q?ages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/page.server.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 0baa0cb9..a4b47e06 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -37,8 +37,7 @@ describe('home page load — dashboard mode', () => { title: 'T', caption: '', excerpt: '', - page: 1, - pages: 2, + totalBlocks: 2, pct: 50, collaborators: [] } @@ -71,6 +70,7 @@ describe('home page load — dashboard mode', () => { expect(result.isDashboard).toBe(true); expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 }); expect(result.resumeDoc).not.toBeNull(); + expect(result.resumeDoc?.totalBlocks).toBe(2); expect(result.pulse).not.toBeNull(); expect(result.activityFeed).toEqual([]); expect(result.documents).toEqual([]); From 12d92c78eaa34dd401df854d61e8cd8d0f99c645 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:23:32 +0200 Subject: [PATCH 28/31] fix(layout): replace hardcoded 'Hochladen' with m.upload_action() + aria-label Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/routes/+layout.svelte | 4 +++- frontend/src/routes/layout.svelte.spec.ts | 16 ++++++++++++++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7e491bec..31a922f5 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -363,6 +363,7 @@ "doc_panel_discussion_annotation_tab": "Annotation · Seite {page}", "pdf_annotations_show": "Annotierungen anzeigen", "pdf_annotations_hide": "Annotierungen verbergen", + "upload_action": "Hochladen", "upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8e0ae64a..c594b6e0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -363,6 +363,7 @@ "doc_panel_discussion_annotation_tab": "Annotation · Page {page}", "pdf_annotations_show": "Show annotations", "pdf_annotations_hide": "Hide annotations", + "upload_action": "Upload", "upload_drop_hint": "Drop one or multiple files at once", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index a481bf85..3195c2a9 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -363,6 +363,7 @@ "doc_panel_discussion_annotation_tab": "Anotación · Página {page}", "pdf_annotations_show": "Mostrar anotaciones", "pdf_annotations_hide": "Ocultar anotaciones", + "upload_action": "Subir", "upload_drop_hint": "Uno o varios archivos a la vez", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", "upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados", diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0469053e..33671854 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import './layout.css'; import { page } from '$app/state'; import { onMount } from 'svelte'; +import * as m from '$lib/paraglide/messages.js'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import NotificationBell from '$lib/components/NotificationBell.svelte'; @@ -55,6 +56,7 @@ const userInitials = $derived.by(() => { {#if data?.user} { - Hochladen + {m.upload_action()} {/if} diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts index 3de2e9a8..7600053a 100644 --- a/frontend/src/routes/layout.svelte.spec.ts +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -48,6 +48,22 @@ describe('Layout – user avatar button', () => { }); }); +// ─── Upload link ────────────────────────────────────────────────────────────── + +describe('Layout – upload link', () => { + it('has aria-label for screen reader access', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i }); + await expect.element(link).toHaveAttribute('aria-label'); + }); + + it('navigates to /documents/new', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i }); + await expect.element(link).toHaveAttribute('href', '/documents/new'); + }); +}); + // ─── Dropdown ───────────────────────────────────────────────────────────────── describe('Layout – user dropdown', () => { From 55ce696428b1577472feaa5f0a1d74e080a23c44 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:28:00 +0200 Subject: [PATCH 29/31] fix(dashboard): fix ContributorStack each-block key and add accessible avatar labels - Replace (actor.name ?? actor.initials + i) with (actor.initials + '-' + actor.color) to fix operator-precedence bug that made keys order-dependent when name is null - Add role="img" + aria-label={actor.name ?? actor.initials} so screen readers and touch users can access contributor names Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/ContributorStack.svelte | 6 ++- .../ContributorStack.svelte.spec.ts | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/ContributorStack.svelte.spec.ts diff --git a/frontend/src/lib/components/ContributorStack.svelte b/frontend/src/lib/components/ContributorStack.svelte index 8e453284..fc627627 100644 --- a/frontend/src/lib/components/ContributorStack.svelte +++ b/frontend/src/lib/components/ContributorStack.svelte @@ -20,11 +20,13 @@ const safeContributors = $derived(contributors ?? []); > {:else} - {#each safeContributors as actor, i (actor.name ?? actor.initials + i)} + {#each safeContributors as actor, i (actor.initials + '-' + actor.color)} {actor.initials} diff --git a/frontend/src/lib/components/ContributorStack.svelte.spec.ts b/frontend/src/lib/components/ContributorStack.svelte.spec.ts new file mode 100644 index 00000000..877167cd --- /dev/null +++ b/frontend/src/lib/components/ContributorStack.svelte.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ContributorStack from './ContributorStack.svelte'; +import type { components } from '$lib/generated/api'; + +type ActivityActorDTO = components['schemas']['ActivityActorDTO']; + +afterEach(() => cleanup()); + +const makeActor = (overrides: Partial = {}): ActivityActorDTO => ({ + initials: 'MR', + color: '#7a4f9a', + name: 'Max Raddatz', + ...overrides +}); + +describe('ContributorStack', () => { + it('contributor avatar is announced by screen readers with actor name', async () => { + const actor = makeActor({ name: 'Anna Meier', initials: 'AM' }); + render(ContributorStack, { contributors: [actor], hasMore: false }); + await expect.element(page.getByRole('img', { name: 'Anna Meier' })).toBeInTheDocument(); + }); + + it('falls back to initials as accessible name when actor name is null', async () => { + const actor = makeActor({ name: undefined, initials: 'AM' }); + render(ContributorStack, { contributors: [actor], hasMore: false }); + await expect.element(page.getByRole('img', { name: 'AM' })).toBeInTheDocument(); + }); + + it('renders two avatars without crashing when actors have identical initials', async () => { + const actors = [ + makeActor({ name: undefined, initials: 'AM', color: '#aa0000' }), + makeActor({ name: undefined, initials: 'AM', color: '#0000bb' }) + ]; + render(ContributorStack, { contributors: actors, hasMore: false }); + await expect.element(page.getByText('AM').first()).toBeInTheDocument(); + }); + + it('renders overflow indicator when hasMore is true', async () => { + render(ContributorStack, { contributors: [makeActor()], hasMore: true }); + await expect.element(page.getByText('…')).toBeInTheDocument(); + }); + + it('renders empty placeholder when no contributors', async () => { + render(ContributorStack, { contributors: [], hasMore: false }); + await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument(); + }); +}); From e1d51728d975864bd2dd2799a88afcab2d178eb5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:43:30 +0200 Subject: [PATCH 30/31] refactor(audit): move AuditLogQueryService, AuditLogQueryRepository, and shared DTOs to audit package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TranscriptionQueueService was importing ActivityActorDTO and AuditLogQueryService from the dashboard package, creating an inverted dependency (service → dashboard). Moving these to the audit package where AuditLog lives gives both DashboardService and TranscriptionQueueService the correct dependency direction (→ audit). Moved to audit: - ActivityActorDTO, ActivityFeedRow, ContributorRow, PulseStatsRow (projections) - AuditLogQueryRepository, AuditLogQueryService Co-Authored-By: Claude Sonnet 4.6 --- .../audit/ActivityActorDTO.java | 10 +++++ .../familienarchiv/audit/ActivityFeedRow.java | 15 +++++++ .../AuditLogQueryRepository.java | 5 +-- .../AuditLogQueryService.java | 2 +- .../{dashboard => audit}/ContributorRow.java | 2 +- .../familienarchiv/audit/PulseStatsRow.java | 9 ++++ .../dashboard/ActivityFeedItemDTO.java | 18 ++++++++ .../dashboard/DashboardController.java | 42 +++++++++++++++++++ .../dashboard/DashboardPulseDTO.java | 15 +++++++ .../dashboard/DashboardResumeDTO.java | 1 + .../dashboard/DashboardService.java | 4 ++ .../dto/TranscriptionQueueItemDTO.java | 2 +- .../service/TranscriptionQueueService.java | 4 +- .../TranscriptionQueueControllerTest.java | 2 +- ...uditLogQueryRepositoryIntegrationTest.java | 4 ++ .../dashboard/DashboardServiceTest.java | 2 + .../TranscriptionQueueServiceTest.java | 4 +- 17 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityActorDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java rename backend/src/main/java/org/raddatz/familienarchiv/{dashboard => audit}/AuditLogQueryRepository.java (97%) rename backend/src/main/java/org/raddatz/familienarchiv/{dashboard => audit}/AuditLogQueryService.java (97%) rename backend/src/main/java/org/raddatz/familienarchiv/{dashboard => audit}/ContributorRow.java (78%) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/audit/PulseStatsRow.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardPulseDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityActorDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityActorDTO.java new file mode 100644 index 00000000..6bc095e5 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityActorDTO.java @@ -0,0 +1,10 @@ +package org.raddatz.familienarchiv.audit; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; + +public record ActivityActorDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String initials, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String color, + @Nullable String name +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java new file mode 100644 index 00000000..384b311e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/ActivityFeedRow.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.audit; + +import java.time.Instant; +import java.util.UUID; + +public interface ActivityFeedRow { + String getKind(); + UUID getActorId(); + String getActorInitials(); + String getActorColor(); + String getActorName(); + UUID getDocumentId(); + Instant getHappenedAt(); + boolean isYouMentioned(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java similarity index 97% rename from backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java rename to backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java index 3a2d186c..adc933d0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java @@ -1,6 +1,5 @@ -package org.raddatz.familienarchiv.dashboard; +package org.raddatz.familienarchiv.audit; -import org.raddatz.familienarchiv.audit.AuditLog; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -38,7 +37,7 @@ public interface AuditLogQueryRepository extends JpaRepository { COALESCE(u.color, '') AS actorColor, CONCAT_WS(' ', u.first_name, u.last_name) AS actorName, a.document_id AS documentId, - a.happened_at AS happenedAt, + a.happened_at AS happened_at, (a.kind = 'MENTION_CREATED' AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned FROM audit_log a diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java similarity index 97% rename from backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java rename to backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java index 035bc2e4..da887b05 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryService.java @@ -1,4 +1,4 @@ -package org.raddatz.familienarchiv.dashboard; +package org.raddatz.familienarchiv.audit; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ContributorRow.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/ContributorRow.java similarity index 78% rename from backend/src/main/java/org/raddatz/familienarchiv/dashboard/ContributorRow.java rename to backend/src/main/java/org/raddatz/familienarchiv/audit/ContributorRow.java index a60a5ca4..6ee5058e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ContributorRow.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/ContributorRow.java @@ -1,4 +1,4 @@ -package org.raddatz.familienarchiv.dashboard; +package org.raddatz.familienarchiv.audit; import java.util.UUID; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/PulseStatsRow.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/PulseStatsRow.java new file mode 100644 index 00000000..e374cccc --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/PulseStatsRow.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.audit; + +public interface PulseStatsRow { + long getPages(); + long getAnnotated(); + long getTranscribed(); + long getUploaded(); + long getYourPages(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java new file mode 100644 index 00000000..0fcdd312 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/ActivityFeedItemDTO.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dashboard; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.AuditKind; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public record ActivityFeedItemDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) AuditKind kind, + @Nullable ActivityActorDTO actor, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java new file mode 100644 index 00000000..1869c2f4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardController.java @@ -0,0 +1,42 @@ +package org.raddatz.familienarchiv.dashboard; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.security.SecurityUtils; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/dashboard") +@RequirePermission(Permission.READ_ALL) +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + private final UserService userService; + + @GetMapping("/resume") + public DashboardResumeDTO getResume(Authentication authentication) { + UUID userId = SecurityUtils.requireUserId(authentication, userService); + return dashboardService.getResume(userId); + } + + @GetMapping("/pulse") + public DashboardPulseDTO getPulse(Authentication authentication) { + UUID userId = SecurityUtils.requireUserId(authentication, userService); + return dashboardService.getPulse(userId); + } + + @GetMapping("/activity") + public List getActivity( + Authentication authentication, + @RequestParam(defaultValue = "7") int limit) { + UUID userId = SecurityUtils.requireUserId(authentication, userService); + return dashboardService.getActivity(userId, Math.min(limit, 20)); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardPulseDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardPulseDTO.java new file mode 100644 index 00000000..59d9e931 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardPulseDTO.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.dashboard; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; + +import java.util.List; + +public record DashboardPulseDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pages, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotated, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int transcribed, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int uploaded, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int yourPages, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List contributors +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardResumeDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardResumeDTO.java index f7fa95ed..44c04b9a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardResumeDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardResumeDTO.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.dashboard; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import java.util.List; import java.util.UUID; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java index 8734c14a..d749a164 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java @@ -2,6 +2,10 @@ package org.raddatz.familienarchiv.dashboard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; +import org.raddatz.familienarchiv.audit.PulseStatsRow; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Person; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java index 5f3d3de5..36d63ca7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/TranscriptionQueueItemDTO.java @@ -1,7 +1,7 @@ package org.raddatz.familienarchiv.dto; import io.swagger.v3.oas.annotations.media.Schema; -import org.raddatz.familienarchiv.dashboard.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import java.time.LocalDate; import java.util.List; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java index 93f93926..6b82abb9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionQueueService.java @@ -1,8 +1,8 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; -import org.raddatz.familienarchiv.dashboard.ActivityActorDTO; -import org.raddatz.familienarchiv.dashboard.AuditLogQueryService; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO; import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO; import org.raddatz.familienarchiv.repository.DocumentRepository; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java index de325050..183d024f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/TranscriptionQueueControllerTest.java @@ -17,7 +17,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import org.raddatz.familienarchiv.dashboard.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; import java.time.LocalDate; import java.util.List; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java index 6121a856..5875baf5 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/AuditLogQueryRepositoryIntegrationTest.java @@ -1,6 +1,10 @@ package org.raddatz.familienarchiv.dashboard; import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditLogQueryRepository; +import org.raddatz.familienarchiv.audit.ContributorRow; +import org.raddatz.familienarchiv.audit.PulseStatsRow; import org.raddatz.familienarchiv.PostgresContainerConfig; import org.raddatz.familienarchiv.config.FlywayConfig; import org.springframework.beans.factory.annotation.Autowired; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java index 23b19ec2..c62fdb8c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/dashboard/DashboardServiceTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.ActivityFeedRow; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.TranscriptionBlock; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java index 7d678249..6c7a47ac 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionQueueServiceTest.java @@ -6,8 +6,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.raddatz.familienarchiv.dashboard.ActivityActorDTO; -import org.raddatz.familienarchiv.dashboard.AuditLogQueryService; +import org.raddatz.familienarchiv.audit.ActivityActorDTO; +import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO; import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO; import org.raddatz.familienarchiv.repository.DocumentRepository; From b6466fcd955d06a3d45ed93d755ece3549c67690 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 18:07:06 +0200 Subject: [PATCH 31/31] fix(admin): wire delete-user button via enhance callback instead of requestSubmit() The delete button used type=button + requestSubmit() to trigger the form, which did not reliably fire SvelteKit's enhance submit listener. Replaced with a type=submit button and an async enhance callback that guards with the confirm dialog and calls cancel() on rejection. Also clears the unsaved-changes dirty flag before the redirect so beforeNavigate doesn't silently block the post-delete navigation. Closes #277 Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/users/[id]/+page.svelte | 29 +++++++++-------- .../admin/users/[id]/page.svelte.spec.ts | 32 ++++++++++++------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte index 9296e057..ac94b9a8 100644 --- a/frontend/src/routes/admin/users/[id]/+page.svelte +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -15,16 +15,6 @@ const unsaved = createUnsavedWarning(); const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []); -let deleteFormEl = $state(null); - -async function handleDelete() { - const confirmed = await confirm({ - title: m.admin_user_delete_confirm({ username: data.editUser.email }), - destructive: true - }); - if (confirmed) deleteFormEl!.requestSubmit(); -} - $effect(() => { if (form?.success) unsaved.clearOnSuccess(); }); @@ -51,10 +41,23 @@ $effect(() => {

{m.admin_user_edit_heading({ username: data.editUser.email })}

-
+ { + const confirmed = await confirm({ + title: m.admin_user_delete_confirm({ username: data.editUser.email }), + destructive: true + }); + if (!confirmed) { + cancel(); + } else { + unsaved.clearOnSuccess(); + } + }} + >