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>
Latency floor (Sara): a merge-blocking regression check, not a benchmark.
Seeds 200 blocks each with one mention of the same person, fires the rename,
and asserts the listener completes the entire find/mutate/saveAllAndFlush
cycle in less than two seconds against the Testcontainers Postgres.
Confirms the partial reload (one Auguste → Augusta) actually persisted so
the timing isn't measuring an empty path.
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>
A block with a sidecar entry pointing at a personId no longer in the
persons table receives a rename event for that ghost id. The listener
detects via PersonService.existsById that the entity is gone and exits
without touching block.text or the sidecar. Defends against any future
async refactor where an event could outlive the entity, or against
malformed events injected by tests / migrations.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the same person is mentioned twice in one block, both substrings flip
to the new display name. String.replace(String, String) is documented to
replace every occurrence, but a future regex-based refactor or a typo could
silently regress to first-match-only — this test guards against that.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Block contains both @Hans-Peter Müller and @Hans Müller; the listener fires
a rename for Hans Müller → Hans Schmidt. The simple replace("@" + old,
"@" + new) hinges on the leading @-and-space anchor: "@Hans Müller" does
not appear inside "@Hans-Peter Müller" (hyphen interrupts), so only the
standalone mention rewrites. Sidecar mirrors the same — Hans Müller's
entry flips to Hans Schmidt while Hans-Peter Müller's entry is preserved.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Save a block with no sidecar entries, fire a rename event for an unrelated
person, and assert the block reloads with its original text and empty
sidecar. Confirms findByMentionedPersons_PersonId returns an empty list and
the saveAll path does not accidentally touch unrelated rows.
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>
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>
Replaces the 86-line duplicated inline add-relationship form with
<AddRelationshipForm onSubmit={handleAddRelationship}>. The {#key node.id}
wrapper resets the form's open state when the selected tree node changes.
Year inputs now have <label> elements (WCAG 1.3.1) via the shared component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When onSubmit is provided the form has no server action and calls the
callback with typed RelFormData instead. Uses a shared {#snippet} for
the form body so the two submission paths share one template.
Co-Authored-By: Claude Sonnet 4.6 <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>
CSS box-shadow rings (focus-visible:ring-*) are invisible inside SVG.
Replace with a conditional <rect> drawn at -3px offset that renders in
all browsers. Name font-size bumped from 14 to 16px for the 60+
transcriber audience (WCAG readability, Leonie medium concerns).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The focus deep-link is a one-time load param — $derived + $effect caused
a deferred write that left the node unselected on first paint. Initialising
$state inline reads the URL once at component mount with no reactive cycle.
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>
All four tests skipped with a reference to issue #363 which tracks
adding the Playwright Chromium install + Docker Compose startup step
to the CI workflow. Remove the skip once #363 is resolved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The <span> in the derived-relationships list is replaced with <a href>
so keyboard and pointer users can navigate directly from the edit card,
consistent with PersonRelationshipsCard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
h-8 w-8 (32px) replaced with h-11 w-11 (44px) to meet the minimum
touch target for the 60+ transcriber audience. Test added to prevent
regression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hardcoded 'Stammbaum & Beziehungen' heading replaced with
m.stammbaum_relationships_heading(); new key added to all
three message files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes local duplicates of the switch-statement label logic already
exported from $lib/relationshipLabels.ts. Adds two direction-sensitive
tests proving the Elternteil-von / Kind-von branch is covered.
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 @leonie blocker: zoom buttons in /stammbaum had no visible focus
indicator for keyboard users. Applied focus-visible:ring-2 focus-visible:ring-focus-ring
focus-visible:outline-none matching the pattern used on nav links.
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>
Addresses @felix blocker: removes the verbatim duplicate switch+2-line helper
from StammbaumCard.svelte and StammbaumSidePanel.svelte; both now import from
the shared $lib/relationshipLabels helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>