Commit Graph

895 Commits

Author SHA1 Message Date
Marcel
1114676ae3 feat(timeline): carry each letter's primary root tag in the DTO
TimelineEntryDTO gains three nullable letter-only fields — rootTagId,
rootTagName, rootTagColor (token) — assembled in-transaction in TimelineService
(ADR-036): id + name + token only, never a serialized Tag entity. A letter's
primary tag is the root ancestor of its alphabetically-first assigned tag
(#827 Resolved Decision 3); roots are resolved through TagService in one
batched pass over the distinct primary tags (no per-letter N+1). The fields are
null for non-letter entries, untagged letters, and (color only) a colorless
root, so they are deliberately not @Schema(requiredMode = REQUIRED).

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:56:18 +02:00
Marcel
0be0a524b3 feat(tag): add a batched root-tag resolver
TagService.resolveRootTags(tags) maps each tag to its root ancestor as a
RootTag (id, name, color token), keyed by the input tag id. A root maps to
itself; a child is walked to the parentless ancestor via the existing
recursive-CTE findAncestorIds — one CTE per distinct non-root tag (memoized),
plus a single batched findAllById — so a timeline of many letters sharing few
tags costs O(distinct tags) queries, never O(letters). The color is read from
the resolved root's stored token (null when the root has none).

This is the shared enrichment the /zeitstrahl tag chip (#835) and, later, the
Thema buckets (#827) both consume. Unit-tested in TagServiceTest; the
DB-dependent ancestry walk is pinned against real Postgres in
TagServiceIntegrationTest.

Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:47:29 +02:00
Marcel
590b00d2d7 fix(timeline): add @Transactional(readOnly=true) to TimelineService.assemble()
Without the annotation, Hibernate closes its sub-transaction after
eventRepository.findAll() returns, leaving TimelineEvent entities
detached. Accessing ev.getPersons() or doc.getReceivers() on those
detached entities throws LazyInitializationException in production
(constitution §1.6). @DataJpaTest and @Transactional test classes
masked the bug by keeping an outer session alive.

Fixes: @felix / @markus review blockers on PR #826

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:23:10 +02:00
Marcel
3a174dd91b test(timeline): add integration tests for TimelineService + findByGeneration
Verifies PersonRepository.findByGeneration handles match, no-match (empty
list not NPE), and null-generation persons (excluded). Also confirms
TimelineService.assemble() returns a persisted curated event in the
correct year band against real Postgres via Testcontainers.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:24:34 +02:00
Marcel
afd1f0b86b feat(timeline): add GET /api/timeline endpoint + 8-test controller suite
TimelineController exposes GET /api/timeline with @RequirePermission(READ_ALL)
and @Validated so @Min(0) on generation fires a 400. Delegates to
TimelineService.assemble(TimelineFilter). DomainException 404/400 propagate
via GlobalExceptionHandler (no extra mapping needed).

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:22:44 +02:00
Marcel
f08b09faeb feat(timeline): add TimelineService assembly + 24-test Mockito suite
Creates TimelineService.assemble(TimelineFilter): merges curated events
(TimelineEventRepository), derived life-events (assembleDerivedEvents()),
and archive letters (DocumentService) into a year-bucketed TimelineDTO.
WITHIN_BAND_ORDER Comparator tested standalone before assembly tests.
ArchUnit Rule 2 entry for ..timeline.. domain added in same commit.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:20:54 +02:00
Marcel
de30f66a2d feat(timeline): add PersonService.getPersonsByGeneration + DocumentService.getAllForTimeline
PersonRepository.findByGeneration(Integer) — boxed to match nullable entity field.
DocumentRepository.findAllForTimeline() — Document.list entity-graph, single query.
Both services delegate with one-liner methods.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:06:03 +02:00
Marcel
184fc9814a refactor(timeline): adapt TimelineEntryDTO to unified #777 shape
Replace the #776 DTO (primary/relatedPersonName + synthetic String id)
with the full #777 spec: kind, senderName, receiverName, eventId,
documentId, linkedPersonIds, title, eventDateEnd. Derived events now use
title=displayName, linkedPersonIds=[UUID...], eventId=null.

DerivedEventsAssemblyTest updated — all 16 tests pass.

Refs #777
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:03:00 +02:00
Marcel
c66d83cfc6 feat(timeline): implement assembleDerivedEvents() with TDD (REQ-001–REQ-016)
Adds RelationshipService dependency to TimelineEventService and implements:
- assembleDerivedEvents() — public @Transactional(readOnly=true) orchestrator
- buildBirthEvents() — Person.birthDate → BIRTH events with precision pass-through
- buildDeathEvents() — Person.deathDate → DEATH events with precision pass-through
- buildMarriageEvents() — SPOUSE_OF edges → MARRIAGE events, dedup on row id

Synthetic prefixed ids (birth:/death:/marriage:) are structurally non-UUID.
Null fromYear marriages are emitted with eventDate=null + precision=UNKNOWN (REQ-006).
Non-family-member persons excluded from birth/death; SPOUSE_OF edges always emit (REQ-013).

All 16 tests in DerivedEventsAssemblyTest pass.

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:35:50 +02:00
Marcel
7810ca7dd7 feat(relationship): add findAllSpouseEdges() for timeline assembly
Returns all SPOUSE_OF edges with JOIN FETCH on both person sides,
preventing N+1 in TimelineService.assembleDerivedEvents() (REQ-011).
Reuses existing findAllByRelationTypeIn query which already JOIN FETCHes.

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:30:30 +02:00
Marcel
4245b821b9 feat(timeline): add DerivedEventType enum and TimelineEntryDTO record
DerivedEventType: BIRTH / DEATH / MARRIAGE discriminator for derived events.
TimelineEntryDTO: unified String-id DTO for both curated and derived events;
id is String (not UUID) to accommodate synthetic prefixed ids (birth:/death:/marriage:).

Refs #776
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:29:36 +02:00
Marcel
210dde6562 fix(timeline): reject reversed RANGE events; thread precision
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m56s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Successful in 5m49s
CI / fail2ban Regex (push) Successful in 49s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
The DB CHECK chk_timeline_event_range enforces only the presence
biconditional (eventDateEnd non-null IFF RANGE), not date ordering, so a
RANGE event with eventDateEnd before eventDate persisted silently and
rendered as a negative span. validateRangeInvariant now also rejects
end-before-start (INVALID_DATE_RANGE); equal dates remain a valid one-day
closed range.

Also compute effectivePrecision once per create/update and thread it into
validateRangeInvariant and applyUpdate instead of recomputing.

Addresses review of #822 (#775).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
d7f8abd6c4 test(timeline): add service integration tests (Testcontainers)
Two service-level integration tests against real Postgres (V77 CHECKs are
Postgres-specific): (1) view-assembly round-trip proving the
@Transactional(readOnly=true) LazyInit guard populates persons/documents after
an em.clear()ed fresh getEvent, with a serialized-JSON assertion that no
notes/provisional/password leak; (2) real optimistic-lock 409 — editor B's
stale version yields TIMELINE_EVENT_CONFLICT end-to-end (the unit test only
proves the catch/guard branches).

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
209f223b9f fix(timeline): engage optimistic lock via explicit version compare
The spec's prescribed mechanism (load managed entity -> setVersion(clientVersion)
-> saveAndFlush -> catch ObjectOptimisticLockingFailureException) does NOT engage
the lock: Hibernate ignores a manually-set @Version on a managed entity and uses
its own loaded-version snapshot for the UPDATE ... WHERE version=? clause, so a
stale client write silently succeeds. The integration test the issue mandated to
'prove the lock engages end-to-end' caught exactly this.

Replace it with requireVersionMatch: an explicit compare of the client's
last-seen token against the freshly-loaded version (the true semantics of the Q1
client-supplied-token decision). The native @Version increment still fires on
every save, and the saveAndFlush+catch is retained as the backstop for two
transactions flushing concurrently. Null token => last-write-wins, unchanged.

Deviation from #775's reviewed setVersion mechanism (per maintainer direction the
issue body is left as-is); version unit tests updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
34146d7309 feat(exception): add optimistic-lock backstop returning generic 409
Centralized @ExceptionHandler(ObjectOptimisticLockingFailureException) net so
any write path losing a @Version race becomes a generic 409 (CONFLICT code) —
never a 500 + Sentry + Hibernate internals (CWE-209). No Sentry, class-name-
only parameterized logging, body free of id/version/class. Entity-agnostic by
design (no switch on getPersistentClassName); the service catch keeps the
precise TIMELINE_EVENT_CONFLICT. Per #775 Q2/R4/R8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
390ab30260 feat(timeline): add TimelineEventController CRUD endpoints
POST→201, PUT→200, DELETE→204, GET→200; @RequirePermission(WRITE_ALL) on the
three writes, GET via global auth baseline (no annotation, documented). @Valid
request body; all bodies are TimelineEventView. Injects UserService + private
requireUserId wrapper. Controller slice tests cover 401/403/exact-status per
verb, GET 404, service PERSON_NOT_FOUND→404, Bean-Validation 400s carrying
code=VALIDATION_ERROR, and ArgumentCaptor proof that actorId is the resolved
session principal (not a forged body field) on both write paths.

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
c51fc5e79f feat(timeline): add TimelineEventService with CRUD + view assembly
create/update/delete write methods (@Transactional) + getEvent read
(@Transactional(readOnly=true) for the LazyInit guard). Persons resolved via
PersonService.getAllById with a distinct-size check; documents via per-id
DocumentService.getDocumentById loop; both dedupe-first, fail-closed. RANGE
invariant (both directions), title-length guard, YEAR date normalization, and
default precision. Audit fields server-set (createdBy+updatedBy on create;
only updatedBy on update). Optimistic-lock conflict translated to
TIMELINE_EVENT_CONFLICT via saveAndFlush+catch. Views assembled after flush.

Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
b7a5cd7b53 feat(timeline): add TimelineEventView response views
TimelineEventView + nested PersonView + timeline-local DocumentRef. Explicit
field allow-list, never the raw entity (lazy-collection 500 + curator-field
leak). DocumentRef stays timeline-local by design (#775 R7). Per ADR-040 §2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
0eea19c0d4 feat(timeline): add TimelineEventRequest input DTO
Flat input DTO with Bean Validation (@NotBlank/@NotNull/@Size). createdBy/
updatedBy deliberately absent (server-populated; CWE-639). version is an
optional concurrency token, exempt from the server-only audit rule. Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
262568f577 feat(timeline): add ErrorCodes for event CRUD
Add TIMELINE_EVENT_NOT_FOUND (404), TIMELINE_EVENT_CONFLICT (409),
TIMELINE_TITLE_TOO_LONG (400), and a generic CONFLICT (409) used by the
optimistic-lock backstop. Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
788a804810 docs(timeline): pin relative issue ordinals to Gitea issue numbers
The issue body's milestone-relative ordinals ("issue 3", "issue 5") become
unreadable once the milestone closes. Resolved against the Zeitstrahl milestone:
issue 3 = #775 (CRUD API: service/controller/DTO), issue 5 = #777 (assembly
endpoint with the per-person filter). Mapping anchored by issue 6 = #778
(date-label helper) and issue 9 = #781 (curator forms) in #774's forward notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:47:22 +02:00
Marcel
62b96f718f fix(timeline): mark always-populated audit fields REQUIRED in OpenAPI schema
createdBy/updatedBy are NOT NULL and createdAt/updatedAt/version are Hibernate-
populated on every persisted row, so per the CLAUDE.md rule they must carry
@Schema(requiredMode = REQUIRED) like id/title/type/eventDate/precision already
do. Keeps the generated TypeScript types honest if the entity ever reaches the
OpenAPI spec (responses in #775 are planned as views, per ADR-040).

Extends the #774 task list (which named only the five domain fields) per PR #816 review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:46:45 +02:00
Marcel
6ed5151e50 fix(timeline): drop join-table indexes redundant with composite PKs
idx_timeline_event_persons_event_id and idx_timeline_event_documents_event_id
duplicated the leading column of their composite primary keys — Postgres already
serves timeline_event_id lookups from the PK index, so the extra indexes only
added write overhead. The inverse-side indexes (person_id, document_id) stay;
they cover the FK cascade path.

Deviates from the #774 task list ("all four FK columns") per PR #816 review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:46:06 +02:00
Marcel
3a7c86fc87 test(timeline): allow timeline package in entity-location ArchRule
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 5m9s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m42s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
The entities_reside_in_domain_packages ArchUnit rule has a hardcoded
allow-list of domain packages; add ..timeline.. so TimelineEvent passes.
CI caught this — the new domain package was not yet whitelisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
1226bd0a07 docs(timeline): register timeline domain in package tables and diagrams
Add timeline/ to the root and backend package tables, TimelineEvent to the
domain-model entity tables, TimelineEvent/EventType/Zeitstrahl to the
glossary, a new l3-backend-timeline C4 component diagram, and the
timeline_events table + two join tables (with their CHECKs and cascade FKs)
to the db-orm and db-relationships ER diagrams. Bumps the db-orm snapshot to
V77.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
cc841a7a4c test(timeline): cover persistence, constraints, and FK cascade
@DataJpaTest against real Postgres (never H2): required-field round-trip,
YEAR default, linked persons/documents, eventDateEnd null/range round-trip,
TEXT description with no length cap, both RANGE-invariant rejections, the
UNKNOWN-precision rejection (NOT_SUPPORTED so the constraint violation does
not poison the test transaction), version null-before-persist/0-after-save,
and a parameterized accept-side proving DAY/MONTH/SEASON/YEAR/APPROX all
persist. makeEvent() defaults createdBy/updatedBy to random UUIDs so every
red is red for the intended reason.

@SpringBootTest cascade guard: deleting a linked Person/Document via the
domain service drops the join row (verified by direct COUNT) and leaves the
event intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
513cdb7a4d feat(timeline): add V77 migration for timeline_events table
Creates timeline_events plus the timeline_event_persons and
timeline_event_documents join tables, all FK columns ON DELETE CASCADE
(a person/document delete drops the join row, the event survives —
V71-class hardening). Two CHECK constraints push integrity to Postgres:
chk_timeline_event_range enforces event_date_end non-null IFF RANGE (a
strict biconditional, intentionally tighter than Document's open-ended
ranges), and chk_timeline_event_precision forbids exactly UNKNOWN while
keeping SEASON/APPROX legal. FK and query-column indexes added up-front
to avoid the V62 retrofit debt. Forward-only, additive DDL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
595007213c feat(timeline): add TimelineEvent entity and repository
Curated timeline event mirroring Document's date block (eventDate /
precision / eventDateEnd) so events and letters share one rendering path.
Audit footprint deliberately diverges from Document: @Version optimistic
lock plus NOT NULL createdBy/updatedBy for the multi-curator edit flow.
precision reuses document.DatePrecision (imported, not duplicated) and
defaults to YEAR. ManyToMany persons/documents with explicit @JoinTable +
@BatchSize, matching Document's join conventions.

Repository is empty for now with a TODO marker for the issue-5 per-person
filter query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
45001f042a feat(timeline): add EventType enum
PERSONAL/HISTORICAL classify a curated timeline event. The string value
names are a stable frontend styling contract (family vs. muted world
accent) — no mapping layer; renaming requires a coordinated frontend
change. First piece of the new timeline domain (Zeitstrahl, issue #774).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:34:18 +02:00
Marcel
3626cd1a6d refactor(person): share yearOf between relationship services
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
fe4e2d97d0 fix(import): degrade gracefully when canonical life dates conflict
The canonical upsert path skips validateLifeDates, so a spreadsheet row
with birth_year > death_year - or a preserved hand-entered birth date
conflicting with a canonical death year - violated the V76 CHECK
constraint at flush time and aborted the whole import batch with a raw
500. Resolve the pairs first and, on conflict, keep the person's stored
life dates (empty for a new person), drop the canonical refresh, and log
a WARN with the sourceRef (REQ-IMP-001: never abort the batch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
687353a819 fix(person): expose life dates on PersonSummaryDTO projection
The mention dropdown renders precise life dates but receives
PersonSummaryDTO items from /api/persons, which only carried the derived
years - the date fields were silently undefined at runtime. Add
birth/death date + precision to the projection and all four native
queries (searchWithDocumentCount's GROUP BY already listed the columns).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
92a2feba1e feat(person): store birth/death as LocalDate + DatePrecision
Entity swap mirroring Document.metaDatePrecision; PersonUpdateDTO takes
date + precision; validateLifeDates (badRequest BIRTH_AFTER_DEATH /
INVALID_DATE_PRECISION) replaces validateYears; preferHumanDate keeps
DAY/MONTH/SEASON hand-entered dates on re-import and refreshes
YEAR/UNKNOWN from the canonical year (ADR-025 extension);
PersonUpsertCommand stays year-shaped. Native queries project
EXTRACT(YEAR ...) so PersonSummaryDTO and PersonNodeDTO stay
year-shaped, null-safe for undated persons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
ba7e8ca6f5 feat(person): V76 migration — birth/death year to date + precision columns
Pre-check aborts on corrupt year data, backfills YYYY-01-01/YEAR,
adds five named CHECK constraints, drops birth_year/death_year.
Staged-Flyway Testcontainers test covers pre-check aborts, backfill
shapes, and post-migration schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
f408f60631 feat(person): add BIRTH_AFTER_DEATH and INVALID_DATE_PRECISION error codes
Backend enum, frontend ErrorCode mirror, getErrorMessage cases, and
error message i18n keys (de/en/es) incl. the mixed-precision workaround
hint in error_birth_after_death.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
38a6d6b0fc feat(geschichten): show blog writers' own drafts on the Geschichten overview (#807) (#813)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m48s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 5m24s
CI / fail2ban Regex (push) Successful in 53s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m9s
2026-06-12 19:46:03 +02:00
b33d0eb850 feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
2026-06-12 14:04:02 +02:00
d650b6c066 refactor(search): remove NLP/smart-search feature entirely (#772)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
## Summary

- Removes the NLP/smart-search feature completely — the feature was too unreliable and slow; users get better results with the regular search filters
- Deletes the entire backend `search/` package (NlSearchController, NlQueryParserService, NlpClient, NlSearchRateLimiter — 14 classes + 6 test classes)
- Deletes the `nlp-service/` Python microservice (FastAPI, rapidfuzz, DB-backed person matching)
- Removes all frontend NL search components: SmartModeToggle, SmartSearchStatus, InterpretationChipRow, DisambiguationPicker, chip-types, theme-chip-removal
- Strips smart-mode logic from SearchFilterBar and documents/+page.svelte
- Removes `SMART_SEARCH_UNAVAILABLE` / `SMART_SEARCH_RATE_LIMITED` error codes from backend, frontend types, and all three i18n files (de/en/es)
- Removes `nlp-service` container and `APP_NLP_BASE_URL` from both docker-compose files
- Removes Ollama/NLP Prometheus scrape job and Grafana dashboard
- Deletes ADRs 028 (×2), 034, 035

## Test plan

- [ ] Backend compiles: `cd backend && ./mvnw compile -q` → BUILD SUCCESS
- [ ] Frontend server tests pass: `cd frontend && npm run test -- --project=server`
- [ ] No NLP/smart-search references remain in source: `grep -r "SmartSearch\|NlSearch\|nlp-service\|SMART_SEARCH" backend/src frontend/src`
- [ ] `docker compose config` validates both compose files
- [ ] Search page loads, filter bar works, no smart-mode toggle visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #772
2026-06-08 10:57:00 +02:00
Marcel
6878419156 merge: resolve conflicts with origin/main (#763 person name-match integration)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
CI / Unit & Component Tests (push) Successful in 3m20s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m48s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
- Drop unused MAX_CANDIDATES constant (not referenced in service)
- Keep detached-entity safety comment in resolveTags()
- Add 3 new partial-name match tests (23a/b/c) from #763
- Use resolveByName() API in test 28 (replaces findByDisplayNameContaining)
- Add NameMatches glossary entry from #763

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:50:48 +02:00
Marcel
09b77e9b36 test(person): pin fetchPool dedup when one person matches two tokens (#763 review)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m20s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m53s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
Assert that when the same person id is returned by two different token
fetches, the person appears exactly once in the result -- pinning
fetchPool's putIfAbsent dedup so a future refactor can't silently
double-classify a candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9d202b042b test(person): close fetch-to-classify seam for alias matches on real Postgres (#763 review)
AC#4 (maiden alias -> direct) and AC#5 (alias first name -> fetchable +
classifiable) were each split across PersonRepositoryTest (the fetch) and
PersonServiceTest (the classifier with stubs) -- nothing walked
searchByName -> resolveByName end-to-end on real Postgres. Add two tests
in the existing @DataJpaTest slice that build a real PersonService over
the autowired repositories, persist a person with a MAIDEN_NAME alias and
one with an alias firstName, and assert both classify as direct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
6959651b36 docs(search): document NameMatches and resolveByName (#763)
GLOSSARY entry for NameMatches (direct vs partial name-match strength and how
the search layer maps it); person/README adds resolveByName to the public
surface. No ADR — the matching rule is localized and justified inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
f1bb9d3a69 feat(search): map direct/partial NameMatches into resolve buckets (#763)
resolveNames now delegates to PersonService.resolveByName and maps by match
strength: 1 direct → resolved (auto-select), ≥2 direct → ambiguous, 0 direct
with partials → ambiguous suggestions, 0 candidates → folded into full-text.
A single direct match no longer forces the picker when looser substring hits
coexist. The MAX_CANDIDATES cap moved into PersonService (after classification);
the MAX_NAME_LENGTH guard, resolved-cap overflow, and sender/receiver mapping
are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
ca52145556 feat(person): add resolveByName for direct/partial name matching (#763)
Token-set containment over all of a person's name components (firstName,
lastName, alias, each PersonNameAlias first+last, title) decides direct vs
partial. Orchestrates tokenize → cap(8) → fetch pool → classify → cap(10)
after classification, with an empty-token guard and a PII-free debug log of
the outcome bucket. MAX_TOKENS is a DoS control; the after-classify cap keeps a
direct match that sorts past position 10 among partials. Read-only transaction
keeps lazy nameAliases reachable during classification (ADR-022).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9a26bf75b0 feat(person): match alias first names in searchByName (#763)
The direct-match classifier accepts alias firstName tokens, so the fetch must
surface candidates matchable only via an alias first name. Add a.firstName to
the searchByName LIKE clause (reuses the bound :query — injection-proof). The
person_name_aliases.first_name column already exists; no migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
9c616f9fb8 feat(person): add name-match tokenizer for direct matching (#763)
Lowercase, split on whitespace/hyphen/apostrophe, drop empties. Applied
symmetrically to query and candidate name components so "Anna-Maria" and
"Anna Maria" tokenize alike. Foundation for resolveByName direct matching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
b825076733 test(search): DataJpaTest for descendant-expansion via TagRepository
Verifies the recursive CTE in findDescendantIdsByName expands a parent tag
to include all child IDs, and that findByNameContainingIgnoreCase matches
both parent and child names when the fragment appears in both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
01df815bad test(search): add 11 tag-resolution test cases to NlQueryParserServiceTest
Covers multi-tag match, no-match FTS fallback, mixed resolution, personRole
bypass, cap at 10, short-keyword skip, dedup, rawQuery suppression when all
keywords resolve, flag independence, colour propagation via resolveEffectiveColors,
and colour=null when depth constraint prevents resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
dcd0e725a7 feat(search): implement keyword→tag resolution in NlQueryParserService
Keywords that substring-match the tag taxonomy become OR-union tag filters;
non-matching keywords stay as FTS text. Resolved tags surface in the
NlQueryInterpretation as TagHint objects with effective colours. The
rawQuery fallback is now guarded by hadStructuredMatch to prevent
double-apply when all keywords resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00
Marcel
5a09cd4cb4 feat(search): extend NlQueryInterpretation with resolvedTags + tagsApplied
Positional record fields added; all 3 construction sites updated with neutral
defaults; NlQueryParserService wired for TagService (4th constructor arg);
NlQueryParserServiceTest and NlSearchControllerTest synced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 08:47:47 +02:00