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
18 KiB
CLAUDE.md
For a human-readable project overview, see README.md.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
For a human-readable project overview, see README.md.
Project Overview
Familienarchiv is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel/ODS batch import, full-text search, conversation threads between family members, and role-based access control.
Collaboration
See COLLABORATING.md for the full rules: issue tracking workflow, commit message conventions, and the Research → Plan → Implement → Validate cycle.
See CODESTYLE.md for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
Spec-Driven Development
This project uses Spec-Driven Development. Before implementing a feature, read .specify/AGENTS.md (the short, machine-readable agent rules) and obey the .specify/constitution.md it references. A feature's contract is its Gitea issue body (EARS REQ-NNN requirements) — there is no committed spec.md; the RTM (.specify/rtm.md) traces each REQ-ID → issue # → test. Full workflow: SPEC_DRIVEN_DEVELOPMENT.md; template/reference: .specify/features/_example/. The LLM reminders below restate constitution rules — the constitution and AGENTS.md are authoritative if they ever diverge.
Stack
→ See README.md §Tech Stack
- Backend: Spring Boot 4.0 (Java 21, Maven, Jetty, JPA/Hibernate, Flyway, Spring Security, Spring Session JDBC)
- Frontend: SvelteKit 2 with Svelte 5, TypeScript, Tailwind CSS 4, Paraglide.js (i18n: de/en/es)
- Database: PostgreSQL 16
- Object Storage: MinIO (S3-compatible)
- Infrastructure: Docker Compose
Common Commands
Running the Full Stack
docker-compose up -d
Backend (Spring Boot)
cd backend
./mvnw spring-boot:run # Run locally
./mvnw clean package # Build JAR (with tests)
./mvnw clean package -DskipTests
./mvnw test # Run all tests
./mvnw test -Dtest=ClassName # Run a single test class
Frontend (SvelteKit)
cd frontend
npm install
npm run dev # Dev server (port 5173)
npm run build # Production build
npm run preview # Preview production build
npm run lint # Prettier + ESLint check
npm run format # Auto-fix formatting
npm run check # svelte-check (type checking)
npm run test # Vitest unit tests
npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
# (requires backend running with --spring.profiles.active=dev)
Backend Architecture
Package Structure
backend/src/main/java/org/raddatz/familienarchiv/
├── audit/ Audit logging
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
├── config/ Infrastructure config (Minio, Async, Web)
├── dashboard/ Dashboard analytics + StatsController/StatsService
├── document/ Document domain (entities, controller, service, repository, DTOs)
│ ├── annotation/ DocumentAnnotation, AnnotationService, AnnotationController
│ ├── comment/ DocumentComment, CommentService, CommentController
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
├── filestorage/ FileService (S3/MinIO)
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
├── notification/ Notification domain + SseEmitterRegistry
├── ocr/ OCR domain — OcrService, OcrBatchService, training
├── person/ Person domain
│ └── relationship/ PersonRelationship sub-domain
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline
└── user/ User domain — AppUser, UserGroup, UserService
Layering Rules
→ See docs/ARCHITECTURE.md §Layering rule
LLM reminder: controllers never call repositories directly; services never reach into another domain's repository — always call the other domain's service instead.
Domain Model
| Entity | Table | Key relationships |
|---|---|---|
Document |
documents |
ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) |
Person |
persons |
Referenced by documents as sender/receiver |
Tag |
tag |
ManyToMany with documents via document_tags |
AppUser |
app_users |
ManyToMany groups (UserGroup) |
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 |
TimelineEntryDTO |
(computed — no table) | Unified DTO for all timeline entries assembled by TimelineService; 13 fields: kind (EVENT|LETTER), precision (raw DatePrecision enum), derived (boolean), senderName (non-null String, "" = unknown), receiverName (non-null String, "" = unknown), eventDate, eventDateEnd, title, type (EventType, null for LETTER), eventId (null for derived entries and letters), documentId (set for letters), linkedPersonIds: List<UUID>, derivedType (DerivedEventType, null for curated/letters); edit-affordance contract: derived == true || eventId == null → no edit link |
DocumentStatus lifecycle: PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED
PLACEHOLDER: created during Excel import, no file yetUPLOADED: file has been stored in S3
Entity Code Style
All entities use these Lombok annotations:
@Entity
@Table(name = "table_name")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MyEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) // marks field as required in OpenAPI spec
private UUID id;
// ...
}
@Schema(requiredMode = REQUIRED)must be added to every field the backend always populates (id, non-null fields). This drives the TypeScript type generation.- Collections use
@Builder.Defaultwithnew HashSet<>()as the default. - Timestamps use
@CreationTimestamp/@UpdateTimestamp.
Services
Services are annotated with @Service, @RequiredArgsConstructor, and optionally @Slf4j.
- Write methods are annotated
@Transactional. - Read methods are not annotated (default non-transactional is fine).
- Each service owns its domain's repository. Cross-domain data access goes through the other domain's service.
DTOs
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — except the geschichte domain, where every response is a view (GeschichteView/GeschichteSummary/JourneyItemView) assembled inside the service transaction and entities never cross the controller boundary. See ADR-036 — lazy collections + open-in-view: false make serialized entities a 500 waiting to happen.
@Schema(requiredMode = REQUIRED)on every field the backend always populates — drives TypeScript generation.
Error Handling
→ See CONTRIBUTING.md §Error handling
LLM reminder: use DomainException.notFound/forbidden/conflict/internal() from service methods — never throw raw exceptions. When adding a new ErrorCode: (1) add to ErrorCode.java, (2) add to ErrorCode type in frontend/src/lib/shared/errors.ts, (3) add a case in getErrorMessage(), (4) add i18n keys in messages/{de,en,es}.json. Valid error codes include: TOO_MANY_LOGIN_ATTEMPTS (returned by LoginRateLimiter as HTTP 429 when a brute-force threshold is exceeded); JOURNEY_NOTE_TOO_LONG, JOURNEY_DOCUMENT_ALREADY_ADDED, GESCHICHTE_TYPE_IMMUTABLE, GESCHICHTE_TITLE_TOO_LONG, GESCHICHTE_INTRO_TOO_LONG (journey/geschichte domain constraints); TIMELINE_EVENT_NOT_FOUND, TIMELINE_EVENT_CONFLICT, TIMELINE_TITLE_TOO_LONG (timeline event CRUD); INVALID_RELATIONSHIP_DATES (relationship toDate before fromDate), plus a generic CONFLICT (409 optimistic-lock backstop).
Security / Permissions
→ See docs/ARCHITECTURE.md §Permission system
LLM reminder: @RequirePermission(Permission.WRITE_ALL) is required on every POST, PUT, PATCH, DELETE endpoint — not optional. Do not mix with Spring Security's @PreAuthorize. Available permissions: READ_ALL, WRITE_ALL, ADMIN, ADMIN_USER, ADMIN_TAG, ADMIN_PERMISSION, ANNOTATE_ALL, BLOG_WRITE.
OpenAPI / API Types
→ See CONTRIBUTING.md §Walkthrough B — Add a new endpoint
LLM reminder: always run npm run generate:api in frontend/ after any backend model or endpoint change — this is the most common cause of TypeScript type errors.
Frontend Architecture
Route Structure
frontend/src/routes/
├── +layout.svelte / +layout.server.ts Global layout, auth cookie
├── +page.svelte / +page.server.ts Home / document search dashboard
├── documents/
│ ├── [id]/ Document detail (view + file preview)
│ ├── [id]/edit/ Edit form (all metadata + file upload)
│ ├── new/ Upload form
│ └── bulk-edit/ Multi-document edit
├── persons/
│ ├── [id]/ Person detail
│ ├── [id]/edit/ Person edit form
│ ├── new/ Create person form
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode)
│ └── events/ Curator event editor (WRITE_ALL-gated) — new (create) + [id]/edit (edit + delete); reuses lib/timeline/EventForm
├── themen/ Topics directory — browsable tag index
├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management
├── hilfe/transkription/ Transcription help page
├── profile/ User profile settings
├── users/[id]/ Public user profile page
├── login/ logout/ register/
└── forgot-password/ reset-password/
API Client Pattern
→ See CONTRIBUTING.md §Frontend API client
LLM reminder: check !result.response.ok (not result.error — breaks when spec has no error responses defined); cast errors as result.error as unknown as { code?: string }; use result.data! after an ok check.
Form Actions Pattern
// +page.server.ts
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get("name") as string;
// ...
return fail(400, { error: "message" }); // on error
throw redirect(303, "/target"); // on success
},
};
Date Handling
→ See CONTRIBUTING.md §Date handling
LLM reminder: always append T12:00:00 when constructing new Date() from an ISO date string — prevents UTC timezone off-by-one errors.
UI Component Library
→ See per-domain READMEs: frontend/src/lib/person/README.md, frontend/src/lib/tag/README.md, frontend/src/lib/document/README.md, frontend/src/lib/shared/README.md
Styling Conventions (Tailwind CSS 4)
Brand color tokens (defined in layout.css):
| Token / Utility | CSS variable | Usage |
|---|---|---|
brand-navy |
--palette-navy |
Tailwind utility — buttons, headers, primary text |
brand-mint |
--palette-mint |
Tailwind utility — accents, hover underlines, icons |
--palette-sand |
--palette-sand |
Palette constant only — use bg-canvas or bg-surface |
Typography:
font-serif(Tinos) — body text, document titles, namesfont-sans(Montserrat) — labels, metadata, UI chrome
Card pattern for content sections:
<div class="rounded-sm border border-line bg-surface shadow-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-ink-3 mb-5">Section Title</h2>
<!-- content -->
</div>
Back button pattern — use the shared <BackButton> component from $lib/shared/primitives/BackButton.svelte. Do not use a static <a href> for back navigation.
Error Handling (Frontend)
→ See CONTRIBUTING.md §Error handling
LLM reminder: when adding a new ErrorCode: (1) add to ErrorCode.java, (2) add to ErrorCode type in frontend/src/lib/shared/errors.ts, (3) add a case in getErrorMessage(), (4) add i18n keys in messages/{de,en,es}.json. Valid error codes include: TOO_MANY_LOGIN_ATTEMPTS (returned by LoginRateLimiter as HTTP 429 when a brute-force threshold is exceeded); JOURNEY_NOTE_TOO_LONG, JOURNEY_DOCUMENT_ALREADY_ADDED, GESCHICHTE_TYPE_IMMUTABLE, GESCHICHTE_TITLE_TOO_LONG, GESCHICHTE_INTRO_TOO_LONG (journey/geschichte domain constraints); TIMELINE_EVENT_NOT_FOUND, TIMELINE_EVENT_CONFLICT, TIMELINE_TITLE_TOO_LONG (timeline event CRUD); INVALID_RELATIONSHIP_DATES (relationship toDate before fromDate), plus a generic CONFLICT (409 optimistic-lock backstop).
Infrastructure
→ See docs/DEPLOYMENT.md
Observability stack (separate compose file)
Run via docker-compose.observability.yml — requires the main stack to be running first. Full setup procedure: docs/DEPLOYMENT.md §4.
| Service | Container | Default Port | Purpose |
|---|---|---|---|
| Grafana | obs-grafana |
3003 | Metrics / logs / traces dashboard |
| Prometheus | obs-prometheus |
9090 (dev only — 127.0.0.1 bound) |
Metrics store |
| Loki | obs-loki |
— (internal) | Log store |
| Tempo | obs-tempo |
— (internal) | Trace store |
| GlitchTip | obs-glitchtip |
3002 | Error tracking (Sentry-compatible) |
Observability env vars
| Variable | Purpose |
|---|---|
PORT_GRAFANA |
Host port for Grafana UI (default: 3003) |
PORT_GLITCHTIP |
Host port for GlitchTip UI (default: 3002) |
PORT_PROMETHEUS |
Host port for Prometheus UI (default: 9090) |
GRAFANA_ADMIN_PASSWORD |
Grafana admin login password — generate with openssl rand -hex 32 |
GLITCHTIP_SECRET_KEY |
Django secret key for GlitchTip — generate with python3 -c "import secrets; print(secrets.token_hex(32))" |
GLITCHTIP_DOMAIN |
Public-facing base URL for GlitchTip (email links, CORS), e.g. https://glitchtip.example.com |
SENTRY_DSN |
GlitchTip/Sentry DSN for the backend (Spring Boot) — leave empty to disable |
VITE_SENTRY_DSN |
GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite |
Observability
→ See docs/OBSERVABILITY.md — where to look for logs, traces, metrics, and errors.
API Testing
HTTP test files are in backend/api_tests/ for use with the VS Code REST Client extension.
Dev Container
→ See .devcontainer/README.md