Files
familienarchiv/docs/architecture/db/db-orm.puml
marcel 8558567688
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
Closes #837

Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free.

Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`).

## What's in it
- **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped.
- **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint.
- New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6).
- `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision.
- Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated.
- Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019.

## Requirements
All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`.

## Test plan
- **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds.
- **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean.

## Notes for reviewers
- **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`.
- `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned).
- **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window.

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

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #841
2026-06-14 21:17:36 +02:00

494 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@startuml db-orm
' Schema source: Flyway V1V78 (excl. V37, V43 — intentionally removed)
' Schema as of: V78 (2026-06-14)
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
hide circle
skinparam linetype ortho
' ── Auth ──
package "Auth" {
entity app_users {
id : UUID <<PK>>
--
email : VARCHAR(255) NOT NULL UNIQUE
password : VARCHAR(255) NOT NULL
first_name : VARCHAR(100)
last_name : VARCHAR(100)
birth_date : DATE
contact : TEXT
enabled : BOOLEAN NOT NULL
color : VARCHAR(20) NOT NULL
notify_on_reply : BOOLEAN NOT NULL
notify_on_mention : BOOLEAN NOT NULL
created_at : TIMESTAMP
}
entity user_groups {
id : UUID <<PK>>
--
name : VARCHAR(255) NOT NULL UNIQUE
}
entity app_users_groups {
app_user_id : UUID <<FK>>
group_id : UUID <<FK>>
}
entity group_permissions {
group_id : UUID <<FK>>
--
permission : VARCHAR(255)
}
entity password_reset_tokens {
id : UUID <<PK>>
--
app_user_id : UUID <<FK>>
token : VARCHAR(64) NOT NULL UNIQUE
expires_at : TIMESTAMP NOT NULL
used : BOOLEAN NOT NULL
created_at : TIMESTAMP NOT NULL
}
entity invite_tokens {
id : UUID <<PK>>
--
code : VARCHAR(10) NOT NULL UNIQUE
label : VARCHAR(255)
max_uses : INTEGER
use_count : INTEGER NOT NULL
prefill_first_name : VARCHAR(255)
prefill_last_name : VARCHAR(255)
prefill_email : VARCHAR(255)
expires_at : TIMESTAMP
created_by : UUID <<FK>>
created_at : TIMESTAMP NOT NULL
revoked : BOOLEAN NOT NULL
}
entity invite_token_group_ids {
invite_token_id : UUID <<FK>>
group_id : UUID <<FK>>
}
}
' ── Documents ──
package "Documents" {
entity documents {
id : UUID <<PK>>
--
title : VARCHAR(255) NOT NULL
original_filename : VARCHAR(255) NOT NULL
status : VARCHAR(255) NOT NULL
file_path : VARCHAR(255)
file_hash : VARCHAR(64)
summary : TEXT
transcription : TEXT
meta_date : DATE
meta_date_precision : VARCHAR(16) NOT NULL
meta_date_end : DATE
meta_date_raw : TEXT
sender_text : TEXT
receiver_text : TEXT
meta_location : VARCHAR(255)
meta_document_location : VARCHAR(255)
archive_box : VARCHAR(255)
archive_folder : VARCHAR(255)
sender_id : UUID <<FK>>
metadata_complete : BOOLEAN NOT NULL
script_type : VARCHAR(30) NOT NULL
thumbnail_key : VARCHAR(255)
thumbnail_generated_at : TIMESTAMP
thumbnail_aspect : VARCHAR(16)
page_count : INTEGER
search_vector : tsvector <<computed>>
created_at : TIMESTAMP
updated_at : TIMESTAMP
}
entity document_receivers {
document_id : UUID <<FK>>
person_id : UUID <<FK>>
}
entity document_tags {
document_id : UUID <<FK>>
tag_id : UUID <<FK>>
}
entity document_versions {
id : UUID <<PK>>
--
document_id : UUID <<FK>>
editor_id : UUID <<FK>>
editor_name : VARCHAR(200) NOT NULL
saved_at : TIMESTAMP NOT NULL
snapshot : JSONB NOT NULL
changed_fields : JSONB NOT NULL
}
entity document_annotations {
id : UUID <<PK>>
--
document_id : UUID <<FK>>
page_number : INTEGER NOT NULL
x : DOUBLE PRECISION NOT NULL
y : DOUBLE PRECISION NOT NULL
width : DOUBLE PRECISION NOT NULL
height : DOUBLE PRECISION NOT NULL
color : VARCHAR(20) NOT NULL
polygon : JSONB
file_hash : VARCHAR(64)
created_by : UUID <<FK>>
created_at : TIMESTAMP NOT NULL
}
entity document_comments {
id : UUID <<PK>>
--
document_id : UUID <<FK>>
annotation_id : UUID <<FK>>
block_id : UUID <<FK>>
parent_id : UUID <<FK>>
author_id : UUID <<FK>>
author_name : VARCHAR(200) NOT NULL
content : TEXT NOT NULL
created_at : TIMESTAMP NOT NULL
updated_at : TIMESTAMP NOT NULL
}
entity document_training_labels {
document_id : UUID <<FK>>
--
label : VARCHAR(50) NOT NULL
}
entity comment_mentions {
comment_id : UUID <<FK>>
app_user_id : UUID <<FK>>
}
}
' ── Persons ──
package "Persons" {
entity persons {
id : UUID <<PK>>
--
first_name : VARCHAR(255)
last_name : VARCHAR(255) NOT NULL
alias : VARCHAR(255)
title : VARCHAR(50)
person_type : VARCHAR(20) NOT NULL
notes : TEXT
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
provisional : BOOLEAN NOT NULL
}
entity person_name_aliases {
id : UUID <<PK>>
--
person_id : UUID <<FK>>
last_name : VARCHAR(255) NOT NULL
first_name : VARCHAR(255)
type : VARCHAR(50) NOT NULL
sort_order : INTEGER NOT NULL
created_at : TIMESTAMPTZ
}
entity person_relationships {
id : UUID <<PK>>
--
person_id : UUID <<FK>>
related_person_id : UUID <<FK>>
relation_type : VARCHAR(30) NOT NULL
from_date : DATE
from_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
to_date : DATE
to_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
notes : VARCHAR(2000)
created_at : TIMESTAMPTZ NOT NULL
}
}
' ── Tags ──
package "Tags" {
entity tag {
id : UUID <<PK>>
--
name : VARCHAR(255) NOT NULL UNIQUE
parent_id : UUID <<FK>>
color : VARCHAR(20)
source_ref : VARCHAR(255) UNIQUE
}
}
' ── Transcription ──
package "Transcription" {
entity transcription_blocks {
id : UUID <<PK>>
--
annotation_id : UUID <<FK>>
document_id : UUID <<FK>>
text : TEXT
label : VARCHAR(200)
sort_order : INTEGER NOT NULL
version : INTEGER NOT NULL
source : VARCHAR(10) NOT NULL
reviewed : BOOLEAN NOT NULL
created_by : UUID <<FK>>
updated_by : UUID <<FK>>
created_at : TIMESTAMP NOT NULL
updated_at : TIMESTAMP NOT NULL
}
entity transcription_block_versions {
id : UUID <<PK>>
--
block_id : UUID <<FK>>
text : TEXT NOT NULL
changed_by : UUID <<FK>>
changed_at : TIMESTAMP NOT NULL
}
entity transcription_block_mentioned_persons {
block_id : UUID <<FK>>
person_id : UUID NOT NULL <<FK>>
--
display_name : VARCHAR(200) NOT NULL
}
}
' ── OCR ──
package "OCR" {
entity ocr_jobs {
id : UUID <<PK>>
--
status : VARCHAR(20) NOT NULL
total_documents : INT NOT NULL
processed_documents : INT NOT NULL
error_count : INT NOT NULL
skipped_count : INT NOT NULL
created_by : UUID
progress_message : TEXT
created_at : TIMESTAMPTZ NOT NULL
updated_at : TIMESTAMPTZ NOT NULL
}
entity ocr_job_documents {
id : UUID <<PK>>
--
job_id : UUID <<FK>>
document_id : UUID <<FK>>
status : VARCHAR(20) NOT NULL
error_message : TEXT
current_page : INT
total_pages : INT
created_at : TIMESTAMPTZ NOT NULL
updated_at : TIMESTAMPTZ NOT NULL
}
entity ocr_training_runs {
id : UUID <<PK>>
--
status : VARCHAR(20) NOT NULL
block_count : INT NOT NULL
document_count : INT NOT NULL
model_name : VARCHAR(100) NOT NULL
error_message : TEXT
triggered_by : UUID <<FK>>
person_id : UUID <<FK>>
cer : DOUBLE PRECISION
loss : DOUBLE PRECISION
accuracy : DOUBLE PRECISION
epochs : INT
created_at : TIMESTAMPTZ NOT NULL
completed_at : TIMESTAMPTZ
}
entity sender_models {
id : UUID <<PK>>
--
person_id : UUID <<FK>> UNIQUE
model_path : TEXT NOT NULL
accuracy : DOUBLE PRECISION
cer : DOUBLE PRECISION
corrected_lines_at_training : INT NOT NULL
created_at : TIMESTAMPTZ NOT NULL
updated_at : TIMESTAMPTZ NOT NULL
}
}
' ── Supporting ──
package "Supporting" {
entity notifications {
id : UUID <<PK>>
--
recipient_id : UUID <<FK>>
type : VARCHAR(32) NOT NULL
document_id : UUID
reference_id : UUID
annotation_id : UUID
actor_name : VARCHAR(255)
read : BOOLEAN NOT NULL
created_at : TIMESTAMP NOT NULL
}
entity audit_log {
id : UUID <<PK>>
--
happened_at : TIMESTAMPTZ NOT NULL
actor_id : UUID <<FK>>
kind : VARCHAR(50) NOT NULL
document_id : UUID <<FK>>
payload : JSONB
}
entity geschichten {
id : UUID <<PK>>
--
title : VARCHAR(255) NOT NULL
body : TEXT CHECK (JOURNEY: length <= 4000)
status : VARCHAR(32) NOT NULL
type : VARCHAR(32) NOT NULL
author_id : UUID <<FK>>
created_at : TIMESTAMP NOT NULL
updated_at : TIMESTAMP NOT NULL
published_at : TIMESTAMP
}
entity geschichten_persons {
geschichte_id : UUID <<FK>>
person_id : UUID <<FK>>
}
entity journey_items {
id : UUID <<PK>>
--
geschichte_id : UUID <<FK>>
document_id : UUID <<FK>>
position : INTEGER NOT NULL CHECK (position > 0)
note : TEXT CHECK (length <= 2000)
==
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
UNIQUE (geschichte_id, document_id) WHERE document_id IS NOT NULL
}
}
' ── 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
group_permissions }o--|| user_groups : group_id
password_reset_tokens }o--|| app_users : app_user_id
invite_tokens }o--|| app_users : created_by
invite_token_group_ids }o--|| invite_tokens : invite_token_id
invite_token_group_ids }o--|| user_groups : group_id
' Document relationships
documents }o--o| persons : sender_id (ON DELETE SET NULL)
document_receivers }o--|| documents : document_id
document_receivers }o--|| persons : person_id (ON DELETE CASCADE)
document_tags }o--|| documents : document_id
document_tags }o--|| tag : tag_id
document_versions }o--|| documents : document_id
document_versions }o--o| app_users : editor_id
document_annotations }o--|| documents : document_id
document_annotations }o--o| app_users : created_by
document_comments }o--|| documents : document_id
document_comments }o--o| document_annotations : annotation_id
document_comments }o--o| transcription_blocks : block_id
document_comments }o--o| app_users : author_id
document_comments }o--o| document_comments : parent_id
document_training_labels }o--|| documents : document_id
comment_mentions }o--|| document_comments : comment_id
comment_mentions }o--|| app_users : app_user_id
' Person relationships
person_name_aliases }o--|| persons : person_id
person_relationships }o--|| persons : person_id
person_relationships }o--|| persons : related_person_id
' Tag self-reference
tag }o--o| tag : parent_id
' Transcription relationships
transcription_blocks }o--|| document_annotations : annotation_id
transcription_blocks }o--|| documents : document_id
transcription_blocks }o--o| app_users : created_by
transcription_blocks }o--o| app_users : updated_by
transcription_block_versions }o--|| transcription_blocks : block_id
transcription_block_versions }o--o| app_users : changed_by
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE)
' OCR relationships
ocr_job_documents }o--|| ocr_jobs : job_id
ocr_job_documents }o--|| documents : document_id
ocr_training_runs }o--o| app_users : triggered_by
ocr_training_runs }o--o| persons : person_id
sender_models ||--|| persons : person_id
' Supporting relationships
notifications }o--|| app_users : recipient_id
audit_log }o--o| app_users : actor_id
audit_log }o--o| documents : document_id
geschichten }o--o| app_users : author_id
geschichten_persons }o--|| geschichten : geschichte_id
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