# CLAUDE.md

> For a human-readable project overview, see [README.md](./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](./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](./COLLABORATING.md) for the full rules: issue tracking workflow, commit message conventions, and the Research → Plan → Implement → Validate cycle.

See [CODESTYLE.md](./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`](./.specify/AGENTS.md)** (the short, machine-readable agent rules) and obey the [`.specify/constitution.md`](./.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`](./.specify/rtm.md)) traces each `REQ-ID → issue # → test`. Full workflow: [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md); template/reference: [`.specify/features/_example/`](./.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](./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

```bash
docker-compose up -d
```

### Backend (Spring Boot)

```bash
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)

```bash
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

<!-- TODO: rewrite post-REFACTOR-1 — see Epic 4 -->

```
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](./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 yet
- `UPLOADED`: file has been stored in S3

### Entity Code Style

All entities use these Lombok annotations:

```java
@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.Default` with `new 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](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — 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](./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](./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](./CONTRIBUTING.md#4-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](./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

```typescript
// +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](./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/person/README.md), [`frontend/src/lib/tag/README.md`](./frontend/src/lib/tag/README.md), [`frontend/src/lib/document/README.md`](./frontend/src/lib/document/README.md), [`frontend/src/lib/shared/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, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome

Card pattern for content sections:

```svelte
<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](./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](./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](./docs/DEPLOYMENT.md#4-logs--observability).

| 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](./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](./.devcontainer/README.md)
