Compare commits

..

31 Commits

Author SHA1 Message Date
Marcel
3a7c86fc87 test(timeline): allow timeline package in entity-location ArchRule
Some checks are pending
CI / Unit & Component Tests (pull_request) Waiting to run
CI / OCR Service Tests (pull_request) Waiting to run
CI / Backend Unit Tests (pull_request) Waiting to run
CI / fail2ban Regex (pull_request) Waiting to run
CI / Semgrep Security Scan (pull_request) Waiting to run
CI / Compose Bucket Idempotency (pull_request) Waiting to run
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
00a00b2c87 docs(adr): add ADR-040 timeline domain data model
Records the architectural commitment for the timeline domain: views-not-
entities for issue 3 (ADR-036 rationale), DatePrecision import coupling
(ADR-025), the UNKNOWN-forbidden / SEASON-APPROX-legal precision contract,
the strict biconditional RANGE CHECK as a deliberate divergence from
Document, the @Version + NOT NULL audit-trail decisions, the optimistic-
lock-to-conflict translation contract (CWE-209), the server-populated-only
createdBy/updatedBy forgery guard (CWE-639), and the EventType stable
frontend styling contract.

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
d11378c254 fix(deps): pin esbuild 0.28.1 and cookie >=0.7.0 to clear npm audit gate
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m46s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Unit & Component Tests (push) Waiting to run
CI / OCR Service Tests (push) Waiting to run
CI / Backend Unit Tests (push) Waiting to run
CI / fail2ban Regex (push) Waiting to run
CI / Semgrep Security Scan (push) Waiting to run
CI / Compose Bucket Idempotency (push) Waiting to run
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 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
The CI step "Security audit (no dev deps)" (npm audit --audit-level=high
--omit=dev) failed repo-wide on every PR: newly-published advisories landed
against the already-pinned esbuild 0.27.7 (GHSA-gv7w-rqvm-qjhr,
GHSA-g7r4-m6w7-qqqr — both high), pulled in transitively via
vite -> @sveltejs/vite-plugin-svelte. The fix for both only exists at
esbuild@0.28.1. A scoped vite@7 minor bump cannot help — all vite 7.x pin
esbuild ^0.27.0.

Add an overrides block:
  - esbuild 0.28.1 (exact, no caret — a future 0.29.x must not silently
    float in and re-break vite; let Renovate propose bumps explicitly)
  - cookie >=0.7.0 (clears the low GHSA-pxg6-pf52-xh8x reaching the prod
    tree via @sentry/sveltekit; drop-in, done in the same pass)

npm audit --audit-level=high --omit=dev now exits 0 with 0 vulnerabilities.
npm run build, lint, and a dev-server boot all succeed with the forced
esbuild 0.28.1 (validated, not assumed — it sits outside vite@7.3.3's
declared ^0.27.0 range).

Closes #817

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:23:31 +02:00
Marcel
f64acbc697 test(geschichten): add drafts to the overview page test mock data
Some checks failed
CI / Compose Bucket Idempotency (pull_request) Waiting to run
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 4m37s
CI / OCR Service Tests (push) Successful in 26s
CI / Backend Unit Tests (push) Successful in 5m25s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
PR #813 made +page.svelte read data.drafts, which the load function
always returns, but the pre-existing page.svelte.test.ts mock predates
the field — all 15 tests crashed with TypeError on main after merge.

Closes #814
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:05:19 +02:00
Marcel
75e48f2922 docs(person): note YEAR seeding of legacy precisions in ADR-039
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m25s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m30s
CI / fail2ban Regex (push) Successful in 45s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m9s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
ad344db2bf fix(i18n): add trailing period to error_invalid_date_precision
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +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
e712477d2b fix(person): block submit while a life-date input is partial
A partial date (e.g. "14.03.") left the hidden ISO input empty, so
saving the edit form silently cleared a stored date. PersonLifeDateField
now delegates to the shared DateInput primitive (inline format error,
calendar validation) and sets a custom validity while the error is
present, so the browser blocks native submission for both person forms.
A full clear stays submittable - that is the intentional clear path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
4419c434a1 fix(person): type mention items as PersonSummaryDTO, regenerate api
The dropdown and editor typed /api/persons list items as the full Person
entity. The actual wire shape is PersonSummaryDTO, which until the
previous commit had no date fields - so the life-date subtitle rendered
blank in production while fixtures (built from the entity type) kept the
tests green. Retype items as the summary projection and guard the two
personId consumers against the schema-optional id.

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
e4e277219e test(person): add now-required precision fields to Person test fixtures
birthDatePrecision/deathDatePrecision are @Schema REQUIRED, so the
generated Person type makes them non-optional — fixtures that were
type-clean before the regen get UNKNOWN defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
a75c46351f docs(person): ADR-039, DB diagrams, and V76 deploy runbook note
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
65a34d48b4 feat(person): date + precision controls on person new/edit forms
New PersonLifeDateField (German date input + hidden ISO + DAY/MONTH/YEAR
precision select, min-h-44px, sm: side-by-side) used for birth and death
in both forms. Legacy APPROX precision seeds the select as YEAR so an
untouched save never claims DAY. Server actions send date+precision
pairs or omit both; obsolete year i18n keys removed, 9 form keys added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
0e7095fee6 feat(person): render precise life dates on cards, hover card, and mention dropdown
Cards compose aria-hidden * / † glyphs in markup so screen readers only
announce the dates; PersonSummaryDTO list card stays year-shaped by
design (ADR-039). MentionDropdown subtitle wraps instead of truncating
so DAY-precision ranges fit at 320px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
adac1b1f99 feat(person): formatLifeDateRange takes date + precision, delegates to formatDocumentDate
New formatLifeDate single-date helper carries no glyph so cards can wrap
* / † in aria-hidden spans. Missing precision falls back to YEAR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:49:16 +02:00
Marcel
29ada9b681 chore(api): regenerate TypeScript types for Person date fields
Person gains birthDate/deathDate + required precision enums;
PersonSummaryDTO, PersonNodeDTO, and RelationshipDTO keep derived
integer years. familyForest/buildLayout tests still pass.

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
Marcel
4bcf568ed4 Merge branch 'main' of ssh://git.raddatz.cloud:222/marcel/familienarchiv
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m22s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m41s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 2m50s
2026-06-08 16:27:41 +02:00
Marcel
ddb1ec4df8 docs(timeline): add Zeitstrahl visual specs (global Concept A, event editor)
Visual design specs for Milestone #14:
- zeitstrahl-global-concepts.html — A/B/C exploration of the global timeline
- zeitstrahl-final-spec.html — canonical Concept A (global + per-person Lebensweg)
- zeitstrahl-event-editor-spec.html — curator event editor + document quick-action

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:27:15 +02:00
Marcel
e63eaadc33 docs(timeline): add Person date+precision migration as foundational issue
Replace Person birthYear/deathYear integers with birthDate/deathDate +
DatePrecision so known exact birthdays render precisely. Migration,
re-import preservation rule, and bounded blast radius captured; becomes
issue 1 the timeline's derived events depend on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:26:13 +02:00
Marcel
d4a25e34d8 docs(timeline): add family timeline (Zeitstrahl) design spec
Hand-curated, year-banded vertical timeline weaving derived person
life-events, curated personal/historical events, and date-placed
letters. Includes proposed sub-issue breakdown for a milestone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:18:55 +02:00
79 changed files with 3101 additions and 441 deletions

View File

@@ -95,6 +95,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
└── user/ User domain — AppUser, UserGroup, UserService
```
@@ -115,6 +116,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`

View File

@@ -42,6 +42,7 @@ src/main/java/org/raddatz/familienarchiv/
│ └── relationship/ # PersonRelationship sub-domain
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ # Tag domain — Tag, TagService, TagController
├── timeline/ # Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
└── user/ # User domain — AppUser, UserGroup, UserService
```
@@ -67,6 +68,7 @@ For per-domain ownership and public surface, see each domain's `README.md`.
| `Comment` | `document_comments` | Threaded comments with mentions |
| `Notification` | `notifications` | User notification feed |
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
| `TimelineEvent` | `timeline_events` | Curated Zeitstrahl event; ManyToMany persons + documents (join FKs ON DELETE CASCADE); `@Version` + NOT NULL createdBy/updatedBy |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`

View File

@@ -15,6 +15,10 @@ public enum ErrorCode {
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
/** A person's birth date is after their death date. 400 */
BIRTH_AFTER_DEATH,
/** A life date and its precision are incoherent: date present with UNKNOWN precision, or precision set without a date. 400 */
INVALID_DATE_PRECISION,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,

View File

@@ -50,6 +50,9 @@ public class GeschichteService {
private static final int DEFAULT_LIMIT = 50;
private static final int MAX_LIMIT = 200;
/** Sentinel used when {@code personIds} is empty to avoid invalid empty IN() SQL. */
private static final UUID NIL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
// turns what would be a DB-level 500 into a friendly 400.
static final int MAX_TITLE_LENGTH = 255;
@@ -65,6 +68,7 @@ public class GeschichteService {
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
}
// readOnly = true: lazy collections resolve within the same tx when called from getView()
@Transactional(readOnly = true)
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
@@ -113,17 +117,22 @@ public class GeschichteService {
*
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
* LazyInitializationException on the non-transactional list path.
*
* <p>Security: {@code null} status always resolves to PUBLISHED — even for blog writers.
* Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts.
* This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts.
*/
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT;
GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
UUID authorId = isDraftRequest ? currentUser().getId() : null;
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
? List.of(NIL_UUID)
: personIds;
long personCount = (personIds == null) ? 0 : personIds.size();

View File

@@ -38,8 +38,11 @@ public class JourneyItem {
@JsonIgnore
private Document document;
// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
// renderer MUST escape this; only Svelte {note} is auto-safe.
/**
* Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output.
*
* <p>CWE-79 tripwire: stored verbatim; only Svelte {note} interpolation is auto-safe.</p>
*/
@Column(columnDefinition = "TEXT")
private String note;

View File

@@ -250,9 +250,11 @@ public class JourneyItemService {
}
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
Throwable cause = e.getMostSpecificCause();
String message = cause != null ? cause.getMessage() : e.getMessage();
return message != null && message.contains("uq_journey_items_geschichte_document");
Throwable cause = e.getCause();
if (cause instanceof java.sql.SQLException sql) {
return "23505".equals(sql.getSQLState());
}
return false;
}
private static String normalizeNote(String raw) {

View File

@@ -12,5 +12,6 @@ public record JourneyItemView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
DocumentSummary document,
/** Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output. */
String note
) {}

View File

@@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -49,8 +51,25 @@ public class Person {
@Column(columnDefinition = "TEXT")
private String notes;
private Integer birthYear;
private Integer deathYear;
// Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
// the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning
// "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN).
// DatePrecision is imported cross-domain from document/ by design (ADR-039).
private LocalDate birthDate;
@Enumerated(EnumType.STRING)
@Column(name = "birth_date_precision", nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;
private LocalDate deathDate;
@Enumerated(EnumType.STRING)
@Column(name = "death_date_precision", nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
// Nullable for persons outside the curated family graph. Drives the

View File

@@ -66,7 +66,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -79,7 +82,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -89,7 +95,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)
@@ -100,7 +106,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -139,7 +148,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount

View File

@@ -1,5 +1,6 @@
package org.raddatz.familienarchiv.person;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
@@ -16,6 +17,7 @@ import org.springframework.lang.Nullable;
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
@@ -299,13 +301,20 @@ public class PersonService {
}
private Person fromCanonical(PersonUpsertCommand cmd) {
DatePrecisionPair none = new DatePrecisionPair(null, DatePrecision.UNKNOWN);
LifeDates dates = degradeIfConflicting(
yearPair(cmd.birthYear()), yearPair(cmd.deathYear()), none, none, cmd.sourceRef());
DatePrecisionPair birth = dates.birth();
DatePrecisionPair death = dates.death();
Person person = personRepository.save(Person.builder()
.sourceRef(cmd.sourceRef())
.firstName(blankToNull(cmd.firstName()))
.lastName(cmd.lastName())
.notes(blankToNull(cmd.notes()))
.birthYear(cmd.birthYear())
.deathYear(cmd.deathYear())
.birthDate(birth.date())
.birthDatePrecision(birth.precision())
.deathDate(death.date())
.deathDatePrecision(death.precision())
.generation(cmd.generation())
.familyMember(cmd.familyMember())
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
@@ -328,8 +337,16 @@ public class PersonService {
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
LifeDates dates = degradeIfConflicting(
preferHumanDate(existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()),
preferHumanDate(existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()),
new DatePrecisionPair(existing.getBirthDate(), existing.getBirthDatePrecision()),
new DatePrecisionPair(existing.getDeathDate(), existing.getDeathDatePrecision()),
cmd.sourceRef());
existing.setBirthDate(dates.birth().date());
existing.setBirthDatePrecision(dates.birth().precision());
existing.setDeathDate(dates.death().date());
existing.setDeathDatePrecision(dates.death().precision());
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
existing.setPersonType(cmd.personType());
@@ -356,6 +373,48 @@ public class PersonService {
return existing != null ? existing : canonical;
}
// Date + precision travel as one value so they can never go out of sync (ADR-039).
record DatePrecisionPair(LocalDate date, DatePrecision precision) {}
record LifeDates(DatePrecisionPair birth, DatePrecisionPair death) {}
// The canonical path skips validateLifeDates (the form-only guard), so a conflicting
// resolved pair would hit chk_person_birth_before_death at flush time and abort the
// whole import batch with a raw 500. Degrade instead (REQ-IMP-001: never abort the
// batch): keep the person's stored life dates — empty for a new person — and drop the
// conflicting canonical refresh. A hand-entered side is preserved by construction,
// since preferHumanDate returned it verbatim and it equals the stored value; two
// stored values can never conflict with each other (they already satisfied the CHECK).
static LifeDates degradeIfConflicting(DatePrecisionPair birth, DatePrecisionPair death,
DatePrecisionPair existingBirth, DatePrecisionPair existingDeath,
String sourceRef) {
if (birth.date() == null || death.date() == null || !birth.date().isAfter(death.date())) {
return new LifeDates(birth, death);
}
log.warn("Conflicting canonical life dates for {}: birth {} is after death {} — keeping stored values",
sourceRef, birth.date(), death.date());
return new LifeDates(existingBirth, existingDeath);
}
// preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than
// the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a
// YEAR-precision or absent date is refreshed from the canonical year.
static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision,
Integer canonicalYear) {
boolean handEntered = existingDate != null && existingPrecision != null
&& existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN;
if (handEntered) {
return new DatePrecisionPair(existingDate, existingPrecision);
}
return yearPair(canonicalYear);
}
private static DatePrecisionPair yearPair(Integer year) {
return year != null
? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR)
: new DatePrecisionPair(null, DatePrecision.UNKNOWN);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
@@ -375,7 +434,8 @@ public class PersonService {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
}
validateYears(dto.getBirthYear(), dto.getDeathYear());
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
dto.getDeathDate(), dto.getDeathDatePrecision());
Person person = Person.builder()
.personType(dto.getPersonType())
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
@@ -383,31 +443,49 @@ public class PersonService {
.lastName(dto.getLastName())
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthYear(dto.getBirthYear())
.deathYear(dto.getDeathYear())
.birthDate(dto.getBirthDate())
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate())
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
.generation(dto.getGeneration())
.build();
return personRepository.save(person);
}
private void validateYears(Integer birthYear, Integer deathYear) {
if (birthYear != null && birthYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
"Birth date " + birthDate + " is after death date " + deathDate);
}
if (deathYear != null && deathYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
}
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date is set but its precision is missing or UNKNOWN");
}
if (birthYear != null && deathYear != null && birthYear > deathYear) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date precision " + precision + " is set without a date");
}
}
private static DatePrecision normalizePrecision(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
}
validateYears(dto.getBirthYear(), dto.getDeathYear());
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
dto.getDeathDate(), dto.getDeathDatePrecision());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setPersonType(dto.getPersonType());
@@ -416,8 +494,10 @@ public class PersonService {
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
person.setBirthDate(dto.getBirthDate());
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate());
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
// Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration());

View File

@@ -1,5 +1,8 @@
package org.raddatz.familienarchiv.person;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.UUID;
/**
@@ -16,6 +19,13 @@ public interface PersonSummaryDTO {
String getAlias();
Integer getBirthYear();
Integer getDeathYear();
// Full date + precision alongside the derived years: list consumers that render
// precise life dates (mention dropdown) read these; year-only consumers keep
// the cheaper getBirthYear/getDeathYear.
LocalDate getBirthDate();
DatePrecision getBirthDatePrecision();
LocalDate getDeathDate();
DatePrecision getDeathDatePrecision();
String getNotes();
boolean isFamilyMember();
boolean isProvisional();

View File

@@ -5,8 +5,11 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.PersonType;
import java.time.LocalDate;
@Data
public class PersonUpdateDTO {
@NotNull
@@ -21,8 +24,10 @@ public class PersonUpdateDTO {
private String alias;
@Size(max = 5000)
private String notes;
private Integer birthYear;
private Integer deathYear;
private LocalDate birthDate;
private DatePrecision birthDatePrecision;
private LocalDate deathDate;
private DatePrecision deathDatePrecision;
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
// PersonGeneration so DB, DTO, and importer all read from one place.
@Min(PersonGeneration.MIN_GENERATION)

View File

@@ -96,7 +96,9 @@ public class RelationshipInferenceService {
if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
p.getId(), p.getDisplayName(),
RelationshipService.yearOf(p.getBirthDate()),
RelationshipService.yearOf(p.getDeathDate()),
p.getGeneration(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
}

View File

@@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -66,7 +67,8 @@ public class RelationshipService {
for (Person p : familyMembers) {
familyIds.add(p.getId());
nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
p.getId(), p.getDisplayName(),
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
p.getGeneration(), true));
}
@@ -155,6 +157,13 @@ public class RelationshipService {
return (s == null || s.isBlank()) ? null : s.trim();
}
// Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
// for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). Package-private
// so RelationshipInferenceService shares the same derivation.
static Integer yearOf(LocalDate date) {
return date != null ? date.getYear() : null;
}
private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest(
@@ -170,11 +179,11 @@ public class RelationshipService {
p.getId(),
rp.getId(),
p.getDisplayName(),
p.getBirthYear(),
p.getDeathYear(),
yearOf(p.getBirthDate()),
yearOf(p.getDeathDate()),
rp.getDisplayName(),
rp.getBirthYear(),
rp.getDeathYear(),
yearOf(rp.getBirthDate()),
yearOf(rp.getDeathDate()),
r.getRelationType(),
r.getFromYear(),
r.getToYear(),

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.timeline;
/**
* Kind of a curated {@link TimelineEvent}.
*
* <p>The string value names are a <strong>stable frontend styling contract</strong>: the
* Svelte timeline components hard-code {@code "PERSONAL"} (family accent) and
* {@code "HISTORICAL"} (muted world accent) as Tailwind class-map keys. There is no
* mapping layer — renaming either value requires a coordinated frontend change. See
* ADR-040.
*/
public enum EventType {
/** A family/personal event (birth, wedding, move) — rendered with the family accent. */
PERSONAL,
/** A world/historical event providing context — rendered with the muted world accent. */
HISTORICAL
}

View File

@@ -0,0 +1,129 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* A curated event on the family timeline (Zeitstrahl). Unlike a {@link Document}, which is
* OCR-derived, a {@code TimelineEvent} is authored by curators — hence the optimistic-lock
* {@link #version} and the {@link #createdBy}/{@link #updatedBy} audit trail that
* {@code Document} lacks.
*
* <p>The date block ({@link #eventDate}, {@link #precision}, {@link #eventDateEnd}) mirrors
* {@code Document}'s so events and letters share one rendering path. The mirror applies to
* the date block only — the audit footprint deliberately diverges (see ADR-040).
*/
@Entity
@Table(name = "timeline_events")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TimelineEvent {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String title;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private EventType type;
/** The most precise date known for the event. Always present — a curated event is never undated. */
@Column(name = "event_date", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate eventDate;
/**
* Precision of {@link #eventDate}. Reuses {@code document.DatePrecision} (one rendering
* path; see ADR-025 / ADR-040). Every value except {@code UNKNOWN} is legal for a curated
* event — including {@code SEASON} ("Sommer 1914") and {@code APPROX} ("ca. 1914"). The DB
* CHECK forbids exactly {@code UNKNOWN}; do not narrow it further.
*/
@Enumerated(EnumType.STRING)
@Column(name = "date_precision", nullable = false, length = 16)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private DatePrecision precision = DatePrecision.YEAR;
/** Range end — non-null <strong>iff</strong> {@link #precision} is {@code RANGE} (DB CHECK, both directions). */
@Column(name = "event_date_end")
private LocalDate eventDateEnd;
@Column(columnDefinition = "TEXT")
private String description;
/** People the event involves. */
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "timeline_event_persons",
joinColumns = @JoinColumn(name = "timeline_event_id"),
inverseJoinColumns = @JoinColumn(name = "person_id"))
@BatchSize(size = 50)
@Builder.Default
private Set<Person> persons = new HashSet<>();
/** Optional supporting letters linked to the event. */
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "timeline_event_documents",
joinColumns = @JoinColumn(name = "timeline_event_id"),
inverseJoinColumns = @JoinColumn(name = "document_id"))
@BatchSize(size = 50)
@Builder.Default
private Set<Document> documents = new HashSet<>();
/**
* UUID of the {@code AppUser} who created the event. Bare UUID, no FK to {@code app_users}
* (sidecar pattern — keeps {@code timeline} decoupled from {@code user}). Server-populated
* from the session principal; never accepted from client input (authorship-forgery vector,
* CWE-639 — see ADR-040).
*/
@Column(name = "created_by", nullable = false)
private UUID createdBy;
@CreationTimestamp
private LocalDateTime createdAt;
/**
* UUID of the {@code AppUser} who last edited the event. Populated from the session
* principal in {@code TimelineEventService}; <strong>must be set before every
* {@code save()}</strong> — {@code @UpdateTimestamp} on {@link #updatedAt} does NOT set this
* automatically, so without an explicit set the timestamp advances while the "who" goes
* stale. Same forgery rationale as {@link #createdBy}.
*/
@Column(name = "updated_by", nullable = false)
private UUID updatedBy;
@UpdateTimestamp
private LocalDateTime updatedAt;
/**
* Optimistic-lock version for the multi-curator edit flow (issue 3). Object {@code Long}
* (not primitive) so it is {@code null} before first persist; Hibernate sets {@code 0} on
* insert. A concurrent-write conflict must be translated to {@code DomainException.conflict}
* in the service layer (ADR-040) — otherwise it surfaces as HTTP 500 with Hibernate
* internals (CWE-209).
*/
@Version
private Long version;
}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.timeline;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface TimelineEventRepository extends JpaRepository<TimelineEvent, UUID> {
// TODO(issue 5): findByPersonsContaining(Person) needed for the per-person filter
}

View File

@@ -0,0 +1,42 @@
-- V76: persons.birth_year/death_year (integer) → birth_date/death_date (date)
-- plus NOT NULL precision columns mirroring documents.meta_date_precision.
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-039).
-- One-way migration: rollback is a targeted pg_restore -t persons from the
-- pre-deploy backup (see docs/DEPLOYMENT.md).
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
-- before any DDL runs. Single-writer family archive, so no race window matters.
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year)
THEN RAISE EXCEPTION 'V76 aborted: % persons have birth_year > death_year — fix data before migrating',
(SELECT COUNT(*) FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year);
END IF;
IF EXISTS (SELECT 1 FROM persons WHERE birth_year = 0 OR death_year = 0)
THEN RAISE EXCEPTION 'V76 aborted: persons table contains birth_year=0 or death_year=0 rows — clean data before migrating';
END IF;
END $$;
ALTER TABLE persons ADD COLUMN birth_date date;
ALTER TABLE persons ADD COLUMN birth_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
ALTER TABLE persons ADD COLUMN death_date date;
ALTER TABLE persons ADD COLUMN death_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR'
WHERE birth_year IS NOT NULL;
UPDATE persons SET death_date = make_date(death_year, 1, 1), death_date_precision = 'YEAR'
WHERE death_year IS NOT NULL;
-- Named constraints: readable Postgres error messages when violated.
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_before_death
CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date);
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_coherence
CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_values
CHECK (birth_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_coherence
CHECK ((death_date IS NULL) = (death_date_precision = 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_values
CHECK (death_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
ALTER TABLE persons DROP COLUMN birth_year;
ALTER TABLE persons DROP COLUMN death_year;

View File

@@ -0,0 +1,65 @@
-- V77: timeline domain foundation (Zeitstrahl) — curated timeline events.
-- Forward-only, additive DDL. No rollback script: rollback requires manual DROP TABLE
-- (timeline_event_documents, timeline_event_persons, then timeline_events). See ADR-040.
--
-- The date block (event_date / date_precision / event_date_end) mirrors documents' so events
-- and letters share one rendering path. Two divergences from documents are INTENTIONAL and
-- enforced here in Postgres (ADR-040):
-- 1. The RANGE rule is a strict biconditional (event_date_end non-null IFF RANGE), unlike
-- documents' open-ended ranges — a curated event always has a known, closed end.
-- 2. date_precision <> 'UNKNOWN' — only OCR-inferred letters are ever undated; a curated
-- event always has at least a year. SEASON and APPROX stay legal (Sommer/ca. 1914).
CREATE TABLE timeline_events (
id UUID NOT NULL DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
type VARCHAR(16) NOT NULL,
event_date DATE NOT NULL,
date_precision VARCHAR(16) NOT NULL DEFAULT 'YEAR',
event_date_end DATE,
description TEXT,
created_by UUID NOT NULL,
created_at TIMESTAMP,
updated_by UUID NOT NULL,
updated_at TIMESTAMP,
version BIGINT,
CONSTRAINT pk_timeline_events PRIMARY KEY (id),
-- event_date_end is non-null IFF precision is RANGE (both directions).
CONSTRAINT chk_timeline_event_range
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL)),
-- Curated events are never undated. Forbids exactly UNKNOWN — every other
-- DatePrecision value (DAY, MONTH, SEASON, YEAR, RANGE, APPROX) stays legal.
CONSTRAINT chk_timeline_event_precision
CHECK (date_precision <> 'UNKNOWN')
);
-- Join table: events ↔ persons involved.
CREATE TABLE timeline_event_persons (
timeline_event_id UUID NOT NULL,
person_id UUID NOT NULL,
CONSTRAINT pk_timeline_event_persons PRIMARY KEY (timeline_event_id, person_id),
CONSTRAINT fk_tep_event
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
CONSTRAINT fk_tep_person
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
);
-- Join table: events ↔ supporting letters.
CREATE TABLE timeline_event_documents (
timeline_event_id UUID NOT NULL,
document_id UUID NOT NULL,
CONSTRAINT pk_timeline_event_documents PRIMARY KEY (timeline_event_id, document_id),
CONSTRAINT fk_ted_event
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
CONSTRAINT fk_ted_document
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
-- Indexes added up-front (avoid the V62 FK-index retrofit debt): the two query columns plus
-- explicit indexes on all four FK columns.
CREATE INDEX idx_timeline_events_event_date ON timeline_events (event_date);
CREATE INDEX idx_timeline_events_type ON timeline_events (type);
CREATE INDEX idx_timeline_event_persons_person_id ON timeline_event_persons (person_id);
CREATE INDEX idx_timeline_event_persons_event_id ON timeline_event_persons (timeline_event_id);
CREATE INDEX idx_timeline_event_documents_document_id ON timeline_event_documents (document_id);
CREATE INDEX idx_timeline_event_documents_event_id ON timeline_event_documents (timeline_event_id);

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -35,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -228,21 +230,7 @@ class GeschichteServiceTest {
geschichteService.list(null, List.of(), null, 50);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteSummary s1 = mock(GeschichteSummary.class);
GeschichteSummary s2 = mock(GeschichteSummary.class);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(s1, s2));
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
assertThat(out).hasSize(2);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
}
@Test
@@ -282,6 +270,21 @@ class GeschichteServiceTest {
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
}
@Test
void list_passes_nil_uuid_sentinel_to_repository_when_no_person_filter_given() {
// B2: when personIds is empty/null the service must pass a sentinel NIL UUID
// so the IN() predicate is skipped without producing invalid empty-IN() SQL.
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), null, 50);
UUID nilUUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
verify(geschichteRepository).findSummaries(
any(), any(), org.mockito.ArgumentMatchers.argThat(ids -> ids.contains(nilUUID)), anyLong(), any());
}
@Test
void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
@@ -293,6 +296,33 @@ class GeschichteServiceTest {
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
@Test
@DisplayName("security: null status for blog writer returns PUBLISHED, never leaks drafts")
void list_with_blog_writer_and_null_status_returns_PUBLISHED_not_all_drafts() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), null, 50);
verify(geschichteRepository).findSummaries(
eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
}
@Test
@DisplayName("security: DRAFT status scopes to current user only")
void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
verify(geschichteRepository).findSummaries(
eq(GeschichteStatus.DRAFT), eq(writer.getId()), any(), anyLong(), any());
}
// ─── create ──────────────────────────────────────────────────────────────
@Test

View File

@@ -25,6 +25,9 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -348,19 +351,22 @@ class JourneyItemServiceTest {
}
@Test
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() {
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// Two concurrent appends can both pass the exists() pre-check; the partial
// unique index then rejects the second INSERT at flush. The service must
// translate that into the same friendly 409 as the pre-check.
// Uses PSQLException with SQLState 23505 — the real payload Postgres delivers.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"duplicate key value violates unique constraint \"uq_journey_items_geschichte_document\""));
"could not execute statement", psqlEx));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
@@ -372,18 +378,48 @@ class JourneyItemServiceTest {
}
@Test
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() {
// An FK violation (document deleted between load and flush) must NOT be
// translated into "already added" — only the dedup index earns that 409.
void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// B1: the dedup check must use PSQLException.getSQLState() == "23505", not
// constraint-name string matchingconstraint renames must not regress this.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
// Simulate a real Postgres unique-violation: PSQLException with SQLState 23505
// wrapped by Spring's DataIntegrityViolationException.
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
org.springframework.dao.DataIntegrityViolationException dive =
new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx);
when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception {
// An FK violation (document deleted between load and flush) must NOT be
// translated into "already added" — only the dedup unique index (23505) earns that 409.
// FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation).
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"insert or update on table \"journey_items\" violates foreign key constraint \"fk_journey_items_document\""));
"could not execute statement", psqlEx));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());

View File

@@ -0,0 +1,244 @@
package org.raddatz.familienarchiv.person;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Verifies V76: persons.birth_year/death_year (integer) become
* birth_date/death_date (date) + *_date_precision columns, with backfill to
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
* pre-check that aborts the migration on corrupt year data.
*
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
* database so the staged migrate-to-V75 → seed → migrate-to-latest flow and
* the abort cases cannot interfere with each other.
*/
class PersonBirthDeathMigrationTest {
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
private String dbUrl;
@BeforeAll
static void startContainer() {
POSTGRES.start();
}
@AfterAll
static void stopContainer() {
POSTGRES.stop();
}
@BeforeEach
void createFreshDatabase() throws SQLException {
String dbName = "mig_v76_" + DB_COUNTER.incrementAndGet();
try (Connection conn = DriverManager.getConnection(
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
Statement stmt = conn.createStatement()) {
stmt.execute("CREATE DATABASE " + dbName);
}
dbUrl = baseUrl(dbName);
}
@Test
void precheck_abortsWhenBirthYearAfterDeathYear() throws SQLException {
migrateTo("75");
seedPerson("Corrupt", 1950, 1940);
assertThatThrownBy(this::migrateToLatest)
.hasMessageContaining("V76 aborted")
.hasMessageContaining("birth_year > death_year");
}
@Test
void precheck_abortsWhenYearZeroPresent() throws SQLException {
migrateTo("75");
seedPerson("Zero", 0, null);
assertThatThrownBy(this::migrateToLatest)
.hasMessageContaining("V76 aborted")
.hasMessageContaining("birth_year=0 or death_year=0");
}
@Test
void backfill_birthOnly_becomesYearPrecisionDate_deathStaysUnknown() throws SQLException {
migrateTo("75");
seedPerson("BirthOnly", 1901, null);
migrateToLatest();
LifeDates row = lifeDates("BirthOnly");
assertThat(row.birthDate()).hasToString("1901-01-01");
assertThat(row.birthPrecision()).isEqualTo("YEAR");
assertThat(row.deathDate()).isNull();
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
}
@Test
void backfill_deathOnly_becomesYearPrecisionDate_birthStaysUnknown() throws SQLException {
migrateTo("75");
seedPerson("DeathOnly", null, 1944);
migrateToLatest();
LifeDates row = lifeDates("DeathOnly");
assertThat(row.birthDate()).isNull();
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
assertThat(row.deathDate()).hasToString("1944-01-01");
assertThat(row.deathPrecision()).isEqualTo("YEAR");
}
@Test
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
migrateTo("75");
seedPerson("NoDates", null, null);
migrateToLatest();
LifeDates row = lifeDates("NoDates");
assertThat(row.birthDate()).isNull();
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
assertThat(row.deathDate()).isNull();
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
}
@Test
void backfill_neverProducesBirthDateAfterDeathDate() throws SQLException {
migrateTo("75");
seedPerson("SameYear", 1901, 1901);
seedPerson("Ordered", 1899, 1972);
migrateToLatest();
assertThat(countWhere("birth_date IS NOT NULL AND death_date IS NOT NULL AND birth_date > death_date"))
.isZero();
}
@Test
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
migrateTo("75");
seedPerson("Schema", 1901, 1944);
migrateToLatest();
assertThat(columnExists("birth_year")).isFalse();
assertThat(columnExists("death_year")).isFalse();
assertThat(columnExists("birth_date")).isTrue();
assertThat(columnExists("death_date")).isTrue();
for (String constraint : new String[]{
"chk_person_birth_before_death",
"chk_person_birth_date_precision_coherence",
"chk_person_birth_date_precision_values",
"chk_person_death_date_precision_coherence",
"chk_person_death_date_precision_values"}) {
assertThat(constraintExists(constraint)).as(constraint).isTrue();
}
}
// --- helpers ---
private static String baseUrl(String dbName) {
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
}
private void migrateTo(String targetVersion) {
flywayBuilder().target(targetVersion).load().migrate();
}
private void migrateToLatest() {
flywayBuilder().load().migrate();
}
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
return Flyway.configure()
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", "test-only"));
}
private void seedPerson(String lastName, Integer birthYear, Integer deathYear) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO persons (id, last_name, person_type, family_member, provisional, birth_year, death_year) "
+ "VALUES (gen_random_uuid(), ?, 'PERSON', false, false, ?, ?)")) {
stmt.setString(1, lastName);
stmt.setObject(2, birthYear);
stmt.setObject(3, deathYear);
stmt.executeUpdate();
}
}
private record LifeDates(Object birthDate, String birthPrecision, Object deathDate, String deathPrecision) {}
private LifeDates lifeDates(String lastName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT birth_date, birth_date_precision, death_date, death_date_precision "
+ "FROM persons WHERE last_name = ?")) {
stmt.setString(1, lastName);
try (ResultSet rs = stmt.executeQuery()) {
assertThat(rs.next()).as("person %s exists", lastName).isTrue();
return new LifeDates(
rs.getObject("birth_date"),
rs.getString("birth_date_precision"),
rs.getObject("death_date"),
rs.getString("death_date_precision"));
}
}
}
private long countWhere(String condition) throws SQLException {
try (Connection conn = connect();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM persons WHERE " + condition)) {
rs.next();
return rs.getLong(1);
}
}
private boolean columnExists(String columnName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND table_name = 'persons' AND column_name = ?")) {
stmt.setString(1, columnName);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
}
}
private boolean constraintExists(String constraintName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
stmt.setString(1, constraintName);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
}
}
private Connection connect() throws SQLException {
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
}
}

View File

@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
@@ -22,6 +23,7 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@@ -215,6 +217,14 @@ class PersonControllerTest {
public String getAlias() { return null; }
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public java.time.LocalDate getBirthDate() { return null; }
public org.raddatz.familienarchiv.document.DatePrecision getBirthDatePrecision() {
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
}
public java.time.LocalDate getDeathDate() { return null; }
public org.raddatz.familienarchiv.document.DatePrecision getDeathDatePrecision() {
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
}
public String getNotes() { return null; }
public boolean isFamilyMember() { return false; }
public boolean isProvisional() { return false; }
@@ -572,18 +582,53 @@ class PersonControllerTest {
void createPerson_returns200_withAllSixFields() throws Exception {
UUID id = UUID.randomUUID();
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
.alias("Oma Maria")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.deathDate(LocalDate.of(1975, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
.notes("Some notes").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"alias\":\"Oma Maria\"," +
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"DAY\"," +
"\"deathDate\":\"1975-01-01\",\"deathDatePrecision\":\"YEAR\"," +
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
.andExpect(jsonPath("$.birthYear").value(1901));
.andExpect(jsonPath("$.birthDate").value("1901-03-14"))
.andExpect(jsonPath("$.birthDatePrecision").value("DAY"));
}
// ─── #773: malformed date payloads return structured 400s, not Jackson traces ──
// Jackson rejects unknown enum values by default. Verified 2026-06-12: the only
// DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper
// (OCR HTTP client) — the Spring MVC mapper has no READ_UNKNOWN_ENUM_VALUES_* override.
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400WithStructuredErrorCode_whenPrecisionEnumInvalid() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"INVALID_VALUE\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400WithStructuredErrorCode_whenBirthDateNotADate() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
"\"birthDate\":\"not-a-date\",\"birthDatePrecision\":\"DAY\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
// ─── Phase 1.2: @Size constraints ─────────────────────────────────────────

View File

@@ -5,7 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
@@ -97,24 +99,120 @@ class PersonImportUpsertTest {
assertThat(result.getNotes()).isEqualTo("Nichte von Herbert");
}
// ─── life dates (ADR-025 extension via preferHumanDate, #773) ─────────────
@Test
void upsertBySourceRef_fillsBlankYears_butPreservesHumanEditedYears_onReimport() {
// Existing has a human-set birthYear and a blank deathYear.
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram")
.lastName("Cram").birthYear(1890).deathYear(null).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
void upsertBySourceRef_preservesDayPrecisionDate_onReimportWithDifferentYear() {
// A human entered the exact birthday in-app; the spreadsheet only knows a year.
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1890, 3, 14)).birthDatePrecision(DatePrecision.DAY).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1888).deathYear(1965)
.birthYear(1888)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthYear()).isEqualTo(1890); // human value kept
assertThat(result.getDeathYear()).isEqualTo(1965); // blank filled from canonical
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 3, 14));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
}
@Test
void upsertBySourceRef_preservesMonthPrecisionDate_onReimport() {
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.deathDate(LocalDate.of(1944, 11, 1)).deathDatePrecision(DatePrecision.MONTH).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1945)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.MONTH);
}
@Test
void upsertBySourceRef_refreshesYearPrecisionDate_whenSpreadsheetYearChanges() {
// YEAR precision means "the importer's year" — a corrected spreadsheet year wins.
Person yearOnly = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1890, 1, 1)).birthDatePrecision(DatePrecision.YEAR).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(yearOnly));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1888)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1888, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void upsertBySourceRef_fillsEmptyDateAtYearPrecision_onReimport() {
Person noDates = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void upsertBySourceRef_keepsDatesEmpty_whenSpreadsheetHasNoYear() {
Person noDates = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_translatesYearToDate_onFirstImport() {
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1890).deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
@@ -199,4 +297,70 @@ class PersonImportUpsertTest {
assertThat(result.getGeneration()).isEqualTo(3);
}
// ─── conflicting canonical life dates degrade instead of hitting the DB CHECK ──
// (chk_person_birth_before_death would abort the whole batch — REQ-IMP-001)
@Test
void upsertBySourceRef_dropsBothDates_whenCanonicalBirthAfterDeath_newPerson() {
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1950).deathYear(1949)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_keepsHandEnteredBirth_andDropsConflictingCanonicalDeath() {
// A human entered an exact birthday; the spreadsheet's death year lies before it.
// The hand-entered side must survive, the conflicting canonical refresh is dropped.
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1950, 6, 1)).birthDatePrecision(DatePrecision.DAY).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1949)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1950, 6, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_keepsExistingYearDates_whenCanonicalRefreshConflicts() {
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1900, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1980, 1, 1)).deathDatePrecision(DatePrecision.YEAR).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1990).deathYear(1985)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1980, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
}

View File

@@ -18,6 +18,9 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -910,4 +913,146 @@ class PersonRepositoryTest {
.setParameter(1, blockId).getSingleResult();
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
}
// ─── #773: PersonSummaryDTO year projection from birth_date/death_date ──────
@Test
void findAllWithDocumentCount_derivesYearsFromDates_nullSafe() {
personRepository.save(Person.builder()
.firstName("Maria").lastName("Datiert")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.build());
personRepository.save(Person.builder()
.firstName("Nora").lastName("Undatiert")
.build());
entityManager.flush();
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
PersonSummaryDTO dated = all.stream()
.filter(p -> "Datiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(dated.getBirthYear()).isEqualTo(1901);
assertThat(dated.getDeathYear()).isNull();
PersonSummaryDTO undated = all.stream()
.filter(p -> "Undatiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(undated.getBirthYear()).isNull();
assertThat(undated.getDeathYear()).isNull();
}
@Test
void searchWithDocumentCount_groupByPath_derivesYearsFromDates() {
personRepository.save(Person.builder()
.firstName("Herbert").lastName("Gruppiert")
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Gruppiert");
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthYear()).isEqualTo(1899);
assertThat(found.get(0).getDeathYear()).isEqualTo(1972);
}
@Test
void findByFilter_derivesYearsFromDates() {
personRepository.save(Person.builder()
.firstName("Filtriert").lastName("Person")
.birthDate(LocalDate.of(1920, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.findByFilter(
null, null, null, null, false, "Filtriert", 10, 0);
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthYear()).isEqualTo(1920);
assertThat(found.get(0).getDeathYear()).isNull();
}
// ─── #773 follow-up: full date + precision exposed on the summary projection ──
// (the mention dropdown renders precise life dates from the list endpoint)
@Test
void findAllWithDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Maria").lastName("Praezise")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.deathDate(LocalDate.of(1972, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
.build());
personRepository.save(Person.builder()
.firstName("Nora").lastName("Datenlos")
.build());
entityManager.flush();
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
PersonSummaryDTO dated = all.stream()
.filter(p -> "Praezise".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(dated.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
assertThat(dated.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(dated.getDeathDate()).isEqualTo(LocalDate.of(1972, 1, 1));
assertThat(dated.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
PersonSummaryDTO undated = all.stream()
.filter(p -> "Datenlos".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(undated.getBirthDate()).isNull();
assertThat(undated.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(undated.getDeathDate()).isNull();
assertThat(undated.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void searchWithDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Herbert").lastName("Suchbar")
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Suchbar");
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthDate()).isEqualTo(LocalDate.of(1899, 1, 1));
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1972, 6, 12));
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
}
@Test
void findTopByDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Top").lastName("Dokumentiert")
.birthDate(LocalDate.of(1910, 5, 1)).birthDatePrecision(DatePrecision.MONTH)
.build());
entityManager.flush();
List<PersonSummaryDTO> top = personRepository.findTopByDocumentCount(10);
PersonSummaryDTO found = top.stream()
.filter(p -> "Dokumentiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(found.getBirthDate()).isEqualTo(LocalDate.of(1910, 5, 1));
assertThat(found.getBirthDatePrecision()).isEqualTo(DatePrecision.MONTH);
assertThat(found.getDeathDate()).isNull();
assertThat(found.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void findByFilter_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Gefiltert").lastName("Genau")
.deathDate(LocalDate.of(1944, 11, 2)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.findByFilter(
null, null, null, null, false, "Gefiltert", 10, 0);
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthDate()).isNull();
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 2));
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
}
}

View File

@@ -8,7 +8,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
import org.raddatz.familienarchiv.person.PersonNameAliasType;
@@ -17,6 +19,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -241,27 +244,49 @@ class PersonServiceTest {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes");
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1975, 11, 2)); dto.setDeathDatePrecision(DatePrecision.DAY);
dto.setNotes("Some notes");
Person result = personService.createPerson(dto);
assertThat(result.getFirstName()).isEqualTo("Maria");
assertThat(result.getLastName()).isEqualTo("Raddatz");
assertThat(result.getAlias()).isEqualTo("Oma Maria");
assertThat(result.getBirthYear()).isEqualTo(1901);
assertThat(result.getDeathYear()).isEqualTo(1975);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1975, 11, 2));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getNotes()).isEqualTo("Some notes");
}
@Test
void createPerson_dto_yearValidationFires_whenBirthYearNegative() {
void createPerson_dto_rejectsDateWithUnknownPrecision() {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1);
dto.setFirstName("Anna"); dto.setLastName("Test");
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.UNKNOWN);
assertThatThrownBy(() -> personService.createPerson(dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
}
@Test
void createPerson_dto_treatsNullPrecisionWithNullDateAsUnknown() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.PERSON);
Person result = personService.createPerson(dto);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
@@ -600,114 +625,135 @@ class PersonServiceTest {
assertThat(result.getNotes()).isNull();
}
// ─── updatePerson (birth/death years) ────────────────────────────────────
// ─── updatePerson (birth/death dates) ────────────────────────────────────
@Test
void updatePerson_persistsBirthAndDeathYear() {
void updatePerson_persistsBirthAndDeathDateWithPrecision() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setDeathDate(LocalDate.of(1965, 6, 12)); dto.setDeathDatePrecision(DatePrecision.DAY);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isEqualTo(1965);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 6, 12));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
}
@Test
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
void updatePerson_throwsBirthAfterDeath_whenBirthDateAfterDeathDate() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1970, 5, 1)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1950, 5, 1)); dto.setDeathDatePrecision(DatePrecision.DAY);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
}
@Test
void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() {
// Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw
void updatePerson_throwsBirthAfterDeath_onMixedPrecisionLateBirthday() {
// Known limitation (#773): DAY-precision birth late in the death's YEAR-precision year
// compares against the year's backfilled Jan 1st and is rejected. The error message
// carries the workaround hint via the BIRTH_AFTER_DEATH i18n key.
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1901, 11, 15)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1901, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
}
@Test
void updatePerson_doesNotThrow_whenBirthDateSetButDeathDateNull() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null);
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isNull();
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void updatePerson_allowsSameYear() {
void updatePerson_allowsEqualBirthAndDeathDate() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1900, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setDeathDate(LocalDate.of(1900, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1900);
assertThat(result.getDeathYear()).isEqualTo(1900);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1900, 1, 1));
}
// ─── Phase 1.3: Year range bounds (> 0) ──────────────────────────────────
// ─── Date/precision coherence (V76 CHECK constraint mirror) ─────────────
@Test
void updatePerson_throwsBadRequest_whenBirthYearIsZero() {
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionUnknown() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0);
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setDeathDate(LocalDate.of(1944, 11, 2)); dto.setDeathDatePrecision(DatePrecision.UNKNOWN);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
}
@Test
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() {
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionNull() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5);
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1901, 3, 14));
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsZero() {
void updatePerson_throwsInvalidDatePrecision_whenPrecisionSetWithoutDate() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0);
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDatePrecision(DatePrecision.DAY);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
}
// ─── findCorrespondents ──────────────────────────────────────────────────

View File

@@ -122,7 +122,8 @@ class ArchitectureTest {
.that().areAnnotatedWith(Entity.class)
.should().resideInAnyPackage(
"..document..", "..person..", "..tag..", "..user..",
"..geschichte..", "..notification..", "..ocr..", "..audit.."
"..geschichte..", "..notification..", "..ocr..", "..audit..",
"..timeline.."
);
// TODO Rule 5: Controllers expose endpoints under their domain prefix

View File

@@ -0,0 +1,95 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves V77's FK {@code ON DELETE CASCADE} on the join tables: deleting a linked Person or
* Document drops the join row and leaves the {@link TimelineEvent} intact (a person/document
* delete must never 500 — V71-class regression guard). Needs the full Spring context for
* {@link PersonService}/{@link DocumentService}, mirroring {@code PersonServiceIntegrationTest}.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineEventCascadeIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineEventRepository events;
@Autowired PersonRepository personRepository;
@Autowired PersonService personService;
@Autowired DocumentRepository documentRepository;
@Autowired DocumentService documentService;
@Autowired JdbcTemplate jdbc;
@PersistenceContext EntityManager em;
private TimelineEvent.TimelineEventBuilder makeEvent() {
return TimelineEvent.builder()
.title("Hochzeit von Anna und Otto")
.type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 7, 28))
.createdBy(UUID.randomUUID())
.updatedBy(UUID.randomUUID());
}
@Test
void deleting_linked_person_keeps_event_and_drops_join_row() {
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
TimelineEvent event = events.save(makeEvent().persons(Set.of(anna)).build());
em.flush();
em.clear();
personService.deletePerson(anna.getId());
em.flush();
em.clear();
assertThat(events.findById(event.getId())).isPresent();
Integer joinRows = jdbc.queryForObject(
"SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?",
Integer.class, event.getId());
assertThat(joinRows).isZero();
}
@Test
void deleting_linked_document_keeps_event_and_drops_join_row() {
Document letter = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
TimelineEvent event = events.save(makeEvent().documents(Set.of(letter)).build());
em.flush();
em.clear();
documentService.deleteDocument(letter.getId(), UUID.randomUUID());
em.flush();
em.clear();
assertThat(events.findById(event.getId())).isPresent();
Integer joinRows = jdbc.queryForObject(
"SELECT COUNT(*) FROM timeline_event_documents WHERE timeline_event_id = ?",
Integer.class, event.getId());
assertThat(joinRows).isZero();
}
}

View File

@@ -0,0 +1,202 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Persistence + DB-constraint tests for {@link TimelineEvent} against real Postgres (V77).
* Mirrors {@code MigrationIntegrationTest}'s slice setup; never H2 — only the real DB proves
* enum-as-varchar storage, the RANGE/UNKNOWN CHECK constraints, FK cascade, and {@code @Version}.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TimelineEventTest {
@Autowired TimelineEventRepository events;
@Autowired PersonRepository persons;
@Autowired DocumentRepository documents;
@Autowired EntityManager em;
/**
* Sensible defaults; each test overrides only what it asserts. {@code createdBy}/{@code updatedBy}
* default to random UUIDs — both columns are NOT NULL and not auto-populated, so without these
* every test would fail at flush with the same constraint violation (red for the wrong reason).
* Precision is intentionally left unset so {@code @Builder.Default YEAR} can be exercised.
*/
private TimelineEvent.TimelineEventBuilder makeEvent() {
return TimelineEvent.builder()
.title("Hochzeit von Anna und Otto")
.type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 7, 28))
.createdBy(UUID.randomUUID())
.updatedBy(UUID.randomUUID());
}
@Test
void persists_and_loads_event_with_required_fields() {
TimelineEvent saved = events.save(makeEvent().build());
assertThat(saved.getId()).isNotNull();
}
@Test
void precision_defaults_to_YEAR_when_not_set() {
TimelineEvent saved = events.save(makeEvent().build());
assertThat(saved.getPrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void persists_event_with_linked_persons() {
Person anna = persons.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
TimelineEvent saved = events.save(makeEvent().persons(Set.of(anna)).build());
em.flush();
em.clear();
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getPersons()).extracting(Person::getId).containsExactly(anna.getId());
}
@Test
void persists_event_with_linked_documents() {
Document letter = documents.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
TimelineEvent saved = events.save(makeEvent().documents(Set.of(letter)).build());
em.flush();
em.clear();
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getDocuments()).extracting(Document::getId).containsExactly(letter.getId());
}
@Test
void eventDateEnd_round_trips_null_for_non_range() {
TimelineEvent saved = events.save(makeEvent().build()); // YEAR precision, no end
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd()).isNull();
}
@Test
void eventDateEnd_round_trips_value_for_range() {
TimelineEvent saved = events.save(makeEvent()
.precision(DatePrecision.RANGE)
.eventDate(LocalDate.of(1914, 1, 1))
.eventDateEnd(LocalDate.of(1918, 12, 31))
.build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd())
.isEqualTo(LocalDate.of(1918, 12, 31));
}
@Test
void description_round_trips_null() {
TimelineEvent saved = events.save(makeEvent().build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isNull();
}
@Test
void description_round_trips_multi_kb_text() {
// Proves TEXT has no length cap — @Column(columnDefinition = "TEXT") overrides
// Hibernate's default VARCHAR(255).
String longText = "Sommertage am See. ".repeat(500); // ~9.5 KB
TimelineEvent saved = events.save(makeEvent().description(longText).build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isEqualTo(longText);
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void range_invariant_rejects_non_null_end_without_range_precision() {
// precision YEAR + non-null end violates chk_timeline_event_range.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.eventDateEnd(LocalDate.of(1918, 12, 31))
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll(); // NOT_SUPPORTED opts out of the rollback; clean any leaked row
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void range_invariant_rejects_range_precision_without_end_date() {
// precision RANGE + null end violates chk_timeline_event_range.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.precision(DatePrecision.RANGE)
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll();
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void unknown_precision_is_rejected() {
// chk_timeline_event_precision forbids UNKNOWN — curated events are never undated.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.precision(DatePrecision.UNKNOWN)
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll();
}
}
@Test
void version_is_null_before_persist_and_zero_after_save() {
TimelineEvent fresh = makeEvent().build();
assertThat(fresh.getVersion()).isNull(); // @Version Long is null pre-persist
TimelineEvent saved = events.saveAndFlush(fresh);
assertThat(saved.getVersion()).isEqualTo(0L); // Hibernate sets 0 on insert
}
@ParameterizedTest
@EnumSource(value = DatePrecision.class, names = {"DAY", "MONTH", "SEASON", "YEAR", "APPROX"})
void all_non_unknown_precisions_are_accepted(DatePrecision precision) {
// Accept-side of chk_timeline_event_precision: every non-RANGE, non-UNKNOWN value persists.
// Documents that SEASON ("Sommer 1914") and APPROX ("ca. 1914") are intentionally legal,
// so an over-tight CHECK cannot ship green. (RANGE is covered by the round-trip test.)
TimelineEvent saved = events.saveAndFlush(makeEvent().precision(precision).build());
assertThat(saved.getId()).isNotNull();
}
}

View File

@@ -124,6 +124,8 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `POSTGRES_PASSWORD` | DB password | `change-me` | YES | YES |
| `POSTGRES_DB` | Database name | `family_archive_db` | YES | — |
> **PgBouncer pooling mode:** The `journey_items.position_seq` dedup constraint uses `DEFERRABLE INITIALLY DEFERRED`. This requires PgBouncer in **transaction-mode** (not statement-mode) pooling. Do not switch to statement-level pooling — deferred constraints only work within a single transaction session.
### MinIO container
| Variable | Purpose | Default | Required? | Sensitive? |
@@ -516,6 +518,26 @@ docker exec -i archive-db psql -U ${POSTGRES_USER} ${POSTGRES_DB} < backup-YYYYM
Automated backup (nightly `pg_dump` + MinIO `mc mirror` over Tailscale to `heim-nas`) is a follow-up issue. Until that ships: **manual backups are the only recovery option.**
### Deploy note — V76 (persons birth/death → date + precision, #773)
V76 drops `persons.birth_year`/`death_year` after backfilling the new
`birth_date`/`death_date` + precision columns — a **one-way migration** (Flyway cannot
roll it back). Before deploying:
1. Take a manual `pg_dump` (see above) — there is no automated nightly backup yet, so
confirm the dump completed before starting the deploy.
2. No maintenance window is required: the pre-check + DDL run in one atomic Flyway
transaction, and this single-writer archive has no concurrent importers during deploy.
If post-deploy data issues are found, restore **only the persons table** from the
pre-migration dump (targeted restore, not a full-database restore):
```bash
pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump
```
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
### Rollback
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:

View File

@@ -164,6 +164,12 @@ _Not to be confused with a document item's optional note_ — a document item's
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
**TimelineEvent** (`TimelineEvent`, table `timeline_events`) `[internal]` — a curated event on the family timeline (*Zeitstrahl*), authored by a curator rather than OCR-derived. Carries the same date block as a `Document` (`eventDate` + `precision` + nullable `eventDateEnd`) so events and letters render through one path, plus a `title`, optional `description`, an `EventType`, and `ManyToMany` links to the `Person`s it involves and the `Document`s that support it (both join FKs `ON DELETE CASCADE`). Diverges from `Document` with an optimistic-lock `@Version` and a NOT NULL `createdBy`/`updatedBy` audit trail (bare UUIDs, no FK to `app_users`) for the multi-curator edit flow. Two DB CHECKs: `event_date_end` is non-null **iff** precision is `RANGE` (a strict biconditional, intentionally tighter than `Document`'s open-ended ranges), and `precision` is never `UNKNOWN` (a curated event always has at least a year; `SEASON`/`APPROX` stay legal). See ADR-040.
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
**Audit log** (`AuditLog`, table `audit_log`) — an append-only event store recording domain-level activity (document edits, user actions, etc.). Append-only by application convention; a `REVOKE UPDATE, DELETE` is attempted at the DB layer (see migrations V46, V47) but is a no-op if the application role is the table owner in PostgreSQL. Do not rely on DB-enforced immutability — the constraint is application-layer only.

View File

@@ -0,0 +1,98 @@
# ADR-039 — Person life dates become LocalDate + DatePrecision
**Status:** Accepted
**Date:** 2026-06-12
**Issue:** #773 (Zeitstrahl milestone, foundational)
## Context
`Person` stored `birthYear`/`deathYear` as `Integer`. A known exact birthday
(`1901-03-14`) had nowhere to live, and every display was stuck at year precision.
The Zeitstrahl's derived life-events need real dates with precision metadata, and the
document domain already solved exactly this problem with
`Document.documentDate` + `metaDatePrecision`.
V76 replaces the two integer columns with `birth_date`/`death_date` (`DATE`, nullable)
plus `birth_date_precision`/`death_date_precision` (`VARCHAR(16) NOT NULL DEFAULT
'UNKNOWN'`), backfilling existing years as `YYYY-01-01` at `YEAR` precision.
## Decisions
### 1. `DatePrecision` stays in `document/` and is imported cross-domain
The `person` package imports `org.raddatz.familienarchiv.document.DatePrecision`
directly. The layering rule (controllers → services → own repository) governs
service-to-repository coupling, **not** value-type sharing — an enum has no behaviour
and no persistence side effects. Creating a `common/` package for one enum would be
premature structure; if a third domain needs it, revisit then. The enum remains a
verbatim mirror of the import normalizer's `Precision` values (ADR-025) — changes must
stay in sync with `tools/import-normalizer/dates.py`.
### 2. Precision columns are NOT NULL with default `UNKNOWN`
Mirrors `Document.metaDatePrecision`. The illegal state "date present, precision null"
cannot exist: the named CHECK constraints
(`chk_person_birth_date_precision_coherence`, `…_values`,
`chk_person_birth_before_death`, and the death-side twins) enforce
`(date IS NULL) = (precision = 'UNKNOWN')` and the temporal order at the DB level;
`PersonService.validateLifeDates` enforces the same rules first so users get a
structured 400 (`INVALID_DATE_PRECISION` / `BIRTH_AFTER_DEATH`) instead of a
constraint-violation 500.
Storage accepts all seven `DatePrecision` values (enum-to-string mapping consistency),
but the person new/edit form offers only **DAY / MONTH / YEAR**`RANGE` and `SEASON`
are semantically nonsensical for a birth or death, and `APPROX` is excluded from the
form to reduce cognitive load for the senior author audience. Legacy `APPROX` rows
still render correctly (display delegates to `formatDocumentDate`).
The edit form seeds a stored non-offered precision (`APPROX`/`SEASON`/`RANGE`) into
the select as `YEAR`, so an untouched save coerces it to `YEAR` ("ca. 1944" becomes
"1944"). Accepted: nothing currently writes those precisions to persons (the form
offers DAY/MONTH/YEAR, the importer writes YEAR/UNKNOWN, V76 backfills YEAR), so the
case is only reachable via direct API writes — and seeding `YEAR` is strictly safer
than the alternative of silently claiming `DAY` precision.
### 3. Derived-year pattern for backward-compatible DTOs
`PersonNodeDTO` (Stammbaum) and `RelationshipDTO` keep `Integer birthYear/deathYear`,
derived null-safely in the relationship services (`birthDate != null ?
birthDate.getYear() : null` — never 0, never empty string; REQ-PERSON-DATE-01). The
native queries behind `PersonSummaryDTO` project
`CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear`.
### 4. `PersonSummaryDTO` intentionally exposes years only
The person list/search views show year precision only; full precision lives on the
person detail page. Do **not** add `LocalDate getBirthDate()` to the interface without
updating all four native queries (`findAllWithDocumentCount`,
`searchWithDocumentCount`, `findTopByDocumentCount`, `findByFilter`) — the interface
projection is satisfied purely by the SQL SELECT aliases.
### 5. `preferHumanDate` extends ADR-025's human-edit-preserve rule
The importer stays year-shaped: `PersonUpsertCommand` keeps `Integer birthYear/
deathYear` because the spreadsheet only knows a year — pushing `LocalDate` into the
importer would fabricate precision. On upsert, `PersonService.preferHumanDate` returns
a `DatePrecisionPair` record (date and precision travel as one value so they cannot go
out of sync):
- existing precision DAY/MONTH/SEASON/RANGE/APPROX → hand-entered, preserved verbatim;
- existing precision YEAR/UNKNOWN → refreshed from the canonical year as
`YYYY-01-01` + `YEAR` (or cleared to null/`UNKNOWN` when the sheet has no year).
The original integer/string `preferHuman` overloads remain for non-date fields.
### 6. Known limitation — mixed-precision comparison
`validateLifeDates` compares stored `LocalDate` values. A DAY-precision birth late in
the same year as a YEAR-precision death (stored as Jan 1st) is rejected with
`BIRTH_AFTER_DEATH`. This is intentional; the `error_birth_after_death` i18n message
carries the workaround hint (enter the following year as the death year).
## Consequences
- V76 is one-way (columns dropped). Rollback = targeted `pg_restore -t persons` from
the pre-deploy dump — see `docs/DEPLOYMENT.md` §5.
- Exact dates now render on person cards, hover cards, and the mention dropdown; the
Stammbaum and person list are visually unchanged.
- The Zeitstrahl can derive birth/death life-events at full precision.

View File

@@ -0,0 +1,112 @@
# ADR-040 — Timeline domain data model
**Status:** Accepted
**Date:** 2026-06-12
**Issue:** #774 (Zeitstrahl milestone, foundational)
## Context
The Zeitstrahl (family timeline) needs a home for *curated* events — births,
weddings, moves, and world-historical context a curator types in by hand, distinct
from the OCR-derived `Document` letters. This ADR commits the new `timeline` domain's
data model: the `TimelineEvent` entity, the `EventType` enum, a repository, and the
V77 migration. No service, controller, or DTO ships here — those land in later issues.
A `TimelineEvent` carries the same date block as `Document` (`eventDate` +
`precision` + `eventDateEnd`) so events and letters render through one path, but its
audit footprint deliberately diverges (see below).
## Decisions
### 1. New `timeline` domain package, separate from `geschichte`
Curated timeline events are their own concern, not a Lesereise/`Geschichte` subtype.
The domain owns `TimelineEvent`, `EventType`, and `TimelineEventRepository`.
### 2. Responses (issue 3) will be views, not serialized entities
`TimelineEvent` has two LAZY `ManyToMany` collections (`persons`, `documents`) and
`open-in-view` is `false` — exactly the shape that motivated ADR-036 for `geschichte`.
Issue 3 must assemble `TimelineEventView`/`TimelineEventSummary` inside the service
transaction; a serialized entity is a 500 waiting to happen. Decided up front so it is
not retrofitted later.
### 3. `precision` reuses `document.DatePrecision` — imported, not duplicated
The `timeline` package imports `org.raddatz.familienarchiv.document.DatePrecision`
directly, the same cross-domain value-type sharing ADR-039 established for `person`.
An enum has no behaviour and no persistence side effects, so the layering rule
(services → own repository) does not govern it. The enum stays a verbatim mirror of the
import normalizer's `Precision` values (ADR-025); changes must stay in sync with
`tools/import-normalizer/dates.py`. Moving `DatePrecision` into a shared package is a
wider refactor (touching `Document`, `importing`, `person`) and its own future ADR.
### 4. `precision = UNKNOWN` is forbidden; every other value is legal
`eventDate` is NOT NULL — a curated event always has at least a year, so only OCR letters
fall into the "Ohne Datum" bucket. The CHECK `chk_timeline_event_precision`
(`date_precision <> 'UNKNOWN'`) forbids exactly that one value. `SEASON` ("Sommer 1914")
and `APPROX` ("ca. 1914") are explicitly legal — family memory is full of both, and the
spec's rendering table covers them. Do not narrow the CHECK to an allow-list; an
over-tight constraint would force curators to fake `YEAR` and render dishonest dates.
### 5. RANGE invariant is a strict biconditional at the DB, intentionally tighter than `Document`
`chk_timeline_event_range` enforces `(date_precision = 'RANGE') = (event_date_end IS NOT
NULL)``eventDateEnd` is non-null **iff** precision is `RANGE`, both directions. This is
*stricter* than `Document`'s open-ended ranges (which allow a null end on a RANGE) because a
curated event always has a known, closed end when it spans a range — it is authored, not
inferred. This divergence is deliberate: a future "bug fix" must not relax it to match
`Document`.
### 6. Audit trail: `@Version` + NOT NULL `createdBy`/`updatedBy`, diverging from `Document`
`Document` has neither a version nor a creator. A curated entity edited by multiple curators
warrants real protection, so `TimelineEvent` adds:
- `@Version Long version` — optimistic locking for the multi-curator edit flow (issue 3).
Object `Long` (not primitive) so it is `null` before first persist; Hibernate sets `0` on
insert. The service **must** catch `ObjectOptimisticLockingFailureException` and translate
it to `DomainException.conflict(...)`. Without that translation a concurrent-write conflict
surfaces as HTTP 500 with Hibernate internals in the body — information disclosure (CWE-209).
- `createdBy`/`updatedBy` as bare `UUID`, `NOT NULL`, no FK to `app_users` (sidecar pattern,
matching `DocumentAnnotation`/`OcrJob`; keeps `timeline` decoupled from `user`, avoids
lazy-load surprises in the read-heavy assembly path). NOT NULL makes a curated event with no
author impossible — an audit gap closed at the schema level. `DocumentAnnotation.createdBy`
is nullable and has no `updatedBy`; the escalation here is deliberate because curated events
are multi-author. Curator display names resolve through `UserService` at render time.
`updatedBy` is **not** advanced by `@UpdateTimestamp` — the service must set it from the
session principal before every `save()`, or the timestamp moves while the "who" goes stale.
### 7. `createdBy`/`updatedBy` are server-populated only — never bound from client input
Both are set from the session principal in the service, never from a request body. Binding
them from client input is an authorship-forgery / mass-assignment vector (CWE-639). Issue 3's
regression suite must include forgery cases on **both** write paths (`POST` body with
`createdBy`, `PUT` body with `updatedBy`) — create and update are separate binding paths, so
testing only one leaves half the vector open. The update test must assert `updatedBy` equals
the *second* editor's UUID, not merely non-null.
### 8. `EventType` string values are a stable frontend styling contract
The Tailwind class map in the timeline Svelte components hard-codes `PERSONAL` (family accent)
and `HISTORICAL` (muted world accent) as strings. There is no mapping layer — renaming either
value requires a coordinated frontend change. Recorded here to prevent a silent regression.
### 9. Explicit `@JoinTable` on both ManyToMany fields
Without explicit `@JoinTable(name, joinColumns, inverseJoinColumns)`, Hibernate's naming
strategy could diverge from the V77 DDL's explicit table/column names. Explicit mapping
guarantees alignment and makes future column renames a deliberate, visible change. All four FK
columns are `ON DELETE CASCADE`: deleting a Person or Document drops the join row and leaves
the event intact (V71/ADR-032 hardening — a person delete must never 500).
## Consequences
- V77 is forward-only; rollback is manual DDL (`DROP TABLE` the two join tables, then
`timeline_events`). No rollback script, no rollback test.
- The `timeline → document.DatePrecision` compile coupling is permanent until a shared-package
refactor; precedent already exists (`importing/DocumentImporter`, `person`).
- The service/controller/DTO layer (issue 3) inherits the view-assembly, optimistic-lock
translation, forgery-guard, and permission obligations recorded above.

View File

@@ -0,0 +1,24 @@
@startuml
!include <C4/C4_Component>
title Component Diagram: API Backend — Timeline (Zeitstrahl)
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). Issue #774 ships the repository empty; the per-person filter query lands in a later issue.")
Component(timelineSvc, "TimelineEventService", "Spring Service (planned, issue 3)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.")
Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, issue 3)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
}
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters")
System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves")
Rel(timelineRepo, db, "SQL queries", "JDBC")
Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)")
Rel(timelineCtrl, timelineSvc, "Delegates to (planned)")
Rel(timelineRepo, personDomain, "References persons via join table")
Rel(timelineRepo, documentDomain, "References documents via join table")
@enduml

View File

@@ -1,6 +1,6 @@
@startuml db-orm
' Schema source: Flyway V1V72 (excl. V37, V43 — intentionally removed)
' Schema as of: V72 (2026-06-08)
' Schema source: Flyway V1V77 (excl. V37, V43 — intentionally removed)
' Schema as of: V77 (2026-06-12)
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
hide circle
@@ -184,8 +184,10 @@ package "Persons" {
title : VARCHAR(50)
person_type : VARCHAR(20) NOT NULL
notes : TEXT
birth_year : INTEGER
death_year : INTEGER
birth_date : DATE
birth_date_precision : VARCHAR(16) NOT NULL
death_date : DATE
death_date_precision : VARCHAR(16) NOT NULL
generation : SMALLINT
family_member : BOOLEAN NOT NULL
source_ref : VARCHAR(255) UNIQUE
@@ -384,6 +386,39 @@ package "Supporting" {
}
}
' ── Timeline (Zeitstrahl) ──
package "Timeline" {
entity timeline_events {
id : UUID <<PK>>
--
title : VARCHAR(255) NOT NULL
type : VARCHAR(16) NOT NULL
event_date : DATE NOT NULL
date_precision : VARCHAR(16) NOT NULL DEFAULT 'YEAR'
event_date_end : DATE
description : TEXT
created_by : UUID NOT NULL
created_at : TIMESTAMP
updated_by : UUID NOT NULL
updated_at : TIMESTAMP
version : BIGINT
==
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL))
CHECK (date_precision <> 'UNKNOWN')
}
entity timeline_event_persons {
timeline_event_id : UUID <<FK>>
person_id : UUID <<FK>>
}
entity timeline_event_documents {
timeline_event_id : UUID <<FK>>
document_id : UUID <<FK>>
}
}
' Auth relationships
app_users_groups }o--|| app_users : app_user_id
app_users_groups }o--|| user_groups : group_id
@@ -447,4 +482,10 @@ geschichten_persons }o--|| persons : person_id
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
' Timeline relationships
timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE)
timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE)
@enduml

View File

@@ -4,6 +4,9 @@
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
' Note: V69 adds columns only (persons.source_ref, tag.source_ref, document
' precision/attribution fields); no new FK relationships, so this diagram is unchanged.
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
' precision columns; columns only, no new FK relationships, diagram unchanged.
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
hide circle
skinparam linetype ortho
@@ -69,6 +72,13 @@ package "Supporting" {
entity journey_items
}
' ── Timeline (Zeitstrahl) ──
package "Timeline" {
entity timeline_events
entity timeline_event_persons
entity timeline_event_documents
}
' Auth relationships
app_users_groups }o--|| app_users : app_user_id
app_users_groups }o--|| user_groups : group_id
@@ -134,4 +144,11 @@ journey_items }o--o| documents : document_id (ON DELETE SET NULL)
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75)
' Timeline relationships (V77)
timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE)
timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE)
note right of timeline_events : CHECK event_date_end non-null IFF RANGE\nCHECK date_precision <> 'UNKNOWN' (V77)
@enduml

View File

@@ -174,12 +174,18 @@
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
"person_label_notes": "Notizen",
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
"person_label_birth_year": "Geburtsjahr",
"person_label_death_year": "Todesjahr",
"person_label_birth_date": "Geburtsdatum",
"person_label_death_date": "Sterbedatum",
"person_label_birth_date_precision": "Genauigkeit",
"person_label_death_date_precision": "Genauigkeit",
"person_precision_hint": "Wie genau ist dieses Datum bekannt?",
"person_precision_day": "Genaues Datum (Tag)",
"person_precision_month": "Monat bekannt",
"person_precision_year": "Nur Jahreszahl",
"person_date_placeholder_hint": "Leer lassen, wenn unbekannt",
"person_label_generation": "Generation",
"person_option_generation_unset": "(keine)",
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
"person_placeholder_year": "z.B. 1923",
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
"person_docs_heading": "Gesendete Dokumente",
@@ -643,6 +649,8 @@
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
"validation_last_name_required": "Nachname ist Pflichtfeld.",
"validation_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
@@ -1042,6 +1050,10 @@
"geschichten_filter_document_chip": "Gefiltert nach Brief:",
"geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen",
"geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief",
"geschichten_published_heading": "Veröffentlicht",
"geschichten_drafts_heading": "Entwürfe",
"geschichten_draft_badge": "Entwurf",
"geschichten_drafts_unfiltered_caption": "(alle Entwürfe)",
"geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}",
"journey_compiled_on": "zusammengestellt am {date}",

View File

@@ -174,12 +174,18 @@
"person_merge_warning": "Warning: This action cannot be undone.",
"person_label_notes": "Notes",
"person_placeholder_notes": "Biographical notes, remarks…",
"person_label_birth_year": "Birth year",
"person_label_death_year": "Death year",
"person_label_birth_date": "Date of birth",
"person_label_death_date": "Date of death",
"person_label_birth_date_precision": "Precision",
"person_label_death_date_precision": "Precision",
"person_precision_hint": "How precisely is this date known?",
"person_precision_day": "Exact date (day)",
"person_precision_month": "Month known",
"person_precision_year": "Year only",
"person_date_placeholder_hint": "Leave empty if unknown",
"person_label_generation": "Generation",
"person_option_generation_unset": "(none)",
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
"person_placeholder_year": "e.g. 1923",
"person_year_error": "Please enter a four-digit year",
"person_years_error_order": "Birth year must be before death year",
"person_docs_heading": "Sent documents",
@@ -643,6 +649,8 @@
"error_alias_not_found": "The name alias was not found.",
"error_invalid_person_type": "The specified person type is not valid.",
"error_invalid_date_range": "The end date must not be before the start date.",
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
"error_invalid_date_precision": "Date and precision do not match.",
"validation_last_name_required": "Last name is required.",
"validation_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.",
@@ -1042,6 +1050,10 @@
"geschichten_filter_document_chip": "Filtered by letter:",
"geschichten_filter_remove_document_chip": "Remove letter {title} from filter",
"geschichten_empty_for_document": "No stories reference this letter yet",
"geschichten_published_heading": "Published",
"geschichten_drafts_heading": "Drafts",
"geschichten_draft_badge": "Draft",
"geschichten_drafts_unfiltered_caption": "(all drafts)",
"geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}",
"journey_compiled_on": "compiled on {date}",

View File

@@ -174,12 +174,18 @@
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
"person_label_notes": "Notas",
"person_placeholder_notes": "Notas biográficas, observaciones…",
"person_label_birth_year": "Año de nacimiento",
"person_label_death_year": "Año de fallecimiento",
"person_label_birth_date": "Fecha de nacimiento",
"person_label_death_date": "Fecha de defunción",
"person_label_birth_date_precision": "Precisión",
"person_label_death_date_precision": "Precisión",
"person_precision_hint": "¿Con qué precisión se conoce esta fecha?",
"person_precision_day": "Fecha exacta (día)",
"person_precision_month": "Mes conocido",
"person_precision_year": "Solo año",
"person_date_placeholder_hint": "Dejar vacío si es desconocido",
"person_label_generation": "Generación",
"person_option_generation_unset": "(ninguna)",
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
"person_placeholder_year": "p.ej. 1923",
"person_year_error": "Introduzca un año de cuatro dígitos",
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
"person_docs_heading": "Documentos enviados",
@@ -643,6 +649,8 @@
"error_alias_not_found": "No se encontro el alias de nombre.",
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
"validation_last_name_required": "El apellido es obligatorio.",
"validation_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
@@ -1042,6 +1050,10 @@
"geschichten_filter_document_chip": "Filtrado por carta:",
"geschichten_filter_remove_document_chip": "Quitar la carta {title} del filtro",
"geschichten_empty_for_document": "Aún no hay historias sobre esta carta",
"geschichten_published_heading": "Publicadas",
"geschichten_drafts_heading": "Borradores",
"geschichten_draft_badge": "Borrador",
"geschichten_drafts_unfiltered_caption": "(todos los borradores)",
"geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}",
"journey_compiled_on": "recopilada el {date}",

View File

@@ -558,9 +558,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -574,9 +574,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -590,9 +590,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -606,9 +606,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -622,9 +622,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -638,9 +638,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -654,9 +654,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -670,9 +670,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -686,9 +686,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -702,9 +702,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -718,9 +718,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -734,9 +734,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -750,9 +750,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -766,9 +766,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -782,9 +782,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -798,9 +798,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -814,9 +814,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -830,9 +830,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -846,9 +846,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -862,9 +862,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -878,9 +878,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -894,9 +894,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -910,9 +910,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -926,9 +926,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -942,9 +942,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -958,9 +958,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -5531,12 +5531,16 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
@@ -5799,9 +5803,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -5811,32 +5815,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {

View File

@@ -73,5 +73,9 @@
"vite-plugin-static-copy": "^4.1.0",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1"
},
"overrides": {
"esbuild": "0.28.1",
"cookie": ">=0.7.0"
}
}

View File

@@ -335,8 +335,10 @@ describe('TranscriptionReadView — person-mention rendering', () => {
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true,
birthYear: 1882,
deathYear: 1944
birthDate: '1882-01-01',
birthDatePrecision: 'YEAR',
deathDate: '1944-01-01',
deathDatePrecision: 'YEAR'
})
});
})

View File

@@ -1714,10 +1714,14 @@ export interface components {
lastName?: string;
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
/** Format: date */
birthDate?: string;
/** @enum {string} */
birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
deathDate?: string;
/** @enum {string} */
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: int32 */
generation?: number;
};
@@ -1731,10 +1735,14 @@ export interface components {
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
/** Format: date */
birthDate?: string;
/** @enum {string} */
birthDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
deathDate?: string;
/** @enum {string} */
deathDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: int32 */
generation?: number;
familyMember: boolean;
@@ -2373,13 +2381,21 @@ export interface components {
documentCount?: number;
alias?: string;
notes?: string;
/** Format: date */
birthDate?: string;
/** @enum {string} */
birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
deathDate?: string;
/** @enum {string} */
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
personType?: string;
familyMember?: boolean;
provisional?: boolean;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
provisional?: boolean;
personType?: string;
familyMember?: boolean;
};
InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"];
@@ -2476,8 +2492,6 @@ export interface components {
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -2486,6 +2500,8 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -2673,7 +2689,7 @@ export interface components {
};
ActivityFeedItemDTO: {
/** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED";
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "DOCUMENT_DELETED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
@@ -5541,7 +5557,7 @@ export interface operations {
query?: {
limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[];
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "DOCUMENT_DELETED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[];
};
header?: never;
path?: never;

View File

@@ -10,6 +10,8 @@ const personFactory = (id: string, displayName: string) => ({
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
displayName,
personType: 'PERSON' as const,
birthDatePrecision: 'UNKNOWN' as const,
deathDatePrecision: 'UNKNOWN' as const,
familyMember: false,
provisional: false
});

View File

@@ -7,12 +7,13 @@ import type { components } from '$lib/generated/api';
type GeschichteRow = Pick<
components['schemas']['GeschichteSummary'],
'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'
'id' | 'title' | 'body' | 'type' | 'status' | 'author' | 'publishedAt'
>;
let { geschichte }: { geschichte: GeschichteRow } = $props();
const isJourney = $derived(geschichte.type === 'JOURNEY');
const isDraft = $derived(geschichte.status === 'DRAFT');
const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'));
@@ -44,12 +45,20 @@ const authorName = $derived(formatAuthorName(geschichte.author));
{m.journey_badge_list()}
</span>
{/if}
{#if isDraft}
<span
data-testid="draft-badge"
class="inline-flex items-center rounded-sm border border-line bg-canvas px-1.5 py-px font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>
{m.geschichten_draft_badge()}
</span>
{/if}
</div>
<!-- Content column -->
<div class="min-w-0 flex-1 p-3 sm:px-4">
<!-- Compact meta line (mobile only) -->
<div class="mb-1 flex items-center gap-1.5 sm:hidden">
<div class="mb-1 flex flex-wrap items-center gap-1.5 sm:hidden">
<!-- 7px initials render as smudge at this size — a plain color dot reads better -->
<span
aria-hidden="true"
@@ -57,6 +66,14 @@ const authorName = $derived(formatAuthorName(geschichte.author));
style="background-color: {personAvatarColor(authorName)}"
></span>
<span class="font-sans text-sm font-semibold text-ink">{authorName}</span>
{#if isDraft}
<span
data-testid="draft-badge-mobile"
class="inline-flex shrink-0 items-center rounded-sm border border-line bg-canvas px-1.5 py-px font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>
{m.geschichten_draft_badge()}
</span>
{/if}
{#if publishedAt}
<span class="ml-auto font-sans text-sm text-ink-3">{publishedAt}</span>
{/if}

View File

@@ -91,4 +91,34 @@ describe('GeschichteListRow', () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
expect(document.body.textContent).toContain('Anna Schmidt');
});
it('shows no draft badge for PUBLISHED stories', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'PUBLISHED' }) } });
expect(document.querySelector('[data-testid="draft-badge"]')).toBeNull();
expect(document.querySelector('[data-testid="draft-badge-mobile"]')).toBeNull();
});
it('shows desktop draft badge for DRAFT stories', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } });
const badge = document.querySelector('[data-testid="draft-badge"]');
expect(badge).not.toBeNull();
});
it('shows mobile draft badge for DRAFT stories', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } });
const badge = document.querySelector('[data-testid="draft-badge-mobile"]');
expect(badge).not.toBeNull();
});
it('draft badge is a plain <span>', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } });
const badge = document.querySelector('[data-testid="draft-badge"]');
expect(badge?.tagName.toLowerCase()).toBe('span');
});
it('draft badge uses text-xs label size', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } });
const badge = document.querySelector('[data-testid="draft-badge"]');
expect(badge!.className).toContain('text-xs');
});
});

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import PersonTypeBadge from '$lib/person/PersonTypeBadge.svelte';
import type { components } from '$lib/generated/api';
@@ -125,10 +124,17 @@ const documentCount = $derived(person.documentCount ?? 0);
<p class="font-sans text-sm text-ink-2 italic">{person.alias}"</p>
{/if}
<!-- Life dates -->
<!-- Life dates — PersonSummaryDTO is year-shaped by design (ADR-039); the glyphs are
aria-hidden so screen readers only announce the years. -->
{#if person.birthYear || person.deathYear}
<p class="font-sans text-sm text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
{#if person.birthYear}
<span aria-hidden="true">*</span> {person.birthYear}
{/if}
{#if person.birthYear && person.deathYear}{/if}
{#if person.deathYear}
<span aria-hidden="true"></span> {person.deathYear}
{/if}
</p>
{/if}

View File

@@ -30,6 +30,27 @@ describe('PersonCard — confirmed person', () => {
render(PersonCard, { props: { person: makePerson() } });
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
// PersonSummaryDTO intentionally stays year-shaped (ADR-039): the list shows
// year precision only; full dates live on the detail page.
it('renders the year-only life-date range', async () => {
render(PersonCard, { props: { person: makePerson({ birthYear: 1899, deathYear: 1972 }) } });
await expect.element(page.getByText(/1899/)).toBeVisible();
await expect.element(page.getByText(/1972/)).toBeVisible();
});
it('renders birth-only without dash and wraps glyphs in aria-hidden spans', async () => {
const { container } = render(PersonCard, {
props: { person: makePerson({ birthYear: 1899 }) }
});
await expect.element(page.getByText(/1899/)).toBeVisible();
expect(container.textContent).not.toContain('');
expect(container.textContent).not.toContain('†');
const hidden = [...container.querySelectorAll('span[aria-hidden="true"]')].map((el) =>
el.textContent?.trim()
);
expect(hidden).toContain('*');
});
});
describe('PersonCard — unconfirmed badge keys off provisional only (badge ⇔ count ⇔ triage parity)', () => {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import { formatLifeDate } from '$lib/person/personLifeDates';
import { chipLabel, otherName } from '$lib/person/relationshipLabels';
import type { components } from '$lib/generated/api';
import type { LoadState } from '$lib/person/personHoverCard';
@@ -31,9 +31,14 @@ const familyChips = $derived(
: []
);
const dateRange = $derived(
const birthText = $derived(
state.status === 'loaded'
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
? formatLifeDate(state.person.birthDate, state.person.birthDatePrecision)
: ''
);
const deathText = $derived(
state.status === 'loaded'
? formatLifeDate(state.person.deathDate, state.person.deathDatePrecision)
: ''
);
@@ -123,8 +128,17 @@ const showMaidenName = $derived(
<div data-testid="person-hover-card-content" class="content">
<div class="header">
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
{#if dateRange}
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
{#if birthText || deathText}
<!-- Glyphs aria-hidden so screen readers only announce the dates -->
<div class="dates" data-testid="person-hover-card-dates">
{#if birthText}
<span aria-hidden="true">*</span> {birthText}
{/if}
{#if birthText && deathText}{/if}
{#if deathText}
<span aria-hidden="true"></span> {deathText}
{/if}
</div>
{/if}
{#if showMaidenName}
<div class="maiden" data-testid="person-hover-card-maiden">

View File

@@ -14,8 +14,10 @@ const AUGUSTE: Person = {
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true,
birthYear: 1882,
deathYear: 1944
birthDate: '1882-01-01',
birthDatePrecision: 'YEAR',
deathDate: '1944-01-01',
deathDatePrecision: 'YEAR'
} as unknown as Person;
const POSITION = { top: 100, left: 200 };
@@ -81,18 +83,76 @@ describe('PersonHoverCard — loaded state', () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
it('renders the life-date range when birthYear and deathYear are present', async () => {
it('renders the life-date range when birth and death dates are present', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
await expect.element(page.getByText('* 1882 † 1944')).toBeInTheDocument();
await expect
.element(page.getByTestId('person-hover-card-dates'))
.toHaveTextContent('* 1882 † 1944');
});
it('omits the life-date line when both years are missing', async () => {
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
it('renders a DAY-precision birth date as a full localized date', async () => {
const exact = {
...AUGUSTE,
birthDate: '1882-03-14',
birthDatePrecision: 'DAY'
} as unknown as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: exact, relationships: [] }
});
await expect
.element(page.getByTestId('person-hover-card-dates'))
.toHaveTextContent('14. März 1882');
});
it('renders APPROX-precision legacy dates with the ca. prefix', async () => {
const approx = {
...AUGUSTE,
birthDate: '1882-01-01',
birthDatePrecision: 'APPROX',
deathDate: undefined,
deathDatePrecision: 'UNKNOWN'
} as unknown as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: approx, relationships: [] }
});
await expect.element(page.getByTestId('person-hover-card-dates')).toHaveTextContent('ca. 1882');
});
it('keeps the * and † glyphs out of the accessible text via aria-hidden', async () => {
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',
position: POSITION,
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
});
const hidden = [
...document.querySelectorAll(
'[data-testid="person-hover-card-dates"] span[aria-hidden="true"]'
)
].map((el) => el.textContent?.trim());
expect(hidden).toContain('*');
expect(hidden).toContain('†');
});
it('omits the life-date line when both dates are missing', async () => {
const noDates = {
...AUGUSTE,
birthDate: undefined,
birthDatePrecision: 'UNKNOWN',
deathDate: undefined,
deathDatePrecision: 'UNKNOWN'
} as unknown as Person;
render(PersonHoverCard, {
personId: 'p-aug',
cardId: 'card-1',

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
const PERSON_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.person_precision_day },
{ value: 'MONTH', label: m.person_precision_month },
{ value: 'YEAR', label: m.person_precision_year }
];
let {
name,
legend,
precisionLabel,
initialIso = '',
initialPrecision = null
}: {
name: string;
legend: string;
precisionLabel: string;
initialIso?: string | null;
initialPrecision?: string | null;
} = $props();
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not
// stomp the user's in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// Legacy APPROX/SEASON/RANGE precision is not editable here — seed YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — submitting then would silently
// clear a stored date. Block native submission until completed or fully emptied.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
const controlCls =
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
</script>
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class={controlCls}
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {precisionLabel}"
bind:value={precision}
class="{controlCls} bg-surface"
>
{#each PERSON_DATE_PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">
{m.person_precision_hint()} · {m.person_date_placeholder_hint()}
</p>
</fieldset>

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import PersonLifeDateField from './PersonLifeDateField.svelte';
import { m } from '$lib/paraglide/messages.js';
afterEach(cleanup);
const baseProps = {
name: 'birthDate',
legend: 'Geburtsdatum',
precisionLabel: 'Genauigkeit'
};
const visibleInput = () => page.getByLabelText('Geburtsdatum', { exact: true });
const hiddenIso = () => document.querySelector<HTMLInputElement>('input[name="birthDate"]');
describe('PersonLifeDateField', () => {
it('shows the format error and flags the input invalid for a partial date', async () => {
render(PersonLifeDateField, { props: baseProps });
await userEvent.fill(visibleInput(), '14.03.');
await expect.element(page.getByText(m.form_date_error())).toBeVisible();
const input = (await visibleInput().element()) as HTMLInputElement;
expect(input.checkValidity()).toBe(false);
expect(hiddenIso()?.value).toBe('');
});
it('clears error and custom validity when the input is fully emptied', async () => {
render(PersonLifeDateField, { props: { ...baseProps, initialIso: '1899-03-14' } });
await userEvent.fill(visibleInput(), '');
await expect.element(page.getByText(m.form_date_error())).not.toBeInTheDocument();
const input = (await visibleInput().element()) as HTMLInputElement;
expect(input.checkValidity()).toBe(true);
// Empty hidden ISO is the intentional clear path — the server action omits the pair.
expect(hiddenIso()?.value).toBe('');
});
it('keeps a stored date submittable when untouched', async () => {
render(PersonLifeDateField, {
props: { ...baseProps, initialIso: '1899-03-14', initialPrecision: 'DAY' }
});
await expect.element(visibleInput()).toHaveValue('14.03.1899');
const input = (await visibleInput().element()) as HTMLInputElement;
expect(input.checkValidity()).toBe(true);
expect(hiddenIso()?.value).toBe('1899-03-14');
});
it('becomes valid again once the partial date is completed', async () => {
render(PersonLifeDateField, { props: baseProps });
await userEvent.fill(visibleInput(), '14.03.');
await userEvent.fill(visibleInput(), '14.03.1899');
await expect.element(page.getByText(m.form_date_error())).not.toBeInTheDocument();
const input = (await visibleInput().element()) as HTMLInputElement;
expect(input.checkValidity()).toBe(true);
expect(hiddenIso()?.value).toBe('1899-03-14');
});
});

View File

@@ -9,8 +9,10 @@ export type PersonFormData = {
firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
birthDate?: string | null;
birthDatePrecision?: string | null;
deathDate?: string | null;
deathDatePrecision?: string | null;
generation?: number | null;
notes?: string | null;
};

View File

@@ -1,24 +1,113 @@
import { describe, it, expect } from 'vitest';
import { formatLifeDateRange } from './personLifeDates';
import { describe, expect, it } from 'vitest';
import { formatLifeDate, formatLifeDateRange } from './personLifeDates';
// Delegates all precision rendering to formatDocumentDate — these tests pin the
// composition (glyphs, dash, empty sides) and one rendering per precision so a
// regression in the delegation is caught here, not on a person card.
describe('formatLifeDateRange', () => {
it('returns both dates when birth and death year are given', () => {
expect(formatLifeDateRange(1882, 1944)).toBe('* 1882 † 1944');
describe('both dates (de default)', () => {
it('renders DAY precision as full dates', () => {
expect(formatLifeDateRange('1901-03-14', 'DAY', '1944-11-02', 'DAY')).toBe(
'* 14. März 1901 † 2. November 1944'
);
});
it('renders MONTH precision as month + year', () => {
expect(formatLifeDateRange('1901-03-01', 'MONTH', '1944-11-01', 'MONTH')).toBe(
'* März 1901 † November 1944'
);
});
it('renders YEAR precision as bare years', () => {
expect(formatLifeDateRange('1901-01-01', 'YEAR', '1944-01-01', 'YEAR')).toBe(
'* 1901 † 1944'
);
});
it('renders mixed precisions per side', () => {
expect(formatLifeDateRange('1901-03-14', 'DAY', '1944-01-01', 'YEAR')).toBe(
'* 14. März 1901 † 1944'
);
});
it('renders APPROX precision with the ca. prefix (legacy imports)', () => {
expect(formatLifeDateRange('1901-01-01', 'APPROX', '1944-01-01', 'APPROX')).toBe(
'* ca. 1901 † ca. 1944'
);
});
});
it('returns only birth year when only birthYear is given', () => {
expect(formatLifeDateRange(1882, undefined)).toBe('* 1882');
describe('single sides and empty states', () => {
it('renders birth only without dash or dagger', () => {
expect(formatLifeDateRange('1901-03-14', 'DAY', null, null)).toBe('* 14. März 1901');
});
it('renders death only without dash or asterisk', () => {
expect(formatLifeDateRange(null, null, '1944-11-02', 'DAY')).toBe('† 2. November 1944');
});
it('renders YEAR birth only', () => {
expect(formatLifeDateRange('1882-01-01', 'YEAR', null, null)).toBe('* 1882');
});
it('renders APPROX death only', () => {
expect(formatLifeDateRange(null, null, '1944-01-01', 'APPROX')).toBe('† ca. 1944');
});
it('returns empty string when both dates are null', () => {
expect(formatLifeDateRange(null, null, null, null)).toBe('');
});
it('returns empty string when both dates are null even with UNKNOWN precisions', () => {
expect(formatLifeDateRange(null, 'UNKNOWN', null, 'UNKNOWN')).toBe('');
});
it('falls back to YEAR rendering when a precision is missing', () => {
expect(formatLifeDateRange('1901-01-01', null, null, null)).toBe('* 1901');
});
});
it('returns only death year when only deathYear is given', () => {
expect(formatLifeDateRange(undefined, 1944)).toBe('† 1944');
});
describe('locales (German-month-leak guard)', () => {
it('renders DAY precision in English', () => {
expect(formatLifeDateRange('1901-03-14', 'DAY', null, null, 'en')).toBe('* March 14, 1901');
});
it('returns empty string when neither year is given', () => {
expect(formatLifeDateRange(undefined, undefined)).toBe('');
});
it('renders MONTH precision in English', () => {
expect(formatLifeDateRange('1901-03-01', 'MONTH', null, null, 'en')).toBe('* March 1901');
});
it('returns empty string when both are null', () => {
expect(formatLifeDateRange(null, null)).toBe('');
it('renders DAY precision in Spanish', () => {
expect(formatLifeDateRange('1901-03-14', 'DAY', null, null, 'es')).toBe(
'* 14 de marzo de 1901'
);
});
it('renders MONTH precision in Spanish', () => {
expect(formatLifeDateRange('1901-03-01', 'MONTH', null, null, 'es')).toBe('* marzo de 1901');
});
});
});
// Single-date helper for components that must keep the * / † glyphs in their own
// aria-hidden markup (PersonCard, PersonHoverCard) instead of in the string.
describe('formatLifeDate', () => {
it('renders a DAY-precision date without any glyph', () => {
expect(formatLifeDate('1901-03-14', 'DAY')).toBe('14. März 1901');
});
it('renders an APPROX-precision date (legacy imports)', () => {
expect(formatLifeDate('1901-01-01', 'APPROX')).toBe('ca. 1901');
});
it('falls back to YEAR rendering when precision is missing', () => {
expect(formatLifeDate('1901-01-01', null)).toBe('1901');
});
it('returns empty string for a null date', () => {
expect(formatLifeDate(null, 'DAY')).toBe('');
});
it('renders in the requested locale', () => {
expect(formatLifeDate('1901-03-14', 'DAY', 'en')).toBe('March 14, 1901');
});
});

View File

@@ -1,20 +1,44 @@
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Formats the life date range for a person.
* Examples:
* * 1882 † 1944 (both)
* * 1882 (birth only)
* † 1944 (death only)
* "" (neither)
* Formats one life date (birth or death) at the precision the data claims,
* delegating all rendering to {@link formatDocumentDate}. Returns '' for a
* missing date. Carries no * / † glyph — components that need the glyphs wrap
* them in their own `aria-hidden` markup so screen readers only hear the date.
*
* A missing precision falls back to YEAR: pre-V76 rows only knew a year, and
* a bare year is the only safe rendering for a date without precision metadata.
*/
export function formatLifeDateRange(birthYear?: number | null, deathYear?: number | null): string {
if (birthYear && deathYear) {
return `* ${birthYear} ${deathYear}`;
export function formatLifeDate(
date: string | null | undefined,
precision: DatePrecision | null | undefined,
locale?: string
): string {
if (!date) {
return '';
}
if (birthYear) {
return `* ${birthYear}`;
}
if (deathYear) {
return `${deathYear}`;
}
return '';
return formatDocumentDate(date, precision ?? 'YEAR', null, null, locale);
}
/**
* Formats the full life date range as plain text, e.g. for dropdown subtitles.
* Examples:
* * 14. März 1901 † 2. November 1944 (both, DAY precision)
* * 1882 (birth only, YEAR precision)
* † ca. 1944 (death only, APPROX precision)
* "" (neither)
*/
export function formatLifeDateRange(
birthDate: string | null | undefined,
birthDatePrecision: DatePrecision | null | undefined,
deathDate: string | null | undefined,
deathDatePrecision: DatePrecision | null | undefined,
locale?: string
): string {
const birth = birthDate ? `* ${formatLifeDate(birthDate, birthDatePrecision, locale)}` : null;
const death = deathDate ? `${formatLifeDate(deathDate, deathDatePrecision, locale)}` : null;
if (birth && death) {
return `${birth} ${death}`;
}
return birth ?? death ?? '';
}

View File

@@ -143,6 +143,8 @@ describe('ReaderRecentDocs', () => {
firstName: 'Anna',
displayName: 'Anna Müller',
personType: 'PERSON' as const,
birthDatePrecision: 'UNKNOWN' as const,
deathDatePrecision: 'UNKNOWN' as const,
familyMember: false,
provisional: false
}

View File

@@ -15,7 +15,9 @@ import { m } from '$lib/paraglide/messages.js';
// — see Felix #3 on PR #629.
import { MAX_QUERY_LENGTH } from './mentionConstants';
type Person = components['schemas']['Person'];
// PersonSummaryDTO, not Person: /api/persons list items are the summary projection.
// Typing them as the full entity hid a runtime bug (missing date fields, #812).
type Person = components['schemas']['PersonSummaryDTO'];
// The dropdown receives a single reactive state object. PersonMentionEditor
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
@@ -306,6 +308,12 @@ function selectItem(item: Person) {
</a>
{:else}
{#each model.items as person, i (person.id)}
{@const lifeDates = formatLifeDateRange(
person.birthDate,
person.birthDatePrecision,
person.deathDate,
person.deathDatePrecision
)}
<div
class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
@@ -325,9 +333,11 @@ function selectItem(item: Person) {
}}
>
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
<span class="truncate font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
{#if lifeDates}
<!-- whitespace-normal: a DAY-precision range (~37 chars) wraps to a second
line at 320px instead of being clipped by truncate. -->
<span class="font-sans text-xs whitespace-normal text-ink-3">
{lifeDates}
</span>
{/if}
</div>

View File

@@ -7,7 +7,9 @@ import MentionDropdownFixture from './MentionDropdown.test-fixture.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// PersonSummaryDTO mirrors the real runtime shape: /api/persons list items are
// the summary projection, not the full entity (#812).
type Person = components['schemas']['PersonSummaryDTO'];
afterEach(cleanup);
@@ -19,6 +21,8 @@ const makePerson = (id: string, name: string, overrides: Partial<Person> = {}):
lastName: parts.slice(1).join(' ') || name,
displayName: name,
personType: 'PERSON',
birthDatePrecision: 'UNKNOWN',
deathDatePrecision: 'UNKNOWN',
familyMember: false,
provisional: false,
...overrides
@@ -103,11 +107,18 @@ describe('MentionDropdown', () => {
expect(option?.getAttribute('aria-selected')).toBe('true');
});
it('renders the life-date range when birthYear or deathYear is present', async () => {
it('renders the life-date range when birth or death date is present', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
items: [makePerson('p1', 'Anna', { birthYear: 1899, deathYear: 1972 })]
items: [
makePerson('p1', 'Anna', {
birthDate: '1899-01-01',
birthDatePrecision: 'YEAR',
deathDate: '1972-01-01',
deathDatePrecision: 'YEAR'
})
]
})
}
});
@@ -115,6 +126,25 @@ describe('MentionDropdown', () => {
await expect.element(page.getByText(/1899/)).toBeVisible();
});
it('renders a DAY-precision birth date as a full date without clipping it away', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
items: [
makePerson('p1', 'Anna', {
birthDate: '1901-03-14',
birthDatePrecision: 'DAY',
deathDate: '1944-11-02',
deathDatePrecision: 'DAY'
})
]
})
}
});
await expect.element(page.getByText(/14\. März 1901/)).toBeVisible();
});
it('falls back to a default position when clientRect returns null', async () => {
render(MentionDropdown, {
props: {

View File

@@ -3,7 +3,7 @@ import { untrack } from 'svelte';
import MentionDropdown from './MentionDropdown.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type Person = components['schemas']['PersonSummaryDTO'];
type DropdownState = {
items: Person[];
command: (item: Person) => void;

View File

@@ -12,7 +12,9 @@ import MentionDropdown from './MentionDropdown.svelte';
import { createMentionNodeView } from './mentionNodeView';
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
type Person = components['schemas']['Person'];
// PersonSummaryDTO, not Person: /api/persons list items are the summary projection.
// Typing them as the full entity hid a runtime bug (missing date fields, #812).
type Person = components['schemas']['PersonSummaryDTO'];
type Props = {
value: string;
@@ -216,14 +218,15 @@ const controller = createMentionController();
// reflected data-person-id or the search input).
function commitRelink(pos: number): CommitFn {
return (item: Person) => {
if (!editor) return;
if (!editor || !item.id) return;
const personId = item.id;
editor
.chain()
.focus()
.command(({ tr, state }) => {
const node = state.doc.nodeAt(pos);
if (!node || node.type.name !== 'mention') return false;
tr.setNodeMarkup(pos, undefined, { ...node.attrs, personId: item.id });
tr.setNodeMarkup(pos, undefined, { ...node.attrs, personId });
return true;
})
.run();
@@ -364,8 +367,10 @@ onMount(() => {
render() {
const buildFreshCommit = (loose: LooseRenderProps): CommitFn => {
const clippedQuery = loose.query.slice(0, MAX_QUERY_LENGTH);
return (item: Person) =>
return (item: Person) => {
if (!item.id) return;
loose.command({ personId: item.id, displayName: clippedQuery });
};
};
return {
onStart(renderProps) {

View File

@@ -16,7 +16,7 @@ import { m } from '$lib/paraglide/messages.js';
// module so the test cannot drift from production. Sara on PR #629 round 3.
import { SEARCH_DEBOUNCE_MS } from './mentionConstants';
type Person = components['schemas']['Person'];
type Person = components['schemas']['PersonSummaryDTO'];
type PersonMention = components['schemas']['PersonMention'];
/**
@@ -35,8 +35,10 @@ const AUGUSTE: Person = {
personType: 'PERSON',
familyMember: false,
provisional: false,
birthYear: 1882,
deathYear: 1944
birthDate: '1882-01-01',
birthDatePrecision: 'YEAR',
deathDate: '1944-01-01',
deathDatePrecision: 'YEAR'
};
const ANNA: Person = {
@@ -47,7 +49,9 @@ const ANNA: Person = {
personType: 'PERSON',
familyMember: false,
provisional: false,
birthYear: 1860
birthDate: '1860-01-01',
birthDatePrecision: 'YEAR',
deathDatePrecision: 'UNKNOWN'
};
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {

View File

@@ -8,6 +8,8 @@ export type ErrorCode =
| 'PERSON_NOT_FOUND'
| 'ALIAS_NOT_FOUND'
| 'INVALID_PERSON_TYPE'
| 'BIRTH_AFTER_DEATH'
| 'INVALID_DATE_PRECISION'
| 'INVALID_DATE_RANGE'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
@@ -96,6 +98,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_alias_not_found();
case 'INVALID_PERSON_TYPE':
return m.error_invalid_person_type();
case 'BIRTH_AFTER_DEATH':
return m.error_birth_after_death();
case 'INVALID_DATE_PRECISION':
return m.error_invalid_date_precision();
case 'INVALID_DATE_RANGE':
return m.error_invalid_date_range();
case 'DOCUMENT_NOT_FOUND':

View File

@@ -11,6 +11,11 @@ interface Props {
placeholder?: string;
class?: string;
onchange?: () => void;
ariaLabel?: string;
ariaDescribedby?: string;
// Escape hatch for callers that need the raw element, e.g. to set a custom
// validity and block native form submission while the date is partial.
inputEl?: HTMLInputElement;
}
let {
@@ -20,7 +25,10 @@ let {
id,
placeholder,
class: className = '',
onchange
onchange,
ariaLabel,
ariaDescribedby,
inputEl = $bindable()
}: Props = $props();
let display = $state(isoToGerman(value ?? ''));
@@ -76,10 +84,13 @@ function handleInput(e: Event) {
</script>
<input
bind:this={inputEl}
type="text"
inputmode="numeric"
maxlength="10"
id={id}
aria-label={ariaLabel}
aria-describedby={ariaDescribedby}
value={display}
placeholder={placeholder ?? m.form_placeholder_date()}
oninput={handleInput}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { settled } from './settled';
describe('settled', () => {
it('returns the data for a fulfilled ok response', () => {
const res: PromiseSettledResult<unknown> = {
status: 'fulfilled',
value: { response: { ok: true } as Response, data: [{ id: '1' }] }
};
expect(settled<{ id: string }[]>(res)).toEqual([{ id: '1' }]);
});
it('returns null for a fulfilled non-ok response', () => {
const res: PromiseSettledResult<unknown> = {
status: 'fulfilled',
value: { response: { ok: false, status: 403 } as Response, data: undefined }
};
expect(settled(res)).toBeNull();
});
it('returns null for a rejected result', () => {
const res: PromiseSettledResult<unknown> = {
status: 'rejected',
reason: new Error('network error')
};
expect(settled(res)).toBeNull();
});
it('returns null for undefined input', () => {
expect(settled(undefined)).toBeNull();
});
it('returns null for a fulfilled null value (Promise.resolve(null) placeholder slot)', () => {
const res: PromiseSettledResult<unknown> = { status: 'fulfilled', value: null };
expect(settled(res)).toBeNull();
});
});

View File

@@ -0,0 +1,5 @@
export function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
if (res?.status !== 'fulfilled') return null;
const v = res.value as { response: Response; data: unknown } | null;
return v?.response?.ok ? ((v.data as T) ?? null) : null;
}

View File

@@ -1,6 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server';
import type { components } from '$lib/generated/api';
import { settled } from '$lib/shared/server/settled';
type StatsDTO = components['schemas']['StatsDTO'];
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
@@ -14,12 +15,6 @@ type DocumentListItem = components['schemas']['DocumentListItem'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
if (res?.status !== 'fulfilled') return null;
const v = res.value as { response: Response; data: unknown };
return v.response.ok ? ((v.data as T) ?? null) : null;
}
export async function load({ fetch, parent }) {
const { canWrite, canAnnotate, canBlogWrite } = await parent();
// READ_ALL without WRITE_ALL or ANNOTATE_ALL — see ADR-007.

View File

@@ -288,6 +288,8 @@ describe('DocumentList undated badge in person grouping', () => {
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON' as const,
birthDatePrecision: 'UNKNOWN' as const,
deathDatePrecision: 'UNKNOWN' as const,
familyMember: false,
provisional: false
};
@@ -296,6 +298,8 @@ describe('DocumentList undated badge in person grouping', () => {
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON' as const,
birthDatePrecision: 'UNKNOWN' as const,
deathDatePrecision: 'UNKNOWN' as const,
familyMember: false,
provisional: false
};

View File

@@ -1,20 +1,23 @@
import { error } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { settled } from '$lib/shared/server/settled';
import type { components } from '$lib/generated/api';
import type { PageServerLoad } from './$types';
type Person = components['schemas']['Person'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const load: PageServerLoad = async ({ url, fetch }) => {
export const load: PageServerLoad = async ({ url, fetch, parent }) => {
const { canBlogWrite } = await parent();
const api = createApiClient(fetch);
const personIds = url.searchParams.getAll('personId');
const rawDocumentId = url.searchParams.get('documentId');
const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null;
const [listResult, docResult, ...personResults] = await Promise.all([
const [listSettled, draftsSettled, docSettled, ...personResults] = await Promise.allSettled([
api.GET('/api/geschichten', {
params: {
query: {
@@ -24,20 +27,32 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
}
}
}),
canBlogWrite
? api.GET('/api/geschichten', { params: { query: { status: 'DRAFT' } } })
: Promise.resolve(null),
documentId
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
: Promise.resolve(null),
...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))
]);
if (!listResult.response.ok) {
throw error(listResult.response.status, getErrorMessage(extractErrorCode(listResult.error)));
const listResult = listSettled.status === 'fulfilled' ? listSettled.value : null;
if (!listResult?.response.ok) {
throw error(
listResult?.response.status ?? 500,
getErrorMessage(extractErrorCode(listResult?.error))
);
}
const personFilters = personResults
.filter((r) => r && r.response.ok && r.data)
.map((r) => r!.data!) as Person[];
const drafts: GeschichteSummary[] = canBlogWrite
? (settled<GeschichteSummary[]>(draftsSettled) ?? [])
: [];
const personFilters = personResults
.filter((r) => r.status === 'fulfilled' && r.value?.response.ok && r.value?.data)
.map((r) => (r as PromiseFulfilledResult<{ response: Response; data: Person }>).value.data);
const docResult = docSettled.status === 'fulfilled' ? docSettled.value : null;
let documentFilter: { id: string; title: string | null } | null = null;
if (documentId) {
if (docResult && docResult.response.ok && docResult.data) {
@@ -53,6 +68,7 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
return {
geschichten: listResult.data ?? [],
drafts,
personFilters,
documentFilter
};

View File

@@ -80,7 +80,7 @@ function removeDocument() {
type="button"
aria-pressed={!hasFilters}
onclick={clearAll}
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-semibold text-ink-2 hover:bg-muted aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-fg"
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-semibold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-fg"
>
{m.geschichten_filter_all_pill()}
</button>
@@ -91,7 +91,7 @@ function removeDocument() {
aria-pressed="true"
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
onclick={() => removePerson(p.id!)}
class="inline-flex h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 font-sans text-xs font-semibold text-primary-fg"
class="inline-flex h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 font-sans text-xs font-semibold tracking-wider text-primary-fg uppercase"
>
{p.displayName}
<span aria-hidden="true">×</span>
@@ -133,7 +133,32 @@ function removeDocument() {
</div>
{/if}
<!-- Rows -->
<!-- Entwürfe section (blog writers only, unfiltered) -->
{#if data.drafts.length > 0}
<div class="border-b-2 border-line">
<h2 class="px-3 pt-3 pb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichten_drafts_heading()}
<span class="ml-1 font-normal text-ink-3 normal-case"
>{m.geschichten_drafts_unfiltered_caption()}</span
>
</h2>
<ul>
{#each data.drafts as g (g.id)}
<li class="border-b border-line-2 last:border-b-0">
<GeschichteListRow geschichte={g} />
</li>
{/each}
</ul>
</div>
{/if}
<!-- Published rows -->
{#if data.drafts.length > 0}
<!-- Heading only when the Entwürfe section is present, to keep the h2 outline balanced -->
<h2 class="px-3 pt-3 pb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichten_published_heading()}
</h2>
{/if}
{#if data.geschichten.length === 0}
<div class="px-4 py-12 text-center font-serif text-sm text-ink-3 italic">
{emptyMessage}

View File

@@ -24,11 +24,18 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
return url;
}
function callLoad(url: URL) {
function callLoad(url: URL, parentData: { canBlogWrite?: boolean } = {}) {
return load({
url,
request: new Request('http://localhost/geschichten'),
fetch: vi.fn() as unknown as typeof fetch
fetch: vi.fn() as unknown as typeof fetch,
parent: vi.fn().mockResolvedValue({
canBlogWrite: false,
canWrite: false,
canAnnotate: false,
user: null,
...parentData
})
});
}
@@ -191,3 +198,60 @@ describe('geschichten page load — documentFilter title resolution', () => {
);
});
});
// ─── draft fetch ──────────────────────────────────────────────────────────────
describe('geschichten page load — draft fetch', () => {
it('makes a second API call for DRAFT stories when canBlogWrite is true', async () => {
const mockGet = mockApi();
await callLoad(makeUrl(), { canBlogWrite: true });
const draftCall = mockGet.mock.calls.find(
([, opts]: [string, { params?: { query?: { status?: string } } }]) =>
opts?.params?.query?.status === 'DRAFT'
);
expect(draftCall).toBeDefined();
});
it('does NOT make a DRAFT API call when canBlogWrite is false', async () => {
const mockGet = mockApi();
await callLoad(makeUrl(), { canBlogWrite: false });
const draftCall = mockGet.mock.calls.find(
([, opts]: [string, { params?: { query?: { status?: string } } }]) =>
opts?.params?.query?.status === 'DRAFT'
);
expect(draftCall).toBeUndefined();
});
it('returns empty drafts array when the DRAFT fetch rejects', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
.mockRejectedValueOnce(new Error('network error'));
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await callLoad(makeUrl(), { canBlogWrite: true });
expect(result.drafts).toEqual([]);
});
it('returns drafts in page data when canBlogWrite is true', async () => {
const draft = { id: 'draft-1', title: 'My Draft', status: 'DRAFT' };
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [draft] });
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await callLoad(makeUrl(), { canBlogWrite: true });
expect(result.drafts).toEqual([draft]);
});
});

View File

@@ -26,6 +26,7 @@ function person(id: string, displayName: string) {
function makeData(overrides: Partial<PageData> = {}): PageData {
return {
geschichten: [],
drafts: [],
personFilters: [],
documentFilter: null,
canBlogWrite: false,
@@ -349,3 +350,62 @@ describe('geschichten page — multi-person filter chips', () => {
expect(addEl.className).toContain('h-11');
});
});
// ─── Entwürfe section ─────────────────────────────────────────────────────────
describe('geschichten page — Entwürfe section', () => {
const draft = () =>
({
id: 'draft-1',
title: 'Mein Entwurf',
body: '<p>test</p>',
type: 'STORY',
status: 'DRAFT',
author: { firstName: 'Max', lastName: 'Muster' },
publishedAt: null
}) as unknown as PageData['geschichten'][0];
it('Entwürfe section is hidden when drafts array is empty', async () => {
render(Page, { data: makeData({ drafts: [] }) });
const heading = Array.from(document.querySelectorAll('h2')).find(
(h) => h.textContent?.includes('Entwürfe') || h.textContent?.includes('Drafts')
);
expect(heading).toBeUndefined();
});
it('Entwürfe section is visible when drafts are present', async () => {
render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) });
const heading = Array.from(document.querySelectorAll('h2')).find(
(h) => h.textContent?.includes('Entwürfe') || h.textContent?.includes('Drafts')
);
expect(heading).not.toBeUndefined();
});
it('renders a row for each draft story', async () => {
render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) });
const link = document.querySelector('a[href="/geschichten/draft-1"]');
expect(link).not.toBeNull();
});
it('draft row shows the draft badge', async () => {
render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) });
const badge = document.querySelector('[data-testid="draft-badge"]');
expect(badge).not.toBeNull();
});
it('Veröffentlicht heading is present when the Entwürfe section is visible', async () => {
render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) });
const heading = Array.from(document.querySelectorAll('h2')).find(
(h) => h.textContent?.includes('Veröffentlicht') || h.textContent?.includes('Published')
);
expect(heading).not.toBeUndefined();
});
it('Veröffentlicht heading is absent when there are no drafts', async () => {
render(Page, { data: makeData({ drafts: [] }) });
const heading = Array.from(document.querySelectorAll('h2')).find(
(h) => h.textContent?.includes('Veröffentlicht') || h.textContent?.includes('Published')
);
expect(heading).toBeUndefined();
});
});

View File

@@ -31,6 +31,7 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
personFilters: [] as { id?: string; displayName: string }[],
documentFilter: null,
canBlogWrite: false,
drafts: [] as Array<{ id: string; title: string }>,
...overrides
});

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import { formatLifeDate } from '$lib/person/personLifeDates';
import PersonTypeBadge from '$lib/person/PersonTypeBadge.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
let {
person,
@@ -15,12 +16,17 @@ let {
personType?: string | null;
title?: string | null;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
birthDate?: string | null;
birthDatePrecision?: DatePrecision | null;
deathDate?: string | null;
deathDatePrecision?: DatePrecision | null;
notes?: string | null;
};
canWrite: boolean;
} = $props();
const birthText = $derived(formatLifeDate(person.birthDate, person.birthDatePrecision));
const deathText = $derived(formatLifeDate(person.deathDate, person.deathDatePrecision));
</script>
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
@@ -91,10 +97,16 @@ let {
<p class="mb-1 text-center font-sans text-sm text-ink-2 italic">{person.alias}"</p>
{/if}
<!-- Life dates — centered -->
{#if person.birthYear || person.deathYear}
<!-- Life dates — centered; glyphs aria-hidden so screen readers only hear the dates -->
{#if birthText || deathText}
<p class="mb-4 text-center font-sans text-sm text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
{#if birthText}
<span aria-hidden="true">*</span> {birthText}
{/if}
{#if birthText && deathText}{/if}
{#if deathText}
<span aria-hidden="true"></span> {deathText}
{/if}
</p>
{:else}
<div class="mb-4"></div>

View File

@@ -82,15 +82,84 @@ describe('PersonCard', () => {
await expect.element(page.getByText(/Annerl/)).toBeVisible();
});
it('renders the life-date range when birthYear or deathYear are present', async () => {
it('renders DAY-precision life dates as full localized dates', async () => {
render(PersonCard, {
props: {
person: { ...basePerson, birthYear: 1899, deathYear: 1972 },
person: {
...basePerson,
birthDate: '1901-03-14',
birthDatePrecision: 'DAY' as const,
deathDate: '1944-11-02',
deathDatePrecision: 'DAY' as const
},
canWrite: false
}
});
await expect.element(page.getByText(/1899/)).toBeVisible();
await expect.element(page.getByText(/14\. März 1901/)).toBeVisible();
await expect.element(page.getByText(/2\. November 1944/)).toBeVisible();
});
it('wraps the * and † glyphs in aria-hidden spans', async () => {
const { container } = render(PersonCard, {
props: {
person: {
...basePerson,
birthDate: '1901-03-14',
birthDatePrecision: 'DAY' as const,
deathDate: '1944-11-02',
deathDatePrecision: 'DAY' as const
},
canWrite: false
}
});
const hidden = [...container.querySelectorAll('span[aria-hidden="true"]')].map((el) =>
el.textContent?.trim()
);
expect(hidden).toContain('*');
expect(hidden).toContain('†');
});
it('renders birth-only without dash or dagger', async () => {
const { container } = render(PersonCard, {
props: {
person: {
...basePerson,
birthDate: '1901-03-14',
birthDatePrecision: 'DAY' as const
},
canWrite: false
}
});
await expect.element(page.getByText(/14\. März 1901/)).toBeVisible();
expect(container.textContent).not.toContain('');
expect(container.textContent).not.toContain('†');
});
it('renders APPROX-precision legacy dates with the ca. prefix', async () => {
render(PersonCard, {
props: {
person: {
...basePerson,
birthDate: '1882-01-01',
birthDatePrecision: 'APPROX' as const
},
canWrite: false
}
});
await expect.element(page.getByText(/ca\. 1882/)).toBeVisible();
});
it('renders no life-date line when both dates are missing', async () => {
const { container } = render(PersonCard, {
props: { person: basePerson, canWrite: false }
});
expect(container.textContent).not.toContain('*');
expect(container.textContent).not.toContain('†');
});
it('renders the notes section when notes are provided', async () => {

View File

@@ -1,6 +1,7 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
import {
normalizePersonType,
validatePersonFields,
@@ -47,10 +48,17 @@ export const actions = {
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const notes = formData.get('notes')?.toString().trim() || undefined;
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
// Empty date input → omit date AND precision: the backend normalises the
// absent pair to null/UNKNOWN, and a lone precision would fail the
// coherence check (INVALID_DATE_PRECISION).
const birthDate = formData.get('birthDate')?.toString().trim() || undefined;
const birthDatePrecision = birthDate
? (formData.get('birthDatePrecision')?.toString() as DatePrecision)
: undefined;
const deathDate = formData.get('deathDate')?.toString().trim() || undefined;
const deathDatePrecision = deathDate
? (formData.get('deathDatePrecision')?.toString() as DatePrecision)
: undefined;
// Must NOT use the conditional-spread idiom for generation: G 0 is a
// valid family-tree-root value. The key always travels in the body so
// an explicit clear (empty option) reaches the backend as null.
@@ -73,8 +81,8 @@ export const actions = {
lastName,
...(alias ? { alias } : {}),
...(notes ? { notes } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {}),
...(birthDate ? { birthDate, birthDatePrecision } : {}),
...(deathDate ? { deathDate, deathDatePrecision } : {}),
generation
}
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import PersonLifeDateField from '$lib/person/PersonLifeDateField.svelte';
import PersonTypeSelector from '$lib/person/PersonTypeSelector.svelte';
import {
PERSON_TYPES as TYPES,
@@ -88,32 +89,20 @@ const inputCls =
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
</div>
<div>
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class={inputCls}
/>
</div>
<PersonLifeDateField
name="birthDate"
legend={m.person_label_birth_date()}
precisionLabel={m.person_label_birth_date_precision()}
initialIso={person.birthDate ?? ''}
initialPrecision={person.birthDatePrecision ?? null}
/>
<PersonLifeDateField
name="deathDate"
legend={m.person_label_death_date()}
precisionLabel={m.person_label_death_date_precision()}
initialIso={person.deathDate ?? ''}
initialPrecision={person.deathDatePrecision ?? null}
/>
<div class="md:col-span-2">
<label for="generation" class={labelCls}>{m.person_label_generation()}</label>
<select

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { page, userEvent } from 'vitest/browser';
import PersonEditForm from './PersonEditForm.svelte';
afterEach(cleanup);
@@ -12,8 +12,10 @@ const personPersonal = {
firstName: 'Anna',
lastName: 'Schmidt',
alias: 'Anni',
birthYear: 1899 as number | null,
deathYear: 1972 as number | null,
birthDate: '1899-03-14' as string | null,
birthDatePrecision: 'DAY' as string | null,
deathDate: '1972-01-01' as string | null,
deathDatePrecision: 'YEAR' as string | null,
notes: 'Wohnte in Berlin.'
};
@@ -24,8 +26,10 @@ const personInstitution = {
firstName: null,
lastName: 'Acme GmbH',
alias: null,
birthYear: null,
deathYear: null,
birthDate: null,
birthDatePrecision: null,
deathDate: null,
deathDatePrecision: null,
notes: null
};
@@ -42,14 +46,14 @@ describe('PersonEditForm', () => {
await expect.element(page.getByLabelText(/titel/i)).toBeVisible();
});
it('hides the firstName / title / alias / year fields for INSTITUTION', async () => {
it('hides the firstName / title / alias / life-date fields for INSTITUTION', async () => {
render(PersonEditForm, { props: { person: personInstitution } });
await expect.element(page.getByLabelText(/vorname/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/^titel$/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/rufname/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/geburtsjahr/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/todesjahr/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/^geburtsdatum$/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/^sterbedatum$/i)).not.toBeInTheDocument();
});
it('uses the "Nachname" label for PERSON', async () => {
@@ -77,13 +81,63 @@ describe('PersonEditForm', () => {
expect(title.value).toBe('Frau Dr.');
});
it('renders birthYear and deathYear inputs with prior values', async () => {
it('renders an existing DAY-precision birth date as dd.mm.yyyy in the date input', async () => {
render(PersonEditForm, { props: { person: personPersonal } });
const birthYear = (await page.getByLabelText(/geburtsjahr/i).element()) as HTMLInputElement;
const deathYear = (await page.getByLabelText(/todesjahr/i).element()) as HTMLInputElement;
expect(birthYear.value).toBe('1899');
expect(deathYear.value).toBe('1972');
const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement;
expect(birthInput.value).toBe('14.03.1899');
});
it('submits the ISO date via the hidden birthDate input', async () => {
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
const hidden = container.querySelector('input[type="hidden"][name="birthDate"]');
expect((hidden as HTMLInputElement).value).toBe('1899-03-14');
});
it('offers only DAY / MONTH / YEAR precisions for life dates', async () => {
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
const select = container.querySelector(
'select[name="birthDatePrecision"]'
) as HTMLSelectElement;
const values = Array.from(select.options).map((o) => o.value);
expect(values).toEqual(['DAY', 'MONTH', 'YEAR']);
});
it('hydrates the precision selects from the person prop', async () => {
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
const birth = container.querySelector('select[name="birthDatePrecision"]') as HTMLSelectElement;
const death = container.querySelector('select[name="deathDatePrecision"]') as HTMLSelectElement;
expect(birth.value).toBe('DAY');
expect(death.value).toBe('YEAR');
});
it('seeds a non-form precision (APPROX legacy import) as YEAR instead of DAY', async () => {
const { container } = render(PersonEditForm, {
props: {
person: { ...personPersonal, birthDate: '1899-01-01', birthDatePrecision: 'APPROX' }
}
});
const birth = container.querySelector('select[name="birthDatePrecision"]') as HTMLSelectElement;
expect(birth.value).toBe('YEAR');
});
it('stacks the date input and precision select without overflow at 320px', async () => {
await page.viewport(320, 640);
const { container } = render(PersonEditForm, { props: { person: personPersonal } });
const input = container.querySelector('#birthDate') as HTMLElement;
const select = container.querySelector('select[name="birthDatePrecision"]') as HTMLElement;
expect(input.getBoundingClientRect().right).toBeLessThanOrEqual(320);
expect(select.getBoundingClientRect().right).toBeLessThanOrEqual(320);
// Stacked, not side by side: the select starts below the input.
expect(select.getBoundingClientRect().top).toBeGreaterThanOrEqual(
input.getBoundingClientRect().bottom
);
await page.viewport(1280, 720);
});
it('renders the notes textarea pre-filled with prior content', async () => {
@@ -103,15 +157,23 @@ describe('PersonEditForm', () => {
it('renders empty inputs when nullable fields are null', async () => {
render(PersonEditForm, {
props: { person: { ...personPersonal, title: null, alias: null, birthYear: null } }
props: {
person: {
...personPersonal,
title: null,
alias: null,
birthDate: null,
birthDatePrecision: null
}
}
});
const title = (await page.getByLabelText(/^titel/i).element()) as HTMLInputElement;
const alias = (await page.getByLabelText(/rufname/i).element()) as HTMLInputElement;
const birthYear = (await page.getByLabelText(/geburtsjahr/i).element()) as HTMLInputElement;
const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement;
expect(title.value).toBe('');
expect(alias.value).toBe('');
expect(birthYear.value).toBe('');
expect(birthInput.value).toBe('');
});
// ─── generation dropdown (#689) ─────────────────────────────────────────────
@@ -157,4 +219,16 @@ describe('PersonEditForm', () => {
const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement;
expect(select.value).toBe('');
});
// ─── partial-date guard (#812 review) ────────────────────────────────────────
it('blocks submission while a stored birth date is partially edited (no silent clear)', async () => {
render(PersonEditForm, { props: { person: personPersonal } });
await userEvent.fill(page.getByLabelText(/^geburtsdatum$/i), '14.03.');
const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement;
expect(birthInput.checkValidity()).toBe(false);
await expect.element(page.getByText(/Bitte im Format TT\.MM\.JJJJ/)).toBeVisible();
});
});

View File

@@ -1,6 +1,7 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
import {
normalizePersonType,
validatePersonFields,
@@ -23,8 +24,17 @@ export const actions = {
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
const birthYearStr = formData.get('birthYear')?.toString().trim();
const deathYearStr = formData.get('deathYear')?.toString().trim();
// Empty date input → omit date AND precision: the backend normalises the
// absent pair to null/UNKNOWN, and a lone precision would fail the
// coherence check (INVALID_DATE_PRECISION).
const birthDate = formData.get('birthDate')?.toString().trim() || undefined;
const birthDatePrecision = birthDate
? (formData.get('birthDatePrecision')?.toString() as DatePrecision)
: undefined;
const deathDate = formData.get('deathDate')?.toString().trim() || undefined;
const deathDatePrecision = deathDate
? (formData.get('deathDatePrecision')?.toString() as DatePrecision)
: undefined;
const notes = formData.get('notes')?.toString().trim() || undefined;
// Must NOT use the conditional-spread idiom for generation: G 0 is a
// valid family-tree-root value. Always travels in the body so an
@@ -45,9 +55,6 @@ export const actions = {
});
}
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
const api = createApiClient(fetch);
const result = await api.POST('/api/persons', {
body: {
@@ -56,8 +63,8 @@ export const actions = {
...(firstName ? { firstName } : {}),
lastName: lastName!,
...(alias ? { alias } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {}),
...(birthDate ? { birthDate, birthDatePrecision } : {}),
...(deathDate ? { deathDate, deathDatePrecision } : {}),
...(notes ? { notes } : {}),
generation
}

View File

@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import PersonLifeDateField from '$lib/person/PersonLifeDateField.svelte';
import PersonTypeSelector from '$lib/person/PersonTypeSelector.svelte';
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person/person-validation';
@@ -102,30 +103,16 @@ const labelCls = 'mb-1 block text-sm font-medium text-ink-2';
class={inputCls}
/>
</div>
<div>
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class={inputCls}
/>
</div>
<div>
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class={inputCls}
/>
</div>
<PersonLifeDateField
name="birthDate"
legend={m.person_label_birth_date()}
precisionLabel={m.person_label_birth_date_precision()}
/>
<PersonLifeDateField
name="deathDate"
legend={m.person_label_death_date()}
precisionLabel={m.person_label_death_date_precision()}
/>
{/if}
<div class="md:col-span-2">