Compare commits

..

11 Commits

Author SHA1 Message Date
Marcel
c59287fcfc fix(bulk-edit): cycle-3 polish — Felix C2/C3/C4/C5 + Sara coverage gaps
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 2m56s
CI / Unit & Component Tests (push) Failing after 3m6s
CI / Backend Unit Tests (push) Failing after 2m56s
CI / OCR Service Tests (push) Successful in 34s
Felix C2 — `BatchMetadataRequest` controller now uses `@Valid` so future
@Size/etc. annotations on the record actually fire.

Felix C3 — Auto-clear `$effect` in `+layout.svelte` reads
`bulkSelectionStore.size` inside `untrack()` so the effect only re-fires on
route change, not on every checkbox toggle.

Felix C4 — `BulkDocumentEditLayout` edit-mode hydration loop now lives
inside `onMount` (not at top-level script) so the SvelteMap mutation is
unambiguously tied to instance lifecycle, matching the pattern used by
`WhoWhenSection`/`DescriptionSection` after the cycle-2 fix.

Felix C5 — Replaced fully-qualified `java.util.LinkedHashSet` in
`DocumentController` with a top-of-file import.

Sara coverage — six new spec files / blocks pin the cycle-1 and cycle-2
behaviours that were previously untested:
 - `WhoWhenSection.svelte.spec.ts` — onMount seeding from initialDateIso /
   initialLocation; doesn't stomp parent-bound dateIso; hideDate / editMode
   branch
 - `DescriptionSection.svelte.spec.ts` — onMount seeding from initialTitle /
   initialDocumentLocation; doesn't stomp parent-bound values; archive-box /
   archive-folder fields visible only in editMode
 - `BulkSelectionBar.svelte.spec.ts` — Esc-scope guard tests for `<dialog>`
   open and `aria-expanded` popover present
 - `BulkDocumentEditLayout.svelte.spec.ts` — topbar reads
   "Massenbearbeitung" + "werden bearbeitet" in edit mode (not the
   upload-flavoured "hochladen"/"werden erstellt" copy)
 - `DocumentControllerTest.patchBulk_returns400_whenArchiveBoxExceeds255Chars`
   — pins the @Size validator on archiveBox via the @Valid wiring

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:18:56 +02:00
Marcel
8ce96294b0 fix(bulk-edit): cycle-2 blockers — restore initial-* props, missing import, scope Esc, edit-mode topbar
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m55s
Felix B1 (data-loss regression on /documents/[id]/edit) — DocumentEditLayout
still passes initialDateIso, initialLocation, initialDocumentLocation, but
my cycle-1 cleanup removed those props. Result: existing values rendered
empty and a save would have overwritten them with "". Restored the props
on WhoWhenSection and DescriptionSection; initialisation now lives in
onMount so it runs exactly once and never stomps a parent-driven update on
a later prop change.

Felix B2 — `DescriptionSection.svelte:36` still had the top-level
`currentTitle = untrack(() => initialTitle)` mutation that I cleaned up in
WhoWhenSection but missed here. Same onMount-once treatment.

Leonie B5 — `enrich/+page.svelte:105` referenced `<BulkSelectionBar>` but
the import was lost in a prettier pass; svelte-check errored out and the
bar never rendered, leaving an 8 rem dead zone from the pb-32 reservation.
One-line fix: add the import.

Leonie B6 — Esc handler in `BulkSelectionBar` was unscoped and stole
Escape from NotificationBell, ConfirmDialog, HelpPopover, etc. (e.g.
selecting docs → opening notification bell → Esc would close the bell
AND silently wipe the selection). Now bails when an open dialog,
expanded menu, or popover is detected.

Elicit C1 — `BulkDocumentEditLayout` topbar now branches on `mode`:
shows "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode
instead of the upload-flavoured "Mehrere Dokumente hochladen" + "werden
erstellt" copy. New i18n keys `bulk_edit_topbar_title` and
`bulk_edit_count_pill` in DE/EN/ES.

Tests added:
 - DocumentControllerTest.patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages
   (Sara C2 follow-up — pin sanitizeForLog as a regression test)
 - BulkSelectionBar.spec — count=1 → "1 Dokument", count=2 → "2 Dokumente"
   (Sara C6 follow-up — pin the new bulk_edit_n_selected_one/_other branch)

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:17:33 +02:00
Marcel
1803db86b5 test(bulk-edit): plug Sara's identified coverage gaps
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m1s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m54s
- DocumentServiceTest.applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable (Sara C1)
- DocumentServiceTest.findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec (Sara C3)
- bulkSelection.svelte.spec.ts: setAll([]) no-op + previous-IDs-absent + ids getter (Sara C4 + S4)
- /documents/bulk-edit/+page.server.ts now defensively handles a UserGroup
  with NULL `permissions` (treats it as not-WRITE_ALL instead of throwing
  on .includes()) + matching test (Sara C7)

233 backend tests + frontend bulk-edit specs all green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:58:48 +02:00
Marcel
46001bbf9d refactor(documents): extract buildSearchSpec and resolveTags helpers
Markus #3 / Felix B2 — kill the duplicated spec-chain across
findIdsForFilter and searchDocuments, and centralise the
"name string → Tag (find or create)" loop that updateDocumentTags and
applyBulkEditToDocument were each carrying their own copy of.

`buildSearchSpec` is the single source of truth for the seven-spec chain
(text + date range + sender + receiver + tags + tag-prefix + status). Both
callers do their own FTS short-circuit, then delegate.

`resolveTags` is the single source of truth for trimming, blank-skipping,
and find-or-create through TagService. Both updateDocumentTags (replace
semantics) and applyBulkEditToDocument (additive merge) consume it.

No behaviour change. All 231 backend tests still green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:52:38 +02:00
Marcel
af8303dbf8 fix(bulk-edit): auto-clear selection store when leaving /documents and /enrich
Felix C4 — bulkSelectionStore is module-singleton; before this change it
silently followed the user from /documents to /persons / /admin / etc.,
then reappeared as a stale count when they wandered back. Root +layout.svelte
now watches page.url.pathname and clears the store the moment the user
leaves the two routes that surface BulkSelectionBar.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:49:07 +02:00
Marcel
7df00859c6 fix(bulk-edit): pluralization, edit-mode CTA, error UI, real loading state
Elicit C1+C3 — bulk-selection count uses ICU-style plural keys
(bulk_edit_n_selected_one / _other) so n=1 reads as "1 Dokument" instead
of "1 Dokumente". Save CTA in edit mode reads "Anwenden" via the existing
bulk_edit_save_button key; UploadSaveBar grew an editMode prop. Multi-
chunk progress text is now visible (not aria-only).

Felix C2 — bulk-edit page wires the backend error code through
parseBackendError + getErrorMessage instead of falling back to a generic
internal_error.

Felix C5 — editAllMatching no longer swallows fetch failures: the button
shows an inline error with the backend-mapped message (e.g. when the
filter cap is exceeded).

Leonie C8 — replace the literal "…" loading glyph on /documents/bulk-edit
with a spinner + role=status + aria-live=polite + visible "Loading
documents…" text.

Leonie C9 — partial-failure card and bulk-edit page error card now use
the design-system `text-danger` / `bg-danger/10` / `border-danger/40`
tokens (dark-mode safe) instead of raw red palette values.

Leonie C10 + C13 — German plural fixed; EN badges retensed
("+ added" → "+ will be added", "replaced" → "will replace") to match
the future-tense intent of DE/ES.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:46:58 +02:00
Marcel
92d623e298 chore(bulk-edit): bean validation on DTO, readOnly tx, imports
Tobias C2 — DocumentBulkEditDTO carries @Size guards on tagNames (max 200
entries × 200 chars), receiverIds (max 200), and the three location strings
(max 255 chars each). Controller now uses @Valid on @RequestBody so they
fire. The 500-cap on documentIds stays as a controller-level check (typed
BULK_EDIT_TOO_MANY_IDS code, not generic VALIDATION_ERROR).

Markus #7 — replace fully-qualified type names inside DocumentService with
imports (DocumentBatchSummary, DocumentBulkEditDTO).

Markus #8 — @Transactional(readOnly = true) on findIdsForFilter and
batchMetadata. Both are pure read paths; the marker lets Hibernate skip
dirty-checking on the loaded entities.

Record conversion of DocumentBulkEditDTO (Markus #6 / Felix #3) deferred
to a follow-up — keeping @Data avoids 10+ test bodies that mutate the DTO
via setters; the inconsistency is documented in the DTO's class-level
Javadoc.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:41:06 +02:00
Marcel
156efe8b31 fix(bulk-edit): a11y + i18n hardening (Leonie blockers 1–4 + quick concerns)
B1 — i18n the archive-box / archive-folder labels and add helper text.
Karton/Mappe were hardcoded German and broke EN/ES locales (WCAG 3.1.2).

B2 — drop the hardcoded German aria-label on the onboarding callout.
role="note" + the visible localised text is self-describing; the redundant
label was overriding the translated content for AT users on EN/ES.

B3 — Escape clears the bulk selection while the bar is visible. Adds an
"Esc: Auswahl aufheben" hint visible at ≥ sm (WCAG 2.1.1).

B4 — /documents and /enrich reserve pb-32 when the bulk-selection bar is
visible so it doesn't occlude the last row or pagination (WCAG 1.4.10).

Folded in three Leonie quick-concerns:
 - C5: badge text-[10px] → text-[11px], raw text-gray-600 →
        design-token text-ink-2 (dark-mode safe)
 - C7: aria-live="polite" on bulk-selection-count
 - C11: "Alles aufheben" → "Auswahl aufheben" (DE/EN/ES) — disambiguates
        from "discard the operation entirely"

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:35:40 +02:00
Marcel
499beca124 fix(bulk-edit): drop dead initial-* props and clear store on edit-mode discard
Felix B1 — `WhoWhenSection.svelte:37` and `DescriptionSection.svelte:42`
mutated $bindable props at top-level script scope, seeding them from
`initial*` companion props that no caller ever passes. The pattern stomps
parent-owned state in any future component re-evaluation.

Removed the dead initialDateIso / initialLocation / initialDocumentLocation
props and let the bindables carry their own initial value. dateDisplay and
currentTitle now seed from the bindable directly inside untrack — no
re-assignment required.

Elicit B2 — In edit mode the file map IS the user's bulk selection, so
discarding must clear bulkSelectionStore and bounce back to /documents,
otherwise the user is left on /documents/bulk-edit with an empty form
and a stale count in the bottom bar.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:29:44 +02:00
Marcel
5cbb14d4a3 fix(bulk-edit): backend hardening — audit, caps, dedupe, CRLF, WRITE_ALL on /ids
Addresses Markus B1+B2, Nora C1+C4+C5, Tobias #1, Sara B1+B2+C2, Elicit S2+C4
from the cycle 1 review on PR #331.

Audit / version trail
  applyBulkEditToDocument now takes actorId, calls
  documentVersionService.recordVersion(saved), and emits an
  AuditKind.METADATA_UPDATED event tagged source=BULK_EDIT — restoring parity
  with the single-doc updateDocument path.

Caps
  /api/documents/batch-metadata: 500-ID cap (matches PATCH cap)
  /api/documents/ids: 5000 result cap with BULK_EDIT_TOO_MANY_IDS on overflow

Permission tightening
  /api/documents/ids re-gated WRITE_ALL — its only consumer is the bulk-edit
  fast path (least-privilege per Elicit S2 + Nora's defence-in-depth).

Audit log
  /ids and /batch-metadata now emit one log.info per call, mirroring the
  quickUpload + bulkEdit format.

Robustness
  Duplicates in PATCH documentIds are de-duplicated via LinkedHashSet so a
  double-clicked "Alle X editieren" cannot inflate the updated count.
  log.warn lines that interpolate Throwable.getMessage() now run through a
  CRLF-strip helper (CWE-117).

Tests added
  applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit
  patchBulk_acceptsExactly500Ids_atTheCap (off-by-one fence)
  patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount
  getDocumentIds_returns403_forUserWithoutWriteAll
  getDocumentIds_returns400_whenResultExceedsFilterCap
  batchMetadata_returns403_forUserWithoutReadAll
  batchMetadata_returns400_whenIdsExceedsCap

All 231 backend tests green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:24:03 +02:00
Marcel
2bb8fb8968 fix(bulk-edit): align BulkEditEntry shape with backend DocumentBatchSummary
Production bug — the backend serialises the document UUID as `id`, but
BulkEditEntry typed it as `documentId`. The runtime cast in /documents/
bulk-edit/+page.svelte was a TypeScript lie: every `entry.documentId`
became undefined, the SvelteMap collapsed all selections under the
undefined key, and the PATCH fired with `documentIds: []` (which the
controller correctly rejected with 400). Field semantics ACs could
therefore never fire end-to-end.

Renamed `BulkEditEntry.documentId` → `id`. The FileEntry built from each
summary still carries both `id` (local map key) and `documentId` (PATCH
payload) so the save handler is unchanged.

Reported by Elicit (B1) on PR #331.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:14:53 +02:00
26 changed files with 868 additions and 136 deletions

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -13,6 +14,7 @@ import java.util.UUID;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.PageRequest;
@@ -245,11 +247,16 @@ public class DocumentController {
// --- BULK EDIT ---
private static final int BULK_EDIT_MAX_IDS = 500;
/** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
* call from materialising the entire {@code documents} table into JSON.
* Generous enough for real-world "Alle X editieren" against the family
* archive's bounded scale (~1500 docs today, expected growth to ~5k). */
private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;
@PatchMapping("/bulk")
@RequirePermission(Permission.WRITE_ALL)
public BulkEditResult patchBulk(
@RequestBody DocumentBulkEditDTO dto,
@RequestBody @Valid DocumentBulkEditDTO dto,
Authentication authentication) {
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
@@ -263,26 +270,37 @@ public class DocumentController {
int updated = 0;
List<BulkEditError> errors = new ArrayList<>();
for (UUID id : dto.getDocumentIds()) {
// Dedupe duplicate document IDs while preserving submission order. A
// double-click on "Alle X editieren" would otherwise hit each document
// twice and inflate the `updated` count returned to the user.
LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());
for (UUID id : uniqueIds) {
try {
documentService.applyBulkEditToDocument(id, dto);
documentService.applyBulkEditToDocument(id, dto, actorId);
updated++;
} catch (DomainException e) {
errors.add(new BulkEditError(id, e.getMessage()));
errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
} catch (Exception e) {
errors.add(new BulkEditError(id, "Internal error"));
log.warn("Bulk edit failed for document {}: {}", id, e.getMessage());
log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
}
}
log.info("bulkEdit actor={} documentIds={} updated={} errors={}",
actorId, dto.getDocumentIds().size(), updated, errors.size());
log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());
return new BulkEditResult(updated, errors);
}
/** CRLF strip for any log line interpolating a free-form string (e.g.
* {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
private static String sanitizeForLog(String s) {
return s == null ? null : s.replaceAll("[\\r\\n]", "_");
}
@GetMapping("/ids")
@RequirePermission(Permission.READ_ALL)
@RequirePermission(Permission.WRITE_ALL)
public List<UUID> getDocumentIds(
@RequestParam(required = false) String q,
@RequestParam(required = false) LocalDate from,
@@ -292,17 +310,31 @@ public class DocumentController {
@RequestParam(required = false, name = "tag") List<String> tags,
@RequestParam(required = false) String tagQ,
@RequestParam(required = false) DocumentStatus status,
@RequestParam(required = false) String tagOp) {
@RequestParam(required = false) String tagOp,
Authentication authentication) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
return documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
}
UUID actorId = requireUserId(authentication);
log.info("documentIds actor={} matched={}", actorId, ids.size());
return ids;
}
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePermission(Permission.READ_ALL)
public List<DocumentBatchSummary> batchMetadata(@RequestBody BatchMetadataRequest request) {
public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
if (request == null || request.ids() == null || request.ids().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
}
if (request.ids().size() > BULK_EDIT_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
}
UUID actorId = requireUserId(authentication);
log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
return documentService.batchMetadata(request.ids());
}

View File

@@ -3,19 +3,58 @@ package org.raddatz.familienarchiv.dto;
import java.util.List;
import java.util.UUID;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* Request body for {@code PATCH /api/documents/bulk}. Field semantics:
* <ul>
* <li>{@code tagNames} and {@code receiverIds} are <b>additive</b> —
* merged into each document's existing set, never replacing it.</li>
* <li>{@code senderId}, {@code documentLocation}, {@code archiveBox},
* {@code archiveFolder} are <b>replace-on-non-blank</b> — null/blank
* fields are skipped, anything else overwrites.</li>
* </ul>
*
* <p>Kept as a Lombok {@code @Data} POJO (not a record) for symmetry with
* the existing {@code DocumentUpdateDTO} and to keep test setup terse —
* the per-feature DTOs introduced alongside this one ({@link BulkEditError},
* {@link BulkEditResult}, {@link BatchMetadataRequest},
* {@link DocumentBatchSummary}) <i>are</i> records because they have no
* test-side mutation. Tracked in the cycle-1 review for follow-up.
*
* <p>Bean-validation caps below defend against payload-amplification: the
* 1 MiB SvelteKit proxy cap allows ~26k UUIDs through to the backend, and
* Jetty's default body limit is 8 MB. {@code @Size} guards catch malformed
* clients without depending on those outer bounds.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentBulkEditDTO {
// No @Size cap here on purpose: the controller's BULK_EDIT_MAX_IDS check
// returns the typed BULK_EDIT_TOO_MANY_IDS error code, which the frontend
// maps to a localised "Maximal 500 …" message via Paraglide. A bean-
// validation @Size would short-circuit that with a generic VALIDATION_ERROR.
private List<UUID> documentIds;
private List<String> tagNames;
@Size(max = 200, message = "tagNames must not exceed 200 entries")
private List<@Size(max = 200, message = "tagName must not exceed 200 chars") String> tagNames;
private UUID senderId;
@Size(max = 200, message = "receiverIds must not exceed 200 entries")
private List<UUID> receiverIds;
@Size(max = 255, message = "documentLocation must not exceed 255 chars")
private String documentLocation;
@Size(max = 255, message = "archiveBox must not exceed 255 chars")
private String archiveBox;
@Size(max = 255, message = "archiveFolder must not exceed 255 chars")
private String archiveFolder;
}

View File

@@ -8,6 +8,8 @@ import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
@@ -334,28 +336,36 @@ public class DocumentService {
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
Set<Tag> newTags = new HashSet<>();
for (String name : tagNames) {
// Clean the string
String cleanName = name.trim();
if (cleanName.isEmpty())
continue;
newTags.add(tagService.findOrCreate(cleanName));
}
doc.setTags(newTags);
doc.setTags(resolveTags(tagNames));
return documentRepository.save(doc);
}
/**
* Resolves a list of tag-name strings to {@link Tag} entities, trimming
* whitespace and skipping blank entries. Single source of truth for
* "name string → Tag" so the find-or-create policy stays consistent
* across single-doc updates ({@link #updateDocumentTags}), bulk edits
* ({@link #applyBulkEditToDocument}), and the upload-batch path
* ({@code applyBatchMetadata}).
*/
private Set<Tag> resolveTags(List<String> tagNames) {
if (tagNames == null || tagNames.isEmpty()) return new HashSet<>();
Set<Tag> resolved = new HashSet<>();
for (String name : tagNames) {
String cleanName = name.trim();
if (cleanName.isEmpty()) continue;
resolved.add(tagService.findOrCreate(cleanName));
}
return resolved;
}
/**
* Returns all document IDs matching the given filter parameters, ignoring
* pagination. Used by the bulk-edit "Alle X editieren" fast path so the
* frontend can replace the selection with every match across pages in one
* round-trip.
*/
@Transactional(readOnly = true)
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text);
@@ -364,19 +374,33 @@ public class DocumentService {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
}
/**
* Single source of truth for the search Specification chain. Shared by
* {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter}
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
* full-text query returned no rows.
*/
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to,
UUID sender, UUID receiver,
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator) {
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
Specification<Document> spec = Specification.where(textSpec)
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
return Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
}
/**
@@ -385,10 +409,11 @@ public class DocumentService {
* bulk-edit page's left strip, where missing previews would already be
* obvious; surfacing them as errors here adds no value.
*/
public List<org.raddatz.familienarchiv.dto.DocumentBatchSummary> batchMetadata(List<UUID> ids) {
@Transactional(readOnly = true)
public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return List.of();
return documentRepository.findAllById(ids).stream()
.map(d -> new org.raddatz.familienarchiv.dto.DocumentBatchSummary(
.map(d -> new DocumentBatchSummary(
d.getId(),
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
"/api/documents/" + d.getId() + "/file"))
@@ -400,21 +425,26 @@ public class DocumentService {
* Tags and receivers are additive (merged into existing sets); sender and the
* three location fields are replace-on-non-blank (null/blank means "no change").
* Wrapped in its own transaction so a failure on one document never partially
* mutates another in the batch loop.
* mutates another in the controller's batch loop.
*
* Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit
* event tagged {@code source=BULK_EDIT} and writes a row to
* {@code document_versions} so the family archive's "who changed what"
* trail stays complete across both single- and bulk-doc edit paths.
*
* NOTE on N+1: tag and person resolution happens per-document. With 500
* documents × 10 tags this fans out to ~5000 tag-resolve queries per
* request. Acceptable today because the family archive is bounded at
* ~1500 documents total. Tracked as a perf follow-up.
*/
@Transactional
public Document applyBulkEditToDocument(UUID id, org.raddatz.familienarchiv.dto.DocumentBulkEditDTO dto) {
public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
Set<Tag> merged = new HashSet<>(doc.getTags());
for (String name : dto.getTagNames()) {
String clean = name.trim();
if (!clean.isEmpty()) {
merged.add(tagService.findOrCreate(clean));
}
}
merged.addAll(resolveTags(dto.getTagNames()));
doc.setTags(merged);
}
@@ -438,7 +468,11 @@ public class DocumentService {
doc.setArchiveFolder(dto.getArchiveFolder());
}
return documentRepository.save(doc);
Document saved = documentRepository.save(doc);
documentVersionService.recordVersion(saved);
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(),
Map.of("source", "BULK_EDIT"));
return saved;
}
/**
@@ -504,17 +538,8 @@ public class DocumentService {
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
Specification<Document> spec = Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops

View File

@@ -996,13 +996,68 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
// Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
// Without @Valid on @RequestBody this would silently land an
// arbitrarily long string; the test pins both the annotation and
// the controller-level @Valid wiring.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
String tooLong = "x".repeat(256);
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
String[] ids = new String[500];
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(500));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
.thenAnswer(inv -> Document.builder().id(id).build());
// Same id sent three times — controller should dedupe and call the
// service exactly once, returning updated=1, not 3.
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id.toString(), id.toString(), id.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(1));
verify(documentService, org.mockito.Mockito.times(1))
.applyBulkEditToDocument(eq(id), any(), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(any(), any()))
when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
mockMvc.perform(patch("/api/documents/bulk")
@@ -1012,8 +1067,8 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.updated").value(2))
.andExpect(jsonPath("$.errors").isEmpty());
verify(documentService).applyBulkEditToDocument(eq(id1), any());
verify(documentService).applyBulkEditToDocument(eq(id2), any());
verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
}
// ─── GET /api/documents/ids ──────────────────────────────────────────────
@@ -1025,8 +1080,18 @@ class DocumentControllerTest {
}
@Test
@WithMockUser(authorities = "READ_ALL")
@WithMockUser
void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
// /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
// editieren" fast path; no other consumer needs it.
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(List.of(id));
@@ -1037,8 +1102,9 @@ class DocumentControllerTest {
}
@Test
@WithMockUser(authorities = "READ_ALL")
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_passesSenderIdParamToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID senderId = UUID.randomUUID();
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
.thenReturn(List.of());
@@ -1049,6 +1115,21 @@ class DocumentControllerTest {
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(tooMany);
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
@Test
@@ -1059,6 +1140,15 @@ class DocumentControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
@@ -1068,9 +1158,28 @@ class DocumentControllerTest {
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
StringBuilder sb = new StringBuilder("{\"ids\":[");
for (int i = 0; i < 501; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(UUID.randomUUID()).append("\"");
}
sb.append("]}");
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content(sb.toString()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.batchMetadata(any())).thenReturn(List.of(
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
@@ -1084,15 +1193,41 @@ class DocumentControllerTest {
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
// Nora C4 — DocumentController.sanitizeForLog defends against
// CWE-117 (log injection) by replacing CR/LF in any free-form string
// it interpolates. Same helper now sanitises BulkEditError.message
// before it round-trips to the frontend.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID badId = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
"evil\r\nFAKE LOG ENTRY: admin logged in"));
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(badId.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.containsString("evil_")));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID okId = UUID.randomUUID();
UUID badId = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(okId), any()))
when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
.thenAnswer(inv -> Document.builder().id(okId).build());
when(documentService.applyBulkEditToDocument(eq(badId), any()))
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));

View File

@@ -16,6 +16,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.dto.TagOperator;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
@@ -1929,7 +1930,7 @@ class DocumentServiceTest {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto()))
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
}
@@ -1949,7 +1950,7 @@ class DocumentServiceTest {
var dto = bulkDto();
dto.setTagNames(List.of("Kurrent"));
documentService.applyBulkEditToDocument(id, dto);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
}
@@ -1965,7 +1966,7 @@ class DocumentServiceTest {
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.applyBulkEditToDocument(id, bulkDto());
documentService.applyBulkEditToDocument(id, bulkDto(), null);
assertThat(doc.getTags()).containsExactly(existing);
verify(tagService, never()).findOrCreate(any());
@@ -1984,7 +1985,7 @@ class DocumentServiceTest {
var dto = bulkDto();
dto.setTagNames(List.of());
documentService.applyBulkEditToDocument(id, dto);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getTags()).containsExactly(existing);
verify(tagService, never()).findOrCreate(any());
@@ -2006,7 +2007,7 @@ class DocumentServiceTest {
var dto = bulkDto();
dto.setSenderId(senderId);
documentService.applyBulkEditToDocument(id, dto);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getSender()).isEqualTo(newSender);
}
@@ -2022,7 +2023,7 @@ class DocumentServiceTest {
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.applyBulkEditToDocument(id, bulkDto());
documentService.applyBulkEditToDocument(id, bulkDto(), null);
assertThat(doc.getSender()).isEqualTo(existing);
verify(personService, never()).getById(any());
@@ -2043,7 +2044,7 @@ class DocumentServiceTest {
var dto = bulkDto();
dto.setReceiverIds(List.of(newReceiverId));
documentService.applyBulkEditToDocument(id, dto);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
}
@@ -2060,12 +2061,30 @@ class DocumentServiceTest {
var dto = bulkDto();
dto.setReceiverIds(List.of());
documentService.applyBulkEditToDocument(id, dto);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getReceivers()).containsExactly(existing);
verify(personService, never()).getAllById(any());
}
@Test
void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() {
UUID id = UUID.randomUUID();
UUID actorId = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
documentService.applyBulkEditToDocument(id, bulkDto(), actorId);
verify(documentVersionService).recordVersion(doc);
verify(auditService).logAfterCommit(
eq(AuditKind.METADATA_UPDATED),
eq(actorId),
eq(id),
eq(java.util.Map.of("source", "BULK_EDIT")));
}
@Test
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
UUID id = UUID.randomUUID();
@@ -2082,13 +2101,34 @@ class DocumentServiceTest {
dto.setArchiveBox("NewBox");
dto.setArchiveFolder("NewFolder");
dto.setDocumentLocation("NewLocation");
documentService.applyBulkEditToDocument(id, dto);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
}
@Test
void applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable() {
// Sara C1 — unresolvable sender flows up as a per-document error chip
// rather than aborting the controller's batch loop.
UUID id = UUID.randomUUID();
UUID unknownSender = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(personService.getById(unknownSender))
.thenThrow(DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND,
"Person not found: " + unknownSender));
var dto = bulkDto();
dto.setSenderId(unknownSender);
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, dto, null))
.isInstanceOf(DomainException.class)
.hasMessageContaining(unknownSender.toString());
}
// ─── findIdsForFilter ────────────────────────────────────────────────────
@Test
@@ -2104,6 +2144,24 @@ class DocumentServiceTest {
assertThat(result).containsExactly(d1.getId(), d2.getId());
}
@Test
void findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec() {
// Sara C3 — tagOp=OR flips useOrLogic at the spec layer; without a
// test pinning this, a refactor that wired OR to AND (or vice versa)
// would slip through.
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of());
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
documentService.findIdsForFilter(
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
// Spec built without throwing → OR branch was exercised. Coverage gain
// is in not-throwing on the OR-specific code path; the actual SQL is
// covered by JPA itself.
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class));
}
@Test
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
@@ -2183,7 +2241,7 @@ class DocumentServiceTest {
dto.setArchiveBox(" ");
dto.setArchiveFolder("");
// documentLocation left null
documentService.applyBulkEditToDocument(id, dto);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");

View File

@@ -875,7 +875,8 @@
"bulk_title_single": "Neues Dokument",
"bulk_title_multi": "Neue Dokumente",
"bulk_edit_button": "Massenbearbeitung",
"bulk_edit_n_selected": "{count} Dokumente ausgewählt",
"bulk_edit_n_selected_one": "1 Dokument ausgewählt",
"bulk_edit_n_selected_other": "{count} Dokumente ausgewählt",
"bulk_edit_clear_all": "Alles aufheben",
"bulk_edit_all_x": "Alle {count} editieren",
"bulk_edit_select_document": "Dokument {title} auswählen",
@@ -887,5 +888,15 @@
"bulk_edit_retry": "Erneut versuchen",
"bulk_edit_title": "Massenbearbeitung",
"bulk_edit_save_button": "Anwenden",
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage."
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage.",
"form_label_archive_box": "Karton",
"form_helper_archive_box": "Welcher Karton im Archiv?",
"form_label_archive_folder": "Mappe",
"form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?",
"bulk_edit_clear_selection": "Auswahl aufheben",
"bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben",
"bulk_edit_loading": "Dokumente werden geladen…",
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
"bulk_edit_topbar_title": "Massenbearbeitung",
"bulk_edit_count_pill": "{count} werden bearbeitet"
}

View File

@@ -875,17 +875,28 @@
"bulk_title_single": "New Document",
"bulk_title_multi": "New Documents",
"bulk_edit_button": "Bulk edit",
"bulk_edit_n_selected": "{count} documents selected",
"bulk_edit_n_selected_one": "1 document selected",
"bulk_edit_n_selected_other": "{count} documents selected",
"bulk_edit_clear_all": "Clear all",
"bulk_edit_all_x": "Edit all {count}",
"bulk_edit_select_document": "Select document {title}",
"bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.",
"bulk_edit_badge_additive": "+ added",
"bulk_edit_badge_replace": "replaced",
"bulk_edit_badge_additive": "+ will be added",
"bulk_edit_badge_replace": "will replace",
"bulk_edit_save_progress": "Batch {done} of {total} processed",
"bulk_edit_save_partial": "{done} of {total} saved",
"bulk_edit_retry": "Retry",
"bulk_edit_title": "Bulk edit",
"bulk_edit_save_button": "Apply",
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request."
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request.",
"form_label_archive_box": "Box",
"form_helper_archive_box": "Which box in the archive?",
"form_label_archive_folder": "Folder",
"form_helper_archive_folder": "Which folder inside the box?",
"bulk_edit_clear_selection": "Clear selection",
"bulk_edit_clear_hint_keyboard": "Esc: clear selection",
"bulk_edit_loading": "Loading documents…",
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
"bulk_edit_topbar_title": "Bulk edit",
"bulk_edit_count_pill": "{count} will be edited"
}

View File

@@ -875,7 +875,8 @@
"bulk_title_single": "Nuevo Documento",
"bulk_title_multi": "Nuevos Documentos",
"bulk_edit_button": "Edición masiva",
"bulk_edit_n_selected": "{count} documentos seleccionados",
"bulk_edit_n_selected_one": "1 documento seleccionado",
"bulk_edit_n_selected_other": "{count} documentos seleccionados",
"bulk_edit_clear_all": "Limpiar todo",
"bulk_edit_all_x": "Editar los {count}",
"bulk_edit_select_document": "Seleccionar documento {title}",
@@ -887,5 +888,15 @@
"bulk_edit_retry": "Reintentar",
"bulk_edit_title": "Edición masiva",
"bulk_edit_save_button": "Aplicar",
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud."
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud.",
"form_label_archive_box": "Caja",
"form_helper_archive_box": "¿Qué caja del archivo?",
"form_label_archive_folder": "Carpeta",
"form_helper_archive_folder": "¿Qué carpeta dentro de la caja?",
"bulk_edit_clear_selection": "Limpiar selección",
"bulk_edit_clear_hint_keyboard": "Esc: limpiar selección",
"bulk_edit_loading": "Cargando documentos…",
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
"bulk_edit_topbar_title": "Edición masiva",
"bulk_edit_count_pill": "Se editarán {count}"
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { onDestroy, untrack } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
@@ -20,8 +20,13 @@ import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route
// passes the parsed `/api/documents/batch-metadata` response straight in, so
// the field names must match what the backend actually serializes (id, not
// documentId). The FileEntry built from each summary still uses both `id` and
// `documentId` so the save handler can drive the PATCH payload by UUID.
export type BulkEditEntry = {
documentId: string;
id: string;
title: string;
pdfUrl: string;
};
@@ -68,20 +73,22 @@ let archiveFolder = $state('');
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
// fetch upstream in the route — by the time this layout mounts, the metadata
// has already been resolved into `initialEditEntries`.
if (mode === 'edit') {
// has already been resolved into `initialEditEntries`. Wrapped in onMount so
// the SvelteMap mutation is unambiguously tied to instance lifecycle, not to
// the script body's first execution (Felix C4 cycle 3).
onMount(() => {
if (mode !== 'edit') return;
for (const entry of untrack(() => initialEditEntries)) {
const id = entry.documentId; // reuse documentId as the local FileEntry key
files.set(id, {
id,
documentId: entry.documentId,
files.set(entry.id, {
id: entry.id,
documentId: entry.id,
title: entry.title,
status: 'idle',
previewUrl: entry.pdfUrl
});
if (!activeId) activeId = id;
if (!activeId) activeId = entry.id;
}
}
});
// --- Derived ---
const isMulti = $derived(files.size >= 2);
@@ -130,6 +137,16 @@ async function handleDiscard() {
});
if (!ok) return;
}
if (mode === 'edit') {
// In edit mode the file map IS the user's bulk selection — discarding
// must clear the upstream store and bounce back to the list, otherwise
// the user is left on /documents/bulk-edit with an empty form and a
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
bulkSelectionStore.clear();
discardAll();
await goto('/documents');
return;
}
discardAll();
}
@@ -310,12 +327,20 @@ async function retrySave() {
</a>
<span class="text-ink-3" aria-hidden="true">·</span>
<span class="font-serif text-sm font-bold text-ink">
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
{#if mode === 'edit'}
{m.bulk_edit_topbar_title()}
{:else}
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
{/if}
</span>
{#if isMulti}
<span class="ml-auto flex items-center gap-3">
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
{m.bulk_count_pill({ count: files.size })}
{#if mode === 'edit'}
{m.bulk_edit_count_pill({ count: files.size })}
{:else}
{m.bulk_count_pill({ count: files.size })}
{/if}
</span>
<button
type="button"
@@ -365,10 +390,12 @@ async function retrySave() {
>
{#if mode === 'edit'}
<!-- Onboarding callout: tells the user that empty fields are skipped
and that tags/receivers are added rather than replaced. -->
and that tags/receivers are added rather than replaced.
No aria-label — role=note + the visible text content is
self-describing; an aria-label would override that text for
AT users on non-DE locales. -->
<div
role="note"
aria-label="Hinweis zur Massenbearbeitung"
data-testid="bulk-edit-callout"
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
>
@@ -479,7 +506,7 @@ async function retrySave() {
<div
role="alert"
data-testid="bulk-edit-partial-failure"
class="rounded-sm border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700"
class="rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
>
<p class="font-medium">
{m.bulk_edit_save_partial({
@@ -505,6 +532,7 @@ async function retrySave() {
onSave={save}
onDiscard={handleDiscard}
disabled={saving}
editMode={mode === 'edit'}
/>
</div>
</div>

View File

@@ -315,9 +315,33 @@ describe('BulkDocumentEditLayout', () => {
// ─── mode="edit" ─────────────────────────────────────────────────────────────
describe('BulkDocumentEditLayout — mode="edit" discard', () => {
it('discard in edit mode clears the selection store and navigates back to /documents', async () => {
const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte');
bulkSelectionStore.setAll(['doc-1']);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [
{ id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' },
{ id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' }
]
});
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
discardBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 });
expect(bulkSelectionStore.size).toBe(0);
});
});
describe('BulkDocumentEditLayout — mode="edit"', () => {
const editEntry = (i: number) => ({
documentId: `doc-${i}`,
id: `doc-${i}`,
title: `Brief ${i}`,
pdfUrl: `/api/documents/doc-${i}/file`
});
@@ -377,6 +401,24 @@ describe('BulkDocumentEditLayout — mode="edit"', () => {
expect(replaceBadges.length).toBeGreaterThanOrEqual(4);
});
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
// Elicit C1 fix — upload-flavoured "Mehrere Dokumente hochladen" /
// "werden erstellt" copy must not appear when mode === 'edit'.
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
// Topbar title slot
const topbar = container.querySelector('span.font-bold.text-ink');
expect(topbar?.textContent).toContain('Massenbearbeitung');
// Count pill
const pill = container.querySelector('span.bg-accent');
expect(pill?.textContent).toContain('werden bearbeitet');
// Negative: must NOT show upload-flavoured copy
expect(topbar?.textContent ?? '').not.toContain('hochladen');
expect(pill?.textContent ?? '').not.toContain('werden erstellt');
});
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',

View File

@@ -6,6 +6,7 @@ import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { canWrite }: { canWrite: boolean } = $props();
const count = $derived(bulkSelectionStore.size);
const visible = $derived(canWrite && count > 0);
function openBulkEdit() {
goto('/documents/bulk-edit');
@@ -14,16 +15,43 @@ function openBulkEdit() {
function clearAll() {
bulkSelectionStore.clear();
}
// Escape clears the selection — keyboard escape hatch when the user has
// drilled into a 50-row selection and wants to bail without Tab-ing through
// the whole footer (WCAG 2.1.1). Bails when an open dialog, expanded menu,
// or popover is in front so we don't steal Esc from NotificationBell,
// ConfirmDialog, HelpPopover, etc.
function onEscape(e: KeyboardEvent) {
if (e.key !== 'Escape' || !visible) return;
if (e.defaultPrevented) return;
const overlay = document.querySelector(
'dialog[open], [aria-expanded="true"], [role="menu"]:not([hidden]), [role="dialog"]:not([hidden])'
);
if (overlay) return;
clearAll();
}
</script>
{#if canWrite && count > 0}
<svelte:window onkeydown={onEscape} />
{#if visible}
<div
data-testid="bulk-selection-bar"
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between gap-3 border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
>
<span class="font-sans text-sm font-medium text-ink" data-testid="bulk-selection-count">
{m.bulk_edit_n_selected({ count })}
</span>
<div class="flex items-baseline gap-3">
<span
class="font-sans text-sm font-medium text-ink"
data-testid="bulk-selection-count"
aria-live="polite"
aria-atomic="true"
>
{count === 1 ? m.bulk_edit_n_selected_one() : m.bulk_edit_n_selected_other({ count })}
</span>
<span class="hidden font-sans text-xs text-ink-3 sm:inline">
{m.bulk_edit_clear_hint_keyboard()}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
@@ -31,7 +59,7 @@ function clearAll() {
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
data-testid="bulk-clear-all"
>
{m.bulk_edit_clear_all()}
{m.bulk_edit_clear_selection()}
</button>
<button
type="button"

View File

@@ -32,6 +32,23 @@ describe('BulkSelectionBar', () => {
await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2');
});
it('uses the singular plural form for count=1 (not "1 Dokumente")', async () => {
bulkSelectionStore.add('only');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveTextContent('1 Dokument ausgewählt');
});
it('uses the plural form for count=2', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveTextContent('2 Dokumente ausgewählt');
});
it('clear button empties the store', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
@@ -46,4 +63,60 @@ describe('BulkSelectionBar', () => {
await page.getByTestId('bulk-edit-open').click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
});
it('selection count region announces via aria-live=polite', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveAttribute('aria-live', 'polite');
});
it('Escape clears the selection while the bar is visible', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await expect.poll(() => bulkSelectionStore.size).toBe(0);
});
it('Escape is a no-op when the bar is hidden (no selection)', async () => {
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Nothing to clear, no error.
expect(bulkSelectionStore.size).toBe(0);
});
it('Escape does not clear when an open <dialog> is present (Leonie B6 scope guard)', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
// Simulate a ConfirmDialog being open in front of the bar.
const overlay = document.createElement('dialog');
overlay.setAttribute('open', '');
document.body.appendChild(overlay);
try {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Escape is captured by the dialog, not the bar — selection survives.
expect(bulkSelectionStore.size).toBe(2);
} finally {
overlay.remove();
}
});
it('Escape does not clear when an aria-expanded popover is present', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
const trigger = document.createElement('button');
trigger.setAttribute('aria-expanded', 'true');
document.body.appendChild(trigger);
try {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(bulkSelectionStore.size).toBe(1);
} finally {
trigger.remove();
}
});
});

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { onMount } from 'svelte';
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
import { m } from '$lib/paraglide/messages.js';
@@ -32,14 +32,18 @@ let {
editMode?: boolean;
} = $props();
// Seed bindables from initial-* props once at mount and only when the parent
// hasn't already supplied a non-empty value through the binding. onMount runs
// exactly once per instance, so this never stomps a parent-driven update on a
// later prop change. Required by the single-doc edit flow which seeds from
// the document; bulk-edit consumers leave the initial-* unset and bind their
// own state.
let titleDirty = $state(false);
currentTitle = untrack(() => initialTitle);
onMount(() => {
if (!currentTitle && initialTitle) currentTitle = initialTitle;
if (!documentLocation && initialDocumentLocation) documentLocation = initialDocumentLocation;
});
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
// Initialize controlled location field once from the legacy initial-* props so
// callers that haven't switched to the bindable form keep their existing
// pre-fill behaviour.
documentLocation = untrack(() => documentLocation || initialDocumentLocation);
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -126,8 +130,8 @@ documentLocation = untrack(() => documentLocation || initialDocumentLocation);
{#if editMode}
<!-- Karton (only in editMode — bulk-editable replace) -->
<div data-testid="description-archive-box">
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2"
>Karton
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_box()}
<FieldLabelBadge variant="replace" />
</label>
<input
@@ -137,12 +141,13 @@ documentLocation = untrack(() => documentLocation || initialDocumentLocation);
bind:value={archiveBox}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
</div>
<!-- Mappe (only in editMode — bulk-editable replace) -->
<div data-testid="description-archive-folder">
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2"
>Mappe
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_folder()}
<FieldLabelBadge variant="replace" />
</label>
<input
@@ -152,6 +157,7 @@ documentLocation = untrack(() => documentLocation || initialDocumentLocation);
bind:value={archiveFolder}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import DescriptionSection from './DescriptionSection.svelte';
afterEach(() => cleanup());
describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fence)', () => {
it('pre-fills the title input from initialTitle when currentTitle is empty', async () => {
render(DescriptionSection, { initialTitle: 'Brief an Anna' });
const titleInput = document.querySelector('input#title') as HTMLInputElement;
expect(titleInput).not.toBeNull();
expect(titleInput.value).toBe('Brief an Anna');
});
it('does not stomp a parent-bound currentTitle that is already non-empty', async () => {
render(DescriptionSection, {
currentTitle: 'Parent Title',
initialTitle: 'Should Not Win'
});
const titleInput = document.querySelector('input#title') as HTMLInputElement;
expect(titleInput.value).toBe('Parent Title');
});
it('pre-fills the documentLocation input from initialDocumentLocation', async () => {
render(DescriptionSection, { initialDocumentLocation: 'Schrank 3, Mappe B' });
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
expect(locationInput.value).toBe('Schrank 3, Mappe B');
});
it('does not stomp a parent-bound documentLocation that is already non-empty', async () => {
render(DescriptionSection, {
documentLocation: 'Bound Value',
initialDocumentLocation: 'Should Not Win'
});
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
expect(locationInput.value).toBe('Bound Value');
});
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
render(DescriptionSection, { editMode: true, hideTitle: true });
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('hides the editMode-only archive fields when editMode=false', async () => {
render(DescriptionSection, { editMode: false });
expect(document.querySelector('[data-testid="description-archive-box"]')).toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).toBeNull();
});
});

View File

@@ -10,7 +10,7 @@ const text = $derived(
<span
data-testid="field-label-badge-{variant}"
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-gray-600"
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[11px] font-medium tracking-wide text-ink-2"
>
{text}
</span>

View File

@@ -21,10 +21,8 @@ describe('FieldLabelBadge', () => {
.toHaveTextContent('wird ersetzt');
});
it('uses text-gray-600 for WCAG-AA contrast on muted backgrounds', async () => {
it('uses the design-system text-ink-2 token (not raw Tailwind palette)', async () => {
render(FieldLabelBadge, { variant: 'replace' });
await expect
.element(page.getByTestId('field-label-badge-replace'))
.toHaveClass(/text-gray-600/);
await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/);
});
});

View File

@@ -6,14 +6,21 @@ let {
chunkProgress,
onSave,
onDiscard,
disabled = false
disabled = false,
editMode = false
}: {
fileCount: number;
chunkProgress?: { done: number; total: number };
onSave: () => void;
onDiscard: () => void | Promise<void>;
disabled?: boolean;
editMode?: boolean;
} = $props();
const saveCta = $derived.by(() => {
if (editMode) return m.bulk_edit_save_button();
return fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount });
});
</script>
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
@@ -24,9 +31,22 @@ let {
aria-valuenow={chunkProgress.done}
aria-valuemin={0}
aria-valuemax={chunkProgress.total}
aria-label={m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
class="[&::-webkit-progress-bar]:bg-brand-sand mb-3 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
aria-label={editMode
? m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })
: m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
class="[&::-webkit-progress-bar]:bg-brand-sand mb-2 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
></progress>
{#if editMode && chunkProgress.total > 1}
<!-- Visible progress text for sighted users on multi-chunk PATCH
(Elicit S3 — the unitless bar isn't enough for a 30-second op). -->
<p
class="mb-2 font-sans text-xs text-ink-2"
aria-live="polite"
data-testid="bulk-edit-chunk-progress-text"
>
{m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })}
</p>
{/if}
{/if}
<div class="flex items-center justify-between gap-3">
<button
@@ -43,7 +63,7 @@ let {
onclick={onSave}
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
>
{fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount })}
{saveCta}
</button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
@@ -33,10 +33,20 @@ let {
editMode?: boolean;
} = $props();
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
dateIso = untrack(() => initialDateIso);
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
// and is then user-driven. onMount runs exactly once, so this never stomps
// the parent's dateIso on a later prop change.
let dateDisplay = $state('');
let dateDirty = $state(false);
onMount(() => {
const seed = dateIso || initialDateIso;
if (seed) {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
});
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) {

View File

@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import WhoWhenSection from './WhoWhenSection.svelte';
afterEach(() => cleanup());
describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', () => {
it('pre-fills the date input from initialDateIso when the bindable is empty', async () => {
render(WhoWhenSection, { initialDateIso: '2024-03-15' });
// isoToGerman('2024-03-15') → '15.03.2024'
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
expect(dateInput).not.toBeNull();
expect(dateInput.value).toBe('15.03.2024');
});
it('does not stomp a parent-bound dateIso that is already non-empty', async () => {
// dateIso bindable is '2026-01-01' from the parent; initialDateIso is the
// "fallback seed". onMount must not overwrite the already-bound value.
render(WhoWhenSection, { dateIso: '2026-01-01', initialDateIso: '1900-01-01' });
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
expect(dateInput.value).toBe('01.01.2026');
});
it('hides the date field when hideDate=true (bulk-edit mode)', async () => {
render(WhoWhenSection, { hideDate: true });
await expect.element(page.getByTestId('who-when-date')).not.toBeInTheDocument();
});
it('renders the meta_location input only outside editMode', async () => {
const { rerender } = render(WhoWhenSection, { editMode: true });
expect(document.querySelector('input#location')).toBeNull();
await rerender({ editMode: false });
expect(document.querySelector('input#location')).not.toBeNull();
});
it('pre-fills the location input from initialLocation', async () => {
render(WhoWhenSection, { editMode: false, initialLocation: 'Berlin' });
const locationInput = document.querySelector('input#location') as HTMLInputElement;
expect(locationInput.value).toBe('Berlin');
});
});

View File

@@ -50,4 +50,27 @@ describe('bulkSelectionStore', () => {
bulkSelectionStore.clear();
expect(bulkSelectionStore.size).toBe(0);
});
it('setAll([]) leaves the store empty (no-op when filter returned zero matches)', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.setAll([]);
expect(bulkSelectionStore.size).toBe(0);
});
it('setAll drops all previously selected ids, not just some', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.setAll(['c', 'd']);
expect(bulkSelectionStore.has('a')).toBe(false);
expect(bulkSelectionStore.has('b')).toBe(false);
expect(bulkSelectionStore.has('c')).toBe(true);
expect(bulkSelectionStore.has('d')).toBe(true);
});
it('ids getter exposes the current SvelteSet', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
const ids = Array.from(bulkSelectionStore.ids);
expect(ids.sort()).toEqual(['a', 'b']);
});
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import './layout.css';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
@@ -10,6 +10,7 @@ import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { provideConfirmService } from '$lib/services/confirm.svelte.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { children, data } = $props();
@@ -17,6 +18,27 @@ let { children, data } = $props();
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
provideConfirmService();
// Auto-clear the bulk-selection store when the user leaves the routes that
// surface the BulkSelectionBar. Without this the selection silently follows
// the user to /persons / /admin etc. and reappears as a stale 3-doc count
// when they wander back to /documents — Felix C4 on PR #331.
//
// `bulkSelectionStore.size` is read inside `untrack` so the effect only
// re-fires on route change, not on every checkbox toggle (Felix C3 cycle 3).
$effect(() => {
const path = page.url.pathname;
const inBulkContext =
path === '/documents' ||
path.startsWith('/documents/') ||
path === '/enrich' ||
path.startsWith('/enrich/');
if (!inBulkContext) {
untrack(() => {
if (bulkSelectionStore.size > 0) bulkSelectionStore.clear();
});
}
});
const isAdmin = $derived(
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
);

View File

@@ -8,6 +8,7 @@ import DocumentList from '../DocumentList.svelte';
import Pagination from '$lib/components/Pagination.svelte';
import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import { getErrorMessage, parseBackendError } from '$lib/errors';
import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -141,16 +142,18 @@ $effect(() => {
});
let editingAll = $state(false);
let editAllError = $state<string | null>(null);
/**
* Fast path: replace the current selection with every document matching the
* active filter (across all pages) and jump to the bulk-edit screen. The
* /api/documents/ids endpoint is uncapped — chunking happens at PATCH time
* inside the bulk-edit page's save handler.
* /api/documents/ids endpoint is hard-capped (5000 results); on cap overflow
* the backend returns BULK_EDIT_TOO_MANY_IDS, which we surface inline.
*/
async function editAllMatching() {
if (editingAll) return;
editingAll = true;
editAllError = null;
try {
const params = buildSearchParams({
q: data.q || '',
@@ -168,12 +171,15 @@ async function editAllMatching() {
params.delete('dir');
const res = await fetch(`/api/documents/ids?${params.toString()}`);
if (!res.ok) {
editingAll = false;
const backend = await parseBackendError(res);
editAllError = getErrorMessage(backend?.code);
return;
}
const ids: string[] = await res.json();
bulkSelectionStore.setAll(ids);
await goto('/documents/bulk-edit');
} catch {
editAllError = m.bulk_edit_all_x_failed();
} finally {
editingAll = false;
}
@@ -200,7 +206,13 @@ $effect(() => {
<title>{m.nav_documents()} Familienarchiv</title>
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
sticky bar does not occlude the last document row or the pagination
controls (WCAG 1.4.10 / 2.4.7). -->
<main
class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8"
class:pb-32={bulkSelectionStore.size > 0 && data.canWrite}
>
<h1 class="sr-only">{m.nav_documents()}</h1>
<SearchFilterBar
@@ -223,7 +235,7 @@ $effect(() => {
/>
{#if data.canWrite && data.totalElements > 0}
<div class="mb-2 flex justify-end">
<div class="mb-2 flex flex-col items-end gap-1">
<button
type="button"
onclick={editAllMatching}
@@ -233,6 +245,11 @@ $effect(() => {
>
{m.bulk_edit_all_x({ count: data.totalElements })}
</button>
{#if editAllError}
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
{editAllError}
</p>
{/if}
</div>
{/if}

View File

@@ -1,9 +1,11 @@
import { redirect } from '@sveltejs/kit';
export async function load({ locals }: { locals: App.Locals }) {
// Defensive: a UserGroup row with NULL permissions returns undefined here
// rather than throwing on .includes() — treat that as "not WRITE_ALL".
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
locals.user?.groups?.some(
(g: { permissions?: string[] }) => g.permissions?.includes('WRITE_ALL') ?? false
) ?? false;
if (!canWrite) throw redirect(303, '/documents');
return { canWrite };

View File

@@ -5,6 +5,7 @@ import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import BulkDocumentEditLayout, {
type BulkEditEntry
} from '$lib/components/document/BulkDocumentEditLayout.svelte';
import { getErrorMessage, parseBackendError } from '$lib/errors';
import { m } from '$lib/paraglide/messages.js';
let entries = $state<BulkEditEntry[]>([]);
@@ -14,6 +15,9 @@ let error = $state<string | null>(null);
onMount(async () => {
const ids = Array.from(bulkSelectionStore.ids);
if (ids.length === 0) {
// Skip the loading flash on the empty-store redirect path — the user
// is bouncing back to /documents in the next tick.
loading = false;
await goto('/documents');
return;
}
@@ -24,14 +28,15 @@ onMount(async () => {
body: JSON.stringify({ ids })
});
if (!res.ok) {
error = m.error_internal_error();
const backend = await parseBackendError(res);
error = getErrorMessage(backend?.code);
loading = false;
return;
}
const summaries = (await res.json()) as BulkEditEntry[];
entries = summaries;
} catch {
error = m.error_internal_error();
error = getErrorMessage(undefined);
} finally {
loading = false;
}
@@ -43,9 +48,35 @@ onMount(async () => {
</svelte:head>
{#if loading}
<div class="flex h-full items-center justify-center p-12 text-sm text-ink-2"></div>
<div
class="flex h-full items-center justify-center gap-3 p-12 text-sm text-ink-2"
role="status"
aria-live="polite"
data-testid="bulk-edit-loading"
>
<svg
class="h-5 w-5 animate-spin text-ink-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>{m.bulk_edit_loading()}</span>
</div>
{:else if error}
<div class="m-6 rounded-sm border border-red-300 bg-red-50 p-4 text-sm text-red-700">
<div
role="alert"
class="m-6 rounded-sm border border-danger/40 bg-danger/10 p-4 text-sm text-danger"
data-testid="bulk-edit-page-error"
>
{error}
</div>
{:else if entries.length > 0}

View File

@@ -43,4 +43,19 @@ describe('/documents/bulk-edit +page.server.ts', () => {
const result = await load({ locals });
expect(result).toEqual({ canWrite: true });
});
it('redirects when a group has no permissions array (defensive)', async () => {
// Sara C7 — a UserGroup row with NULL permissions used to throw on
// .includes(); the guard now treats that case as "not WRITE_ALL".
const locals = {
user: { groups: [{ permissions: undefined as unknown as string[] }] }
};
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({ locals });
throw new Error('expected redirect');
} catch (e) {
expect((e as { status?: number }).status).toBe(303);
}
});
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import BackButton from '$lib/components/BackButton.svelte';
import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { data } = $props();
@@ -10,7 +11,9 @@ const count = $derived(documents.length);
const canWrite = $derived(data.canWrite);
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
sticky bar does not occlude the last document row (WCAG 1.4.10). -->
<div class="mx-auto max-w-4xl px-4 py-10" class:pb-32={bulkSelectionStore.size > 0 && canWrite}>
<!-- Back Link -->
<BackButton />