Two regression guards on the "iff different" semantics in updatePerson.
Person.alias and Person.notes are not part of getDisplayName() — they live
outside DisplayNameFormatter — so changing only those fields must not fire
PersonDisplayNameChangedEvent. If a future refactor accidentally pulls
either field into the display name (or trips the comparison), these tests
catch it before transcription blocks get rewritten with stale "@OldAlias"
text.
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>
Regression guard for the @NotNull on PersonMention.personId paired with
@Valid on the DTO field. The wiring was added in the previous commit; this
test ensures dropping either annotation in the future causes a loud test
failure rather than silently allowing payloads with no personId to reach
the service layer (where the listener relies on the UUID being present).
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>
@DataJpaTest + Testcontainers exercises the V56 migration plus the
@ElementCollection wiring end-to-end. Saves a block with two PersonMention
entries, clears the persistence context, reloads, asserts both entries
return with their personId + displayName intact. Second test guards the
@Builder.Default — a block without explicit mentions reloads with an empty
list, not null.
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>
Proves the in-memory filter correctly drops edges where one Person is
not in findAllFamilyMembers(), preventing non-family relationships from
leaking into the graph.
Co-Authored-By: Claude Sonnet 4.6 <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>
Addresses @sara blocker: RelationshipControllerTest now has 6 tests covering
the two previously untested @RequirePermission(WRITE_ALL) endpoints. Prevents
silent permission regression if the controller is refactored.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @sara blocker: documents that Spring Security's anyRequest().authenticated()
guards these read endpoints and provides regression protection against accidental
@PermitAll additions in future.
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>
Confirms that DELETE /api/documents/{id}/annotations/{id} requires at
least ANNOTATE_ALL; a user with only READ_ALL receives 403 Forbidden.
Closes the permission audit raised during PR review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Regression guards verifying that Spring Security returns 401 (not 200) when
no credentials are provided, complementing the existing 403 permission tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the wait+clear cycles that existed only to drain the audit events
emitted by createUserOrUpdate(null, ...). Timeouts increased 5 → 10 s to
reduce CI flakiness under load.
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>
Creates a real actor user first (needed for audit_log FK constraint),
then creates and deletes a target user, asserts USER_DELETED is newest
and USER_CREATED is second via findRecentUserManagementEvents.
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>
Adds jsonPath("$.code").value("INVALID_PERSON_TYPE") to verify the full
error response shape, not just the HTTP status.
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>