13 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
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
# From repo root — starts PostgreSQL, MinIO, and Spring Boot backend
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 3000)
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/
├── controller/ REST endpoints — thin, delegate everything to services
├── service/ Business logic — the only place that touches repositories
├── repository/ Spring Data JPA interfaces
├── model/ JPA entities
├── dto/ Input objects (request bodies/form data)
├── exception/ DomainException + ErrorCode enum
├── security/ SecurityConfig, Permission enum, @RequirePermission, PermissionAspect
└── config/ MinioConfig, AsyncConfig
Layering Rules (strictly enforced)
Controller → Service → Repository → DB
- Controllers never inject or call repositories directly.
- Services never reach into another domain's repository. Call the other domain's service instead.
- ✅
DocumentService→PersonService.getById()→PersonRepository - ❌
DocumentService→PersonRepositorydirectly
- ✅
- This keeps domain boundaries clear and business logic testable in isolation.
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 |
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.
Existing services:
| Service | Responsibility |
|---|---|
DocumentService |
Document CRUD, search, tag cascade delete |
PersonService |
Person CRUD, find-or-create by alias |
TagService |
Tag find/create/update/delete |
UserService |
User and group CRUD |
FileService |
S3/MinIO upload and download |
MassImportService |
Async ODS/Excel import; delegates to PersonService and TagService |
ExcelService |
Lower-level spreadsheet parsing |
DTOs
Input DTOs live in dto/. Response types are the model entities themselves (no response DTOs).
DocumentUpdateDTO— used for both create and update (all fields optional)CreateUserRequest— user creationGroupDTO— group create/update
Error Handling
Use DomainException for all domain errors. Never throw raw exceptions from service methods.
// Static factories match common HTTP status codes:
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)
DomainException.forbidden("Access denied")
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "Already running")
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Upload failed: " + e.getMessage())
ErrorCode is an enum in exception/ErrorCode.java. When adding a new error case, add the value there and mirror it in the frontend's src/lib/errors.ts + add a Paraglide translation key.
For simple validation in controllers (not domain logic), ResponseStatusException is acceptable:
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "firstName is required");
Security / Permissions
Use @RequirePermission on controller methods (or the whole controller class):
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(...) { ... }
Available permissions: READ_ALL, WRITE_ALL, ADMIN, ADMIN_USER, ADMIN_TAG, ADMIN_PERMISSION
PermissionAspect (AOP) checks the current user's UserGroup.permissions at runtime.
OpenAPI / API Types
SpringDoc generates the spec at /v3/api-docs (only accessible when running with --spring.profiles.active=dev).
When changing any model field or endpoint:
- Rebuild the backend JAR with
-DskipTests - Start it with
--spring.profiles.active=dev - Run
npm run generate:apiinfrontend/
Frontend Architecture
Route Structure
frontend/src/routes/
├── +layout.svelte Global header (sticky), nav links, logout
├── +layout.server.ts Loads current user, injects auth cookie
├── +page.svelte Home / document search
├── +page.server.ts Load: search documents; no actions
├── documents/
│ ├── [id]/+page.svelte Document detail (view + file preview)
│ └── [id]/edit/ Edit form (all metadata + file upload)
│ └── new/ Create form (same fields, empty)
├── persons/
│ ├── +page.svelte Person list with search
│ ├── [id]/+page.svelte Person detail (inline edit + merge)
│ └── new/ Create person form
├── conversations/ Bilateral conversation timeline
├── admin/ User + group + tag management
└── login/ logout/ Auth pages
API Client Pattern
All server-side API calls use the typed client from $lib/api.server.ts:
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
Key rules:
- Use
!result.response.okfor error checking (notif (result.error)— this breaks when the spec has no error responses defined) - Cast errors as
result.error as unknown as { code?: string }to extract the backend error code - Use
result.data!(non-null assertion) after an ok check — TypeScript knows it's present
For multipart/form-data endpoints (file uploads), bypass the typed client and use raw fetch:
const res = await fetch(`${baseUrl}/api/documents`, { method: 'POST', body: formData });
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; // cast needed — FormData returns FormDataEntryValue
// ...
return fail(400, { error: 'message' }); // on error
throw redirect(303, '/target'); // on success
}
};
Date Handling
- Forms: German format
dd.mm.yyyywith auto-dot insertion viahandleDateInput(). A hidden<input type="hidden" name="documentDate" value={dateIso}>sends ISO format to the backend. - Display: Always use
Intl.DateTimeFormatwithT12:00:00suffix to prevent UTC timezone off-by-one:new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) .format(new Date(doc.documentDate + 'T12:00:00'))
UI Component Library
Custom components in src/lib/components/:
| Component | Props | Description |
|---|---|---|
PersonTypeahead |
name, label, value, initialName, on:change |
Single-person selector with typeahead dropdown |
PersonMultiSelect |
selectedPersons (bind) |
Chip-based multi-person selector |
TagInput |
tags (bind), allowCreation?, on:change |
Tag chip input with typeahead |
Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in layout.css):
| Class | Value | Usage |
|---|---|---|
brand-navy |
#002850 |
Primary text, buttons, headers |
brand-mint |
#A6DAD8 |
Accents, hover underlines, icons |
brand-sand |
#E4E2D7 |
Page background, card borders |
Typography:
font-serif(Merriweather) — body text, document titles, namesfont-sans(Montserrat) — labels, metadata, UI chrome
Card pattern for content sections:
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section Title</h2>
<!-- content -->
</div>
Save bar pattern — use sticky full-bleed for long forms (edit document), card-style with mt-4 for short forms (new person):
<!-- Long forms: sticky, full-bleed -->
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
<!-- Short forms: card, top margin -->
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
Back button pattern — use the shared <BackButton> component from $lib/components/BackButton.svelte:
<script lang="ts">
import BackButton from '$lib/components/BackButton.svelte';
</script>
<BackButton />
The component calls history.back() so the user returns to wherever they came from. Label is always "Zurück" (no contextual suffix — destination is unknown). Touch target ≥ 44px and focus ring are built in. Do not use a static <a href> for back navigation.
Subtle action link (e.g. "new document/person"):
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors">
<svg class="w-4 h-4" ...><!-- plus icon --></svg>
Neues Dokument
</a>
Error Handling (Frontend)
src/lib/errors.ts mirrors the backend ErrorCode enum and maps codes to Paraglide translation keys. When adding a new ErrorCode on the backend:
- Add it to
ErrorCode.java - Add it to the
ErrorCodetype inerrors.ts - Add a
caseingetErrorMessage() - Add the translation key in
messages/de.json,en.json,es.json
Infrastructure
The docker-compose.yml at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the archive-documents bucket. The backend container depends on both db and minio being healthy.
Database migrations live in backend/src/main/resources/db/migration/ (Flyway, SQL files named V{n}__{description}.sql).
API Testing
HTTP test files are in backend/api_tests/ for use with the VS Code REST Client extension.
Dev Container
A .devcontainer/ config is available (Java 21 + Node 24, ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" for a pre-configured environment.