feat: bulk metadata edit for existing documents (select → panel → PATCH) #225
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
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
/documents/bulk-editUX: Onboarding Cues (empty fields are intentional)
Users coming from single-document edit may be confused by all-empty fields. Two lightweight cues:
+ wird hinzugefügtwird ersetzt+ wird hinzugefügtwird ersetztBulk-Edit Panel (adapts existing
BulkDocumentEditLayout)New
mode="edit"prop on the existing component — no separate layout file.mode="edit")WhoWhenSection+DescriptionSectionPOST /api/documents/quick-uploadPATCH /api/documents/bulkField Semantics
tagNamessenderIdreceiverIdsdocumentLocation/archiveBox/archiveFolderBackend: New Endpoint
Same partial-failure pattern as
quick-upload. No arbitrary cap — PATCH bodies carry no file payload.Frontend Changes
+page.svelte— checkbox per row + sticky selection bar (rendered only withWRITE_ALL); on action click: write IDs to store, navigate to/documents/bulk-edit/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 panelBulkDocumentEditLayout— newmode="edit"prop: hides drop zone, makes title read-only, hides date field, shows onboarding callout, callsPATCHon saveOut of Scope
Acceptance Criteria
WRITE_ALL/documents/bulk-editwith IDs in store/documents/bulk-editwith empty store redirects to document listmode="edit"PATCH /api/documents/bulkrequiresWRITE_ALL; returns partial-failure shapeREAD_ALL-only usersfeat: batch operations for documents (bulk tag, sender, metadata)to feat: bulk metadata edit for existing documents (select → panel → PATCH)🏗️ Markus Keller — Senior Application Architect
Observations
mode="edit"on the existingBulkDocumentEditLayoutis 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.GET /api/documents/{id}calls for a 200-document selection would hammer the backend on mount. Recommend aPOST /api/documents/batch-metadataendpoint (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.documentIdsis 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.quick-upload.{ updated: N, errors: [{ id, message }] }is the right contract.Recommendations
POST /api/documents/batch-metadatato fetch lightweight summaries for the bulk-edit left panel. Scope it to the document IDs provided; gate it behindREAD_ALL(notWRITE_ALL— reading titles doesn't require write permission).BULK_EDIT_TOO_MANY_IDSErrorCode. Document the cap in the issue's "Out of Scope" section so it's visible.👨💻 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 thanisEditMode: 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.FileEntrytype needs extension for edit mode. The existingFileSwitcherStripand left panel useFileEntrywhich has{ id, file, title, previewUrl, status }. In edit mode,fileis absent,previewUrlis 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 newEditEntrytype."Massenbearbeitung","N Dokumente ausgewählt","Nur ausgefüllte Felder werden angewendet. Tags werden hinzugefügt, nicht ersetzt.","+ wird hinzugefügt","wird ersetzt". Add them tomessages/de.json,en.json,es.jsonbefore starting implementation — generated keys are needed for the component.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.canWritepattern is already established.+layout.server.tscomputescanWritefromWRITE_ALLand 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
BulkEditEntrytype (or extendFileEntrywith optional fields) before writing the component. The type boundary makes the mode branching explicit.mode="edit"behavior (date hidden, title read-only, callout visible, PATCH called) before touching the component implementation.🧪 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 DOMmode_edit_title_is_read_only— title<input>is absent ordisabled; display element presentmode_edit_shows_onboarding_callout— info strip is visible whenmode="edit"mode_edit_additive_badge_visible_on_tags— badge text"+ wird hinzugefügt"renders next to tags labelmode_edit_replace_badge_visible_on_sender— badge"wird ersetzt"renders next to sender labelmode_edit_calls_patch_on_save— save triggersPATCH /api/documents/bulk, notPOSTService 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 unchangedshould_replace_sender_when_senderId_providedshould_apply_receivers_additivelyshould_skip_location_when_blankshould_return_error_entry_for_unknown_document_idshould_return_updated_count_correctly_on_partial_failureController layer (
@WebMvcTest—DocumentControllerTest.java):bulk_edit_returns_403_for_READ_ALL_user— critical; must exist before any mergebulk_edit_returns_401_for_unauthenticatedbulk_edit_returns_400_when_documentIds_is_emptybulk_edit_returns_partial_failure_shape_on_mixed_successE2E (Playwright — 1 golden-path test):
READ_ALL-only user sessionEdge cases not covered by current ACs:
documentIdslist is empty — 400 or 200 withupdated: 0? Needs to be specified and tested./documents/bulk-editwith empty store → redirect to list (AC exists, needs E2E test)Recommendations
bulk_edit_returns_403_for_READ_ALL_usercontroller test is a hard prerequisite — the endpoint must not merge without it.📋 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,DocumentVersionentity 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
@Transactionalsave so partial field application is impossible.🔐 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 inDocumentController. Frontend checkbox gating oncanWriteis 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_ALLuser can edit any document. The concern is operational, not security. Good.Resource exhaustion via unbounded
documentIdslist — CWE-400. The issue explicitly says "no hard cap." AWRITE_ALLuser (legitimate or compromised) could submit a list of 100,000 UUIDs, generating a singleWHERE 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):
Tag name resolution needs parameterized queries. The
tagNameslist is user-supplied and gets resolved viaTagService.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-uploadandattachFile. 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
BULK_EDIT_TOO_MANY_IDS(add toErrorCode.javaanderrors.ts).@WebMvcTesttest:bulk_edit_returns_400_when_documentIds_exceeds_cap.@WebMvcTesttest:bulk_edit_returns_401_for_unauthenticatedandbulk_edit_returns_403_for_READ_ALL_user. These must exist before merge.log.info(...)in the bulk-edit service method consistent with thequickUploadaudit log pattern.🎨 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) andwird ersetzt(replace) is clear and honest.Callout needs
role="note"andaria-live. Screen readers won't announce it on page load without proper ARIA. Use: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. Usearia-label:Sticky selection bar and iOS Safari bottom chrome. On iPhone (younger family members), the sticky
bottom-0bar overlaps the browser's gesture bar. Addpadding-bottom: env(safe-area-inset-bottom)or usepb-safe(if the project's Tailwind config includes it). The "Massenbearbeitung" button must meet ≥44px touch target — withpy-2 px-4it may fall short depending on font-size. Specifymin-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.PdfViewermust show a skeleton or spinner while loading, otherwise the left panel appears broken during document switches. Check ifPdfViewercurrently handles this gracefully (it renders a blank canvas while loading — acceptable but worth a skeleton).Field-label badge contrast. If the
+ wird hinzugefügtandwird ersetztbadges use a muted gray tone, verify they meet 4.5:1 against the card background. Muted text on white easily fails. Use at minimumtext-gray-600(contrast 5.9:1 on white).Split-panel layout on tablet (768px). The existing
BulkDocumentEditLayoutusesflex-[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 forPersonMultiSelectchips. 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 isdisplay: none(not justopacity: 0orpointer-events-none), so screen readers don't encounter it.Recommendations
role="note"andaria-labelto the onboarding callout.min-h-[44px]on the "Massenbearbeitung" button and on each row checkbox's touch target.pb-[env(safe-area-inset-bottom)]to the sticky selection bar for iOS compatibility.text-gray-600minimum, nottext-gray-400).hidden(not opacity tricks) to suppress the date field in edit mode.🛠️ 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
quickUploadlogsactor, files, totalBytes, withMetadata, created, updated, errorsat INFO level. The newPATCH /api/documents/bulkendpoint should follow the same pattern: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
log.info(...)in the bulk-edit service method consistent with thequickUploadaudit log. This is a one-liner but important for production diagnostics.🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Architecture
/documents/bulk-edit. The page needs document titles (for the read-only left strip) and PDF URLs (for the preview). Two options: (A) N individualGET /api/documents/{id}calls on mount — simple, no new endpoint, but hammers the backend for large selections; (B) newPOST /api/documents/batch-metadataendpoint 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
@Transactionalwrapping each document's update individually, not the whole batch). (Raised by: Elicit)📋 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-metadataNew lightweight endpoint: accepts
{ ids: [] }, returns document summaries (id, title, PDF URL). One round-trip on bulk-edit page mount regardless of selection size. Gate behindREAD_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
@Transactionalcall. If any field update throws, the entire update for that document rolls back — it goes into theerrorslist 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:
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/idsendpoint 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.
X / N Batches verarbeitetas 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
POST /api/documents/batch-metadata,GET /api/documents/ids📋 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.
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
POST /api/documents/batch-metadata,GET /api/documents/ids„(aktuelle Seite)"hint✅ Implemented & merged to
main12 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
779ffaaba59feec8@Transactional)f0da033ed251806eb662117e660e34e025446c9a27e3d290d4f32ed5fa5dc4386d3489d0f13f6351Decisions made during planning
Alle X editierenactiondocumentIdsPATCH bodydocumentIds is requiredGET /api/documents/idscap+page.server.ts, redirect to/documentsfile?: File+documentId?: string(no full discriminated union to keep diff small)BulkDocumentEditLayoutwithrole="note"FieldLabelBadgecomponent, variant:additive/replaceFiles touched
Backend (5 modified, 5 created):
ErrorCode.java,DocumentController.java,DocumentService.java,DocumentControllerTest.java,DocumentServiceTest.javaDocumentBulkEditDTO.java,BulkEditError.java,BulkEditResult.java,BatchMetadataRequest.java,DocumentBatchSummary.javaFrontend (modified):
messages/{de,en,es}.json,errors.ts,generated/api.tsDocumentRow.svelte(+spec),DocumentList.svelte,documents/+page.svelte,enrich/+page.{server.ts,svelte}BulkDocumentEditLayout.svelte(+spec),WhoWhenSection.svelte,DescriptionSection.svelte,FileSwitcherStrip.svelte,PersonTypeahead.svelteFrontend (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.tse2e/bulk-edit.spec.tsHow it works end-to-end
/documents(or/enrich) ticks one or more row checkboxes → IDs accumulate in the livebulkSelectionStore(SvelteSet, module-singleton)BulkSelectionBarappears with count +Massenbearbeitung+Alles aufheben(44px touch targets, iOS safe-area honoured)Massenbearbeitung→/documents/bulk-edit(server load redirects READ_ALL away)POST /api/documents/batch-metadata, hands them toBulkDocumentEditLayoutinmode="edit"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}/filePATCH /api/documents/bulkrequests sequentially; per-document errors become red chips, chunk-level failures stop and surface the partial-save retry UI/documentsThe
Alle X editierenfast-path button next to the result count fetches every UUID matching the active filter viaGET /api/documents/idsand 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.