Defense in depth: until now both list and single-person reads only required
authentication, while the write endpoints (POST/PUT/DELETE) were already
gated with @RequirePermission. The hover-card and typeahead introduced in
issue #362 expose person details (life dates, notes, family relationships)
to anyone who can authenticate — adding READ_ALL aligns the GETs with the
write endpoints and matches the access tier already enforced for documents
and transcription blocks.
Two new controller-slice tests assert 403 when an authenticated user lacks
READ_ALL; existing 200-path tests now stipulate `authorities = "READ_ALL"`
explicitly.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the propagation listener saves blocks with a stale @Version (because
another transcriber's autosave incremented version mid-rename), Hibernate
raises ObjectOptimisticLockingFailureException — Spring's translation of
the underlying JPA exception. PersonService.updatePerson now wraps the
publishEvent call in a catch for OptimisticLockingFailureException and
re-throws as DomainException(PERSON_RENAME_CONFLICT, 409). The whole
@Transactional boundary still rolls back, but the client gets a structured
409 with the localised "please retry" message instead of a generic 500.
The listener was switched from saveAll to saveAllAndFlush so the conflict
fires inside the listener call (where the catch can see it), not at
transaction commit (which is too late for in-method handling).
Test stubs the eventPublisher to throw OptimisticLockingFailureException
and asserts the translated DomainException carries PERSON_RENAME_CONFLICT
and HTTP 409. End-to-end DB-level reproduction of the JPA optimistic-lock
race requires multi-threading or two physical connections, which is
impractical inside @DataJpaTest; the underlying JPA mechanism is well
covered by Hibernate's own test suite.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the structured error code returned when a rename rolls back because a
referenced transcription block was edited concurrently (OptimisticLockException
on transcription_blocks.version). Mirrors the contract in
frontend src/lib/errors.ts and adds the localised message keys
error_person_rename_conflict in de/en/es so the UI surfaces a retry hint
instead of a generic 500.
The actual translation of OptimisticLockException → DomainException
(PERSON_RENAME_CONFLICT) lands in the next commit alongside the integration
test that proves the rollback semantics.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Synchronous @EventListener consumer of PersonDisplayNameChangedEvent.
Finds every block whose sidecar references the renamed person via the
derived query, replaces "@OldName" with "@NewName" inside block.text, and
updates the matching PersonMention.displayName in the sidecar list. saveAll
in one batch; SLF4J info log records the audit line.
Synchronous on purpose: the rename and the propagation must commit as one
transaction so a half-applied rewrite never reaches the archive. If the
archive grows past tens of thousands of blocks, switch to
@TransactionalEventListener(AFTER_COMMIT) + @Async.
Adds PersonService.existsById to give the listener a layered way to verify
the personId still corresponds to a real Person — defensive guard for any
future async refactor where an event could outlive the entity. The check
goes through PersonService rather than PersonRepository to honour the
"services never reach into another domain's repository" rule.
Happy-path @DataJpaTest + Testcontainers asserts a single-block, single-
mention rewrite mutates both the text and the sidecar entry. blockRepository
.flush() is called explicitly so saveAll is committed before em.clear() —
in production the surrounding @Transactional flushes on commit; in test we
substitute by flushing manually.
Implements PR-A tasks 13 and 15 as one red→green cycle.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spring Data resolves the method name to a join over
transcription_block_mentioned_persons, returning every block whose sidecar
contains the given personId. The B-tree index on person_id (V56) keeps the
lookup O(log n) — required for the rename propagation that fans out to
every block referencing the renamed person, and for the future
"show all blocks mentioning person X" query on the person detail page.
The underscore between MentionedPersons and PersonId is the explicit
property-boundary form, immune to ambiguous longest-match parsing if the
embeddable later gains another nested object.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PersonService now emits a domain event whenever Person.getDisplayName()
flips during an update. The snapshot is taken before the setter chain so we
compare like-for-like against the post-save value, and the event only
publishes when the two strings differ.
The test captures the published event via ArgumentCaptor and asserts the
title flip from "Herr" to "Frau" reaches the publisher with the correct
personId, oldDisplayName, and newDisplayName. Title participates in
DisplayNameFormatter, so this is the canonical case for "rename triggered
by something other than first/last name."
Implements PR-A tasks 9 and 10 as one red→green cycle (the test drove the
production change). Subsequent commits cover the negative cases (alias /
notes only) and the propagation listener that consumes the event.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires @Valid on the @RequestBody parameter of TranscriptionBlockController's
createBlock and updateBlock methods so JSR-303 actually fires for incoming
DTOs. With @Valid on the field-level mentionedPersons in the DTO (added in
the previous commit), Jakarta validation now recurses into each
PersonMention element and rejects displayName values past the @Size(max=200)
ceiling.
The test posts a 201-char displayName and asserts the global handler maps
the resulting MethodArgumentNotValidException to 400 + code:VALIDATION_ERROR.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CreateTranscriptionBlockDTO and UpdateTranscriptionBlockDTO gain a
List<PersonMention> mentionedPersons field. @Valid is on the field itself,
not just on the controller method, so JSR-303 recurses into the list
elements when the controller boundary calls @Valid on the @RequestBody. The
collection defaults to an empty ArrayList via @Builder.Default; existing
constructor call sites in TranscriptionServiceTest are extended with
List.of() to match the new @AllArgsConstructor signature.
The controller-side @Valid wiring lands in the next commit alongside the
length-201 validation test.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ElementCollection(LAZY) on List<PersonMention>, mapped to V56's
transcription_block_mentioned_persons via explicit @CollectionTable that
matches the migration name byte-for-byte (immune to Hibernate naming-strategy
changes). @Builder.Default keeps the field initialized to an empty list, so
existing transcription block construction stays untouched.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Carries personId + oldDisplayName + newDisplayName so transcription-side
listeners can rewrite block.text and sidecar entries when a person is
renamed. First custom application event in this codebase — the only prior
@EventListener consumes Spring's built-in ApplicationReadyEvent. Class doc
sets the convention for future cross-domain decoupling.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Value object held in TranscriptionBlock.mentionedPersons via @ElementCollection.
Carries the personId UUID (so renamed persons can be located) and the
displayName text (so block.text rewrites match exactly via "@" + name). Both
fields are non-null; displayName capped at 200 chars to match the V56 column
and bound the rename propagation cost.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Child table for @-mentions inside transcription block text. Each row binds
one block to one person via personId + displayName; the literal "@DisplayName"
stays in block.text. No FK on person_id so deleted persons degrade gracefully
to plain unlinked text rather than cascade-deleting the block. Indexed on
person_id for the future "blocks mentioning person X" query and on block_id
for the @ElementCollection load.
Schema choice diverges from document_comments.comment_mentions (many-to-many
to AppUser): the latter cascades, this one degrades. Mirrors the established
UserGroup.permissions / group_permissions @ElementCollection pattern.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spring deserializes the enum directly; invalid values are caught by the
HttpMessageNotReadableException → 400 handler added in 99d00537, returning
a structured VALIDATION_ERROR. The manual parseType() helper is therefore
redundant and removed. Tests updated to construct requests with the enum.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8 hops covers great-grandparents ↔ great-great-grandchildren and second
cousins — the practical horizon for a 1899–1950 archive. Prevents future
blind tuning of the constant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>