Addresses @markus/@nora suggestion: makes explicit that the missing
@RequirePermission on read endpoints is intentional — all authenticated
family members may read the family graph; unauthenticated access is still
blocked by Spring Security's anyRequest().authenticated() rule.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getRelationshipBetween now throws DomainException with RELATIONSHIP_NOT_FOUND
instead of ResponseStatusException, so the frontend receives a typed error code.
Removed redundant validateRelationType() guard — RelationshipService.parseType()
already handles this with the same DomainException/VALIDATION_ERROR path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes direct PersonRepository injection from the relationship domain,
routing cross-domain person resolution through PersonService.getAllById()
per the layering rules.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both /api/network and /api/persons/{id}/relationships threw
LazyInitializationException when toDTO read Person.getDisplayName():
the read-side service methods aren't @Transactional, so the session
closed before the proxy could initialize.
Eagerly fetch r.person and r.relatedPerson in the two queries used
by these endpoints, keeping the no-@Transactional convention for
read methods.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Seven endpoints in one controller, two roots:
- GET /api/network → NetworkDTO
- GET /api/persons/{id}/relationships → List<RelationshipDTO>
- GET /api/persons/{id}/inferred-relationships
- GET /api/persons/{aId}/relationship-to/{bId} → 200 or 404
- POST /api/persons/{id}/relationships WRITE_ALL
- DEL /api/persons/{id}/relationships/{relId} WRITE_ALL, 204
- PATCH /api/persons/{id}/family-member WRITE_ALL
PersonController is intentionally untouched. Controller-boundary
validation via RelationType.valueOf catches unknown types as 400 before
the service is invoked. FamilyMemberPatchDTO is a one-field record for
the family-member toggle.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add PersonService.setFamilyMember (write, @Transactional) and
findAllFamilyMembers; PersonRepository gains the
findByFamilyMemberTrueOrderBy projection.
- RelationshipService orchestrates PersonService + the inference
service; never reaches into PersonRepository directly. addRelationship
guards self-relationship, year range, circular PARENT_OF (Nora B2),
and DataIntegrityViolation→DUPLICATE_RELATIONSHIP. deleteRelationship
enforces ownership from either side (Nora B1).
- Extend RelationshipDTO with personDisplayName + birth/death year so
the frontend can render rows from either viewpoint.
- 8 unit tests, written against a stub (red), then green: FORBIDDEN
delete, CIRCULAR add, DUPLICATE add, self-relationship, year range,
happy-path persistence, ownership-from-object, RELATIONSHIP_NOT_FOUND.
Full backend suite: 1399/1399 green.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RelationToken enum (UP/DOWN/SPOUSE/SIBLING) with reverse(), and
RelationshipInferenceService with:
- Bidirectional adjacency map: PARENT_OF emits UP and DOWN, SPOUSE_OF
and SIBLING_OF both directions.
- Virtual SIBLING edges derived from shared parents — no SIBLING_OF
row required for siblings to appear.
- BFS with MAX_DEPTH=8.
- 17-entry LABEL_MAP covering parent, child, spouse, sibling, grand*,
great-grand*, uncle/aunt, niece/nephew, great-uncle/aunt, great-niece/
nephew, in-law parent/child, sibling-in-law (both paths), cousin_1.
- "distant" fallback for any path not in LABEL_MAP.
- Two-sided labels via path reversal.
18 unit tests written first against a stub; all 18 confirmed red, then
green after implementation. PersonControllerTest's anonymous DTO updated
for the new isFamilyMember() projection.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RelationType enum (9 values), PersonRelationship entity with
@ToString(exclude = "notes") and LAZY person FKs.
- PersonRelationshipRepository with the network bulk fetch, the
per-person subgraph fetch, and the existsBy check for the circular
PARENT_OF guard.
- Six DTO records: CreateRelationshipRequest, RelationshipDTO,
PersonNodeDTO, NetworkDTO, InferredRelationshipDTO,
InferredRelationshipWithPersonDTO. @Schema(REQUIRED) on every
always-populated field so OpenAPI/TS codegen stays accurate.
- Person entity gains familyMember, PersonSummaryDTO gains
isFamilyMember, both PersonRepository projections select
p.family_member.
- Three new ErrorCodes: RELATIONSHIP_NOT_FOUND, CIRCULAR_RELATIONSHIP,
DUPLICATE_RELATIONSHIP.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds persons.family_member flag and person_relationships table with
ON DELETE CASCADE on both FKs, no_self_rel check, unique_rel composite,
indexes on both person columns, and partial unique index for symmetric
SIBLING_OF pairs (LEAST/GREATEST trick).
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
createUserOrUpdate(UUID actorId, ...) is always called from the controller with
a real authenticated actor. createUserForBootstrap() handles seeding/test setup
without emitting an audit event, making the two contracts unambiguous.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds findRecentByKinds JPQL query to AuditLogQueryRepository and
findRecentUserManagementEvents(int limit) to AuditLogQueryService,
returning the N most recent USER_CREATED/USER_DELETED/GROUP_MEMBERSHIP_CHANGED
events ordered newest-first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds actorId param to adminUpdateUser(), captures beforeGroups before
mutation, computes added/removed group names, emits logAfterCommit only
when the group set actually changes. Payload contains group names, not
permission strings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds actorId param to deleteUser(), captures email before deletion,
emits logAfterCommit(USER_DELETED) with userId+email in payload.
Updates UserController to resolve and pass actorId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED to AuditKind.
Injects AuditService into UserService; changes createUserOrUpdate to
accept actorId and emits logAfterCommit(USER_CREATED) only on the
new-user branch. Updates UserController to resolve and pass actorId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PersonController trims title (both create + update) matching the existing firstName/lastName trim pattern
- PersonControllerTest: verifies title is trimmed before service call (ArgumentCaptor)
- PersonControllerTest: verifies createPerson returns 400 when personType is SKIP
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Words like "Wille" stem to "will" via the German Snowball stemmer, which is
also a German stop word. The prefix-transform step (websearch_to_tsquery text →
regexp_replace → to_tsquery) was passing already-stemmed lexemes back through
the German dictionary, causing them to be silently dropped as stop words. Using
the 'simple' configuration skips stop-word processing entirely while the
tsvector @@ tsquery comparison still works because lexemes are matched by
string value, not by configuration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
READ_ALL-gated endpoint returning all document UUIDs matching the same
filter parameters as /search, ignoring page/size. Powers the "Alle X
editieren" fast path so the bulk-edit page can replace the selection
with every match in one round-trip.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
READ_ALL-gated batch endpoint returning lightweight summaries (id, title,
server PDF URL) for the bulk-edit page's left strip. Unknown IDs are silently
dropped — missing previews would be obvious to the user already.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WRITE_ALL-gated batch endpoint that applies a partial DTO to up to 500
documents per request. Per-document failures (DOCUMENT_NOT_FOUND, etc.)
are collected into the response's errors[] without aborting the batch.
Logs an audit line consistent with quickUpload.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per-document atomic mutation method for the upcoming bulk PATCH endpoint.
Tags and receivers merge additively into existing sets; sender and the three
location fields replace only when the DTO field is non-blank. Wrapped in its
own @Transactional so a per-document failure cannot partially mutate other
documents in the outer batch loop.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the request/response shapes for the upcoming PATCH /api/documents/bulk,
POST /api/documents/batch-metadata, and the new error code for the 500-ID cap.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
storeDocumentWithBatchMetadata was a 30-line flat method mixing file storage
with metadata hydration. The private helper makes each concern visible at a
glance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces comma-delimited String with a proper JSON array field — callers no
longer need to pre-serialise. Service drops the split/trim/filter step and
passes tagNames directly to updateDocumentTags().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Validation guards (BATCH_TOO_LARGE, titles > files) are domain rules and
belong in the service where they can be unit-tested without the HTTP layer.
Controller now delegates to documentService.validateBatch().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add DocumentBatchMetadataDTO (titles, senderId, receiverIds, documentDate, location, tags, metadataComplete)
- Add BATCH_TOO_LARGE to ErrorCode
- Extend quickUpload to accept optional @RequestPart("metadata"); dispatches to storeDocumentWithBatchMetadata when present
- Cap batch at 50 files/request; reject 400 when titles.size > files.size
- Add DocumentService.storeDocumentWithBatchMetadata applying shared fields + index-based titles to both created and updated docs
- Raise max-request-size to 500MB (10-file chunk at max per-file size)
- Add structured SLF4J logging for every quickUpload call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fast path (DATE/TITLE/UPLOAD_DATE) pushes sort + paging into the DB via
findAll(Specification, PageRequest) and enriches only the returned slice
— 30× cheaper than enriching all 1500 matches when the user is only
going to see 50. In-memory sort paths (SENDER/RECEIVER/RELEVANCE) keep
their LEFT JOIN-friendly sort but now slice in-memory too, so enrichment
still runs against the page slice only.
Controller passes PageRequest.of(page, size) built from @RequestParam
values. Plan-level "add @Validated" prerequisite comes in the next commit.
All existing tests updated mechanically to pass a pageable argument
(PageRequest.of(0, 10_000) as an "effectively unpaged" sentinel). Stubs
that previously matched findAll(Specification, Sort) for the fast path
now match findAll(Specification, Pageable) with PageImpl<>.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rename `total` → `totalElements` for Spring-Page parity and add three new
required paging fields: pageNumber, pageSize, totalPages. Adds a `paged(
slice, pageable, totalElements)` factory alongside the existing single-page
`of(list)` shortcut. Enables offset pagination of /documents search (#315).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DashboardService now reads the URL from the Document's computed getter
instead of passing null, so the resume strip can display the real
thumbnail of whatever the user was last working on (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@JsonProperty makes the computed getter part of every Document response
Jackson produces, so any DTO returning a Document automatically carries
the thumbnail URL without per-controller plumbing. The accompanying
comment warns future readers that the cache-buster is load-bearing
for the endpoint's `immutable` cache header (CWE-525) (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Matches the shape the frontend previously built via
encodeURIComponent(thumbnailGeneratedAt), so the backend is now the
single source of truth for the thumbnail URL convention (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The no-cache-buster branch covers documents whose thumbnail key is set
but whose thumbnailGeneratedAt is still null — which only happens in
the narrow window between the key being persisted and the async worker
stamping the timestamp (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First TDD step for centralising the thumbnail URL convention on the
Document entity (#309). Adds a stub getter returning null and a test
that locks the "no key → no URL" branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>