feat: bulk metadata edit for existing documents (select → panel → PATCH) #225

Closed
opened 2026-04-12 08:49:35 +02:00 by marcel · 11 comments
Owner

Problem

All document metadata editing is one-at-a-time today. With 1500+ documents, assigning the same tags, sender, or archive location to a batch means opening each document individually. This is the main bottleneck during digitisation sprints.

Note: bulk upload (/documents/new) already covers shared metadata at upload time. This issue is about post-hoc bulk editing of documents already in the system.

Solution

Checkbox-based multi-select on any document list → "Massenbearbeitung" button → bulk-edit panel that patches existing documents using the same split-panel structure as the upload form.

Gated by WRITE_ALL. Checkboxes and the action button are only rendered for users with that permission.

UX Flow

  1. User checks one or more rows on the document list (search results page or future enrichment queue)
  2. Sticky selection bar appears at bottom: "N Dokumente ausgewählt · [Massenbearbeitung]"
  3. Clicking "Massenbearbeitung" sets the selected IDs in a Svelte store and navigates to /documents/bulk-edit
  4. Bulk-edit panel reads from the store on mount — selected documents populate the left file-switcher strip; all shared metadata fields start empty
  5. User fills in only the fields they want to apply; blank fields are never written
  6. Save → backend patches all documents → redirect to document list

UX: Onboarding Cues (empty fields are intentional)

Users coming from single-document edit may be confused by all-empty fields. Two lightweight cues:

  1. Inline callout at the top of the right panel (subdued, one-line, no modal):

    "Nur ausgefüllte Felder werden angewendet. Tags werden hinzugefügt, nicht ersetzt."

  2. Field label badges next to each label — small, muted:
    • Tags → + wird hinzugefügt
    • Sender → wird ersetzt
    • Empfänger → + wird hinzugefügt
    • Archivort → wird ersetzt

Bulk-Edit Panel (adapts existing BulkDocumentEditLayout)

New mode="edit" prop on the existing component — no separate layout file.

Area Bulk Upload (existing) Bulk Edit (mode="edit")
Left panel Local blob PDF preview Server PDF via existing download URL
Per-file card Editable title input Read-only title display
Shared metadata WhoWhenSection + DescriptionSection Same components — date field hidden
Onboarding callout Inline info strip above fields
Save POST /api/documents/quick-upload PATCH /api/documents/bulk
Discard Clears file queue Navigates back to list

Field Semantics

Field Semantic Behaviour
tagNames Additive Merged into each document's existing tag set
senderId Replace Overwrites existing sender; blank = no change
receiverIds Additive Added to each document's existing receiver set
documentLocation / archiveBox / archiveFolder Replace Overwrites; blank = no change

Backend: New Endpoint

PATCH /api/documents/bulk
@RequirePermission(WRITE_ALL)

{
  "documentIds":      ["uuid", …],   // required, no hard cap
  "tagNames":         ["Brief"],     // optional — additive
  "senderId":         "uuid",        // optional — replace
  "receiverIds":      ["uuid", …],   // optional — additive
  "documentLocation": "Keller",      // optional — replace
  "archiveBox":       "Karton 3",    // optional — replace
  "archiveFolder":    "1920er"       // optional — replace
}

Response: { updated: N, errors: [{ id, message }] }

Same partial-failure pattern as quick-upload. No arbitrary cap — PATCH bodies carry no file payload.

Frontend Changes

  1. Document list +page.svelte — checkbox per row + sticky selection bar (rendered only with WRITE_ALL); on action click: write IDs to store, navigate to /documents/bulk-edit
  2. New route /documents/bulk-edit/+page.svelte — reads IDs from store on mount (redirect to list if store is empty), loads document metadata for each ID, renders adapted panel
  3. BulkDocumentEditLayout — new mode="edit" prop: hides drop zone, makes title read-only, hides date field, shows onboarding callout, calls PATCH on save

Out of Scope

  • Bulk delete
  • Bulk status transitions
  • "Select all matching current filter" (follow-up)
  • Date field (excluded — too risky for bulk replace)

Acceptance Criteria

  • Checkboxes on document list rows; hidden for users without WRITE_ALL
  • Sticky selection bar visible when ≥ 1 item selected; shows document count
  • Clicking "Massenbearbeitung" navigates to /documents/bulk-edit with IDs in store
  • Navigating directly to /documents/bulk-edit with empty store redirects to document list
  • Bulk-edit panel renders with correct documents in left strip; all metadata fields start empty
  • Inline callout and field-label badges visible in mode="edit"
  • Tags are additive — existing tags on each document are preserved
  • Sender replaces; blank sender field = no change applied
  • Receivers are additive — existing receivers are preserved
  • Blank location fields = no change applied
  • Partial failure: error chips for failed docs, successful docs removed from strip
  • PATCH /api/documents/bulk requires WRITE_ALL; returns partial-failure shape
  • Checkboxes and action button never rendered for READ_ALL-only users
## Problem All document metadata editing is one-at-a-time today. With 1500+ documents, assigning the same tags, sender, or archive location to a batch means opening each document individually. This is the main bottleneck during digitisation sprints. **Note:** bulk upload (`/documents/new`) already covers shared metadata at upload time. This issue is about post-hoc bulk editing of documents already in the system. ## Solution Checkbox-based multi-select on any document list → "Massenbearbeitung" button → bulk-edit panel that patches existing documents using the same split-panel structure as the upload form. **Gated by `WRITE_ALL`.** Checkboxes and the action button are only rendered for users with that permission. ## UX Flow 1. User checks one or more rows on the document list (search results page or future enrichment queue) 2. Sticky selection bar appears at bottom: **"N Dokumente ausgewählt · [Massenbearbeitung]"** 3. Clicking "Massenbearbeitung" sets the selected IDs in a Svelte store and navigates to `/documents/bulk-edit` 4. Bulk-edit panel reads from the store on mount — selected documents populate the left file-switcher strip; all shared metadata fields start empty 5. User fills in only the fields they want to apply; blank fields are never written 6. Save → backend patches all documents → redirect to document list ## UX: Onboarding Cues (empty fields are intentional) Users coming from single-document edit may be confused by all-empty fields. Two lightweight cues: 1. **Inline callout** at the top of the right panel (subdued, one-line, no modal): > *"Nur ausgefüllte Felder werden angewendet. Tags werden hinzugefügt, nicht ersetzt."* 2. **Field label badges** next to each label — small, muted: - Tags → `+ wird hinzugefügt` - Sender → `wird ersetzt` - Empfänger → `+ wird hinzugefügt` - Archivort → `wird ersetzt` ## Bulk-Edit Panel (adapts existing `BulkDocumentEditLayout`) New `mode="edit"` prop on the existing component — no separate layout file. | Area | Bulk Upload (existing) | Bulk Edit (`mode="edit"`) | |---|---|---| | Left panel | Local blob PDF preview | Server PDF via existing download URL | | Per-file card | Editable title input | Read-only title display | | Shared metadata | `WhoWhenSection` + `DescriptionSection` | Same components — **date field hidden** | | Onboarding callout | — | Inline info strip above fields | | Save | `POST /api/documents/quick-upload` | `PATCH /api/documents/bulk` | | Discard | Clears file queue | Navigates back to list | ## Field Semantics | Field | Semantic | Behaviour | |---|---|---| | `tagNames` | **Additive** | Merged into each document's existing tag set | | `senderId` | **Replace** | Overwrites existing sender; blank = no change | | `receiverIds` | **Additive** | Added to each document's existing receiver set | | `documentLocation` / `archiveBox` / `archiveFolder` | **Replace** | Overwrites; blank = no change | ## Backend: New Endpoint ``` PATCH /api/documents/bulk @RequirePermission(WRITE_ALL) { "documentIds": ["uuid", …], // required, no hard cap "tagNames": ["Brief"], // optional — additive "senderId": "uuid", // optional — replace "receiverIds": ["uuid", …], // optional — additive "documentLocation": "Keller", // optional — replace "archiveBox": "Karton 3", // optional — replace "archiveFolder": "1920er" // optional — replace } Response: { updated: N, errors: [{ id, message }] } ``` Same partial-failure pattern as `quick-upload`. No arbitrary cap — PATCH bodies carry no file payload. ## Frontend Changes 1. **Document list `+page.svelte`** — checkbox per row + sticky selection bar (rendered only with `WRITE_ALL`); on action click: write IDs to store, navigate to `/documents/bulk-edit` 2. **New route `/documents/bulk-edit/+page.svelte`** — reads IDs from store on mount (redirect to list if store is empty), loads document metadata for each ID, renders adapted panel 3. **`BulkDocumentEditLayout`** — new `mode="edit"` prop: hides drop zone, makes title read-only, hides date field, shows onboarding callout, calls `PATCH` on save ## Out of Scope - Bulk delete - Bulk status transitions - "Select all matching current filter" (follow-up) - Date field (excluded — too risky for bulk replace) ## Acceptance Criteria - [ ] Checkboxes on document list rows; hidden for users without `WRITE_ALL` - [ ] Sticky selection bar visible when ≥ 1 item selected; shows document count - [ ] Clicking "Massenbearbeitung" navigates to `/documents/bulk-edit` with IDs in store - [ ] Navigating directly to `/documents/bulk-edit` with empty store redirects to document list - [ ] Bulk-edit panel renders with correct documents in left strip; all metadata fields start empty - [ ] Inline callout and field-label badges visible in `mode="edit"` - [ ] Tags are **additive** — existing tags on each document are preserved - [ ] Sender replaces; blank sender field = no change applied - [ ] Receivers are **additive** — existing receivers are preserved - [ ] Blank location fields = no change applied - [ ] Partial failure: error chips for failed docs, successful docs removed from strip - [ ] `PATCH /api/documents/bulk` requires `WRITE_ALL`; returns partial-failure shape - [ ] Checkboxes and action button never rendered for `READ_ALL`-only users
marcel added the collaborationfeature labels 2026-04-12 08:49:41 +02:00
marcel changed title from feat: batch operations for documents (bulk tag, sender, metadata) to feat: bulk metadata edit for existing documents (select → panel → PATCH) 2026-04-25 12:39:56 +02:00
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Observations

  • mode="edit" on the existing BulkDocumentEditLayout is the right call. Avoids a second layout file and keeps the split-panel, FileSwitcherStrip, and metadata-section composition in one place. The existing component is 320 lines — the mode branching will grow it, but that's manageable.
  • The Svelte store as inter-route communication is an architectural smell — but it's the right tradeoff here. Client-side ephemeral state (doesn't survive refresh, can't be bookmarked) is acceptable for a flow the issue explicitly protects with the empty-store redirect AC. The coupling between list page and bulk-edit page is invisible but bounded. Accept it; just don't spread the pattern.
  • Batch metadata fetch is unspecified and matters. The bulk-edit page needs to populate the left panel with server PDFs and read-only titles for each selected document. The issue doesn't say how. N individual GET /api/documents/{id} calls for a 200-document selection would hammer the backend on mount. Recommend a POST /api/documents/batch-metadata endpoint (body: { ids: [...] }, response: lightweight document summaries). This keeps the PATCH endpoint clean and gives the frontend a single round-trip to hydrate the left panel.
  • No hard cap on documentIds is a problem. The issue argues "PATCH bodies carry no file payload" — true, but 1500 UUIDs is ~50 KB of body and can produce an IN-clause with 1500 parameters. More critically, locking 1500 rows in a single transaction is a real concern at digitisation-sprint scale. Add a backend cap (recommend 500 IDs, 400 on violation) and let the frontend send multiple PATCH requests if needed — the chunk pattern from quick-upload already handles this model.
  • Partial-failure shape is consistent with quick-upload. { updated: N, errors: [{ id, message }] } is the right contract.
  • No Flyway migration needed — this is pure application code. The tag-additive and receiver-additive semantics use existing join tables.

Recommendations

  • Add POST /api/documents/batch-metadata to fetch lightweight summaries for the bulk-edit left panel. Scope it to the document IDs provided; gate it behind READ_ALL (not WRITE_ALL — reading titles doesn't require write permission).
  • Add a 500-ID hard cap on the PATCH body. Return 400 with BULK_EDIT_TOO_MANY_IDS ErrorCode. Document the cap in the issue's "Out of Scope" section so it's visible.
  • Document the store coupling with a one-line comment in the store file explaining it's intentionally ephemeral (so a future developer doesn't add persistence to it).
## 🏗️ Markus Keller — Senior Application Architect ### Observations - **`mode="edit"` on the existing `BulkDocumentEditLayout` is the right call.** Avoids a second layout file and keeps the split-panel, FileSwitcherStrip, and metadata-section composition in one place. The existing component is 320 lines — the mode branching will grow it, but that's manageable. - **The Svelte store as inter-route communication is an architectural smell — but it's the right tradeoff here.** Client-side ephemeral state (doesn't survive refresh, can't be bookmarked) is acceptable for a flow the issue explicitly protects with the empty-store redirect AC. The coupling between list page and bulk-edit page is invisible but bounded. Accept it; just don't spread the pattern. - **Batch metadata fetch is unspecified and matters.** The bulk-edit page needs to populate the left panel with server PDFs _and_ read-only titles for each selected document. The issue doesn't say how. N individual `GET /api/documents/{id}` calls for a 200-document selection would hammer the backend on mount. Recommend a `POST /api/documents/batch-metadata` endpoint (body: `{ ids: [...] }`, response: lightweight document summaries). This keeps the PATCH endpoint clean and gives the frontend a single round-trip to hydrate the left panel. - **No hard cap on `documentIds` is a problem.** The issue argues "PATCH bodies carry no file payload" — true, but 1500 UUIDs is ~50 KB of body and can produce an IN-clause with 1500 parameters. More critically, locking 1500 rows in a single transaction is a real concern at digitisation-sprint scale. Add a backend cap (recommend 500 IDs, 400 on violation) and let the frontend send multiple PATCH requests if needed — the chunk pattern from quick-upload already handles this model. - **Partial-failure shape is consistent** with `quick-upload`. `{ updated: N, errors: [{ id, message }] }` is the right contract. - **No Flyway migration needed** — this is pure application code. The tag-additive and receiver-additive semantics use existing join tables. ### Recommendations - Add `POST /api/documents/batch-metadata` to fetch lightweight summaries for the bulk-edit left panel. Scope it to the document IDs provided; gate it behind `READ_ALL` (not `WRITE_ALL` — reading titles doesn't require write permission). - Add a 500-ID hard cap on the PATCH body. Return 400 with `BULK_EDIT_TOO_MANY_IDS` ErrorCode. Document the cap in the issue's "Out of Scope" section so it's visible. - Document the store coupling with a one-line comment in the store file explaining it's intentionally ephemeral (so a future developer doesn't add persistence to it).
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • mode="edit" is a boolean flag in prop form. The issue specifies it as a single string enum ("upload" / "edit"), which is better than isEditMode: boolean — but it still means the component's template branches on mode for: drop zone, title editability, date field visibility, PDF source (blob vs. server URL), onboarding callout, and save handler. At 320 lines already, the component will push 500+ with both modes fully implemented. Watch for the 40-line template split signal and extract sub-components if needed. KISS wins for now; flag it at implementation time.
  • FileEntry type needs extension for edit mode. The existing FileSwitcherStrip and left panel use FileEntry which has { id, file, title, previewUrl, status }. In edit mode, file is absent, previewUrl is a server URL (/api/documents/{id}/file), and the component also needs the document UUID to call the PATCH endpoint. The issue doesn't define the edit-mode entry shape. Recommend a discriminated union or a new EditEntry type.
  • Missing: how the bulk-edit page fetches document metadata on mount. The store only holds IDs. To display read-only titles and server PDFs in the left panel, the page must fetch metadata for each selected document client-side on mount. With N=200, this is either N individual GETs (bad) or a batch endpoint (not yet specified). This is a blocking implementation detail.
  • i18n is incomplete in the issue. The following strings need Paraglide keys: "Massenbearbeitung", "N Dokumente ausgewählt", "Nur ausgefüllte Felder werden angewendet. Tags werden hinzugefügt, nicht ersetzt.", "+ wird hinzugefügt", "wird ersetzt". Add them to messages/de.json, en.json, es.json before starting implementation — generated keys are needed for the component.
  • Spec file already exists: BulkDocumentEditLayout.svelte.spec.ts. New cases to add: mode_edit_hides_date_field, mode_edit_shows_onboarding_callout, mode_edit_title_is_read_only, mode_edit_calls_patch_not_post_on_save.
  • The canWrite pattern is already established. +layout.server.ts computes canWrite from WRITE_ALL and threads it through layout data. The document list already receives it as a prop (DocumentList.svelte:14). The checkbox rendering follows the same pattern — no new permission-check machinery needed.

Recommendations

  • Define a BulkEditEntry type (or extend FileEntry with optional fields) before writing the component. The type boundary makes the mode branching explicit.
  • Write failing tests first for mode="edit" behavior (date hidden, title read-only, callout visible, PATCH called) before touching the component implementation.
  • Add all Paraglide translation keys as the first commit — they block everything else from type-checking cleanly.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - **`mode="edit"` is a boolean flag in prop form.** The issue specifies it as a single string enum (`"upload"` / `"edit"`), which is better than `isEditMode: boolean` — but it still means the component's template branches on mode for: drop zone, title editability, date field visibility, PDF source (blob vs. server URL), onboarding callout, and save handler. At 320 lines already, the component will push 500+ with both modes fully implemented. Watch for the 40-line template split signal and extract sub-components if needed. KISS wins for now; flag it at implementation time. - **`FileEntry` type needs extension for edit mode.** The existing `FileSwitcherStrip` and left panel use `FileEntry` which has `{ id, file, title, previewUrl, status }`. In edit mode, `file` is absent, `previewUrl` is a server URL (`/api/documents/{id}/file`), and the component also needs the document UUID to call the PATCH endpoint. The issue doesn't define the edit-mode entry shape. Recommend a discriminated union or a new `EditEntry` type. - **Missing: how the bulk-edit page fetches document metadata on mount.** The store only holds IDs. To display read-only titles and server PDFs in the left panel, the page must fetch metadata for each selected document client-side on mount. With N=200, this is either N individual GETs (bad) or a batch endpoint (not yet specified). This is a blocking implementation detail. - **i18n is incomplete in the issue.** The following strings need Paraglide keys: `"Massenbearbeitung"`, `"N Dokumente ausgewählt"`, `"Nur ausgefüllte Felder werden angewendet. Tags werden hinzugefügt, nicht ersetzt."`, `"+ wird hinzugefügt"`, `"wird ersetzt"`. Add them to `messages/de.json`, `en.json`, `es.json` before starting implementation — generated keys are needed for the component. - **Spec file already exists:** `BulkDocumentEditLayout.svelte.spec.ts`. New cases to add: `mode_edit_hides_date_field`, `mode_edit_shows_onboarding_callout`, `mode_edit_title_is_read_only`, `mode_edit_calls_patch_not_post_on_save`. - **The `canWrite` pattern is already established.** `+layout.server.ts` computes `canWrite` from `WRITE_ALL` and threads it through layout data. The document list already receives it as a prop (`DocumentList.svelte:14`). The checkbox rendering follows the same pattern — no new permission-check machinery needed. ### Recommendations - Define a `BulkEditEntry` type (or extend `FileEntry` with optional fields) before writing the component. The type boundary makes the mode branching explicit. - Write failing tests first for `mode="edit"` behavior (date hidden, title read-only, callout visible, PATCH called) before touching the component implementation. - Add all Paraglide translation keys as the first commit — they block everything else from type-checking cleanly.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Observations

The issue has solid ACs, but several behaviors need explicit test cases that aren't yet implied by the AC list.

Unit layer (BulkDocumentEditLayout.svelte.spec.ts — extend existing file):

  • mode_edit_hides_date_field — date input is not rendered in the DOM
  • mode_edit_title_is_read_only — title <input> is absent or disabled; display element present
  • mode_edit_shows_onboarding_callout — info strip is visible when mode="edit"
  • mode_edit_additive_badge_visible_on_tags — badge text "+ wird hinzugefügt" renders next to tags label
  • mode_edit_replace_badge_visible_on_sender — badge "wird ersetzt" renders next to sender label
  • mode_edit_calls_patch_on_save — save triggers PATCH /api/documents/bulk, not POST

Service layer (DocumentBulkEditServiceTest.java — new):

  • should_apply_tags_additively_without_removing_existing — document had [Brief], PATCH adds [Kurrent] → result is [Brief, Kurrent]
  • should_skip_sender_when_senderId_is_null — sender remains unchanged
  • should_replace_sender_when_senderId_provided
  • should_apply_receivers_additively
  • should_skip_location_when_blank
  • should_return_error_entry_for_unknown_document_id
  • should_return_updated_count_correctly_on_partial_failure

Controller layer (@WebMvcTestDocumentControllerTest.java):

  • bulk_edit_returns_403_for_READ_ALL_usercritical; must exist before any merge
  • bulk_edit_returns_401_for_unauthenticated
  • bulk_edit_returns_400_when_documentIds_is_empty
  • bulk_edit_returns_partial_failure_shape_on_mixed_success

E2E (Playwright — 1 golden-path test):

  • Select 2 documents → click "Massenbearbeitung" → add tag → save → verify tag appears on both document detail pages
  • Verify checkboxes are absent for a READ_ALL-only user session

Edge cases not covered by current ACs:

  • Duplicate document ID in the PATCH body (send same UUID twice) — what does the backend do?
  • documentIds list is empty — 400 or 200 with updated: 0? Needs to be specified and tested.
  • User navigates directly to /documents/bulk-edit with empty store → redirect to list (AC exists, needs E2E test)
  • Network failure mid-PATCH → all documents in that request marked as error chips (AC exists, needs unit test)

Recommendations

  • The bulk_edit_returns_403_for_READ_ALL_user controller test is a hard prerequisite — the endpoint must not merge without it.
  • The additive tag test is the highest-value service test: it verifies the most surprising behavior (tags not replaced) and is the most likely implementation mistake.
  • One E2E test is enough — do not expand to cover individual field semantics at the E2E layer (those belong at service layer).
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Observations The issue has solid ACs, but several behaviors need explicit test cases that aren't yet implied by the AC list. **Unit layer (`BulkDocumentEditLayout.svelte.spec.ts` — extend existing file):** - `mode_edit_hides_date_field` — date input is not rendered in the DOM - `mode_edit_title_is_read_only` — title `<input>` is absent or `disabled`; display element present - `mode_edit_shows_onboarding_callout` — info strip is visible when `mode="edit"` - `mode_edit_additive_badge_visible_on_tags` — badge text `"+ wird hinzugefügt"` renders next to tags label - `mode_edit_replace_badge_visible_on_sender` — badge `"wird ersetzt"` renders next to sender label - `mode_edit_calls_patch_on_save` — save triggers `PATCH /api/documents/bulk`, not `POST` **Service layer (`DocumentBulkEditServiceTest.java` — new):** - `should_apply_tags_additively_without_removing_existing` — document had `[Brief]`, PATCH adds `[Kurrent]` → result is `[Brief, Kurrent]` - `should_skip_sender_when_senderId_is_null` — sender remains unchanged - `should_replace_sender_when_senderId_provided` - `should_apply_receivers_additively` - `should_skip_location_when_blank` - `should_return_error_entry_for_unknown_document_id` - `should_return_updated_count_correctly_on_partial_failure` **Controller layer (`@WebMvcTest` — `DocumentControllerTest.java`):** - `bulk_edit_returns_403_for_READ_ALL_user` — **critical; must exist before any merge** - `bulk_edit_returns_401_for_unauthenticated` - `bulk_edit_returns_400_when_documentIds_is_empty` - `bulk_edit_returns_partial_failure_shape_on_mixed_success` **E2E (Playwright — 1 golden-path test):** - Select 2 documents → click "Massenbearbeitung" → add tag → save → verify tag appears on both document detail pages - Verify checkboxes are absent for a `READ_ALL`-only user session **Edge cases not covered by current ACs:** - Duplicate document ID in the PATCH body (send same UUID twice) — what does the backend do? - `documentIds` list is empty — 400 or 200 with `updated: 0`? Needs to be specified and tested. - User navigates directly to `/documents/bulk-edit` with empty store → redirect to list (AC exists, needs E2E test) - Network failure mid-PATCH → all documents in that request marked as error chips (AC exists, needs unit test) ### Recommendations - The `bulk_edit_returns_403_for_READ_ALL_user` controller test is a hard prerequisite — the endpoint must not merge without it. - The additive tag test is the highest-value service test: it verifies the most surprising behavior (tags not replaced) and is the most likely implementation mistake. - One E2E test is enough — do not expand to cover individual field semantics at the E2E layer (those belong at service layer).
Author
Owner

📋 Elicit — Requirements Engineer

Observations

The issue is well-specified for a complex feature — field semantics table, UX flow, partial-failure shape, ACs, and out-of-scope list are all present. A few gaps that affect implementation decisions:

Gap 1 — Multi-page selection is silent about its limitation.
The checkbox approach covers the current search results page only. A user searching for "Familie Raddatz" across 400 results (8 pages) can only bulk-edit the current page's visible rows. This is acknowledged as a follow-up ("Select all matching current filter") but not surfaced to the user in the UX. The selection bar should show a hint like "Auswahl auf aktuelle Seite beschränkt" to prevent the user from thinking they've selected their entire result set.

Gap 2 — Metadata loading on the bulk-edit page is unspecified.
The bulk-edit page receives only IDs from the store. To show read-only titles in the left strip and server PDFs in the preview, it must fetch document metadata. The issue doesn't specify: one GET per document (problematic at N=200) or a batch fetch endpoint (not yet in the API). This is a blocking implementation gap — the AC "bulk-edit panel renders with correct documents in left strip" cannot be satisfied without a concrete fetch strategy.

Gap 3 — Transaction atomicity per document is unspecified.
The partial-failure response assumes each document either succeeds or errors. But the issue doesn't specify what happens if a single document's update fails mid-way (e.g., tags applied but sender update throws). Is each document's update wrapped in its own transaction (so it's all-or-nothing per document), or can a document end up with partial metadata applied? For a PATCH touching multiple join tables (tags, receivers, sender), this matters.

Gap 4 — Undo path is absent.
Accidentally setting the wrong sender on 200 documents is unrecoverable without this feature. The issue has document versioning (DocumentVersionService, DocumentVersion entity already exist). Bulk-edit operations should create a version snapshot before applying changes, enabling manual rollback. This doesn't need to be in scope v1, but it should be in "Out of Scope" so the team is aware.

Gap 5 — Progress feedback during PATCH.
With 200 documents, the PATCH could take 2–5 seconds. The issue specifies partial-failure chips after save completes, but no loading state during the PATCH. A spinner or progress indicator on the save button is needed.

Recommendations

  • Add to the UX: a hint in the selection bar clarifying the selection is page-scoped.
  • Specify the metadata fetch strategy in the issue before implementation starts (it affects both backend scope and frontend mount behavior).
  • Clarify per-document transaction semantics — recommend wrapping each document's update in its own @Transactional save so partial field application is impossible.
  • Add to "Out of Scope": "Undo via document version rollback (existing versioning infrastructure supports this as a follow-up)."
  • Add to ACs: save button shows a loading state during PATCH; document count and estimated time are not required.
## 📋 Elicit — Requirements Engineer ### Observations The issue is well-specified for a complex feature — field semantics table, UX flow, partial-failure shape, ACs, and out-of-scope list are all present. A few gaps that affect implementation decisions: **Gap 1 — Multi-page selection is silent about its limitation.** The checkbox approach covers the current search results page only. A user searching for "Familie Raddatz" across 400 results (8 pages) can only bulk-edit the current page's visible rows. This is acknowledged as a follow-up ("Select all matching current filter") but **not surfaced to the user in the UX**. The selection bar should show a hint like _"Auswahl auf aktuelle Seite beschränkt"_ to prevent the user from thinking they've selected their entire result set. **Gap 2 — Metadata loading on the bulk-edit page is unspecified.** The bulk-edit page receives only IDs from the store. To show read-only titles in the left strip and server PDFs in the preview, it must fetch document metadata. The issue doesn't specify: one GET per document (problematic at N=200) or a batch fetch endpoint (not yet in the API). This is a blocking implementation gap — the AC "bulk-edit panel renders with correct documents in left strip" cannot be satisfied without a concrete fetch strategy. **Gap 3 — Transaction atomicity per document is unspecified.** The partial-failure response assumes each document either succeeds or errors. But the issue doesn't specify what happens if a single document's update fails mid-way (e.g., tags applied but sender update throws). Is each document's update wrapped in its own transaction (so it's all-or-nothing per document), or can a document end up with partial metadata applied? For a PATCH touching multiple join tables (tags, receivers, sender), this matters. **Gap 4 — Undo path is absent.** Accidentally setting the wrong sender on 200 documents is unrecoverable without this feature. The issue has document versioning (`DocumentVersionService`, `DocumentVersion` entity already exist). Bulk-edit operations should create a version snapshot before applying changes, enabling manual rollback. This doesn't need to be in scope v1, but it should be in "Out of Scope" so the team is aware. **Gap 5 — Progress feedback during PATCH.** With 200 documents, the PATCH could take 2–5 seconds. The issue specifies partial-failure chips after save completes, but no loading state during the PATCH. A spinner or progress indicator on the save button is needed. ### Recommendations - Add to the UX: a hint in the selection bar clarifying the selection is page-scoped. - Specify the metadata fetch strategy in the issue before implementation starts (it affects both backend scope and frontend mount behavior). - Clarify per-document transaction semantics — recommend wrapping each document's update in its own `@Transactional` save so partial field application is impossible. - Add to "Out of Scope": "Undo via document version rollback (existing versioning infrastructure supports this as a follow-up)." - Add to ACs: save button shows a loading state during PATCH; document count and estimated time are not required.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Observations

The core authorization model is correct. @RequirePermission(WRITE_ALL) on the PATCH endpoint matches every other write endpoint in DocumentController. Frontend checkbox gating on canWrite is defense-in-depth — appropriate. No new auth machinery needed.

No IDOR risk in this system's context. The PATCH body accepts an arbitrary list of document UUIDs. In a multi-tenant system this would be a critical flaw — but Familienarchiv has no per-document ownership: any WRITE_ALL user can edit any document. The concern is operational, not security. Good.

Resource exhaustion via unbounded documentIds list — CWE-400. The issue explicitly says "no hard cap." A WRITE_ALL user (legitimate or compromised) could submit a list of 100,000 UUIDs, generating a single WHERE id IN (...) with 100,000 parameters and locking a proportional number of rows. In PostgreSQL, large IN-clauses are expanded to individual equality predicates — this is both slow and memory-intensive. This is a confirmed DoS vector against the database, not a theoretical one.

Fix: add input validation in the controller (before the service call):

if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
    throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
}
if (dto.getDocumentIds().size() > 500) {
    throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
        "Maximum 500 documents per request, got: " + dto.getDocumentIds().size());
}

Tag name resolution needs parameterized queries. The tagNames list is user-supplied and gets resolved via TagService.findOrCreate(). As long as that service uses Spring Data JPA with named parameters (which the existing tag service does), there's no injection risk. Verify before implementation; do not hand-roll any string interpolation in the bulk-edit path.

No file payload = no content-type or path-traversal attack surface. The absence of multipart upload eliminates the vulnerability classes present in quick-upload and attachFile. The PATCH endpoint is cleaner from a security standpoint than the existing upload endpoints.

Logging requirement. The new endpoint should log the same pattern as quickUpload: actor, documentCount, updatedCount, errorCount. This creates an audit trail for bulk mutations — important when 200 documents change hands in a single request.

Recommendations

  • Add the 500-ID cap as a controller-level guard before the service call. Return BULK_EDIT_TOO_MANY_IDS (add to ErrorCode.java and errors.ts).
  • Add a @WebMvcTest test: bulk_edit_returns_400_when_documentIds_exceeds_cap.
  • Add a @WebMvcTest test: bulk_edit_returns_401_for_unauthenticated and bulk_edit_returns_403_for_READ_ALL_user. These must exist before merge.
  • Add log.info(...) in the bulk-edit service method consistent with the quickUpload audit log pattern.
## 🔐 Nora "NullX" Steiner — Application Security Engineer ### Observations **The core authorization model is correct.** `@RequirePermission(WRITE_ALL)` on the PATCH endpoint matches every other write endpoint in `DocumentController`. Frontend checkbox gating on `canWrite` is defense-in-depth — appropriate. No new auth machinery needed. **No IDOR risk in this system's context.** The PATCH body accepts an arbitrary list of document UUIDs. In a multi-tenant system this would be a critical flaw — but Familienarchiv has no per-document ownership: any `WRITE_ALL` user can edit any document. The concern is operational, not security. Good. **Resource exhaustion via unbounded `documentIds` list — CWE-400.** The issue explicitly says "no hard cap." A `WRITE_ALL` user (legitimate or compromised) could submit a list of 100,000 UUIDs, generating a single `WHERE id IN (...)` with 100,000 parameters and locking a proportional number of rows. In PostgreSQL, large IN-clauses are expanded to individual equality predicates — this is both slow and memory-intensive. **This is a confirmed DoS vector against the database, not a theoretical one.** Fix: add input validation in the controller (before the service call): ```java if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required"); } if (dto.getDocumentIds().size() > 500) { throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS, "Maximum 500 documents per request, got: " + dto.getDocumentIds().size()); } ``` **Tag name resolution needs parameterized queries.** The `tagNames` list is user-supplied and gets resolved via `TagService.findOrCreate()`. As long as that service uses Spring Data JPA with named parameters (which the existing tag service does), there's no injection risk. Verify before implementation; do not hand-roll any string interpolation in the bulk-edit path. **No file payload = no content-type or path-traversal attack surface.** The absence of multipart upload eliminates the vulnerability classes present in `quick-upload` and `attachFile`. The PATCH endpoint is cleaner from a security standpoint than the existing upload endpoints. **Logging requirement.** The new endpoint should log the same pattern as `quickUpload`: `actor, documentCount, updatedCount, errorCount`. This creates an audit trail for bulk mutations — important when 200 documents change hands in a single request. ### Recommendations - Add the 500-ID cap as a controller-level guard before the service call. Return `BULK_EDIT_TOO_MANY_IDS` (add to `ErrorCode.java` and `errors.ts`). - Add a `@WebMvcTest` test: `bulk_edit_returns_400_when_documentIds_exceeds_cap`. - Add a `@WebMvcTest` test: `bulk_edit_returns_401_for_unauthenticated` and `bulk_edit_returns_403_for_READ_ALL_user`. These must exist before merge. - Add `log.info(...)` in the bulk-edit service method consistent with the `quickUpload` audit log pattern.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

The onboarding callout and field-label badges are the right design decisions. Users coming from the single-document edit form will expect all-empty fields to mean "clear these fields" — the callout prevents destructive misuse. The distinction between + wird hinzugefügt (additive) and wird ersetzt (replace) is clear and honest.

Callout needs role="note" and aria-live. Screen readers won't announce it on page load without proper ARIA. Use:

<div role="note" aria-label="Hinweis zur Massenbearbeitung" class="...">
  {m.bulk_edit_hint()}
</div>

Checkbox accessibility. Each row checkbox needs an associated label with the document title, not just <input type="checkbox"> alone. Screen readers announce "checkbox" with no context. Use aria-label:

<input type="checkbox" aria-label="{m.bulk_select_document({ title: doc.title })}" />

Sticky selection bar and iOS Safari bottom chrome. On iPhone (younger family members), the sticky bottom-0 bar overlaps the browser's gesture bar. Add padding-bottom: env(safe-area-inset-bottom) or use pb-safe (if the project's Tailwind config includes it). The "Massenbearbeitung" button must meet ≥44px touch target — with py-2 px-4 it may fall short depending on font-size. Specify min-h-[44px].

Left panel loading state for server PDFs. The upload mode shows local blob previews instantly. The edit mode fetches from /api/documents/{id}/file — network latency applies. PdfViewer must show a skeleton or spinner while loading, otherwise the left panel appears broken during document switches. Check if PdfViewer currently handles this gracefully (it renders a blank canvas while loading — acceptable but worth a skeleton).

Field-label badge contrast. If the + wird hinzugefügt and wird ersetzt badges use a muted gray tone, verify they meet 4.5:1 against the card background. Muted text on white easily fails. Use at minimum text-gray-600 (contrast 5.9:1 on white).

Split-panel layout on tablet (768px). The existing BulkDocumentEditLayout uses flex-[55] left / flex-[45] right. At 768px, this gives approximately 420px left / 346px right. The right panel at 346px is workable for the metadata form but tight for PersonMultiSelect chips. Verify in the browser at exactly 768px.

The date field being hidden in mode="edit" is correct — dates are per-document and too risky to bulk-replace. Make sure the hidden state is display: none (not just opacity: 0 or pointer-events-none), so screen readers don't encounter it.

Recommendations

  • Add role="note" and aria-label to the onboarding callout.
  • Specify min-h-[44px] on the "Massenbearbeitung" button and on each row checkbox's touch target.
  • Add pb-[env(safe-area-inset-bottom)] to the sticky selection bar for iOS compatibility.
  • Verify badge text contrast meets WCAG AA (use text-gray-600 minimum, not text-gray-400).
  • Use hidden (not opacity tricks) to suppress the date field in edit mode.
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations **The onboarding callout and field-label badges are the right design decisions.** Users coming from the single-document edit form will expect all-empty fields to mean "clear these fields" — the callout prevents destructive misuse. The distinction between `+ wird hinzugefügt` (additive) and `wird ersetzt` (replace) is clear and honest. **Callout needs `role="note"` and `aria-live`.** Screen readers won't announce it on page load without proper ARIA. Use: ```svelte <div role="note" aria-label="Hinweis zur Massenbearbeitung" class="..."> {m.bulk_edit_hint()} </div> ``` **Checkbox accessibility.** Each row checkbox needs an associated label with the document title, not just `<input type="checkbox">` alone. Screen readers announce "checkbox" with no context. Use `aria-label`: ```svelte <input type="checkbox" aria-label="{m.bulk_select_document({ title: doc.title })}" /> ``` **Sticky selection bar and iOS Safari bottom chrome.** On iPhone (younger family members), the sticky `bottom-0` bar overlaps the browser's gesture bar. Add `padding-bottom: env(safe-area-inset-bottom)` or use `pb-safe` (if the project's Tailwind config includes it). The "Massenbearbeitung" button must meet ≥44px touch target — with `py-2 px-4` it may fall short depending on font-size. Specify `min-h-[44px]`. **Left panel loading state for server PDFs.** The upload mode shows local blob previews instantly. The edit mode fetches from `/api/documents/{id}/file` — network latency applies. `PdfViewer` must show a skeleton or spinner while loading, otherwise the left panel appears broken during document switches. Check if `PdfViewer` currently handles this gracefully (it renders a blank canvas while loading — acceptable but worth a skeleton). **Field-label badge contrast.** If the `+ wird hinzugefügt` and `wird ersetzt` badges use a muted gray tone, verify they meet 4.5:1 against the card background. Muted text on white easily fails. Use at minimum `text-gray-600` (contrast 5.9:1 on white). **Split-panel layout on tablet (768px).** The existing `BulkDocumentEditLayout` uses `flex-[55]` left / `flex-[45]` right. At 768px, this gives approximately 420px left / 346px right. The right panel at 346px is workable for the metadata form but tight for `PersonMultiSelect` chips. Verify in the browser at exactly 768px. **The date field being hidden in `mode="edit"` is correct** — dates are per-document and too risky to bulk-replace. Make sure the hidden state is `display: none` (not just `opacity: 0` or `pointer-events-none`), so screen readers don't encounter it. ### Recommendations - Add `role="note"` and `aria-label` to the onboarding callout. - Specify `min-h-[44px]` on the "Massenbearbeitung" button and on each row checkbox's touch target. - Add `pb-[env(safe-area-inset-bottom)]` to the sticky selection bar for iOS compatibility. - Verify badge text contrast meets WCAG AA (use `text-gray-600` minimum, not `text-gray-400`). - Use `hidden` (not opacity tricks) to suppress the date field in edit mode.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Observations

No new infrastructure required. No new Docker services, no new environment variables, no MinIO bucket, no Flyway migration. This is pure application code — Compose file untouched.

Reverse proxy body size is not a concern. 500 UUIDs (after the recommended cap) is ~20 KB. Caddy's default body limit is 10 MB. Well within bounds even without the cap.

Audit logging is missing from the spec. The existing quickUpload logs actor, files, totalBytes, withMetadata, created, updated, errors at INFO level. The new PATCH /api/documents/bulk endpoint should follow the same pattern:

log.info("bulkEdit actor={} documentCount={} updated={} errors={}",
    actorId, dto.getDocumentIds().size(), updated, errors.size());

Without this, a bulk mutation touching 200 documents leaves no trace in the application log. For a family archive with sensitive historical documents, this matters.

CI impact is minimal. The existing Docker Compose CI setup runs E2E tests against the full stack — no new services needed for the bulk-edit flow. One new Playwright test for the golden path. CI time impact: negligible (one E2E scenario adds ~30 seconds).

No observability gaps beyond logging. The operation is synchronous (unlike async OCR jobs), so no SSE, no job status polling, no new health check endpoint needed.

Recommendations

  • Add log.info(...) in the bulk-edit service method consistent with the quickUpload audit log. This is a one-liner but important for production diagnostics.
  • No other DevOps concerns. Implementation can proceed without infrastructure coordination.
## 🛠️ Tobias Wendt — DevOps & Platform Engineer ### Observations **No new infrastructure required.** No new Docker services, no new environment variables, no MinIO bucket, no Flyway migration. This is pure application code — Compose file untouched. **Reverse proxy body size is not a concern.** 500 UUIDs (after the recommended cap) is ~20 KB. Caddy's default body limit is 10 MB. Well within bounds even without the cap. **Audit logging is missing from the spec.** The existing `quickUpload` logs `actor, files, totalBytes, withMetadata, created, updated, errors` at INFO level. The new `PATCH /api/documents/bulk` endpoint should follow the same pattern: ```java log.info("bulkEdit actor={} documentCount={} updated={} errors={}", actorId, dto.getDocumentIds().size(), updated, errors.size()); ``` Without this, a bulk mutation touching 200 documents leaves no trace in the application log. For a family archive with sensitive historical documents, this matters. **CI impact is minimal.** The existing Docker Compose CI setup runs E2E tests against the full stack — no new services needed for the bulk-edit flow. One new Playwright test for the golden path. CI time impact: negligible (one E2E scenario adds ~30 seconds). **No observability gaps beyond logging.** The operation is synchronous (unlike async OCR jobs), so no SSE, no job status polling, no new health check endpoint needed. ### Recommendations - Add `log.info(...)` in the bulk-edit service method consistent with the `quickUpload` audit log. This is a one-liner but important for production diagnostics. - No other DevOps concerns. Implementation can proceed without infrastructure coordination.
Author
Owner

🗳️ Decision Queue — Action Required

2 decisions need your input before implementation starts.

Architecture

  • Metadata fetch strategy for the bulk-edit page — The store passes only IDs to /documents/bulk-edit. The page needs document titles (for the read-only left strip) and PDF URLs (for the preview). Two options: (A) N individual GET /api/documents/{id} calls on mount — simple, no new endpoint, but hammers the backend for large selections; (B) new POST /api/documents/batch-metadata endpoint returning lightweight summaries — one round-trip, cleaner, but adds backend scope. At N≤50 option A is fine; at N=200+ it's noticeably slow. (Raised by: Markus, Felix, Elicit)

Data Integrity

  • Per-document transaction atomicity — Each document's bulk update touches multiple join tables (tags, receivers, sender, location). If the sender update succeeds but the tag merge throws, should the document end up with partial metadata applied (tags un-changed, sender changed), or should the entire update for that document roll back atomically? The issue's partial-failure model implies all-or-nothing per document, but this needs to be made explicit so the service method is implemented correctly (@Transactional wrapping each document's update individually, not the whole batch). (Raised by: Elicit)
## 🗳️ Decision Queue — Action Required _2 decisions need your input before implementation starts._ ### Architecture - **Metadata fetch strategy for the bulk-edit page** — The store passes only IDs to `/documents/bulk-edit`. The page needs document titles (for the read-only left strip) and PDF URLs (for the preview). Two options: (A) N individual `GET /api/documents/{id}` calls on mount — simple, no new endpoint, but hammers the backend for large selections; (B) new `POST /api/documents/batch-metadata` endpoint returning lightweight summaries — one round-trip, cleaner, but adds backend scope. At N≤50 option A is fine; at N=200+ it's noticeably slow. _(Raised by: Markus, Felix, Elicit)_ ### Data Integrity - **Per-document transaction atomicity** — Each document's bulk update touches multiple join tables (tags, receivers, sender, location). If the sender update succeeds but the tag merge throws, should the document end up with partial metadata applied (tags un-changed, sender changed), or should the entire update for that document roll back atomically? The issue's partial-failure model implies all-or-nothing per document, but this needs to be made explicit so the service method is implemented correctly (`@Transactional` wrapping each document's update individually, not the whole batch). _(Raised by: Elicit)_
Author
Owner

📋 Elicit — Requirements Engineer · Discussion Follow-up

Working through the open items from my earlier comment and the Decision Queue. All five items resolved.


Item 1 — Metadata fetch strategy

Decision: Option B — POST /api/documents/batch-metadata

New lightweight endpoint: accepts { ids: [] }, returns document summaries (id, title, PDF URL). One round-trip on bulk-edit page mount regardless of selection size. Gate behind READ_ALL (reading titles does not require write permission).

Add to backend spec and AC list.


Item 2 — Per-document transaction atomicity

Decision: All-or-nothing per document.

Each document's update is wrapped in its own @Transactional call. If any field update throws, the entire update for that document rolls back — it goes into the errors list with a descriptive message explaining why (e.g. "Sender-ID nicht gefunden"). The outer batch loop is non-transactional so partial success across documents is preserved. No per-field error granularity needed.


Item 3 — Page-scoped selection + "Alle X editieren"

Decision: Both the hint and the fast-path action are in scope.

Two additions to the frontend:

  1. Hint in the sticky selection bar: „N Dokumente ausgewählt (aktuelle Seite)" — clarifies that checkbox selection is limited to the current page.
  2. New „Alle X editieren" action near the results count: fetches all matching IDs for the current search filter via a new GET /api/documents/ids?<filter-params> endpoint, writes to the store, and navigates directly to /documents/bulk-edit — bypassing the checkbox accumulation step entirely.

Additional backend scope: lightweight GET /api/documents/ids endpoint with the same filter params as the existing search, returning UUIDs only.

PATCH save handler chunks requests at the 500-ID cap (same pattern as quick-upload). Bulk-edit page contract unchanged.


Item 4 — Progress feedback during PATCH

Decision: Loading state for single chunk; progress bar for 2+ chunks.

  • Single-chunk operation (≤500 docs): save button enters disabled/loading state on click.
  • Multi-chunk operation (501+ docs): progress bar showing X / N Batches verarbeitet as each sequential chunk completes; save button remains disabled throughout.

Add two ACs to cover these behaviors.


Item 5 — Undo path

Decision: Out of scope — add to Out of Scope list.

Suggested wording: „Rückgängig per Dokumentversions-Rollback (die bestehende Versionierungsinfrastruktur unterstützt dies als Follow-up)."


Net additions to the issue

  • New backend endpoints: POST /api/documents/batch-metadata, GET /api/documents/ids
  • Updated ACs: batch-metadata fetch on mount, page-scoped hint, "Alle X editieren" fast path, loading state, progress bar for multi-chunk
  • Updated Out of Scope: undo/rollback noted as follow-up
## 📋 Elicit — Requirements Engineer · Discussion Follow-up Working through the open items from my earlier comment and the Decision Queue. All five items resolved. --- ### ✅ Item 1 — Metadata fetch strategy **Decision: Option B — `POST /api/documents/batch-metadata`** New lightweight endpoint: accepts `{ ids: [] }`, returns document summaries (id, title, PDF URL). One round-trip on bulk-edit page mount regardless of selection size. Gate behind `READ_ALL` (reading titles does not require write permission). Add to backend spec and AC list. --- ### ✅ Item 2 — Per-document transaction atomicity **Decision: All-or-nothing per document.** Each document's update is wrapped in its own `@Transactional` call. If any field update throws, the entire update for that document rolls back — it goes into the `errors` list with a descriptive message explaining why (e.g. `"Sender-ID nicht gefunden"`). The outer batch loop is non-transactional so partial success across documents is preserved. No per-field error granularity needed. --- ### ✅ Item 3 — Page-scoped selection + "Alle X editieren" **Decision: Both the hint and the fast-path action are in scope.** Two additions to the frontend: 1. Hint in the sticky selection bar: *„N Dokumente ausgewählt (aktuelle Seite)"* — clarifies that checkbox selection is limited to the current page. 2. New *„Alle X editieren"* action near the results count: fetches all matching IDs for the current search filter via a new `GET /api/documents/ids?<filter-params>` endpoint, writes to the store, and navigates directly to `/documents/bulk-edit` — bypassing the checkbox accumulation step entirely. Additional backend scope: lightweight `GET /api/documents/ids` endpoint with the same filter params as the existing search, returning UUIDs only. PATCH save handler chunks requests at the 500-ID cap (same pattern as quick-upload). Bulk-edit page contract unchanged. --- ### ✅ Item 4 — Progress feedback during PATCH **Decision: Loading state for single chunk; progress bar for 2+ chunks.** - Single-chunk operation (≤500 docs): save button enters disabled/loading state on click. - Multi-chunk operation (501+ docs): progress bar showing `X / N Batches verarbeitet` as each sequential chunk completes; save button remains disabled throughout. Add two ACs to cover these behaviors. --- ### ✅ Item 5 — Undo path **Decision: Out of scope — add to Out of Scope list.** Suggested wording: *„Rückgängig per Dokumentversions-Rollback (die bestehende Versionierungsinfrastruktur unterstützt dies als Follow-up)."* --- ### Net additions to the issue - **New backend endpoints:** `POST /api/documents/batch-metadata`, `GET /api/documents/ids` - **Updated ACs:** batch-metadata fetch on mount, page-scoped hint, "Alle X editieren" fast path, loading state, progress bar for multi-chunk - **Updated Out of Scope:** undo/rollback noted as follow-up
Author
Owner

📋 Elicit — Requirements Engineer · Cross-page Selection Correction

One additional gap surfaced after the previous comment.


Cross-page selection — store model correction

Problem: The issue implies the Svelte store is written once when the user clicks "Massenbearbeitung." This means navigating from page 1 to page 2 would silently reset the selection — only the current page's checkboxes would be passed to the bulk-edit page. Users universally expect selection to persist across pages.

Decision: The store is a live selection accumulator.

  • Checking a box adds the document ID to the store immediately
  • Unchecking removes it
  • Navigating between pages leaves the store untouched
  • The sticky selection bar always shows the total count across all pages (not just the current page)
  • A „Alles aufheben" action in the selection bar lets users reset the selection without refreshing

Correction to previous comment (Item 3): Drop the „(aktuelle Seite)" hint — with the live-accumulator model it is no longer accurate and would actively mislead users. The "Alle X editieren" fast path remains in scope as a separate action for filter-wide selection.

Chunking: The 500-ID cap is handled silently at save time. No user-facing warning when the selection exceeds 500 documents — chunking is a pure implementation detail.

Store comment: The store's inline comment should describe it as a persistent selection accumulator for the duration of the selection flow, not as ephemeral inter-route state.


Updated net additions to the issue

  • New backend endpoints: POST /api/documents/batch-metadata, GET /api/documents/ids
  • Updated ACs: live-accumulator store, cross-page selection, "Alles aufheben" action, "Alle X editieren" fast path, loading state, progress bar for multi-chunk
  • Removed AC: „(aktuelle Seite)" hint
  • Updated Out of Scope: undo/rollback noted as follow-up
## 📋 Elicit — Requirements Engineer · Cross-page Selection Correction One additional gap surfaced after the previous comment. --- ### ✅ Cross-page selection — store model correction **Problem:** The issue implies the Svelte store is written once when the user clicks "Massenbearbeitung." This means navigating from page 1 to page 2 would silently reset the selection — only the current page's checkboxes would be passed to the bulk-edit page. Users universally expect selection to persist across pages. **Decision: The store is a live selection accumulator.** - Checking a box adds the document ID to the store immediately - Unchecking removes it - Navigating between pages leaves the store untouched - The sticky selection bar always shows the total count across all pages (not just the current page) - A *„Alles aufheben"* action in the selection bar lets users reset the selection without refreshing **Correction to previous comment (Item 3):** Drop the *„(aktuelle Seite)"* hint — with the live-accumulator model it is no longer accurate and would actively mislead users. The "Alle X editieren" fast path remains in scope as a separate action for filter-wide selection. **Chunking:** The 500-ID cap is handled silently at save time. No user-facing warning when the selection exceeds 500 documents — chunking is a pure implementation detail. **Store comment:** The store's inline comment should describe it as a persistent selection accumulator for the duration of the selection flow, not as ephemeral inter-route state. --- ### Updated net additions to the issue - **New backend endpoints:** `POST /api/documents/batch-metadata`, `GET /api/documents/ids` - **Updated ACs:** live-accumulator store, cross-page selection, "Alles aufheben" action, "Alle X editieren" fast path, loading state, progress bar for multi-chunk - **Removed AC:** `„(aktuelle Seite)"` hint - **Updated Out of Scope:** undo/rollback noted as follow-up
Author
Owner

Implemented & merged to main

12 atomic commits on main (b690c74d..f13f6351). Backend: 1334 tests green. Frontend bulk-edit specs: 69 tests green. Playwright E2E: 5 passed (1 skipped — no incomplete docs in test DB).

Commits

779ffaab scaffold DTOs and BULK_EDIT_TOO_MANY_IDS error code
a59feec8 DocumentService.applyBulkEditToDocument (per-document @Transactional)
f0da033e PATCH /api/documents/bulk endpoint
d251806e POST /api/documents/batch-metadata endpoint
b662117e GET /api/documents/ids endpoint
660e34e0 i18n keys (de/en/es), error mapping, regenerate api types
25446c9a bulkSelection store backed by SvelteSet
27e3d290 canWrite-gated row checkboxes on /documents and /enrich
d4f32ed5 BulkSelectionBar component + Alle-X-editieren fast path
fa5dc438 BulkDocumentEditLayout mode="edit" + FieldLabelBadge
6d3489d0 /documents/bulk-edit route
f13f6351 E2E coverage

Decisions made during planning

Question Decision Source
Multi-chunk save failure UX Stop at first chunk failure, show partial-save with retry user
Selection-bar scope /documents and /enrich only user
Alle X editieren action Replace (drop prior selection) user
Empty documentIds PATCH body 400 with documentIds is required Felix
GET /api/documents/ids cap uncapped, ignores page/size Felix
Selection cleared when only on successful save Felix
Bulk-edit page permission WRITE_ALL guard via +page.server.ts, redirect to /documents Nora
FileEntry extension optional file?: File + documentId?: string (no full discriminated union to keep diff small) Felix
Onboarding callout inline in BulkDocumentEditLayout with role="note" Felix
Field-label badges new FieldLabelBadge component, variant: additive/replace Felix

Files touched

Backend (5 modified, 5 created):

  • ErrorCode.java, DocumentController.java, DocumentService.java, DocumentControllerTest.java, DocumentServiceTest.java
  • New: DocumentBulkEditDTO.java, BulkEditError.java, BulkEditResult.java, BatchMetadataRequest.java, DocumentBatchSummary.java

Frontend (modified):

  • messages/{de,en,es}.json, errors.ts, generated/api.ts
  • DocumentRow.svelte (+spec), DocumentList.svelte, documents/+page.svelte, enrich/+page.{server.ts,svelte}
  • BulkDocumentEditLayout.svelte (+spec), WhoWhenSection.svelte, DescriptionSection.svelte, FileSwitcherStrip.svelte, PersonTypeahead.svelte

Frontend (created):

  • lib/stores/bulkSelection.svelte.{ts,spec.ts}
  • lib/components/document/BulkSelectionBar.svelte (+spec)
  • lib/components/document/FieldLabelBadge.svelte (+spec)
  • routes/documents/bulk-edit/+page.{server.ts,svelte} + page.server.spec.ts
  • e2e/bulk-edit.spec.ts

How it works end-to-end

  1. WRITE_ALL user on /documents (or /enrich) ticks one or more row checkboxes → IDs accumulate in the live bulkSelectionStore (SvelteSet, module-singleton)
  2. Sticky BulkSelectionBar appears with count + Massenbearbeitung + Alles aufheben (44px touch targets, iOS safe-area honoured)
  3. Massenbearbeitung/documents/bulk-edit (server load redirects READ_ALL away)
  4. Page mount fetches summaries via POST /api/documents/batch-metadata, hands them to BulkDocumentEditLayout in mode="edit"
  5. Layout renders the role="note" onboarding callout, additive/replace badges next to fields, hidden date and meta_location, read-only titles in the file strip, server PDFs from /api/documents/{id}/file
  6. Save chunks IDs into 500-sized PATCH /api/documents/bulk requests sequentially; per-document errors become red chips, chunk-level failures stop and surface the partial-save retry UI
  7. On full success the store clears and the user lands back on /documents

The Alle X editieren fast-path button next to the result count fetches every UUID matching the active filter via GET /api/documents/ids and replaces the store before navigating.

Backend audit log for every bulk PATCH: bulkEdit actor=… documentIds=… updated=… errors=….

Next suggested action

Manually walk the golden path with the docker stack: log in as admin@familyarchive.local, tick two rows, hit Massenbearbeitung, add a tag, save, verify both documents got the tag.

## ✅ Implemented & merged to `main` 12 atomic commits on `main` (`b690c74d..f13f6351`). Backend: 1334 tests green. Frontend bulk-edit specs: 69 tests green. Playwright E2E: 5 passed (1 skipped — no incomplete docs in test DB). ### Commits | | | |---|---| | `779ffaab` | scaffold DTOs and BULK_EDIT_TOO_MANY_IDS error code | | `a59feec8` | DocumentService.applyBulkEditToDocument (per-document `@Transactional`) | | `f0da033e` | PATCH /api/documents/bulk endpoint | | `d251806e` | POST /api/documents/batch-metadata endpoint | | `b662117e` | GET /api/documents/ids endpoint | | `660e34e0` | i18n keys (de/en/es), error mapping, regenerate api types | | `25446c9a` | bulkSelection store backed by SvelteSet | | `27e3d290` | canWrite-gated row checkboxes on /documents and /enrich | | `d4f32ed5` | BulkSelectionBar component + Alle-X-editieren fast path | | `fa5dc438` | BulkDocumentEditLayout mode="edit" + FieldLabelBadge | | `6d3489d0` | /documents/bulk-edit route | | `f13f6351` | E2E coverage | ### Decisions made during planning | Question | Decision | Source | |---|---|---| | Multi-chunk save failure UX | **Stop at first chunk failure**, show partial-save with retry | user | | Selection-bar scope | **/documents and /enrich only** | user | | `Alle X editieren` action | **Replace** (drop prior selection) | user | | Empty `documentIds` PATCH body | 400 with `documentIds is required` | Felix | | `GET /api/documents/ids` cap | uncapped, ignores page/size | Felix | | Selection cleared when | only on successful save | Felix | | Bulk-edit page permission | WRITE_ALL guard via `+page.server.ts`, redirect to `/documents` | Nora | | FileEntry extension | optional `file?: File` + `documentId?: string` (no full discriminated union to keep diff small) | Felix | | Onboarding callout | inline in `BulkDocumentEditLayout` with `role="note"` | Felix | | Field-label badges | new `FieldLabelBadge` component, variant: `additive`/`replace` | Felix | ### Files touched **Backend (5 modified, 5 created):** - `ErrorCode.java`, `DocumentController.java`, `DocumentService.java`, `DocumentControllerTest.java`, `DocumentServiceTest.java` - New: `DocumentBulkEditDTO.java`, `BulkEditError.java`, `BulkEditResult.java`, `BatchMetadataRequest.java`, `DocumentBatchSummary.java` **Frontend (modified):** - `messages/{de,en,es}.json`, `errors.ts`, `generated/api.ts` - `DocumentRow.svelte` (+spec), `DocumentList.svelte`, `documents/+page.svelte`, `enrich/+page.{server.ts,svelte}` - `BulkDocumentEditLayout.svelte` (+spec), `WhoWhenSection.svelte`, `DescriptionSection.svelte`, `FileSwitcherStrip.svelte`, `PersonTypeahead.svelte` **Frontend (created):** - `lib/stores/bulkSelection.svelte.{ts,spec.ts}` - `lib/components/document/BulkSelectionBar.svelte` (+spec) - `lib/components/document/FieldLabelBadge.svelte` (+spec) - `routes/documents/bulk-edit/+page.{server.ts,svelte}` + `page.server.spec.ts` - `e2e/bulk-edit.spec.ts` ### How it works end-to-end 1. WRITE_ALL user on `/documents` (or `/enrich`) ticks one or more row checkboxes → IDs accumulate in the live `bulkSelectionStore` (SvelteSet, module-singleton) 2. Sticky `BulkSelectionBar` appears with count + `Massenbearbeitung` + `Alles aufheben` (44px touch targets, iOS safe-area honoured) 3. `Massenbearbeitung` → `/documents/bulk-edit` (server load redirects READ_ALL away) 4. Page mount fetches summaries via `POST /api/documents/batch-metadata`, hands them to `BulkDocumentEditLayout` in `mode="edit"` 5. Layout renders the `role="note"` onboarding callout, additive/replace badges next to fields, hidden date and meta_location, read-only titles in the file strip, server PDFs from `/api/documents/{id}/file` 6. Save chunks IDs into 500-sized `PATCH /api/documents/bulk` requests sequentially; per-document errors become red chips, chunk-level failures stop and surface the partial-save retry UI 7. On full success the store clears and the user lands back on `/documents` The `Alle X editieren` fast-path button next to the result count fetches every UUID matching the active filter via `GET /api/documents/ids` and replaces the store before navigating. Backend audit log for every bulk PATCH: `bulkEdit actor=… documentIds=… updated=… errors=…`. ### Next suggested action Manually walk the golden path with the docker stack: log in as `admin@familyarchive.local`, tick two rows, hit Massenbearbeitung, add a tag, save, verify both documents got the tag.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#225