feat(documents): bulk upload — split-panel with file switcher #317
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?
Context
Extends #294 (new-document split-panel) with bulk uploads. When a user drops N files on
/documents/new, each becomes its own document; every metadata field is shared across all N except the title, which is pre-filled from the filename and editable per file. A single POST to/api/documents/quick-uploadcreates N documents in one pass.The backend already supports batch multipart uploads via
quick-upload— this is primarily a frontend extension plus a small metadata-part addition to the endpoint.Design spec
Final spec:
docs/specs/bulk-upload-split-panel-spec.html— all states (N=0 / N=1 / N≥2), four viewports (320 / 375 / 768 / 1280), both themes (light / dark). Tokens match the reallayout.css.Exploration trail (3 concepts + decision matrix):
docs/specs/bulk-upload-concepts.html.The one-screen model
/documents/newis one route with three visual states. Same DOM skeleton; only three pieces of chrome appear/disappear based on file count:Scope
New frontend components
BulkDropZone.svelte— full-panel drop target; renders on N=0 and as "add more" target on N≥1.FileSwitcherStrip.svelte— horizontal chip list + prev/next arrows + aria-live announcements.ScopeCard.svelte— two variants: mint-tinted "Nur diese Datei" (per-file) and neutral "Gilt für alle N" (shared).Frontend extensions
documents/new/+page.svelte— state owner (files array, active index, shared metadata, mode based onfiles.length).DocumentEditLayout.svelte— accept{ files, activeIndex }props; emitselect/removeevents. Single-file prop shape unchanged.UploadSaveBar.svelte— plural-aware label, determinate progress bar during save.Backend (small additive change)
POST /api/documents/quick-uploadto accept an optionalmetadataJSON part carrying{ senderId, receiverId, documentDate, location, tags[], archiveBox, archiveFolder, metadataComplete, titles[] }.titles[i]→files[i]by index.{ created[], updated[], errors[] }.i18n (Paraglide)
15 new keys in de/en/es — full table in the spec. Notable:
bulk_save_ctauses ICU plurals so "Speichern →" / "5 speichern →" / "Guardar 5 →" are all single messages.Filename → title regex
basename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim()— marks the inputsuggested(mint border, accent-bg) until the user edits it.Acceptance criteria
prefers-reduced-motionrespected via the existing global @media rule.Test plan
Unit (Vitest browser)
FileSwitcherStrip.svelte.spec.ts: renders N chips, active chip hasaria-current="true"+ caret prefix, arrow keys cycle focus, error chip renders dashed border + ⚠.ScopeCard.svelte.spec.ts: per-file variant renders mint bg, shared variant renders neutral bg, badge text interpolates{count}.BulkDropZone.svelte.spec.ts: drop of 3 files emitsfilesAddedwith 3 entries; filename-to-title regex applied;multipleattribute on file input.documents/new/page.svelte.spec.ts: at N=1 no switcher renders (lock the invariant); at N=5 switcher + two cards render; changing active index auto-focuses the title input.E2E (Playwright)
new-document-bulk.spec.ts: drop 3 PDFs, fill shared fields, click save → redirect to/documentswith a toast listing 3 created docs; verify all 3 have the shared sender/receiver/date in the DB (via API).bulk-upload-a11y.spec.ts: axe sweep at 375 / 1280 in light + dark with N=5 state seeded.Manual smoke
Out of scope for v1
webkitdirectory.References
DocumentController.quickUpload(backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java)🏛️ Markus Keller — Senior Application Architect
Observations
DocumentEditLayout.sveltecurrently requires a persistedDocwith anidand calls/api/documents/{id}/filein an$effectto load bytes. The spec says "accept{ files, activeIndex }props" — that makes the component polymorphic across two fundamentally different lifecycle stages (pre-upload vs post-upload). Coupling rises; the single-file contract gets noisier.metadataJSON part carriestitles[]matched by index tofiles[]. That is an implicit wire coupling — frontend and backend must agree on ordering, and the spec does not address length mismatch.open. The parent issue has an unresolved design question (placeholder-first vs pre-upload mode). You can't promise parity with a layout that hasn't shipped. Order of delivery matters here.Recommendations
DocumentEditLayout.sveltewith{ files, activeIndex }. IntroduceBulkDocumentEditLayout.svelteas a new wrapper that usesDocumentEditLayoutinternally for the preview region and owns the switcher + scope cards itself. The single-file prop shape stays untouched — which is also the cleanest way to lock the "no regression for N=1" invariant at the type level, not at the snapshot-test level.metadatapart a typedDocumentBatchMetadataDTOin the backend —@RequestPart("metadata") DocumentBatchMetadataDTO metadata. Spring deserializes JSON to the DTO, OpenAPI codegen gives the frontend a typed shape, no ad-hoc map lookups.titles.size() <= files.size()server-side; return 400 with a structuredErrorCode.INVALID_INPUT. Silent truncation hides bugs.Open Decisions
/quick-uploadvs new/api/documents/batchendpoint — Current design overloads one endpoint to dispatch on presence of themetadatapart, serving both the (existing) drag-anywhere tooling and the new UI. A separate endpoint would split contracts cleanly. Tradeoff: overload keeps URL surface small and doesn't deprecate; split gives each caller a typed schema. I lean overload iff themetadatapart is a typed DTO (not an opaque blob) — otherwise split.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
/documents/new/+page.svelteis not the split-panel layout — it usesFileSectionNew.svelte+ a collapsible<details>. "Byte-identical to #294 at N=1" is only meaningful once #294 ships.UploadSaveBar.svelteis marked as "already exists for single-file — extend". It does not exist in the repo (find frontend/src -name UploadSaveBar*returns nothing). This is a net-new component.DocumentEditLayout.svelterequiresdoc: Docwith anidand callsfetch('/api/documents/${doc.id}/file')in an$effect(see lines 44–63). Passing{ files, activeIndex }as the spec suggests breaks that contract.parseFilename()andstripExtension()already live in$lib/utils/filename.tsand are already reused by the current new-doc flow. The filename-to-title regex in the issue (.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim()) subtly diverges fromparseFilename's fallback — that's a drift risk./api/documentsand redirects to/documents/{id}. The bulk flow POSTs to/api/documents/quick-uploadand redirects to/documentswith a toast. Even at N=1 the wire path and post-save navigation differ — treat this as an intentional behavior change, not a "zero regression" claim.Recommendations
documents/new/page.svelte.spec.ts→ at N=1 noFileSwitcherStrip, no count pill, no "Alle verwerfen", no "Nur diese Datei" card — asserts the invariant by the absence of test ids, not by snapshot.FileSwitcherStrip.svelte.spec.ts→ N chips, active hasaria-current="true"+ caret,ArrowLeft/ArrowRightcycle,Enter/Spaceselect, error chip has dashed border + ⚠.ScopeCard.svelte.spec.ts→{count}interpolation; per-file variant usesbg-accent-bg border-accent, shared usesbg-muted border-line.BulkDropZone.svelte.spec.ts→ drop 3 files emitsfilesAddedwith 3 entries;<input multiple>is set.DocumentControllerTest.quickUpload_appliesSharedMetadata_toAllCreatedDocuments().DocumentControllerTest.quickUpload_mapsTitlesByIndex_whenCountsMatch()and…_rejects400_whenTitlesLongerThanFiles().parseFilename()/stripExtension()— do not inline the regex. If the spec's regex is intentionally different, make that explicit by adding a named helper next to the existing ones so the two live side-by-side, not silently.BulkDocumentEditLayout.svelteas a sibling; don't mutateDocumentEditLayout's prop shape (see Markus).SvelteMap<string, FileEntry>keyed by a generated id (e.g.crypto.randomUUID()). Arrays break when you remove the middle entry — active index and per-file state drift. ThenArray.from(files.values())for rendering.{#each files.values() as f (f.id)}. Without a stable key, Svelte reconciles by position and local state corrupts on removal.$derivedforactiveFile,isMulti = files.size >= 2,saveLabel = m.bulk_save_cta({ count: files.size }). Never$state+$effectto compute a derived value.DocumentBatchMetadata— frontend gets compile-time shape checks without hand-maintaining it.Open Decisions (none — these are grounded in the current code and project conventions.)
🔐 Nora "NullX" Steiner — Application Security Engineer
Observations
/api/documents/quick-uploadis already@RequirePermission(Permission.WRITE_ALL). Only writers can hit it — good.ALLOWED_CONTENT_TYPES) is enforced server-side today inDocumentController.quickUpload(line 201). The metadata-aware variant must preserve that check on every file in the batch.application.yamlsetsspring.servlet.multipart.max-file-size: 50MBbut notmax-request-size. Spring's default total cap is 10MB. A 3-file batch at 20MB each fails with an opaqueMaxUploadSizeExceededExceptionbefore your controller sees the request. It is also the path by which a writer account could drive memory pressure by sending many large parts (multipart parsing buffers before the limit is checked per-file).titles[]strings are user-controlled and flow intoDocument.title. Svelte's template escaping is the primary XSS defense — make suretitleis never injected intoinnerHTML, email templates, or notification bodies without encoding.UploadError.filename. Fine for client-rendered error lists (Svelte escapes). Verify all backend logging uses SLF4J's{}placeholder, not string concatenation — a malicious filename like${jndi:ldap://…}should never end up in alog.info("..." + filename)call.Recommendations
backend/src/main/resources/application.yaml:quickUploadaddif (files.size() > 50) throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "…"). Otherwise a writer can push 500× 1KB files and force O(N) DB round-trips.ErrorCode.BATCH_TOO_LARGE(ErrorCode.java), mirror infrontend/src/lib/errors.ts, add the Paraglide keys inde/en/es.json. Wire intogetErrorMessage()so the user sees "Zu viele Dateien auf einmal — bitte in Blöcken hochladen" (de), not a stack trace.@RequestPart("metadata") DocumentBatchMetadataDTO metadata, notMap<String, Object>. Mass-assignment protection by construction — unknown fields are ignored, no reflection-based setter discovery.log.info("quickUpload actor={} files={} totalBytes={}", actorId, files.size(), totalBytes). Parameterized, Log4Shell-safe, useful for audit.Open Decisions (none — these are defense-in-depth additions to an already auth-gated endpoint.)
🧪 Sara Holt — Senior QA Engineer
Observations
display:noneFileSwitcherStrip passes a snapshot but breaks tab order). Structural assertions are stronger./documents/newredirects to/documents/{id}. The new bulk flow redirects to/documentswith a toast, even at N=1. That's a behavioral change from today's UX baseline. If intentional, existing E2Enew-document.spec.tsmust be updated explicitly; if not, it is a silent regression.beforeunloadprotection described in the spec.quickUploadtests inDocumentControllerTestcover auth, content-type, duplicate-filename-goes-to-updated. None of them cover metadata — that gap must close before ship.Recommendations
DocumentControllerTestadditions:quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments()— 3 files in, all 3 createdDocuments share the sender/date/tags.quickUpload_withMetadata_mapsTitlesByIndex()—titles=["A","B","C"],files=[a.pdf,b.pdf,c.pdf], assert titles land correctly.quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize().quickUpload_withMetadata_appliesToUpdatedDocuments()or…doesNotOverrideMetadataOfMatchedPlaceholder()— decide the semantics first, then lock with a test. CurrentlystoreDocumentmatches by filename and returns inupdated[]— unclear if shared metadata should merge into an existing doc.quickUpload_withMetadata_createsPartial_whenOneFileFails()— 3 files, 1 unsupported content-type → 2 created + 1 error row, metadata applied to the 2 successes.[320, 375, 768, 1280]×[light, dark]×[N=0, N=1, N=5]. Budget: ~24 runs × ~6s = well under the 8-min E2E cap.new-document-bulk.spec.ts: drop 3 PDFs → shared metadata → save → assert 3Documents visible in the list view (user-facing path, not DB).page.on('dialog', …)asserts the prompt fires; explicit "Alle verwerfen" → no prompt.postgres:16-alpinefor the integration additions — never H2.Open Decisions (none — all additions, no tradeoffs.)
🎨 Leonie Voss — UX & Accessibility Lead
Observations
aria-current; error chip = color + dashed border + ⚠).role="tablist"in favor of a visually-hidden label +aria-liveis the right AT pattern — chips here aren't tabs for tabbed content, they're a selection list.prefers-reduced-motionrespected via the global@mediarule — good.drop-subto8.5px. The mock is scaled (~55%) but if a developer translates scaled pixels literally, it ships too small. Our floor is 12px.1/Ntab pill overflow: at 320px in Spanish ("Vista previa" + pill), 2-digit counts (12/99) may wrap the tab label. Worth specifyingtabular-numsand a minimum pill width.suggestedstate ambiguity for seniors: mint border + 15% accent-bg on the title input reads as AAA contrast, but visually it can feel like a disabled state to users unfamiliar with the pattern. No helper text tells them "tap to replace with your own".text-danger— in dark mode some token sets don't hit 7:1 on navy surface. Must verify.Recommendations
text-xs) on 320px. Do not translate the 8.5px mockup value. Annotate theimpl-refto say "scaled 8.5px → ship astext-xs/ 12px".1/Npill:tabular-nums min-w-[2.5rem] text-center. Keeps the pill width constant from1/1through99/99so the tab label doesn't jitter.suggestedtitle input: a single linetext-xs text-ink-3like "Vorschlag aus Dateiname — zum Bearbeiten anklicken", auto-dismissed on first user edit. Costs nothing; removes the "is this greyed out?" confusion for seniors.text-dangercontrast in dark mode against the dark surface token. If the Alle-verwerfen link doesn't hit 7:1, switch totext-danger-fg(or bump the weight tofont-boldso the AA large-text rule applies at 3:1).transition-[width]for no transition underprefers-reduced-motion: reduce. The width still animates via JS state change; the CSS easing is what gets dropped.focus-visible:ring-2 focus-visible:ring-dangerso keyboard users can see where they are.Open Decisions
←/→in the strip, every arrow press ejects focus out of the strip. Options: (a) auto-focus only on mouse/touch selection, keep focus in strip on arrow navigation; (b) always auto-focus; (c) never auto-focus — user opts in by clicking the input. I lean (a) — matches both mental models — but confirm before implementation.🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
application.yamlsetsmax-file-size: 50MBbut notmax-request-size. Spring's default total cap is 10MB. A 3-file batch at 20MB each = 60MB total → rejected with a 413 before your controller runs. The feature cannot meet its documented "50 MB pro Datei × N" promise without this config./quick-upload— no structured log of batch size, total bytes, or durations. For a new bulk feature with user-controlled N we'll diagnose "uploads feel slow" by guessing unless we instrument upfront.thumbnailAsyncRunner.dispatchAfterCommitper document. A 20-file batch = 20 async jobs. IfAsyncConfig's thumbnail executor has a bounded queue and the default abort policy, some thumbnails get dropped silently under load.RateLimitInterceptorexists but I don't see/quick-uploadin the per-endpoint rules. Without a limit, a writer can hammer the batch endpoint; accidental double-click on the save button submits N files twice.Recommendations
documents.quickupload.batch.size— distribution summarydocuments.quickupload.errors— counter tagged bycodedocuments.quickupload.duration— timerGrafana panel follow-up — one dashboard card each.
AsyncConfig.thumbnailExecutorqueue capacity and rejection policy. For bursty batches,CallerRunsPolicymeans the quick-upload request blocks briefly instead of silently losing thumbnails. Confirm before ship./quick-uploadat 5 requests/minute per user. A genuine drag-and-drop workflow doesn't exceed this; double-clicks and programmatic abuse do.docs/infrastructure/production-compose.md: "If users report '413 Payload Too Large' on bulk upload, check both Springmax-request-sizeand Caddyrequest_body { max_size }. They must match."Open Decisions
🗳️ Decision Queue — Action Required
4 decisions need your input before implementation starts. Everything else was turned into concrete recommendations.
Architecture
Pre-upload bridging strategy for
DocumentEditLayout(carried over from #294) —DocumentEditLayouttoday requires a persistedDocwith anidand loads/api/documents/{id}/filein an$effect. The new-doc flow needs a bridge. Option A (PLACEHOLDER-first): POST N placeholder Documents on first file drop, then drive the rest through the unchangedDocumentEditLayout(matches/enrich/[id]). Option B (pre-upload mode): teach the layout to accept optionaldoc+ file props and submit one multipart POST at the end (the #317 spec implicitly picks this). A keeps the layout contract clean and aligns with sibling routes; B keeps single-submit UX but makes the component polymorphic across lifecycle stages. This decision determines the test strategy, component boundaries, and how thequick-uploadendpoint is called. Pick before writing code. (Raised by: original #294 + Markus, Felix)Overload
/quick-uploadvs new/api/documents/batch— The current plan overloads one endpoint to dispatch on presence of themetadatapart (serving both drag-anywhere tooling and the new bulk UI). A separate endpoint would split contracts cleanly. Overload keeps the URL surface small and avoids deprecation churn; a split gives each caller a typed schema and simpler OpenAPI docs. Markus leans overload iff themetadatapart is a typed DTO, not an opaque blob. (Raised by: Markus)Infrastructure
max-request-sizecap for/quick-upload— Must be set (today it defaults to 10MB, which makes the feature unshippable as specified). The question is the value.500MBcovers a 10-file batch at the documented 50MB per-file cap with headroom.300MBbounds memory/parse time more aggressively (6-file batches).1GB+enables power-use but widens the DoS surface for a compromised writer account. Tobias leans 500MB for v1 and widens on telemetry. (Raised by: Tobias, Nora)UX
←/→in the file switcher, every arrow press ejects focus out of the strip — forcing them to re-tab back in. Options: (a) auto-focus only on mouse/touch; keep focus in strip for arrow navigation. (b) always auto-focus (current spec). (c) never auto-focus; user clicks into the title. Leonie leans (a) — matches both mental models. (Raised by: Leonie)Cross-cutting observations flagged by multiple personas (no decision needed, just awareness):
UploadSaveBar.sveltedoes not exist in the repo despite the spec claiming "already exists — extend". Treat as a new component. (Felix)storeDocumentreturns existing filename-matches inupdated[]. Unclear whether the new sharedmetadatashould merge into those existing docs or leave them alone. Decide before writing the implementation, then lock with a test. (Sara, Markus)DocumentEditLayout's prop shape affects/documents/[id]/editand/enrich/[id]. Keep backwards-compatible or scope out. (carried over from #294)📎 Carried over from #294 (now closed — superseded by this issue)
Three items from #294 that should be explicit in the #317 scope:
1. Pre-implementation design decision (was #294's "resolve first")
DocumentEditLayouttoday takes a persistedDocwith anidand loads/api/documents/{id}/filein an$effect. The new-doc flow needs to bridge the gap between "no document exists yet" and "edit a persisted document". Two options:PLACEHOLDERDocument on page load (or on first file drop), then drive the rest of the flow through the unchangedDocumentEditLayout. For bulk, create N placeholders up front. Matches the/enrich/[id]pattern exactly.DocumentEditLayoutto accept an optionaldoc, show an UploadZone until files land, submit everything in one multipart POST at the end. The current #317 spec implicitly picks this.Option A keeps
DocumentEditLayout's contract clean and aligns with existing routes; Option B keeps the single-submit UX but makes the layout polymorphic across lifecycle stages. Decide before writing code — the test strategy and component boundaries hinge on this.2. Acceptance criterion: no regressions in sibling routes
/documents/[id]/editand/enrich/[id]also useDocumentEditLayout. Any prop-shape change to that component affects all three routes. The #317 spec's "accept{ files, activeIndex }" proposal directly blasts into these two other routes. Add to the acceptance criteria:(My earlier Markus/Felix recommendation — create
BulkDocumentEditLayout.svelteas a sibling rather than mutatingDocumentEditLayout— is the direct fix for this constraint.)3. Filename-derived suggestions beyond title
The current
/documents/newflow usesparseFilename()from$lib/utils/filename.tsto suggest title, date, and sender from the filename (seeFileSectionNew.svelteand+page.svelte→parsedSuggestion). #317 only mentions title derivation.Clarify the scope: