Compare commits
170 Commits
feat/issue
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ec4815e24 | ||
|
|
a7bbf2424f | ||
|
|
7c2c4741ab | ||
|
|
d464bca9f3 | ||
|
|
2283f733cc | ||
|
|
cc20583ae6 | ||
|
|
86d75d91be | ||
|
|
a98ca0e5d3 | ||
|
|
1c515a3145 | ||
|
|
43d36c898c | ||
|
|
60326cfb0a | ||
|
|
e598f5a506 | ||
|
|
e1c78e3fbe | ||
|
|
ae6355d206 | ||
|
|
b5f9fcfdfd | ||
|
|
2f48dfabd1 | ||
|
|
495210052f | ||
|
|
a072701632 | ||
|
|
eac2356948 | ||
|
|
d554fc7e6b | ||
|
|
7bd477d24e | ||
|
|
b1c2132aa6 | ||
|
|
f7eefb525f | ||
|
|
500611925d | ||
|
|
64bcc8d031 | ||
|
|
5a8a1898f8 | ||
|
|
b4f24f4965 | ||
|
|
9e1754bbb0 | ||
|
|
797852b494 | ||
|
|
518334bc38 | ||
|
|
c8b1a890be | ||
|
|
1f592958d7 | ||
|
|
9b5547757a | ||
|
|
92587b050e | ||
|
|
2be2087a95 | ||
|
|
4d9234244e | ||
|
|
9b82621770 | ||
|
|
a58e796ffa | ||
|
|
6a46a1e3eb | ||
|
|
5b645f6374 | ||
|
|
d76ee5fa31 | ||
|
|
5146aeb568 | ||
|
|
9fd1f3cde2 | ||
|
|
5cd6ecc624 | ||
|
|
86de118d63 | ||
|
|
00f35ab675 | ||
|
|
c0a1f04df5 | ||
|
|
7f99c64d45 | ||
|
|
18aaf1f3e8 | ||
|
|
dd0a77a5a2 | ||
|
|
f68d16ef58 | ||
|
|
301cfffd1a | ||
|
|
bf501b7d62 | ||
|
|
5d749b2415 | ||
|
|
1d6016cb19 | ||
|
|
48da819a54 | ||
|
|
153752a901 | ||
|
|
3b6b117c75 | ||
|
|
2e9ce8e1da | ||
|
|
c9be6cc165 | ||
|
|
ffe617dba8 | ||
|
|
47841b9110 | ||
|
|
360db1ae33 | ||
|
|
e5739d7f8e | ||
|
|
219d9a816e | ||
|
|
00682bac4f | ||
|
|
77d282bbeb | ||
|
|
52827ccc87 | ||
|
|
61d1c1793b | ||
|
|
c06987da95 | ||
|
|
5028082da4 | ||
|
|
ea106e9414 | ||
|
|
dfdcacdb85 | ||
|
|
c9fb677499 | ||
|
|
6aceafda8e | ||
|
|
5d92f5a32b | ||
|
|
a6123e1867 | ||
|
|
bd81ff81f9 | ||
|
|
76023a99ed | ||
|
|
e92e9e452e | ||
|
|
59a2faa145 | ||
|
|
8e29f428d7 | ||
|
|
e8fb8150b7 | ||
|
|
6786c0112d | ||
|
|
d43d73f231 | ||
|
|
ad82f2e1e2 | ||
|
|
5fdcc95c3d | ||
|
|
142459b916 | ||
|
|
b31979c4f0 | ||
|
|
1060be7def | ||
|
|
fbf4725e97 | ||
|
|
c90b42d045 | ||
|
|
e61e3797d1 | ||
|
|
ce0c013f0f | ||
|
|
baa0a9811c | ||
|
|
9ef3c82398 | ||
|
|
708fd9d63e | ||
|
|
abe8ab8668 | ||
|
|
e3a3f209f9 | ||
|
|
e877847b7e | ||
|
|
7c25d08506 | ||
|
|
c10e8e8a3a | ||
|
|
0c765d8112 | ||
|
|
cdb54c7545 | ||
|
|
6ab7abb9df | ||
|
|
d28c455991 | ||
|
|
0fa90d58cb | ||
|
|
172bafe202 | ||
|
|
ba0bfc6a7e | ||
|
|
d4b5c14a26 | ||
|
|
e209d4877d | ||
|
|
66c1998d2f | ||
|
|
62bef1d267 | ||
|
|
c3d4762ca0 | ||
|
|
421d7ffd37 | ||
|
|
dbf19037fe | ||
|
|
9387fcc17b | ||
|
|
264db4e1c9 | ||
|
|
12f0e21b21 | ||
|
|
3e33021129 | ||
|
|
32396c6253 | ||
|
|
11b4206fe2 | ||
|
|
eede9f93a7 | ||
|
|
260bb8e164 | ||
|
|
9b82d8e7dd | ||
|
|
ab6117c87e | ||
|
|
b1f9f1603c | ||
|
|
f2a901eabf | ||
|
|
d6ca0f12c9 | ||
|
|
537bfb79f0 | ||
|
|
f74b586f29 | ||
|
|
eb464b351a | ||
|
|
9ad172084a | ||
|
|
0582edd840 | ||
|
|
9986af7c3d | ||
|
|
a4bde0953e | ||
|
|
1b55588aee | ||
|
|
1c560289c8 | ||
|
|
61e58e98ba | ||
|
|
3608a9723a | ||
|
|
63f00ce0a0 | ||
|
|
0a5b290e6c | ||
|
|
ab1a1d1a3d | ||
|
|
9d22a5134f | ||
|
|
883c3381a7 | ||
|
|
f34967f764 | ||
|
|
12487d187f | ||
|
|
d01b9a7508 | ||
|
|
d69a3abc3b | ||
|
|
5c72364899 | ||
|
|
50b18f0849 | ||
|
|
6cf5405b7a | ||
|
|
86c13a230c | ||
|
|
513fda2888 | ||
|
|
995c696c6a | ||
|
|
9b2ed48689 | ||
|
|
a1b89670c0 | ||
|
|
a3c17750cd | ||
|
|
83db80b867 | ||
|
|
a944563560 | ||
|
|
8225baf578 | ||
|
|
bab30fe29c | ||
|
|
69b564b34b | ||
|
|
fc53038af2 | ||
|
|
869885eb78 | ||
|
|
a9b8e19dea | ||
|
|
080e8eb55f | ||
|
|
a5f4b0df31 | ||
|
|
9dae044eec | ||
|
|
5302075124 |
@@ -410,6 +410,23 @@ Never Kafka for teams under 10 or <100k events/day. Never gRPC inside a monolith
|
||||
4. Identify missing database-layer enforcement (constraints, RLS)
|
||||
5. Check transport choices — simpler protocol available?
|
||||
6. Propose a concrete simpler alternative, not just a critique
|
||||
7. Verify documentation currency. For each category below, check whether the PR triggered the update. Flag missing updates as blockers.
|
||||
|
||||
| PR contains | Required doc update |
|
||||
|---|---|
|
||||
| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` |
|
||||
| New `@ManyToMany` join table or FK | Both DB diagrams |
|
||||
| New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` |
|
||||
| New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` |
|
||||
| New SvelteKit route | `CLAUDE.md` route table + matching `docs/architecture/c4/l3-frontend-*.puml` |
|
||||
| New Docker service or infrastructure component | `docs/architecture/c4/l2-containers.puml` + `docs/DEPLOYMENT.md` |
|
||||
| New external system integrated | `docs/architecture/c4/l1-context.puml` |
|
||||
| Auth or upload flow change | `docs/architecture/c4/seq-auth-flow.puml` or `docs/architecture/c4/seq-document-upload.puml` |
|
||||
| New `ErrorCode` or `Permission` value | `CLAUDE.md` + `docs/ARCHITECTURE.md` |
|
||||
| New domain concept or term | `docs/GLOSSARY.md` |
|
||||
| Architectural decision with lasting consequences | New ADR in `docs/adr/` |
|
||||
|
||||
A doc omission is a blocker, not a concern — the PR does not merge until the diagram or text matches the code.
|
||||
|
||||
### Designing Systems
|
||||
1. Start with the data model — get the schema right before application code
|
||||
|
||||
@@ -980,6 +980,24 @@ Mark with `@pytest.mark.asyncio` so pytest runs the coroutine. Without it, the t
|
||||
5. Refactor — apply clean code, extract if 3+ duplications, rename for intent
|
||||
6. Repeat for the next behavior
|
||||
7. When all behaviors are green, review for SOLID violations across the full stack
|
||||
8. Update documentation before opening the PR. Use the table below to know which doc to touch.
|
||||
|
||||
| What changed in code | Doc(s) to update |
|
||||
|---|---|
|
||||
| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) |
|
||||
| New `@ManyToMany` join table or FK relationship | Both DB diagrams above |
|
||||
| New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain |
|
||||
| New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain |
|
||||
| New SvelteKit route (`+page.svelte`) | `CLAUDE.md` (route structure section) **and** the matching `docs/architecture/c4/l3-frontend-*.puml` diagram |
|
||||
| New Docker service / infrastructure component | `docs/architecture/c4/l2-containers.puml` **and** `docs/DEPLOYMENT.md` |
|
||||
| New external system integrated (new API, new S3 bucket, etc.) | `docs/architecture/c4/l1-context.puml` |
|
||||
| Auth flow or document-upload flow changes | `docs/architecture/c4/seq-auth-flow.puml` or `docs/architecture/c4/seq-document-upload.puml` |
|
||||
| New `ErrorCode` enum value | `CLAUDE.md` error handling section **and** `CONTRIBUTING.md` |
|
||||
| New `Permission` enum value | `CLAUDE.md` security section **and** `docs/ARCHITECTURE.md` |
|
||||
| New domain term introduced (entity name, status, concept) | `docs/GLOSSARY.md` |
|
||||
| Architectural decision with lasting consequences (new tech, new transport protocol, new pattern) | New ADR in `docs/adr/` |
|
||||
|
||||
Skip a doc only if the change genuinely does not affect what that doc describes.
|
||||
|
||||
### Reviewing Code
|
||||
1. TDD evidence — are there tests? Do they precede the implementation?
|
||||
|
||||
@@ -38,10 +38,10 @@ Screen readers and search engines rely on landmarks to navigate. Every page need
|
||||
|
||||
2. **Use CSS custom properties for all brand colors**
|
||||
```css
|
||||
/* layout.css */
|
||||
--color-ink: #002850;
|
||||
--color-accent: #A6DAD8;
|
||||
--color-surface: #E4E2D7;
|
||||
/* layout.css — semantic tokens backed by CSS variables (see --palette-* for raw values) */
|
||||
--color-ink: var(--c-ink);
|
||||
--color-accent: var(--c-accent);
|
||||
--color-surface: var(--c-surface);
|
||||
```
|
||||
```svelte
|
||||
<div class="text-ink bg-surface border-line">
|
||||
@@ -103,9 +103,9 @@ unsaved work without warning.
|
||||
|
||||
1. **Enforce WCAG AA contrast ratios**
|
||||
```
|
||||
brand-navy (#002850) on white: 14.5:1 -- AAA pass
|
||||
brand-mint (#A6DAD8) on navy: 7.2:1 -- AAA pass for large text
|
||||
Gray-500 on white: check >= 4.5:1 -- AA minimum for body text
|
||||
brand-navy (--palette-navy) on white: ~14.5:1 -- AAA pass (verify exact value in layout.css)
|
||||
brand-mint (--palette-mint) on navy: ~7.2:1 -- AAA pass for large text
|
||||
Gray-500 on white: check >= 4.5:1 -- AA minimum for body text
|
||||
```
|
||||
Always verify contrast with a tool. AA is the floor (4.5:1 normal text, 3:1 large text). Target AAA (7:1) for body copy.
|
||||
|
||||
@@ -134,8 +134,8 @@ Color-blind users (8% of men) cannot distinguish status by color alone. Always p
|
||||
/* Silver #CACAC9 on white = 1.5:1 -- fails all WCAG levels */
|
||||
.caption { color: #CACAC9; }
|
||||
|
||||
/* brand-mint on white = 2.8:1 -- fails AA for normal text */
|
||||
.label { color: #A6DAD8; }
|
||||
/* brand-mint on white = ~2.8:1 -- fails AA for normal text */
|
||||
.label { color: var(--palette-mint); }
|
||||
```
|
||||
Test every text color against its background. Decorative palette colors are for borders and backgrounds, not text.
|
||||
|
||||
@@ -338,7 +338,7 @@ Test at 320px (small phone), 768px (tablet), and 1440px (desktop). Review diffs
|
||||
<table>
|
||||
<tr><td>Section title</td><td><code>text-xs font-bold uppercase tracking-widest</code></td>
|
||||
<td>12px / 700</td><td>Most commonly undersized</td></tr>
|
||||
<tr><td>Card container</td><td><code>bg-white shadow-sm border border-brand-sand rounded-sm p-6</code></td>
|
||||
<tr><td>Card container</td><td><code>bg-surface shadow-sm border border-line rounded-sm p-6</code></td>
|
||||
<td>padding 24px</td><td>—</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -376,10 +376,10 @@ await page.setViewportSize({ width: 1440, height: 900 });
|
||||
## Domain Expertise
|
||||
|
||||
### Brand Palette
|
||||
- **Primary**: brand-navy `#002850` (text, buttons, headers), brand-mint `#A6DAD8` (accents, hover), brand-sand `#E4E2D7` (backgrounds, borders)
|
||||
- **Typography**: `font-serif` (Merriweather) for body/titles, `font-sans` (Montserrat) for labels/UI chrome
|
||||
- **Card pattern**: `bg-white shadow-sm border border-brand-sand rounded-sm p-6`
|
||||
- **Section title**: `text-xs font-bold uppercase tracking-widest text-gray-400 mb-5`
|
||||
- **Primary**: `brand-navy` (`--palette-navy`) — text, buttons, headers; `brand-mint` (`--palette-mint`) — accents, hover; sand (`--palette-sand`) — page background (use `bg-canvas` or `bg-surface` as Tailwind utilities, not `bg-brand-sand`)
|
||||
- **Typography**: `font-serif` (Tinos) for body/titles, `font-sans` (Montserrat) for labels/UI chrome
|
||||
- **Card pattern**: `bg-surface shadow-sm border border-line rounded-sm p-6`
|
||||
- **Section title**: `text-xs font-bold uppercase tracking-widest text-ink-3 mb-5`
|
||||
|
||||
### Dual-Audience Design (25-42 AND 60+)
|
||||
- Seniors: 16px minimum body text (prefer 18px), 44px touch targets (prefer 48px), redundant cues, calm layouts, persistent navigation, no timed interactions
|
||||
|
||||
@@ -1,96 +1,3 @@
|
||||
# Dev Container — Familienarchiv
|
||||
# Dev Container
|
||||
|
||||
## Overview
|
||||
|
||||
VS Code Dev Container configuration for a pre-configured development environment. Includes Java 21, Maven, and Node.js 24 — everything needed to work on both backend and frontend.
|
||||
|
||||
## Configuration
|
||||
|
||||
File: `.devcontainer/devcontainer.json`
|
||||
|
||||
### Included Features
|
||||
|
||||
| Feature | Version | Purpose |
|
||||
|---|---|---|
|
||||
| Java | 21 | Spring Boot backend |
|
||||
| Maven | bundled with Java feature | Build tool |
|
||||
| Node.js | 24 | SvelteKit frontend |
|
||||
|
||||
### VS Code Extensions (Auto-installed)
|
||||
|
||||
| Extension | Purpose |
|
||||
|---|---|
|
||||
| `vscjava.vscode-java-pack` | Java language support, debugging, testing |
|
||||
| `vmware.vscode-spring-boot` | Spring Boot tooling |
|
||||
| `gabrielbb.vscode-lombok` | Lombok annotation support |
|
||||
| `humao.rest-client` | HTTP request files (for `backend/api_tests/`) |
|
||||
|
||||
### Ports
|
||||
|
||||
- `8080` forwarded to host — access backend at `http://localhost:8080`
|
||||
|
||||
### User
|
||||
|
||||
Runs as `vscode` user (not root) for security.
|
||||
|
||||
## How to Use
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- VS Code with the **Dev Containers** extension installed
|
||||
- Docker running locally
|
||||
|
||||
### Open in Dev Container
|
||||
|
||||
1. Open the project in VS Code
|
||||
2. Press `F1` → type "Dev Containers: Reopen in Container"
|
||||
3. VS Code will:
|
||||
- Build the container using the root `docker-compose.yml`
|
||||
- Install Java 21, Maven, and Node 24
|
||||
- Install the listed extensions
|
||||
- Mount the workspace folder
|
||||
|
||||
### Working Inside the Container
|
||||
|
||||
Once inside the container, you have access to both stacks:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
./mvnw spring-boot:run
|
||||
|
||||
# Frontend (in a new terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The container reuses the `docker-compose.yml` services, so PostgreSQL and MinIO are available automatically.
|
||||
|
||||
### Forwarding Frontend Port
|
||||
|
||||
The devcontainer config only forwards port 8080 by default. To access the frontend dev server (port 5173 or 3000), either:
|
||||
|
||||
1. Add `5173` to `forwardPorts` in `devcontainer.json`, or
|
||||
2. Use the VS Code "Ports" panel to forward it dynamically
|
||||
|
||||
## Limitations
|
||||
|
||||
- The devcontainer attaches to the `backend` service from `docker-compose.yml`, so it inherits those environment variables
|
||||
- OCR service and other containers should be started separately via `docker-compose up -d`
|
||||
- GPU passthrough for OCR training is not configured
|
||||
|
||||
## Customization
|
||||
|
||||
To add more tools or extensions, edit `.devcontainer/devcontainer.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/python:1": {
|
||||
"version": "3.11"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [8080, 5173, 3000]
|
||||
}
|
||||
```
|
||||
→ See [.devcontainer/README.md](./README.md) for configuration, usage, and known limitations.
|
||||
|
||||
94
.devcontainer/README.md
Normal file
94
.devcontainer/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Dev Container — Familienarchiv
|
||||
|
||||
VS Code Dev Container configuration for a pre-configured development environment. Includes Java 21, Maven, and Node.js 24 — everything needed to work on both backend and frontend.
|
||||
|
||||
## Configuration
|
||||
|
||||
File: `.devcontainer/devcontainer.json`
|
||||
|
||||
### Included Features
|
||||
|
||||
| Feature | Version | Purpose |
|
||||
| ------- | ------------------------- | ------------------- |
|
||||
| Java | 21 | Spring Boot backend |
|
||||
| Maven | bundled with Java feature | Build tool |
|
||||
| Node.js | 24 | SvelteKit frontend |
|
||||
|
||||
### VS Code Extensions (Auto-installed)
|
||||
|
||||
| Extension | Purpose |
|
||||
| --------------------------- | --------------------------------------------- |
|
||||
| `vscjava.vscode-java-pack` | Java language support, debugging, testing |
|
||||
| `vmware.vscode-spring-boot` | Spring Boot tooling |
|
||||
| `gabrielbb.vscode-lombok` | Lombok annotation support |
|
||||
| `humao.rest-client` | HTTP request files (for `backend/api_tests/`) |
|
||||
|
||||
### Ports
|
||||
|
||||
- `8080` forwarded to host — access backend at `http://localhost:8080`
|
||||
|
||||
### User
|
||||
|
||||
Runs as `vscode` user (not root) for security.
|
||||
|
||||
## How to Use
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- VS Code with the **Dev Containers** extension installed
|
||||
- Docker running locally
|
||||
|
||||
### Open in Dev Container
|
||||
|
||||
1. Open the project in VS Code
|
||||
2. Press `F1` → type "Dev Containers: Reopen in Container"
|
||||
3. VS Code will:
|
||||
- Build the container using the root `docker-compose.yml`
|
||||
- Install Java 21, Maven, and Node 24
|
||||
- Install the listed extensions
|
||||
- Mount the workspace folder
|
||||
|
||||
### Working Inside the Container
|
||||
|
||||
Once inside the container, you have access to both stacks:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
./mvnw spring-boot:run
|
||||
|
||||
# Frontend (in a new terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The container reuses the `docker-compose.yml` services, so PostgreSQL and MinIO are available automatically.
|
||||
|
||||
### Forwarding Frontend Port
|
||||
|
||||
The devcontainer config only forwards port 8080 by default. To access the frontend dev server (port 5173 or 3000), either:
|
||||
|
||||
1. Add `5173` to `forwardPorts` in `devcontainer.json`, or
|
||||
2. Use the VS Code "Ports" panel to forward it dynamically
|
||||
|
||||
## Limitations
|
||||
|
||||
- The devcontainer attaches to the `backend` service from `docker-compose.yml`, so it inherits those environment variables
|
||||
- OCR service and other containers should be started separately via `docker-compose up -d`
|
||||
- GPU passthrough for OCR training is not configured
|
||||
|
||||
## Customization
|
||||
|
||||
To add more tools or extensions, edit `.devcontainer/devcontainer.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/python:1": {
|
||||
"version": "3.11"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [8080, 5173, 3000]
|
||||
}
|
||||
```
|
||||
@@ -40,6 +40,10 @@ jobs:
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -18,5 +18,11 @@ scripts/large-data.sql
|
||||
.claude/worktrees/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Run artifacts from verification tooling
|
||||
proofshot-artifacts/
|
||||
|
||||
# Root-level Node.js tooling artifacts
|
||||
node_modules/
|
||||
|
||||
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
||||
frontend/yarn.lock
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"plantuml.render": "PlantUMLServer",
|
||||
"plantuml.server": "http://heim-nas:8500"
|
||||
}
|
||||
239
CLAUDE.md
239
CLAUDE.md
@@ -4,6 +4,8 @@
|
||||
|
||||
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.
|
||||
@@ -18,6 +20,8 @@ See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS tr
|
||||
|
||||
## 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
|
||||
@@ -27,12 +31,13 @@ See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS tr
|
||||
## Common Commands
|
||||
|
||||
### Running the Full Stack
|
||||
|
||||
```bash
|
||||
# From repo root — starts PostgreSQL, MinIO, and Spring Boot backend
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Backend (Spring Boot)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
@@ -44,11 +49,12 @@ cd backend
|
||||
```
|
||||
|
||||
### Frontend (SvelteKit)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
npm install
|
||||
npm run dev # Dev server (port 3000)
|
||||
npm run dev # Dev server (port 5173)
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
|
||||
@@ -66,7 +72,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
||||
|
||||
### Package Structure
|
||||
|
||||
Package-by-domain: each domain owns its controller, service, repository, entities, and DTOs.
|
||||
<!-- TODO: rewrite post-REFACTOR-1 — see Epic 4 -->
|
||||
|
||||
```
|
||||
backend/src/main/java/org/raddatz/familienarchiv/
|
||||
@@ -90,27 +96,21 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
└── user/ User domain — AppUser, UserGroup, UserService, auth controllers
|
||||
```
|
||||
|
||||
### Layering Rules (strictly enforced)
|
||||
### Layering Rules
|
||||
|
||||
```
|
||||
Controller → Service → Repository → DB
|
||||
```
|
||||
→ See [docs/ARCHITECTURE.md §Layering rule](./docs/ARCHITECTURE.md#layering-rule)
|
||||
|
||||
- **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` → `PersonRepository` directly
|
||||
- This keeps domain boundaries clear and business logic testable in isolation.
|
||||
**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` |
|
||||
| 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`
|
||||
|
||||
@@ -120,6 +120,7 @@ Controller → Service → Repository → DB
|
||||
### Entity Code Style
|
||||
|
||||
All entities use these Lombok annotations:
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "table_name")
|
||||
@@ -148,65 +149,29 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
||||
- 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 |
|
||||
|
||||
### DTOs
|
||||
|
||||
Input DTOs live in `dto/`. Response types are the model entities themselves (no response DTOs).
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
|
||||
|
||||
- `DocumentUpdateDTO` — used for both create and update (all fields optional)
|
||||
- `CreateUserRequest` — user creation
|
||||
- `GroupDTO` — group create/update
|
||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||
|
||||
### Error Handling
|
||||
|
||||
Use `DomainException` for all domain errors. Never throw raw exceptions from service methods.
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
```java
|
||||
// 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:
|
||||
```java
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "firstName is required");
|
||||
```
|
||||
**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) mirror in `frontend/src/lib/shared/errors.ts`, (3) add i18n keys in `messages/{de,en,es}.json`.
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
Use `@RequirePermission` on controller methods (or the whole controller class):
|
||||
→ See [docs/ARCHITECTURE.md §Permission system](./docs/ARCHITECTURE.md#permission-system)
|
||||
|
||||
```java
|
||||
@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.
|
||||
**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
|
||||
|
||||
SpringDoc generates the spec at `/v3/api-docs` (only accessible when running with `--spring.profiles.active=dev`).
|
||||
→ See [CONTRIBUTING.md §Walkthrough B — Add a new endpoint](./CONTRIBUTING.md#4-walkthrough-b--add-a-new-endpoint)
|
||||
|
||||
When changing any model field or endpoint:
|
||||
1. Rebuild the backend JAR with `-DskipTests`
|
||||
2. Start it with `--spring.profiles.active=dev`
|
||||
3. Run `npm run generate:api` in `frontend/`
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -216,147 +181,99 @@ When changing any model field or endpoint:
|
||||
|
||||
```
|
||||
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
|
||||
├── +layout.svelte / +layout.server.ts Global layout, auth cookie
|
||||
├── +page.svelte / +page.server.ts Home / document search dashboard
|
||||
├── documents/
|
||||
│ ├── [id]/+page.svelte Document detail (view + file preview)
|
||||
│ └── [id]/edit/ Edit form (all metadata + file upload)
|
||||
│ └── new/ Create form (same fields, empty)
|
||||
│ ├── [id]/ Document detail (view + file preview)
|
||||
│ ├── [id]/edit/ Edit form (all metadata + file upload)
|
||||
│ ├── new/ Upload form
|
||||
│ └── bulk-edit/ Multi-document edit
|
||||
├── persons/
|
||||
│ ├── +page.svelte Person list with search
|
||||
│ ├── [id]/+page.svelte Person detail (inline edit + merge)
|
||||
│ ├── [id]/ Person detail
|
||||
│ ├── [id]/edit/ Person edit form
|
||||
│ └── new/ Create person form
|
||||
├── conversations/ Bilateral conversation timeline
|
||||
├── admin/ User + group + tag management
|
||||
└── login/ logout/ Auth pages
|
||||
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
|
||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||
├── stammbaum/ Family tree (Stammbaum)
|
||||
├── 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/
|
||||
└── demo/ Dev-only demos
|
||||
```
|
||||
|
||||
### API Client Pattern
|
||||
|
||||
All server-side API calls use the typed client from `$lib/api.server.ts`:
|
||||
→ See [CONTRIBUTING.md §Frontend API client](./CONTRIBUTING.md#frontend-api-client)
|
||||
|
||||
```typescript
|
||||
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.ok` for error checking (not `if (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`:
|
||||
```typescript
|
||||
const res = await fetch(`${baseUrl}/api/documents`, { method: 'POST', body: formData });
|
||||
```
|
||||
**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; // cast needed — FormData returns FormDataEntryValue
|
||||
// ...
|
||||
return fail(400, { error: 'message' }); // on error
|
||||
throw redirect(303, '/target'); // on success
|
||||
}
|
||||
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
|
||||
|
||||
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO format to the backend.
|
||||
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC timezone off-by-one:
|
||||
```typescript
|
||||
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
.format(new Date(doc.documentDate + 'T12:00:00'))
|
||||
```
|
||||
→ 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
|
||||
|
||||
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 |
|
||||
→ 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 utilities (defined in `layout.css`):
|
||||
Brand color tokens (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 |
|
||||
| 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` (Merriweather) — body text, document titles, names
|
||||
|
||||
- `font-serif` (Tinos) — body text, document titles, names
|
||||
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||
|
||||
Card pattern for content sections:
|
||||
|
||||
```svelte
|
||||
<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>
|
||||
<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>
|
||||
```
|
||||
|
||||
Save bar pattern — use **sticky full-bleed** for long forms (edit document), **card-style with `mt-4`** for short forms (new person):
|
||||
```svelte
|
||||
<!-- 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`:
|
||||
```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"):
|
||||
```svelte
|
||||
<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>
|
||||
```
|
||||
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)
|
||||
|
||||
`src/lib/errors.ts` mirrors the backend `ErrorCode` enum and maps codes to Paraglide translation keys. When adding a new `ErrorCode` on the backend:
|
||||
1. Add it to `ErrorCode.java`
|
||||
2. Add it to the `ErrorCode` type in `errors.ts`
|
||||
3. Add a `case` in `getErrorMessage()`
|
||||
4. Add the translation key in `messages/de.json`, `en.json`, `es.json`
|
||||
→ 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`.
|
||||
|
||||
---
|
||||
|
||||
## 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`).
|
||||
→ See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
|
||||
|
||||
## API Testing
|
||||
|
||||
@@ -364,4 +281,4 @@ HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client
|
||||
|
||||
## 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.
|
||||
→ See [.devcontainer/README.md](./.devcontainer/README.md)
|
||||
|
||||
@@ -180,6 +180,8 @@ When in doubt, commit more often rather than less.
|
||||
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack.
|
||||
|
||||
For domain terminology (Person vs AppUser, DocumentStatus lifecycle, Chronik vs Aktivität, etc.) see [docs/GLOSSARY.md](./docs/GLOSSARY.md).
|
||||
|
||||
Quick reminders:
|
||||
- Pure functions over stateful helpers where possible
|
||||
- No premature abstractions — KISS beats DRY
|
||||
|
||||
305
CONTRIBUTING.md
Normal file
305
CONTRIBUTING.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Contributing to Familienarchiv
|
||||
|
||||
For the full collaboration rules (issue workflow, PR process, Red/Green TDD, commit conventions) see [COLLABORATING.md](./COLLABORATING.md).
|
||||
For coding style see [CODESTYLE.md](./CODESTYLE.md).
|
||||
For the system architecture see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (introduced in DOC-2; until that PR merges, see [docs/architecture/c4-diagrams.md](./docs/architecture/c4-diagrams.md)).
|
||||
For domain terminology see [docs/GLOSSARY.md](./docs/GLOSSARY.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Environment setup
|
||||
|
||||
**Prerequisites:** Java 21 (SDKMAN), Node 24 (nvm), Docker
|
||||
|
||||
**Activate SDKMAN and nvm before running `java`, `mvn`, `node`, or `npm`:**
|
||||
|
||||
```bash
|
||||
source "$HOME/.sdkman/bin/sdkman-init.sh"
|
||||
export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Daily development workflow
|
||||
|
||||
**Startup order — services must start in this sequence:**
|
||||
|
||||
```bash
|
||||
# 1. Start PostgreSQL and MinIO
|
||||
docker compose up -d db minio
|
||||
|
||||
# 2. Start the backend (separate terminal)
|
||||
cd backend && ./mvnw spring-boot:run
|
||||
|
||||
# 3. Start the frontend (separate terminal)
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
> `npm install` also wires up the Husky pre-commit hook via the `prepare` script.
|
||||
> Run it before your first commit, or the hook will fail to execute.
|
||||
|
||||
> **Do not use `docker-compose.ci.yml` locally** — it disables the bind mounts that the dev workflow depends on.
|
||||
|
||||
**Regenerate TypeScript types after any backend API change:**
|
||||
|
||||
```bash
|
||||
# Backend must be running with dev profile
|
||||
cd frontend && npm run generate:api
|
||||
```
|
||||
|
||||
> ⚠️ Forgetting this step is the most common cause of "where did my TypeScript type go?" — always regenerate after changing models or endpoints.
|
||||
|
||||
**Test commands:**
|
||||
|
||||
```bash
|
||||
cd backend && ./mvnw test # backend unit + slice tests
|
||||
cd frontend && npm run test # Vitest unit tests
|
||||
cd frontend && npm run check # svelte-check (type errors)
|
||||
cd frontend && npx playwright test # Playwright e2e tests
|
||||
```
|
||||
|
||||
**Branch naming:** `<type>/<issue-number>-<short-description>`, e.g. `feat/398-contributing`
|
||||
|
||||
**Commits:** one logical change per commit; reference the Gitea issue:
|
||||
|
||||
```
|
||||
feat(person): add aliases endpoint
|
||||
|
||||
Closes #42
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
### Test-type decision matrix
|
||||
|
||||
| What you're testing | Test type | Tool |
|
||||
|---|---|---|
|
||||
| Service business logic, calculations | Unit test | JUnit + `@ExtendWith(MockitoExtension.class)` |
|
||||
| HTTP contract, request validation, error codes | Controller slice test | `@WebMvcTest` |
|
||||
| Server `load` function | Vitest unit | Import directly, mock `fetch` |
|
||||
| Shared UI component | Vitest browser-mode | `render()` + `getByRole()` |
|
||||
| Full user-facing flow, navigation, forms | E2E | Playwright |
|
||||
|
||||
---
|
||||
|
||||
## 3. Walkthrough A — Add a new domain
|
||||
|
||||
**Example:** adding a `citation` domain (formal references to documents).
|
||||
|
||||
Both the backend and frontend are organised **domain-first**. A new domain means adding a package on both sides under the same name.
|
||||
|
||||
### Backend
|
||||
|
||||
1. Create `backend/src/main/java/org/raddatz/familienarchiv/citation/`
|
||||
|
||||
2. Add entity, repository, service, controller, and DTOs flat in the package:
|
||||
- **Entity** `Citation.java` — annotate with `@Entity @Data @Builder @NoArgsConstructor @AllArgsConstructor`; use `@GeneratedValue(strategy = GenerationType.UUID)` for the `id` field; add `@Schema(requiredMode = REQUIRED)` on every field the backend always populates
|
||||
- **Repository** `CitationRepository.java` — extends `JpaRepository<Citation, UUID>`
|
||||
- **Service** `CitationService.java` — `@Service @RequiredArgsConstructor`; write methods `@Transactional`, read methods unannotated; cross-domain data goes through the other domain's service, never its repository
|
||||
- **Controller** `CitationController.java` — `@RestController @RequestMapping("/api/citations")`
|
||||
|
||||
3. Add `@RequirePermission(Permission.WRITE_ALL)` on every `POST`, `PUT`, `PATCH`, and `DELETE` endpoint — **this is not optional**. Read-only `GET` endpoints stay unannotated.
|
||||
|
||||
4. Add a Flyway migration: `backend/src/main/resources/db/migration/V{n}__{description}.sql` (use the next sequential number after the highest existing one).
|
||||
|
||||
5. **Write failing tests before any implementation** (Red step):
|
||||
- Service unit test for business logic (`@ExtendWith(MockitoExtension.class)`)
|
||||
- `@WebMvcTest` slice test for each HTTP endpoint
|
||||
|
||||
6. Rebuild with `--spring.profiles.active=dev` and run `npm run generate:api` in `frontend/`.
|
||||
|
||||
### Frontend
|
||||
|
||||
7. Create `frontend/src/lib/citation/` — domain-specific Svelte components and TypeScript utilities go here.
|
||||
|
||||
8. Add routes under `frontend/src/routes/citations/` as needed.
|
||||
|
||||
9. Add a per-domain `README.md` in both the backend package folder and `frontend/src/lib/citation/` (per DOC-6).
|
||||
|
||||
### Documentation
|
||||
|
||||
10. Update `docs/ARCHITECTURE.md` Section 2 to include the new domain.
|
||||
11. Update `docs/GLOSSARY.md` if new terms are introduced.
|
||||
12. Update the ESLint boundary allow-list in `frontend/eslint.config.js` if the domain needs to import from another domain.
|
||||
|
||||
---
|
||||
|
||||
## 4. Walkthrough B — Add a new endpoint
|
||||
|
||||
**Example:** `POST /api/persons/{id}/aliases` — attach a name alias to an existing person.
|
||||
|
||||
### Red (write failing tests first)
|
||||
|
||||
1. Write a failing `@WebMvcTest` controller slice test:
|
||||
```java
|
||||
@Test
|
||||
void addAlias_returns201_whenAliasCreated() { ... }
|
||||
```
|
||||
|
||||
2. Write a failing service unit test:
|
||||
```java
|
||||
@Test
|
||||
void addAlias_throwsNotFound_whenPersonDoesNotExist() { ... }
|
||||
```
|
||||
|
||||
### Green (implement)
|
||||
|
||||
3. Add the service method in `PersonService.java`:
|
||||
```java
|
||||
@Transactional
|
||||
public PersonNameAlias addAlias(UUID personId, PersonNameAliasDTO dto) { ... }
|
||||
```
|
||||
|
||||
4. Add the controller method in `PersonController.java`:
|
||||
```java
|
||||
@PostMapping("/{id}/aliases")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<PersonNameAlias> addAlias(@PathVariable UUID id,
|
||||
@RequestBody PersonNameAliasDTO dto) { ... }
|
||||
```
|
||||
`@RequirePermission(Permission.WRITE_ALL)` on every state-mutating endpoint — **not optional**.
|
||||
|
||||
5. Validate user-supplied inputs at the controller boundary:
|
||||
```java
|
||||
if (dto.name() == null || dto.name().isBlank())
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name is required");
|
||||
```
|
||||
Validate at system boundaries; trust internal service code.
|
||||
|
||||
6. Use `DomainException` for domain errors:
|
||||
```java
|
||||
DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)
|
||||
```
|
||||
If you need a new error code, add it to `ErrorCode.java`, mirror it in
|
||||
`frontend/src/lib/shared/errors.ts`, and add translation keys in `messages/{de,en,es}.json`.
|
||||
|
||||
7. Mark every field the backend always populates with `@Schema(requiredMode = REQUIRED)` — this drives TypeScript type generation.
|
||||
|
||||
### Types and tests
|
||||
|
||||
8. Rebuild with `--spring.profiles.active=dev`, then `npm run generate:api` in `frontend/`.
|
||||
|
||||
> ⚠️ **Always regenerate types after any API change.** This is the #1 cause of "where did my TypeScript type go?"
|
||||
|
||||
9. Run the full test suite — all green before committing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Walkthrough C — Add a new frontend page
|
||||
|
||||
**Example:** `/persons/[id]/timeline` — a chronological event timeline for one person.
|
||||
|
||||
### Red (write failing test first)
|
||||
|
||||
1. Write a failing Playwright E2E test for the user flow:
|
||||
```typescript
|
||||
test('timeline shows events in chronological order', async ({ page }) => {
|
||||
await page.goto('/persons/1/timeline');
|
||||
// assertions...
|
||||
});
|
||||
```
|
||||
|
||||
### Green (implement)
|
||||
|
||||
2. Create `frontend/src/routes/persons/[id]/timeline/+page.svelte`
|
||||
|
||||
3. Add `frontend/src/routes/persons/[id]/timeline/+page.server.ts` for the SSR load:
|
||||
```typescript
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/persons/{id}', { params: { path: { id: params.id } } });
|
||||
if (!result.response.ok) throw error(result.response.status, '...');
|
||||
return { person: result.data! };
|
||||
};
|
||||
```
|
||||
|
||||
4. Domain-specific components (e.g. `TimelineEntry.svelte`) → `frontend/src/lib/person/`
|
||||
|
||||
5. Shared primitives (e.g. a generic date-range display) → `frontend/src/lib/shared/primitives/`
|
||||
|
||||
6. UI patterns to follow:
|
||||
- Back navigation: `import BackButton from '$lib/shared/primitives/BackButton.svelte'`
|
||||
- Date display: always append `T12:00:00` — `new Intl.DateTimeFormat('de-DE', …).format(new Date(val + 'T12:00:00'))` — prevents UTC off-by-one errors
|
||||
- Brand colors: `brand-navy`, `brand-mint`, `brand-sand` (defined in `src/routes/layout.css`)
|
||||
- Accessibility: touch targets ≥ 44 px (`min-h-[44px]`); focus rings (`focus-visible:ring-2 focus-visible:ring-brand-navy`); `aria-label` on icon-only buttons; `aria-live="polite"` on dynamic status messages
|
||||
|
||||
7. Add Paraglide i18n keys in `messages/de.json`, `messages/en.json`, `messages/es.json`.
|
||||
|
||||
8. If adding a new error code: mirror in `frontend/src/lib/shared/errors.ts` and add translation keys.
|
||||
|
||||
9. Make all tests green before committing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Conventions reference
|
||||
|
||||
### Error handling
|
||||
|
||||
| Scenario | Pattern |
|
||||
|---|---|
|
||||
| Domain entity not found | `DomainException.notFound(ErrorCode.X, "…")` |
|
||||
| Permission denied | `DomainException.forbidden("…")` |
|
||||
| Concurrent edit conflict | `DomainException.conflict(ErrorCode.X, "…")` |
|
||||
| Infrastructure failure | `DomainException.internal(ErrorCode.X, "…")` |
|
||||
| Simple controller validation | `throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "…")` |
|
||||
|
||||
New error code: `ErrorCode.java` → `frontend/src/lib/shared/errors.ts` → `messages/{de,en,es}.json`.
|
||||
|
||||
### DTOs
|
||||
|
||||
- Input DTOs live flat in the domain package (e.g. `PersonUpdateDTO.java`)
|
||||
- Responses are the entity itself — no separate response DTOs
|
||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates
|
||||
|
||||
### Frontend API client
|
||||
|
||||
```typescript
|
||||
const api = createApiClient(fetch); // from $lib/shared/api.server
|
||||
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
||||
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! }; // non-null assertion is safe after the ok check
|
||||
```
|
||||
|
||||
For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it.
|
||||
|
||||
### Date handling
|
||||
|
||||
| Context | Pattern |
|
||||
|---|---|
|
||||
| Form display | German `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()` |
|
||||
| Wire format | ISO 8601 via a hidden `<input type="hidden" name="documentDate" value={dateIso}>` |
|
||||
| Display | `new Intl.DateTimeFormat('de-DE', …).format(new Date(val + 'T12:00:00'))` |
|
||||
|
||||
### Security checklist (new endpoint)
|
||||
|
||||
- `@RequirePermission(Permission.WRITE_ALL)` on every `POST`, `PUT`, `PATCH`, `DELETE` — required, not optional
|
||||
- Validate all user-supplied inputs at the controller boundary before passing to the service
|
||||
- Parameterised queries only — never interpolate user input into JPQL/SQL strings
|
||||
- No raw user input in log messages — use `{}` placeholders: `log.warn("Not found: {}", id)`
|
||||
- Validate content-type and size on upload endpoints before reading the stream
|
||||
|
||||
### Accessibility baseline (new frontend page)
|
||||
|
||||
- Touch targets ≥ 44 px on all interactive elements (`min-h-[44px]`)
|
||||
- Focus rings on all focusable elements (`focus-visible:ring-2 focus-visible:ring-brand-navy`)
|
||||
- `aria-label` on every icon-only button
|
||||
- `aria-live="polite"` on dynamic status messages
|
||||
- Color is never the sole status indicator
|
||||
|
||||
Full WCAG 2.1 AA reference: [docs/STYLEGUIDE.md](./docs/STYLEGUIDE.md).
|
||||
|
||||
### Lint and format
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && npm run lint # Prettier + ESLint check
|
||||
cd frontend && npm run format # Auto-fix formatting
|
||||
cd frontend && npm run check # svelte-check (type errors)
|
||||
|
||||
# Backend — no standalone lint tool; compilation and test runs catch style issues
|
||||
cd backend && ./mvnw test # compile + test
|
||||
cd backend && ./mvnw clean package -DskipTests # compile-only check
|
||||
```
|
||||
@@ -11,7 +11,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
|
||||
- **Server**: Jetty (not Tomcat — excluded in pom.xml)
|
||||
- **Data**: PostgreSQL 16, JPA/Hibernate, Spring Data JPA
|
||||
- **Migrations**: Flyway (SQL files in `src/main/resources/db/migration/`)
|
||||
- **Security**: Spring Security, Spring Session JDBC, JWT tokens
|
||||
- **Security**: Spring Security, Spring Session JDBC
|
||||
- **File Storage**: MinIO via AWS SDK v2 (S3-compatible)
|
||||
- **Spreadsheet Import**: Apache POI 5.5.0 (Excel/ODS)
|
||||
- **API Docs**: SpringDoc OpenAPI 3.x (`/v3/api-docs` — dev profile only)
|
||||
@@ -19,7 +19,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
|
||||
|
||||
## Package Structure
|
||||
|
||||
Package-by-domain: each domain owns its controller, service, repository, entities, and DTOs.
|
||||
<!-- TODO: rewrite post-REFACTOR-1 — see Epic 4 -->
|
||||
|
||||
```
|
||||
src/main/java/org/raddatz/familienarchiv/
|
||||
@@ -43,31 +43,28 @@ src/main/java/org/raddatz/familienarchiv/
|
||||
└── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
|
||||
```
|
||||
|
||||
## Layering Rules (Strict)
|
||||
For per-domain ownership and public surface, see each domain's `README.md`.
|
||||
|
||||
```
|
||||
Controller → Service → Repository → DB
|
||||
```
|
||||
## Layering Rules
|
||||
|
||||
- **Controllers never call repositories directly.**
|
||||
- **Services never reach into another domain's repository.** Call the other domain's service instead.
|
||||
- ✅ `DocumentService` → `PersonService.getById()` → `PersonRepository`
|
||||
- ❌ `DocumentService` → `PersonRepository` directly
|
||||
→ 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.
|
||||
|
||||
## Key Entities
|
||||
|
||||
| Entity | Table | Key Relationships |
|
||||
|---|---|---|
|
||||
| `Document` | `documents` | ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver; name aliases table |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags`; self-referencing parent for tree |
|
||||
| `AppUser` | `app_users` | ManyToMany groups (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
| `TranscriptionBlock` | `transcription_blocks` | Per-document, per-page text blocks with polygons |
|
||||
| `DocumentAnnotation` | `document_annotations` | Free-form annotations on document pages |
|
||||
| `Comment` | `document_comments` | Threaded comments with mentions |
|
||||
| `Notification` | `notifications` | User notification feed |
|
||||
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
|
||||
| Entity | Table | Key Relationships |
|
||||
| --------------------------- | ------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `Document` | `documents` | ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver; name aliases table |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags`; self-referencing parent for tree |
|
||||
| `AppUser` | `app_users` | ManyToMany groups (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
| `TranscriptionBlock` | `transcription_blocks` | Per-document, per-page text blocks with polygons |
|
||||
| `DocumentAnnotation` | `document_annotations` | Free-form annotations on document pages |
|
||||
| `Comment` | `document_comments` | Threaded comments with mentions |
|
||||
| `Notification` | `notifications` | User notification feed |
|
||||
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
@@ -104,32 +101,15 @@ public class MyEntity {
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use `DomainException` for all domain errors:
|
||||
→ See [CONTRIBUTING.md §Error handling](../CONTRIBUTING.md#error-handling)
|
||||
|
||||
```java
|
||||
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "...")
|
||||
DomainException.forbidden("...")
|
||||
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "...")
|
||||
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "...")
|
||||
```
|
||||
|
||||
When adding a new `ErrorCode`:
|
||||
1. Add to `ErrorCode.java`
|
||||
2. Mirror in frontend `src/lib/errors.ts`
|
||||
3. Add Paraglide translation key in `messages/{de,en,es}.json`
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` — never throw raw exceptions from service methods. For simple controller validation (not domain logic), `ResponseStatusException` is acceptable: `throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "…")`. When adding a new `ErrorCode`: add to `ErrorCode.java`, mirror in `frontend/src/lib/shared/errors.ts`, add i18n keys in `messages/{de,en,es}.json`.
|
||||
|
||||
## Security / Permissions
|
||||
|
||||
Use `@RequirePermission` on controller methods or classes:
|
||||
→ See [docs/ARCHITECTURE.md §Permission system](../docs/ARCHITECTURE.md#permission-system)
|
||||
|
||||
```java
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Document updateDocument(...) { ... }
|
||||
```
|
||||
|
||||
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
|
||||
|
||||
`PermissionAspect` checks the current user's `UserGroup.permissions` at runtime.
|
||||
**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`.
|
||||
|
||||
## OCR Integration
|
||||
|
||||
@@ -141,49 +121,35 @@ The backend orchestrates OCR by calling the Python `ocr-service` microservice vi
|
||||
- `OcrBatchService` — handles batch/job workflows
|
||||
- `OcrAsyncRunner` — async execution of OCR jobs
|
||||
|
||||
For ocr-service internals, see [`ocr-service/README.md`](../ocr-service/README.md).
|
||||
|
||||
## API Testing
|
||||
|
||||
HTTP test files in `backend/api_tests/` for the VS Code REST Client extension.
|
||||
|
||||
## How to Run
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Run with dev profile (requires PostgreSQL + MinIO running via docker-compose)
|
||||
./mvnw spring-boot:run
|
||||
|
||||
# Build JAR (with tests)
|
||||
./mvnw clean package
|
||||
|
||||
# Build JAR skipping tests
|
||||
./mvnw spring-boot:run # Run with dev profile (requires PostgreSQL + MinIO)
|
||||
./mvnw clean package # Build JAR (with tests)
|
||||
./mvnw clean package -DskipTests
|
||||
|
||||
# Run all tests
|
||||
./mvnw test
|
||||
|
||||
# Run a single test class
|
||||
./mvnw test -Dtest=ClassName
|
||||
|
||||
# Run with coverage (JaCoCo)
|
||||
./mvnw clean verify
|
||||
./mvnw test # Run all tests
|
||||
./mvnw test -Dtest=ClassName # Run a single test class
|
||||
./mvnw clean verify # Run with JaCoCo coverage report
|
||||
```
|
||||
|
||||
### OpenAPI TypeScript Generation
|
||||
**OpenAPI / TypeScript type generation:**
|
||||
|
||||
1. Build and start backend with `--spring.profiles.active=dev`
|
||||
2. In `frontend/`, run: `npm run generate:api`
|
||||
1. Start backend with `--spring.profiles.active=dev`
|
||||
2. In `frontend/`: `npm run generate:api`
|
||||
|
||||
### Profiles
|
||||
|
||||
- **dev** (default): Enables OpenAPI, dev configs, e2e seeds
|
||||
- **prod**: Production profile — no dev endpoints
|
||||
**LLM reminder:** always regenerate types after any model or endpoint change — the most common cause of "where did my TypeScript type go?"
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: Mockito + JUnit, pure in-memory
|
||||
- Slice tests: `@WebMvcTest`, `@DataJpaTest` with Testcontainers PostgreSQL
|
||||
- Integration tests: Full Spring context with Testcontainers
|
||||
- Coverage gate: 88% branch coverage overall (JaCoCo)
|
||||
- Coverage gate: 88% branch coverage (JaCoCo)
|
||||
|
||||
@@ -190,6 +190,13 @@
|
||||
<artifactId>owasp-java-html-sanitizer</artifactId>
|
||||
<version>20240325.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- HTML → plain-text extraction for comment previews -->
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.18.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# audit
|
||||
|
||||
Append-only event store for all domain mutations. Every write across the application produces an `audit_log` row. The activity feed and Family Pulse dashboard aggregate from this table.
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Table: `audit_log` (append-only by convention — no UPDATE or DELETE in application code).
|
||||
Features: log mutations, query activity feed, query per-entity history.
|
||||
|
||||
**Admission criteria (why this is cross-cutting, not a Tier-1 domain):** consumed by 5+ domains; has no user-facing CRUD of its own; the data model is fixed (event log, not a business entity).
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
Nothing beyond the log table. `audit/` is an infrastructure layer, not a business domain.
|
||||
|
||||
## Public surface (called from other domains)
|
||||
|
||||
| Method | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `logAfterCommit(event)` | document, person, user, ocr, geschichte | Record a mutation event after the DB transaction commits |
|
||||
|
||||
`logAfterCommit` is the only write-path. Query paths (`AuditLogQueryService`) are consumed by `dashboard/` and the activity feed route.
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `AuditService` — `logAfterCommit()` (write)
|
||||
- `AuditLogQueryService` — query by entity, by user, for the activity feed
|
||||
- `AuditLog` (entity) → table `audit_log`
|
||||
- `AuditLogRepository`
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
None. `audit/` is consumed by other domains; it does not call out to any of them.
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
No direct frontend counterpart. Audit data surfaces in the `activity/` and `conversation/` frontend domains via the dashboard API.
|
||||
@@ -29,5 +29,11 @@ public record ActivityFeedItemDTO(
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
||||
)
|
||||
UUID annotationId
|
||||
UUID annotationId,
|
||||
@Nullable
|
||||
@Schema(
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
description = "Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments."
|
||||
)
|
||||
String commentPreview
|
||||
) {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentService;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentData;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
@@ -133,9 +134,9 @@ public class DashboardService {
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
|
||||
Map<UUID, CommentData> commentDataByComment = commentIds.isEmpty()
|
||||
? Map.of()
|
||||
: commentService.findAnnotationIdsByIds(commentIds);
|
||||
: commentService.findDataByIds(commentIds);
|
||||
|
||||
return rows.stream().map(row -> {
|
||||
ActivityActorDTO actor = row.getActorId() != null
|
||||
@@ -146,7 +147,10 @@ public class DashboardService {
|
||||
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
||||
: null;
|
||||
UUID commentId = row.getCommentId();
|
||||
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
|
||||
CommentData commentData = commentId != null ? commentDataByComment.get(commentId) : null;
|
||||
UUID annotationId = commentData != null ? commentData.annotationId() : null;
|
||||
String commentPreview = commentData != null && !commentData.preview().isBlank()
|
||||
? commentData.preview() : null;
|
||||
return new ActivityFeedItemDTO(
|
||||
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
||||
actor,
|
||||
@@ -158,7 +162,8 @@ public class DashboardService {
|
||||
row.getCount(),
|
||||
happenedAtUntil,
|
||||
commentId,
|
||||
annotationId
|
||||
annotationId,
|
||||
commentPreview
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# dashboard
|
||||
|
||||
Stats aggregation for the admin dashboard and the Family Pulse widget. This is a derived domain — it has no tables of its own; all data is computed on-the-fly from Tier-1 domain data.
|
||||
|
||||
## What this domain owns
|
||||
|
||||
No entities. Routes: `/api/dashboard/*`, `/api/stats/*`.
|
||||
Features: document counts, person counts, publication stats, weekly activity data, incomplete-document list, enrichment queue, Family Pulse widget data, admin statistics.
|
||||
|
||||
**Admission criteria (cross-cutting):** aggregates from 3+ domains; no owned entities.
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
None of the underlying data — it reads from `document/`, `person/`, `audit/`, `notification/`, `geschichte/`.
|
||||
|
||||
## Public surface
|
||||
|
||||
`dashboard/` is a leaf domain — no other domain calls its services. It is the aggregator, not the aggregated.
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `StatsController` — REST under `/api/stats`
|
||||
- `DashboardController` — REST under `/api/dashboard`
|
||||
- `StatsService` — aggregated counts (documents, persons, geschichten, incomplete, etc.)
|
||||
- `DashboardService` — activity feed composition, Family Pulse data
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
- `DocumentService.count()` — total document count (StatsService)
|
||||
- `DocumentService.getDocumentById(UUID)` / `getDocumentsByIds(List<UUID>)` — document enrichment for activity feed (DashboardService)
|
||||
- `PersonService.count()` — total person count (StatsService)
|
||||
- `TranscriptionService.listBlocks(UUID)` — transcription block lookup for resume widget (DashboardService)
|
||||
- `UserService.getById(UUID)` — actor name resolution in activity feed (DashboardService)
|
||||
- `CommentService.findAnnotationIdsByIds(...)` — annotation context lookup for activity feed (DashboardService)
|
||||
- `AuditLogQueryService.findMostRecentDocumentForUser()` / `getPulseStats()` / `findActivityFeed()` — audit-sourced feed rows (DashboardService)
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
Activity feed and Pulse widget are assembled in `frontend/src/lib/shared/dashboard/` and in the `aktivitaeten` route; no dedicated `dashboard/` lib folder.
|
||||
@@ -1,7 +1,12 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* Aggregate counts for the dashboard/persons stats bar.
|
||||
*/
|
||||
public record StatsDTO(long totalPersons, long totalDocuments) {
|
||||
public record StatsDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalPersons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalDocuments,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalStories) {
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.dashboard.StatsDTO;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -12,8 +13,9 @@ public class StatsService {
|
||||
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final GeschichteService geschichteService;
|
||||
|
||||
public StatsDTO getStats() {
|
||||
return new StatsDTO(personService.count(), documentService.count());
|
||||
return new StatsDTO(personService.count(), documentService.count(), geschichteService.countPublished());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The non-date filters honoured by {@link DocumentService#getDensity(DensityFilters)}.
|
||||
* Date bounds (from/to) are deliberately excluded — see the service Javadoc for why.
|
||||
*
|
||||
* Kept as a record so the seven values are passed as one named bundle instead of a
|
||||
* positional argument list where two UUIDs (sender vs. receiver) can be swapped by
|
||||
* accident at the call site.
|
||||
*/
|
||||
public record DensityFilters(
|
||||
String text,
|
||||
UUID sender,
|
||||
UUID receiver,
|
||||
List<String> tags,
|
||||
String tagQ,
|
||||
DocumentStatus status,
|
||||
TagOperator tagOperator) {}
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.document;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -48,6 +49,7 @@ import org.raddatz.familienarchiv.filestorage.FileService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -388,6 +390,23 @@ public class DocumentController {
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<DocumentDensityResult> density(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||
@RequestParam(required = false) String tagQ,
|
||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
|
||||
.body(result);
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
public record TrainingLabelRequest(String label, boolean enrolled) {}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of the timeline density aggregation.
|
||||
*
|
||||
* <p>{@code minDate} / {@code maxDate} are intentionally not marked
|
||||
* {@code @Schema(requiredMode = REQUIRED)} — the empty-result case (no
|
||||
* documents match the filter) returns them as {@code null}, which surfaces in
|
||||
* the generated TypeScript as {@code minDate?: string | null}. Frontend code
|
||||
* must treat them as optional.
|
||||
*/
|
||||
public record DocumentDensityResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<MonthBucket> buckets,
|
||||
LocalDate minDate,
|
||||
LocalDate maxDate
|
||||
) {
|
||||
/** The "no documents match the filter" result, with no buckets and null date bounds. */
|
||||
public static DocumentDensityResult empty() {
|
||||
return new DocumentDensityResult(List.of(), null, null);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import java.io.IOException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
@@ -125,6 +126,74 @@ public class DocumentService {
|
||||
return titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-month document counts for the timeline density widget (issue #385).
|
||||
*
|
||||
* <p>Filter-reactive: the chart recomputes when other filters (sender,
|
||||
* receiver, tag, q, status) change so it always matches the list it sits
|
||||
* above. Date bounds (`from`/`to`) are deliberately omitted — the chart is
|
||||
* the surface for picking those, so it must always span the broader space
|
||||
* the user is selecting within.
|
||||
*
|
||||
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
|
||||
* because the existing {@link Specification} predicates compose easily
|
||||
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
|
||||
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
|
||||
* controller layer absorbs repeated browse loads.
|
||||
*
|
||||
* <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
|
||||
* — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
|
||||
* 'YYYY-MM')) and accept that the criteria/specification surface needs a
|
||||
* parallel native-query path.
|
||||
*/
|
||||
public DocumentDensityResult getDensity(DensityFilters filters) {
|
||||
List<UUID> ftsIds = resolveFtsIds(filters.text());
|
||||
if (ftsIds != null && ftsIds.isEmpty()) {
|
||||
return DocumentDensityResult.empty();
|
||||
}
|
||||
List<LocalDate> dates = loadFilteredDates(filters, ftsIds);
|
||||
return aggregateByMonth(dates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the FTS-ranked document IDs when {@code text} is non-blank, or {@code null}
|
||||
* when no full-text query is active. An empty list means the FTS query ran but
|
||||
* matched zero documents — the caller short-circuits on that signal.
|
||||
*/
|
||||
private List<UUID> resolveFtsIds(String text) {
|
||||
if (!StringUtils.hasText(text)) return null;
|
||||
return documentRepository.findRankedIdsByFts(text);
|
||||
}
|
||||
|
||||
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
||||
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
|
||||
boolean hasFts = ftsIds != null;
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasFts, ftsIds, null, null,
|
||||
filters.sender(), filters.receiver(),
|
||||
filters.tags(), filters.tagQ(),
|
||||
filters.status(), filters.tagOperator());
|
||||
return documentRepository.findAll(spec).stream()
|
||||
.map(Document::getDocumentDate)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Buckets {@code dates} into one {@link MonthBucket} per YYYY-MM and computes min/max. */
|
||||
private DocumentDensityResult aggregateByMonth(List<LocalDate> dates) {
|
||||
if (dates.isEmpty()) return DocumentDensityResult.empty();
|
||||
Map<String, Integer> counts = new java.util.TreeMap<>();
|
||||
for (LocalDate d : dates) {
|
||||
counts.merge(YearMonth.from(d).toString(), 1, Integer::sum);
|
||||
}
|
||||
List<MonthBucket> buckets = counts.entrySet().stream()
|
||||
.map(e -> new MonthBucket(e.getKey(), e.getValue()))
|
||||
.toList();
|
||||
LocalDate minDate = dates.stream().min(LocalDate::compareTo).orElse(null);
|
||||
LocalDate maxDate = dates.stream().max(LocalDate::compareTo).orElse(null);
|
||||
return new DocumentDensityResult(buckets, minDate, maxDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine Datei hoch.
|
||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||
@@ -658,6 +727,7 @@ public class DocumentService {
|
||||
return switch (sort) {
|
||||
case TITLE -> Sort.by(direction, "title");
|
||||
case UPLOAD_DATE -> Sort.by(direction, "createdAt");
|
||||
case UPDATED_AT -> Sort.by(direction, "updatedAt");
|
||||
default -> Sort.by(direction, "documentDate");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
public enum DocumentSort {
|
||||
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, RELEVANCE
|
||||
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, UPDATED_AT, RELEVANCE
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record MonthBucket(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "1915-08")
|
||||
String month,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int count
|
||||
) {}
|
||||
@@ -0,0 +1,50 @@
|
||||
# document
|
||||
|
||||
The archive's core concept. A `Document` represents one physical artefact (a letter, a postcard, a photo) stored in MinIO and described by metadata.
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Entities: `Document`, `DocumentVersion`, `TranscriptionBlock`, `DocumentAnnotation`, `DocumentComment`.
|
||||
Features: document CRUD, file upload/download, full-text search, bulk editing, transcription workflows, annotation canvas, threaded comments, thumbnail generation (PDFBox).
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- `Person` (sender / receivers) — referenced by ID, resolved via `PersonService`
|
||||
- `Tag` — referenced by ID; the join is on the document side but tags are owned by `tag/`
|
||||
- `AppUser` — comments reference `AppUser` IDs, but user management lives in `user/`
|
||||
- OCR processing — `ocr/` orchestrates jobs; `ocr-service/` executes them
|
||||
|
||||
## Public surface (called from other domains)
|
||||
|
||||
| Method | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `getDocumentById(UUID)` | ocr, notification | Fetch a single document |
|
||||
| `getDocumentsByIds(List<UUID>)` | ocr | Bulk fetch for OCR job |
|
||||
| `findByOriginalFilename(String)` | importing | Deduplication during mass import |
|
||||
| `deleteTagCascading(UUID tagId)` | tag | Remove a tag from all documents before deleting it |
|
||||
| `findWeeklyStats()` | dashboard | Activity data for Family Pulse widget |
|
||||
| `count()` | dashboard | Total document count for stats |
|
||||
| `addTrainingLabel(...)` | ocr | Attach a confirmed sender label to a document |
|
||||
| `findSegmentationQueue(int limit)` / `findTranscriptionQueue(int limit)` / `findReadyToReadQueue(int limit)` | ocr | OCR pipeline queues |
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `DocumentController` — REST under `/api/documents`
|
||||
- `DocumentService` — CRUD, search (JPA Specifications), bulk edit
|
||||
- `DocumentRepository` — includes bidirectional conversation-thread query
|
||||
- `DocumentSpecifications` — composable `Specification` predicates for search
|
||||
- `DocumentVersionService` / `DocumentVersionRepository` — append-only version history
|
||||
- `ThumbnailService` + `ThumbnailAsyncRunner` — PDFBox thumbnail generation (separate thread pool)
|
||||
- Sub-packages: `annotation/`, `comment/`, `transcription/`
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
- `PersonService.getById()` / `getAllById()` — resolve sender and receivers
|
||||
- `TagService.expandTagNamesToDescendantIdSets()` — tag filter expansion
|
||||
- `FileService.uploadFile()` / `downloadFile()` / `generatePresignedUrl()` — S3 I/O
|
||||
- `NotificationService.notifyMentions()` / `.notifyReply()` — comment mentions
|
||||
- `AuditService.logAfterCommit()` — every mutation is audited
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
`frontend/src/lib/document/README.md`
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.document.comment;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CommentData(@Nullable UUID annotationId, String preview) {}
|
||||
@@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.document.comment.DocumentComment;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentRepository;
|
||||
import org.raddatz.familienarchiv.notification.NotificationService;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -28,21 +29,29 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class CommentService {
|
||||
|
||||
private static final int PREVIEW_MAX_CHARS = 120;
|
||||
|
||||
private final CommentRepository commentRepository;
|
||||
private final UserService userService;
|
||||
private final NotificationService notificationService;
|
||||
private final AuditService auditService;
|
||||
private final TranscriptionService transcriptionService;
|
||||
|
||||
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
|
||||
public Map<UUID, CommentData> findDataByIds(Collection<UUID> commentIds) {
|
||||
if (commentIds == null || commentIds.isEmpty()) return Map.of();
|
||||
Map<UUID, UUID> result = new HashMap<>();
|
||||
Map<UUID, CommentData> result = new HashMap<>();
|
||||
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
|
||||
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
|
||||
result.put(c.getId(), new CommentData(c.getAnnotationId(), stripAndTruncate(c.getContent())));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String stripAndTruncate(String html) {
|
||||
if (html == null || html.isBlank()) return "";
|
||||
String text = Jsoup.parse(html).text().trim();
|
||||
return text.length() > PREVIEW_MAX_CHARS ? text.substring(0, PREVIEW_MAX_CHARS) : text;
|
||||
}
|
||||
|
||||
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
||||
return withRepliesAndMentions(roots);
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
// "Handler" is Spring's @RestControllerAdvice naming convention — not a generic suffix.
|
||||
@RestControllerAdvice
|
||||
@Slf4j
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@@ -56,6 +56,10 @@ public class GeschichteService {
|
||||
|
||||
// ─── Read API ────────────────────────────────────────────────────────────
|
||||
|
||||
public long countPublished() {
|
||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||
}
|
||||
|
||||
public Geschichte getById(UUID id) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
@@ -77,8 +81,10 @@ public class GeschichteService {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||
|
||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
||||
Specification<Geschichte> spec = Specification.allOf(
|
||||
GeschichteSpecifications.hasStatus(effective),
|
||||
GeschichteSpecifications.hasAuthor(authorId),
|
||||
GeschichteSpecifications.hasAllPersons(personIds),
|
||||
GeschichteSpecifications.hasDocument(documentId),
|
||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||
|
||||
@@ -42,6 +42,12 @@ public final class GeschichteSpecifications {
|
||||
};
|
||||
}
|
||||
|
||||
// null authorId → no restriction (PUBLISHED path passes null; Spring Data skips null predicates)
|
||||
public static Specification<Geschichte> hasAuthor(UUID authorId) {
|
||||
return (root, query, cb) ->
|
||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||
}
|
||||
|
||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||
return (root, query, cb) -> {
|
||||
if (documentId == null) return null;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# geschichte
|
||||
|
||||
Family stories — curated narrative pieces that weave together persons, documents, and commentary into a publishable article. German: *Geschichte* (story / history).
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Entity: `Geschichte`.
|
||||
Lifecycle: `DRAFT → PUBLISHED` (only published stories are visible to non-authors).
|
||||
Features: story CRUD, rich-text editing with person and document cross-references, publish/unpublish toggle, comment thread (shared component from `shared/discussion/`).
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- `Person` or `Document` records — stories reference them by ID. Deleting a Person or Document does not cascade to Geschichte.
|
||||
- Comment storage — shared comment infrastructure is in `document/comment/` (or `shared/discussion/` on the frontend).
|
||||
|
||||
## Public surface (called from other domains)
|
||||
|
||||
| Method | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `getById(UUID)` | notification | Resolve story context in mention notifications |
|
||||
| `list(...)` | dashboard | Recent stories for the activity feed |
|
||||
| `count()` | dashboard | Published story count for stats |
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `GeschichteController` — REST under `/api/geschichten`
|
||||
- `GeschichteService` — CRUD, publish lifecycle
|
||||
- `GeschichteRepository` — list by status, author
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
- `PersonService.getById()` / `getAllById()` — resolve person references in story body
|
||||
- `DocumentService.getDocumentsByIds()` — resolve document references in story body
|
||||
- `AuditService.logAfterCommit()` — story mutations are audited
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
`frontend/src/lib/geschichte/README.md`
|
||||
@@ -0,0 +1,41 @@
|
||||
# notification
|
||||
|
||||
In-app messages delivered in real time via SSE and persisted in the bell-icon dropdown. Notifications are created by other domains in response to events (comment mentions, replies).
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Entity: `Notification`.
|
||||
Features: create and deliver notifications, unread count, mark-read, SSE real-time push, per-user delivery preferences (stored as fields on `AppUser`, managed by `user/`).
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- `AppUser` (recipient) — owned by `user/`
|
||||
- `Document` or `Geschichte` (notification context) — referenced by ID only
|
||||
|
||||
## Public surface (called from other domains)
|
||||
|
||||
| Method | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `notifyMentions(mentionedUserIds, comment)` | document (comment) | Push mention notifications when a comment contains @mentions |
|
||||
| `notifyReply(reply, participantIds)` | document (comment) | Push reply notification to all thread participants |
|
||||
| `countUnread(userId)` | user session | Unread badge count in the nav bar |
|
||||
| `getNotifications(userId)` | dashboard / activity | Notification list for bell dropdown |
|
||||
| `markRead(id)` / `markAllRead(userId)` | notification controller | User-driven read-state updates |
|
||||
| `updatePreferences(userId, dto)` | notification controller | Per-user delivery preferences |
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `NotificationController` — REST under `/api/notifications`
|
||||
- `NotificationService` — create, query, mark-read
|
||||
- `SseEmitterRegistry` — runtime-stateful component that keeps one `SseEmitter` per connected user. On `notifyMentions()` / `notifyReply()`, the service writes to `SseEmitterRegistry` to push real-time events. SSE connections go **backend → browser directly**, not via the SvelteKit SSR layer.
|
||||
- `NotificationRepository` — persisted notification rows
|
||||
- `NotificationPreferenceDTO` — read/write DTO for preference endpoints (prefs stored on `AppUser`)
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
**Outbound (this domain calls):**
|
||||
- `DocumentService.findTitlesByIds(List<UUID>)` — enriches notification DTOs with document titles for display in the bell dropdown
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
`frontend/src/lib/notification/README.md`
|
||||
@@ -0,0 +1,44 @@
|
||||
# ocr
|
||||
|
||||
OCR/HTR pipeline orchestration. This domain manages job lifecycle and result ingestion — it does **not** perform OCR. Actual text recognition runs in the Python `ocr-service/` container (port 8000, internal network only).
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Entities: `OcrJob`, `OcrJobDocument`, `SenderModel`.
|
||||
Features: start OCR jobs, track job lifecycle (`PENDING → RUNNING → DONE / FAILED`), stream transcription blocks back into `document/transcription/`, sender-model training, segmentation training.
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- Document content — `Document` and `TranscriptionBlock` are owned by `document/`
|
||||
- File storage — presigned MinIO URLs are generated by `filestorage/FileService` and passed to the OCR service
|
||||
- OCR processing — the Python `ocr-service/` executes Surya (typewritten) and Kraken (Kurrent/Sütterlin HTR) and streams results back
|
||||
|
||||
## Public surface (called from other domains)
|
||||
|
||||
| Method | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `startOcr(documentId, ...)` | document | Trigger an OCR job for a document |
|
||||
| `getJob(UUID)` | document | Fetch job status |
|
||||
| `getDocumentOcrStatus(UUID)` | document | Per-document OCR status summary |
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `OcrController` — REST under `/api/ocr`
|
||||
- `OcrService` — job creation, presigned URL generation, result ingestion
|
||||
- `OcrBatchService` — batch job workflows
|
||||
- `OcrAsyncRunner` — `@Async` execution of OCR jobs
|
||||
- `OcrTrainingService` — calls `/train` and `/segtrain` on the Python service (protected by `X-Training-Token` header)
|
||||
- `OcrJobRepository` / `OcrJobDocumentRepository`
|
||||
- `SenderModelRepository` — trained sender-recognition models
|
||||
- `OcrClient` (interface) / `RestClientOcrClient` — HTTP client for the Python OCR service; mockable for tests
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
- `DocumentService.getDocumentById()` / `getDocumentsByIds()` — resolve target documents
|
||||
- `DocumentService.addTrainingLabel()` — attach confirmed sender labels after training
|
||||
- `FileService.generatePresignedUrl()` — generate MinIO presigned URLs passed to the OCR service (PDF bytes never flow through the backend)
|
||||
- `AuditService.logAfterCommit()` — OCR job events are audited
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
`frontend/src/lib/ocr/README.md`
|
||||
@@ -35,7 +35,14 @@ public class PersonController {
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false, defaultValue = "0") int size,
|
||||
@RequestParam(required = false) String sort) {
|
||||
if ("documentCount".equals(sort) && size > 0 && q == null) {
|
||||
int safeSize = Math.min(size, 50);
|
||||
return ResponseEntity.ok(personService.findTopByDocumentCount(safeSize));
|
||||
}
|
||||
return ResponseEntity.ok(personService.findAll(q));
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,22 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
nativeQuery = true)
|
||||
List<PersonSummaryDTO> searchWithDocumentCount(@Param("query") String query);
|
||||
|
||||
// ORDER BY uses the computed alias "documentCount" — valid PostgreSQL (aliases allowed in ORDER BY,
|
||||
// unlike WHERE/HAVING). This is intentional; it would silently fail on MySQL or H2.
|
||||
@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.family_member AS familyMember,
|
||||
(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
|
||||
FROM persons p
|
||||
ORDER BY documentCount DESC
|
||||
LIMIT :limit
|
||||
""",
|
||||
nativeQuery = true)
|
||||
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
|
||||
@@ -41,6 +41,10 @@ public class PersonService {
|
||||
return personRepository.searchWithDocumentCount(q.trim());
|
||||
}
|
||||
|
||||
public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
|
||||
return personRepository.findTopByDocumentCount(limit);
|
||||
}
|
||||
|
||||
public Person getById(UUID id) {
|
||||
return personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# person
|
||||
|
||||
Historical individuals referenced by documents. A `Person` is a family member who appears as a sender or receiver in the archive — they are never login accounts.
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Entities: `Person`, `PersonNameAlias`, `PersonRelationship`.
|
||||
Features: person CRUD, name alias management, person merge (deduplication), family-member designation, relationship graph, person type classification (FAMILY, CORRESPONDENT, INSTITUTION).
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- `AppUser` — login accounts are in `user/`. A `Person` record has no login credentials. The separation is deliberate: a historical family member from 1905 is never a system user.
|
||||
- Document content — `Person` records are referenced by documents (as sender/receiver), not the other way around.
|
||||
- Relationship rendering — the Stammbaum view is derived by the frontend from `PersonRelationship` data.
|
||||
|
||||
## Public surface (called from other domains)
|
||||
|
||||
| Method | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||
| `findAll(String q)` | document, dashboard | List all persons |
|
||||
| `findByName(String firstName, String lastName)` | document | Typeahead search |
|
||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
|
||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||
| `count()` | dashboard | Total person count for stats |
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `PersonController` — REST under `/api/persons`
|
||||
- `PersonService` — CRUD, merge, alias management, family-member designation
|
||||
- `PersonRepository` — sorted list, name search
|
||||
- `PersonNameAlias` / `PersonNameAliasRepository` — alternative name spellings
|
||||
- `PersonNameParser` / `PersonTypeClassifier` — name parsing utilities
|
||||
- `PersonSummaryDTO` — lightweight DTO for typeahead / list views
|
||||
- Sub-package: `relationship/` — `PersonRelationship`, `RelationshipService`, `RelationshipController`
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
- `AuditService.logAfterCommit()` — person mutations are audited
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
`frontend/src/lib/person/README.md`
|
||||
@@ -7,6 +7,7 @@ import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
// Cross-cutting auth helper; no domain home — "Utils" is the correct suffix here.
|
||||
public final class SecurityUtils {
|
||||
|
||||
private SecurityUtils() {}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# tag
|
||||
|
||||
Hierarchical document categories. Tags form a tree via a self-referencing `parent_id` column and are applied to documents for filtering and browse navigation.
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Entity: `Tag` (self-referencing `parent_id` tree).
|
||||
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
|
||||
- Tag assignment — adding/removing a tag from a document is handled by `DocumentService`.
|
||||
|
||||
## Public surface (called from other domains)
|
||||
|
||||
| Method | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `delete(UUID)` | document | Remove the tag record; called by `DocumentService.deleteTagCascading()` after all document references are unlinked |
|
||||
| `deleteWithDescendants(UUID)` | admin tag UI | Recursive subtree deletion |
|
||||
| `expandTagNamesToDescendantIdSets(List<String>)` | document | Expand tag filter to include descendant tags |
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `TagController` — REST under `/api/tags`
|
||||
- `TagService` — CRUD, hierarchy traversal, cascade-delete coordination
|
||||
- `TagRepository` — find-or-create by name (case-insensitive), subtree queries
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
None. Documents reference tags; tags do not reference documents or other domains.
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
`frontend/src/lib/tag/README.md`
|
||||
@@ -0,0 +1,35 @@
|
||||
# user
|
||||
|
||||
Login accounts and permission groups. An `AppUser` is a system user who can authenticate and act in the application — they are never a historical family member.
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Entities: `AppUser`, `UserGroup`, password-reset tokens, invite tokens.
|
||||
Features: user CRUD, group CRUD, password change, password reset flow, invite links.
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- `Person` records — historical family members. An `AppUser` is never linked to a `Person`. This separation is intentional: a person who digitized letters in 2024 is not the same entity as their great-grandmother who wrote them in 1912. See `docs/GLOSSARY.md`.
|
||||
- Permission enforcement — `security/` owns `@RequirePermission` and `PermissionAspect`. `user/` only manages which permissions are stored on `UserGroup`.
|
||||
|
||||
## Public surface
|
||||
|
||||
`UserService` methods are consumed primarily by the security infrastructure and the admin UI. No other business-logic domain calls `UserService` directly.
|
||||
|
||||
The Spring Security chain (via `CustomUserDetailsService` in `security/`) calls `AppUserRepository.findByUsername()` on every authenticated request.
|
||||
|
||||
## Internal layout
|
||||
|
||||
- `UserController` — REST under `/api/users` (current user, CRUD)
|
||||
- `AuthController` — password reset, invite flow
|
||||
- `UserService` — BCrypt-encoded passwords, group assignment
|
||||
- `AppUserRepository` — find by username (used by Spring Security)
|
||||
- `UserGroupRepository` — group and permission management
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
- `AuditService.logAfterCommit()` — user-management mutations are audited
|
||||
|
||||
## Frontend counterpart
|
||||
|
||||
`frontend/src/lib/user/README.md`
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC);
|
||||
@@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentService;
|
||||
import org.raddatz.familienarchiv.document.comment.CommentData;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
@@ -142,7 +143,8 @@ class DashboardServiceTest {
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
|
||||
when(commentService.findDataByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, new CommentData(null, "preview text")));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
@@ -162,8 +164,8 @@ class DashboardServiceTest {
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, annotationId));
|
||||
when(commentService.findDataByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, new CommentData(annotationId, "preview text")));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
@@ -187,7 +189,62 @@ class DashboardServiceTest {
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).commentId()).isNull();
|
||||
assertThat(items.get(0).annotationId()).isNull();
|
||||
verify(commentService, never()).findAnnotationIdsByIds(anyList());
|
||||
verify(commentService, never()).findDataByIds(anyList());
|
||||
}
|
||||
|
||||
// ─── getActivity commentPreview ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getActivity_populates_commentPreview_for_COMMENT_ADDED_rows() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID commentId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findDataByIds(List.of(commentId)))
|
||||
.thenReturn(Map.of(commentId, new CommentData(null, "Hello family!")));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items.get(0).commentPreview()).isEqualTo("Hello family!");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActivity_leaves_commentPreview_null_for_TEXT_SAVED_rows() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items.get(0).commentPreview()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getActivity_leaves_commentPreview_null_when_comment_is_deleted() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID deletedCommentId = UUID.randomUUID();
|
||||
|
||||
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", deletedCommentId);
|
||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
|
||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||
));
|
||||
when(commentService.findDataByIds(List.of(deletedCommentId))).thenReturn(Map.of());
|
||||
|
||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||
|
||||
assertThat(items.get(0).commentPreview()).isNull();
|
||||
}
|
||||
|
||||
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
|
||||
|
||||
@@ -44,7 +44,7 @@ class StatsControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getStats_returns200_withCorrectCounts() throws Exception {
|
||||
when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L));
|
||||
when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L, 2L));
|
||||
|
||||
mockMvc.perform(get("/api/stats"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -55,7 +55,7 @@ class StatsControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getStats_returns200_withZeroCounts() throws Exception {
|
||||
when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L));
|
||||
when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L, 0L));
|
||||
|
||||
mockMvc.perform(get("/api/stats"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.dashboard.StatsDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -17,6 +18,7 @@ class StatsServiceTest {
|
||||
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock GeschichteService geschichteService;
|
||||
@InjectMocks StatsService statsService;
|
||||
|
||||
@Test
|
||||
@@ -30,6 +32,17 @@ class StatsServiceTest {
|
||||
assertThat(stats.totalDocuments()).isEqualTo(12L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStats_includes_totalStories() {
|
||||
when(personService.count()).thenReturn(3L);
|
||||
when(documentService.count()).thenReturn(7L);
|
||||
when(geschichteService.countPublished()).thenReturn(5L);
|
||||
|
||||
StatsDTO stats = statsService.getStats();
|
||||
|
||||
assertThat(stats.totalStories()).isEqualTo(5L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStats_returnsZero_whenNoEntities() {
|
||||
when(personService.count()).thenReturn(0L);
|
||||
|
||||
@@ -44,6 +44,7 @@ import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
@@ -1240,4 +1241,100 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.errors[0].message").value(
|
||||
org.hamcrest.Matchers.containsString("not found")));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/density ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void density_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_returns200_withResultBody_whenAuthenticated() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(
|
||||
List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)),
|
||||
java.time.LocalDate.of(1915, 8, 3),
|
||||
java.time.LocalDate.of(1915, 9, 1)));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.buckets").isArray())
|
||||
.andExpect(jsonPath("$.buckets[0].month").value("1915-08"))
|
||||
.andExpect(jsonPath("$.buckets[0].count").value(2))
|
||||
.andExpect(jsonPath("$.minDate").value("1915-08-03"))
|
||||
.andExpect(jsonPath("$.maxDate").value("1915-09-01"));
|
||||
}
|
||||
|
||||
// Pins produces=APPLICATION_JSON_VALUE on the density mapping so the OpenAPI/TypeScript
|
||||
// codegen records application/json instead of the wildcard. Without produces= the
|
||||
// request-mapping accepts any Accept header and the OpenAPI emit falls back to the
|
||||
// wildcard. Sending an Accept header that JSON cannot satisfy must NOT return 200 —
|
||||
// Spring rejects with 406 (HttpMediaTypeNotAcceptableException), which our
|
||||
// GlobalExceptionHandler may surface as 400. Either way it proves the route is
|
||||
// locked to JSON.
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_declaresApplicationJsonContentType() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.accept(MediaType.APPLICATION_XML))
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_emitsPrivateCacheControlHeader() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("max-age=300")))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("private")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_forwardsSenderAndTagFilters() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
UUID senderId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.param("senderId", senderId.toString())
|
||||
.param("tag", "Familie")
|
||||
.param("tag", "Urlaub")
|
||||
.param("tagOp", "OR"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getDensity(eq(new DensityFilters(
|
||||
null, senderId, null,
|
||||
List.of("Familie", "Urlaub"),
|
||||
null, null,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.OR)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_forwardsStatusAndQueryText() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
mockMvc.perform(get("/api/documents/density")
|
||||
.param("q", "Brief")
|
||||
.param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getDensity(eq(new DensityFilters(
|
||||
"Brief", null, null, null, null,
|
||||
DocumentStatus.REVIEWED,
|
||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
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.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end test for the filter-reactive density aggregation.
|
||||
* Density bars must recompute as the user changes other filters (sender, tag,
|
||||
* status, …). The endpoint deliberately does NOT honour `from`/`to` — the chart
|
||||
* is the surface for picking those, so it must always span the broader space
|
||||
* the user is selecting within.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class DocumentDensityIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired TagRepository tagRepository;
|
||||
|
||||
private Person hans;
|
||||
private Person anna;
|
||||
private Tag familieTag;
|
||||
private Tag urlaubTag;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
anna = personRepository.save(Person.builder().firstName("Anna").lastName("Weber").build());
|
||||
familieTag = tagRepository.save(Tag.builder().name("Familie").build());
|
||||
urlaubTag = tagRepository.save(Tag.builder().name("Urlaub").build());
|
||||
}
|
||||
|
||||
private static DensityFilters noFilters() {
|
||||
return new DensityFilters(null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsAllMonths_whenNoFiltersApplied() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of());
|
||||
save("b", LocalDate.of(1915, 8, 17), null, Set.of());
|
||||
save("c", LocalDate.of(1915, 9, 1), null, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(noFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1915-09");
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
|
||||
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersBySender() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
|
||||
save("b", LocalDate.of(1916, 1, 4), hans, Set.of());
|
||||
save("c", LocalDate.of(1920, 5, 1), anna, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, hans.getId(), null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1916-01");
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1916, 1, 4));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersByTag() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of(familieTag));
|
||||
save("b", LocalDate.of(1920, 5, 1), null, Set.of(urlaubTag));
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, null, null, List.of("Familie"), null, null, TagOperator.AND));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_combinesSenderAndTag() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of(familieTag));
|
||||
save("b", LocalDate.of(1916, 1, 4), hans, Set.of(urlaubTag));
|
||||
save("c", LocalDate.of(1920, 5, 1), anna, Set.of(familieTag));
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, hans.getId(), null, List.of("Familie"), null, null, TagOperator.AND));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_filtersByStatus() {
|
||||
save("a", LocalDate.of(1915, 8, 3), null, Set.of(), DocumentStatus.UPLOADED);
|
||||
save("b", LocalDate.of(1916, 1, 4), null, Set.of(), DocumentStatus.PLACEHOLDER);
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, null, null, null, null, DocumentStatus.UPLOADED, null));
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month).containsExactly("1915-08");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsEmpty_whenNoDocumentsMatch() {
|
||||
save("a", LocalDate.of(1915, 8, 3), hans, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(null, anna.getId(), null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
assertThat(result.minDate()).isNull();
|
||||
assertThat(result.maxDate()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_excludesDocumentsWithNullDate() {
|
||||
save("dated", LocalDate.of(1915, 8, 3), null, Set.of());
|
||||
save("undated", null, null, Set.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(noFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
|
||||
}
|
||||
|
||||
private void save(String suffix, LocalDate date, Person sender, Set<Tag> tags) {
|
||||
save(suffix, date, sender, tags, DocumentStatus.UPLOADED);
|
||||
}
|
||||
|
||||
private void save(String suffix, LocalDate date, Person sender, Set<Tag> tags, DocumentStatus status) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Doc " + suffix)
|
||||
.originalFilename("doc-" + suffix + "-" + UUID.randomUUID() + ".pdf")
|
||||
.status(status)
|
||||
.documentDate(date)
|
||||
.sender(sender)
|
||||
.tags(new HashSet<>(tags))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -1402,6 +1403,21 @@ class DocumentServiceTest {
|
||||
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() {
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
DocumentSort.UPDATED_AT, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||
assertThat(captor.getValue().getSort())
|
||||
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
||||
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
||||
@@ -2321,4 +2337,61 @@ class DocumentServiceTest {
|
||||
assertThat(documentService.save(doc)).isEqualTo(doc);
|
||||
verify(documentRepository).save(doc);
|
||||
}
|
||||
|
||||
// ─── getDensity ────────────────────────────────────────────────────────────
|
||||
|
||||
private static DensityFilters anyFilters() {
|
||||
return new DensityFilters(null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_returnsEmptyResult_whenNoDocumentsMatch() {
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
assertThat(result.minDate()).isNull();
|
||||
assertThat(result.maxDate()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_groupsMatchingDocumentsByMonth() {
|
||||
Document a = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
|
||||
Document b = Document.builder().documentDate(LocalDate.of(1915, 8, 17)).build();
|
||||
Document c = Document.builder().documentDate(LocalDate.of(1915, 9, 1)).build();
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c));
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::month)
|
||||
.containsExactly("1915-08", "1915-09");
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
|
||||
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
|
||||
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_excludesDocumentsWithNullDate() {
|
||||
Document dated = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
|
||||
Document undated = Document.builder().documentDate(null).build();
|
||||
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated));
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(anyFilters());
|
||||
|
||||
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
|
||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters("xyz", null, null, null, null, null, null));
|
||||
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.raddatz.familienarchiv.notification.NotificationService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -644,62 +645,99 @@ class CommentServiceTest {
|
||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
|
||||
// ─── findDataByIds ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
|
||||
UUID commentA = UUID.randomUUID();
|
||||
UUID annotationA = UUID.randomUUID();
|
||||
UUID commentB = UUID.randomUUID();
|
||||
UUID annotationB = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(commentA, commentB)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(commentA).annotationId(annotationA).build(),
|
||||
DocumentComment.builder().id(commentB).annotationId(annotationB).build()
|
||||
));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(commentA, commentB)))
|
||||
.containsOnly(
|
||||
java.util.Map.entry(commentA, annotationA),
|
||||
java.util.Map.entry(commentB, annotationB)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_returnsEmptyMap_forEmptyInput() {
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of())).isEmpty();
|
||||
void findDataByIds_returns_empty_map_when_input_is_empty() {
|
||||
assertThat(commentService.findDataByIds(List.of())).isEmpty();
|
||||
verify(commentRepository, never()).findAllById(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_omitsUnknownIds() {
|
||||
UUID known = UUID.randomUUID();
|
||||
UUID knownAnnotation = UUID.randomUUID();
|
||||
UUID missing = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(known, missing)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
|
||||
));
|
||||
void findDataByIds_strips_html_and_extracts_plain_text() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||
.content("<p><strong>Hello</strong> world</p>").build()));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
|
||||
.containsOnly(java.util.Map.entry(known, knownAnnotation))
|
||||
.doesNotContainKey(missing);
|
||||
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(id));
|
||||
|
||||
assertThat(result.get(id).preview()).isEqualTo("Hello world");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
|
||||
UUID legacy = UUID.randomUUID();
|
||||
UUID block = UUID.randomUUID();
|
||||
UUID annotation = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(legacy, block)))
|
||||
.thenReturn(List.of(
|
||||
DocumentComment.builder().id(legacy).annotationId(null).build(),
|
||||
DocumentComment.builder().id(block).annotationId(annotation).build()
|
||||
));
|
||||
void findDataByIds_truncates_at_exactly_120_chars() {
|
||||
UUID id = UUID.randomUUID();
|
||||
String text121 = "a".repeat(121);
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id).content(text121).build()));
|
||||
|
||||
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
|
||||
.containsOnly(java.util.Map.entry(block, annotation))
|
||||
.doesNotContainKey(legacy);
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_preserves_content_at_exactly_120_chars() {
|
||||
UUID id = UUID.randomUUID();
|
||||
String text120 = "a".repeat(120);
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id).content(text120).build()));
|
||||
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_returns_empty_string_for_blank_content() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id).content(" ").build()));
|
||||
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_returns_empty_string_for_null_content() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id).content(null).build()));
|
||||
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_omits_deleted_comments_from_result_map() {
|
||||
UUID present = UUID.randomUUID();
|
||||
UUID deleted = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(present, deleted)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(present).content("Hi").build()));
|
||||
|
||||
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(present, deleted));
|
||||
|
||||
assertThat(result).containsKey(present);
|
||||
assertThat(result).doesNotContainKey(deleted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_preserves_annotationId_alongside_preview() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||
.annotationId(annotationId).content("Text").build()));
|
||||
|
||||
CommentData data = commentService.findDataByIds(List.of(id)).get(id);
|
||||
|
||||
assertThat(data.annotationId()).isEqualTo(annotationId);
|
||||
assertThat(data.preview()).isEqualTo("Text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDataByIds_sets_null_annotationId_when_comment_has_no_annotation() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(commentRepository.findAllById(List.of(id)))
|
||||
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||
.annotationId(null).content("Text").build()));
|
||||
|
||||
assertThat(commentService.findDataByIds(List.of(id)).get(id).annotationId()).isNull();
|
||||
}
|
||||
|
||||
private void stubBlock(UUID docId, UUID blockId) {
|
||||
|
||||
@@ -159,6 +159,26 @@ class GeschichteServiceIntegrationTest {
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_DRAFT_does_not_return_other_users_drafts() {
|
||||
// writer creates a draft; writer2 (also BLOG_WRITE) should not see it
|
||||
AppUser writer2 = appUserRepository.save(AppUser.builder()
|
||||
.email("writer2-int@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Writer 1 draft");
|
||||
dto.setBody("<p>private</p>");
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle(title);
|
||||
|
||||
@@ -81,6 +81,29 @@ class PersonControllerTest {
|
||||
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_delegatesTopByDocumentCount_whenSortAndSizeGiven() throws Exception {
|
||||
PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz");
|
||||
when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top));
|
||||
|
||||
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].firstName").value("Käthe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_capsTopByDocumentCount_atFifty() throws Exception {
|
||||
ArgumentCaptor<Integer> sizeCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||
when(personService.findTopByDocumentCount(sizeCaptor.capture())).thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "999"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(sizeCaptor.getValue()).isEqualTo(50);
|
||||
}
|
||||
|
||||
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||
return new PersonSummaryDTO() {
|
||||
public java.util.UUID getId() { return UUID.randomUUID(); }
|
||||
|
||||
146
docs/ARCHITECTURE.md
Normal file
146
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,146 @@
|
||||
<!-- Last reviewed: 2026-05-05 -->
|
||||
|
||||
# Familienarchiv — Architecture
|
||||
|
||||
**Target reader:** a PM-with-CS background who has read the README.
|
||||
**Goal:** accurate mental model after one read — enough to sketch the system on a whiteboard.
|
||||
|
||||
For domain terminology, see [docs/GLOSSARY.md](GLOSSARY.md).
|
||||
For security policies and hardening, see [docs/security-guide.md](security-guide.md).
|
||||
For low-level ADR details, see [docs/adr/](adr/).
|
||||
|
||||
---
|
||||
|
||||
## 1. High-level diagram
|
||||
|
||||
The updated container diagram below shows all six deployable units and their communication paths.
|
||||
|
||||
See [docs/architecture/c4-diagrams.md](architecture/c4-diagrams.md) for the full C4 L1/L2/L3 diagrams (Mermaid, Gitea-rendered).
|
||||
|
||||
Key points not visible in the diagram:
|
||||
|
||||
- **OCR network boundary:** the OCR service has no external port — it is reachable only on the internal Docker Compose network. Only the backend calls it. The OCR service fetches PDF files from MinIO using a presigned URL that the backend generates and passes in the request body; the PDF bytes never pass through the backend.
|
||||
- **SSE path:** server-sent event notifications go directly from the backend to the user's browser (not via the SvelteKit SSR layer) over a long-lived HTTP connection managed by `SseEmitterRegistry`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Domain set
|
||||
|
||||
Both stacks are organised **package-by-domain**: each domain owns its entities, service, controller, repository, and DTOs. Domain names are identical across `backend/src/main/java/.../` and `frontend/src/lib/`.
|
||||
|
||||
### Tier-1 domains — have entities and user-facing CRUD
|
||||
|
||||
**`document`** — the archive's core concept. Owns `Document`, `DocumentVersion`, `TranscriptionBlock`, `DocumentAnnotation`, `DocumentComment`. Does NOT own persons or tags (references them by ID). Cross-domain deps: `person` (sender/receivers), `tag` (labels), `ocr` (HTR pipeline), `notification` (comment mentions), `audit` (every mutation).
|
||||
|
||||
**`person`** — historical individuals referenced by documents. Owns `Person`, `PersonNameAlias`, `PersonRelationship`. Does NOT own `AppUser` (login accounts are a separate domain). Cross-domain deps: `document` (relationship queries).
|
||||
|
||||
**`tag`** — hierarchical document categories. Owns `Tag` (self-referencing `parent_id` tree). Does NOT own documents; the join is document-side. No cross-domain deps.
|
||||
|
||||
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
|
||||
|
||||
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
|
||||
|
||||
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
|
||||
|
||||
**`ocr`** — OCR/HTR pipeline orchestration. Owns `OcrJob`, `OcrJobDocument`, `SenderModel`. Calls the Python OCR service; maps streamed transcription blocks back to `document`. Cross-domain deps: `document` (target), `filestorage` (presigned URLs).
|
||||
|
||||
### Tier-2 domains — derived (UI without dedicated tables)
|
||||
|
||||
A **derived domain** has its own routes and UI but no database tables of its own; it is assembled from data owned by Tier-1 domains.
|
||||
|
||||
**`conversation`** (route: `/briefwechsel`) — bilateral letter timeline between two `Person`s. Derived from `Document` sender/receiver relationships. The `DocumentRepository` bidirectional query is the only data source.
|
||||
|
||||
**`activity`** (route: `/aktivitaeten`) — family activity feed. Derived from `audit_log`, `notifications`, and document events. No aggregation table; computed on-the-fly by `DashboardService` and composed in the SvelteKit load function.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-cutting layer
|
||||
|
||||
Members of the cross-cutting layer have no entity of their own, no user-facing CRUD, and are consumed by two or more domains — or are framework infrastructure that every domain depends on.
|
||||
|
||||
| Member (backend package) | Purpose | Admission criteria |
|
||||
|---|---|---|
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
|
||||
**Frontend `shared/`** follows the same admission criteria. Key members: `api.server.ts` (typed openapi-fetch client factory), `errors.ts` (backend `ErrorCode` → i18n mapping), `shared/primitives/` (generic UI components used across ≥2 domains), `shared/discussion/` (comment/mention editor used by `document` and `geschichte`), `shared/utils/` (pure date/sort/debounce utilities).
|
||||
|
||||
---
|
||||
|
||||
## 4. Stack-symmetry principle
|
||||
|
||||
**Rule:** a domain has the same name on both stacks.
|
||||
|
||||
| Backend | Frontend |
|
||||
|---|---|
|
||||
| `backend/src/main/java/.../document/` | `frontend/src/lib/document/` |
|
||||
| `backend/src/main/java/.../person/` | `frontend/src/lib/person/` |
|
||||
| … | … |
|
||||
|
||||
Adding a new Tier-1 domain means creating a package on **both** sides under the same name. Adding only a backend package without a corresponding frontend folder (or vice versa) is a red flag in code review.
|
||||
|
||||
The backend has been domain-first since the project started. The frontend `src/lib/` was restructured from flat-by-type to domain-first in issue #408 (May 2026).
|
||||
|
||||
---
|
||||
|
||||
## 5. Key architectural decisions
|
||||
|
||||
### ADR-001 — OCR as a Python microservice
|
||||
The two OCR engines required (Surya for typewritten text, Kraken for Kurrent/Sütterlin HTR) exist only in the Python ecosystem. A separate `ocr-service` Python container exposes a simple HTTP API; the Spring Boot backend calls it via `RestClient`. All job tracking and business logic remain in Spring Boot. See [ADR-001](adr/001-ocr-python-microservice.md).
|
||||
|
||||
### ADR-002 — Polygon JSONB storage for annotations
|
||||
Kraken outputs polygon boundaries for historical handwriting; axis-aligned bounding boxes approximate them poorly. Annotation and transcription-block positions are stored as `polygon JSONB` columns. Display-only — server-side geometry continues to use the AABB fields. See [ADR-002](adr/002-polygon-jsonb-storage.md).
|
||||
|
||||
### ADR-003 — Unified activity feed (Chronik/Aktivität)
|
||||
Personal notifications and ambient activity (uploads, transcriptions, comments) are merged into one `/aktivitaeten` page. The SvelteKit load function composes data from `/api/dashboard/activity` and `/api/notifications` — no new backend orchestrator endpoint. See [ADR-003](adr/003-chronik-unified-activity-feed.md).
|
||||
|
||||
### ADR-004 — In-process PDFBox thumbnails
|
||||
Thumbnails are rendered in Spring Boot using Apache PDFBox (already a dependency) rather than delegating to the OCR service. A dedicated `thumbnailExecutor` pool isolates the work. See [ADR-004](adr/004-pdfbox-thumbnails.md).
|
||||
|
||||
### ADR-005 — thumbnailAspect + pageCount
|
||||
Aspect ratio (`PORTRAIT` / `LANDSCAPE`) and page count are persisted alongside the thumbnail JPEG at generation time — cheap to derive then, expensive to re-derive later. See [ADR-005](adr/005-thumbnail-aspect-and-page-count.md).
|
||||
|
||||
### ADR-006 — Synchronous domain events inside the publisher's transaction
|
||||
When a `Person` display name changes, all `TranscriptionBlock` `@mention` text must be rewritten atomically. This is done via Spring `ApplicationEventPublisher` + `@EventListener @Transactional` to avoid a circular dependency between `PersonService` and `TranscriptionBlockService`. See [ADR-006](adr/006-synchronous-domain-events-in-transaction.md).
|
||||
|
||||
### Layering rule
|
||||
```
|
||||
Controller → Service → Repository → DB
|
||||
```
|
||||
Controllers never call repositories directly. Services never reach into another domain's repository — they call the other domain's service. This keeps domain boundaries clear and business logic testable without a running database.
|
||||
|
||||
### Permission system
|
||||
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
|
||||
|
||||
Sessions use a Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data flow walkthroughs
|
||||
|
||||
### Document upload
|
||||
|
||||
1. User submits the edit form (file + metadata) from the browser.
|
||||
2. The SvelteKit server action sends `PUT /api/documents/{id}` as `multipart/form-data`. `hooks.server.ts` (`handleFetch`) transparently injects the `Authorization` header from the `auth_token` cookie — the action itself is unaware of auth.
|
||||
3. `PermissionAspect` intercepts the controller method, verifies the user has `WRITE_ALL`, and proceeds.
|
||||
4. `DocumentController` delegates to `DocumentService.updateDocument()`.
|
||||
5. `DocumentService` resolves the `Person` sender by ID (via `PersonService`), resolves or creates `Tag`s (via `TagService`), then calls `FileService.uploadFile()`.
|
||||
6. `FileService` generates a key (`documents/{UUID}_{filename}`), streams the file to MinIO via the AWS SDK v2 S3Client.
|
||||
7. `DocumentService` persists the S3 key, sets `status = UPLOADED`, and saves to PostgreSQL.
|
||||
8. `AuditService` writes an `UPLOADED` event to `audit_log` in the same transaction.
|
||||
9. Backend returns the updated `Document` JSON; SvelteKit refreshes the document detail page.
|
||||
|
||||
### Transcription block autosave
|
||||
|
||||
1. The transcriber pauses typing; the frontend's `useBlockAutoSave` factory fires after a debounce interval.
|
||||
2. The browser sends `PUT /api/documents/{documentId}/transcription-blocks/{blockId}` with the new text and the block's current `version` (optimistic lock). `hooks.server.ts` (`handleFetch`) injects the `Authorization` header from the cookie.
|
||||
3. `TranscriptionService.saveBlock()` loads the block, checks the `@Version` field for concurrent edits, updates `block.text` and any `@mention` sidecars, and calls `saveAndFlush`.
|
||||
4. If a concurrent save collides (version mismatch), the backend returns `409 Conflict`; the frontend's `saveBlockWithConflictRetry` helper re-fetches and retries.
|
||||
5. On success, `AuditService` logs a `BLOCK_SAVED` event.
|
||||
6. If the block text contains a new `@PersonName` mention, `NotificationService` creates a `Notification` row for the mentioned person's `AppUser`.
|
||||
7. `SseEmitterRegistry` broadcasts the notification over the open SSE connection to that user's browser in real time.
|
||||
@@ -1,97 +1,5 @@
|
||||
# Docs — Familienarchiv
|
||||
# docs/
|
||||
|
||||
## Overview
|
||||
→ See [docs/README.md](./README.md) for the folder structure and documentation guide.
|
||||
|
||||
Project documentation organized into four categories: architecture decision records (ADRs), system architecture diagrams, infrastructure runbooks, and detailed UI/UX specifications.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── adr/ # Architecture Decision Records
|
||||
├── architecture/ # C4 model diagrams and system architecture docs
|
||||
├── infrastructure/ # Deployment, CI/CD, and ops guides
|
||||
├── specs/ # UI/UX feature specifications (HTML)
|
||||
├── app-analysis-*.md # Application analysis reports
|
||||
├── mail.md # Mail system documentation
|
||||
├── security-guide.md # Security policies and hardening guide
|
||||
├── STYLEGUIDE.md # Coding and design style guide
|
||||
├── TODO-backend.md # Backend backlog
|
||||
└── TODO-frontend.md # Frontend backlog
|
||||
```
|
||||
|
||||
## ADR (`adr/`)
|
||||
|
||||
Architecture Decision Records capture major technical decisions and their rationale.
|
||||
|
||||
| ADR | Title | Status |
|
||||
|---|---|---|
|
||||
| `001-ocr-python-microservice.md` | OCR as a separate Python container | Accepted |
|
||||
| `002-polygon-jsonb-storage.md` | Polygon coordinates in JSONB columns | Accepted |
|
||||
| `003-chronik-unified-activity-feed.md` | Unified activity feed (Chronik) | Accepted |
|
||||
|
||||
When making a significant architectural change (new service, data model change, technology swap), write a new ADR following the format:
|
||||
- Status (Proposed / Accepted / Deprecated / Superseded)
|
||||
- Context (forces at play)
|
||||
- Decision (what we decided)
|
||||
- Consequences (trade-offs)
|
||||
- Alternatives Considered (table format)
|
||||
|
||||
## Architecture (`architecture/`)
|
||||
|
||||
Contains C4 model diagrams describing the system at different zoom levels:
|
||||
|
||||
- **Context diagram** — How Familienarchiv fits into the user and system ecosystem
|
||||
- **Container diagram** — The high-level technology choices (Spring Boot, SvelteKit, PostgreSQL, MinIO, OCR service)
|
||||
- **Component diagram** — Major structural components within the backend
|
||||
|
||||
Written in Markdown with embedded Mermaid or PlantUML diagrams (`c4-diagrams.md`).
|
||||
|
||||
## Infrastructure (`infrastructure/`)
|
||||
|
||||
Operational documentation for running Familienarchiv in production and CI.
|
||||
|
||||
| Document | Purpose |
|
||||
|---|---|
|
||||
| `ci-gitea.md` | Gitea CI/CD pipeline configuration |
|
||||
| `production-compose.md` | Production Docker Compose setup |
|
||||
| `s3-migration.md` | Migrating documents between S3 buckets |
|
||||
| `self-hosted-catalogue.md` | Self-hosted software catalogue |
|
||||
|
||||
## Specs (`specs/`)
|
||||
|
||||
High-fidelity UI/UX specifications written as standalone HTML files. These are design documents that describe exact layout, interactions, and responsive behavior before implementation.
|
||||
|
||||
Each spec typically includes:
|
||||
- Visual mockups with CSS-in-HTML styling
|
||||
- Interaction flows and state transitions
|
||||
- Responsive breakpoint behavior
|
||||
- Accessibility requirements
|
||||
|
||||
Examples of active spec areas:
|
||||
- Document detail page (`document-topbar-*.html`, `documents-page-spec.html`)
|
||||
- Admin interfaces (`admin-redesign-*.html`, `admin-tag-overhaul.html`)
|
||||
- Transcription workflows (`inline-transcription-*.html`, `annotation-transcription-*.html`)
|
||||
- Dashboard and activity feeds (`dashboard-*.html`, `chronik-spec.html`)
|
||||
- OCR admin (`ocr-admin-spec.html`)
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Before implementing a feature**, check `specs/` for an existing specification.
|
||||
2. **When proposing a new architecture**, draft an ADR in `adr/` and discuss before coding.
|
||||
3. **When deploying**, follow `infrastructure/production-compose.md`.
|
||||
4. **Keep TODO files updated** — they serve as lightweight backlogs.
|
||||
|
||||
## Style Guide
|
||||
|
||||
`STYLEGUIDE.md` covers:
|
||||
- Code formatting and linting rules
|
||||
- Component naming conventions
|
||||
- Color palette and typography
|
||||
- Accessibility standards (WCAG 2.1 AA)
|
||||
|
||||
## Contributing
|
||||
|
||||
- ADRs should be sequential (`NNN-descriptive-name.md`).
|
||||
- Specs should be self-contained HTML files viewable in a browser.
|
||||
- Infrastructure docs should include copy-pasteable commands.
|
||||
**LLM reminder:** ADRs are sequential — use the next number after the highest existing one in `docs/adr/`. When making a significant architectural change (new service, data model change, technology swap), write a new ADR before implementing.
|
||||
|
||||
274
docs/DEPLOYMENT.md
Normal file
274
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,274 @@
|
||||
<!-- Last reviewed: 2026-05-05 — reviewed at every milestone close -->
|
||||
|
||||
# Familienarchiv — Deployment Reference
|
||||
|
||||
> **If the app is down right now → jump to [§4 Logs](#4-logs--observability).**
|
||||
|
||||
This doc is the Day-1 checklist and operational reference. It links to the canonical infrastructure docs in `docs/infrastructure/` rather than duplicating them.
|
||||
|
||||
**Audience:** operator bringing up a fresh instance, or Successor-X debugging a live incident.
|
||||
|
||||
**Ownership:** project owner. Update this file in any PR that changes the container topology, env vars, or backup procedure.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Deployment topology](#1-deployment-topology)
|
||||
2. [Environment variables](#2-environment-variables)
|
||||
3. [Bootstrap from scratch](#3-bootstrap-from-scratch)
|
||||
4. [Logs + observability](#4-logs--observability)
|
||||
5. [Backup + recovery](#5-backup--recovery)
|
||||
6. [Common operational tasks](#6-common-operational-tasks)
|
||||
7. [Known limitations](#7-known-limitations)
|
||||
|
||||
---
|
||||
|
||||
## 1. Deployment topology
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Browser -->|HTTPS| Caddy["Caddy (TLS termination)"]
|
||||
Caddy -->|HTTP :5173| Frontend["Web Frontend\nSvelteKit / Node.js"]
|
||||
Caddy -->|HTTP :8080| Backend["API Backend\nSpring Boot / Jetty :8080"]
|
||||
Backend -->|JDBC :5432| DB[(PostgreSQL 16)]
|
||||
Backend -->|S3 API :9000| MinIO[(MinIO / Hetzner OBS)]
|
||||
Backend -->|HTTP :8000 internal| OCR["OCR Service\nPython FastAPI"]
|
||||
OCR -->|presigned URL| MinIO
|
||||
Browser -->|SSE direct| Backend
|
||||
```
|
||||
|
||||
**Key facts:**
|
||||
- Caddy terminates TLS and reverse-proxies to frontend and backend. See the Caddyfile in [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md).
|
||||
- The OCR service has **no external port** — reachable only on the internal Docker network from the backend.
|
||||
- SSE notifications go directly backend → browser (not via the SvelteKit SSR layer).
|
||||
- Management port 8081 (Spring Actuator / Prometheus scrape) is internal only — the Caddy config blocks `/actuator/*` externally.
|
||||
|
||||
### OCR memory requirements
|
||||
|
||||
The OCR service requires significant RAM for model loading. The dev compose sets `mem_limit: 12g`.
|
||||
|
||||
| Production target | RAM | Recommended OCR limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Hetzner CX42 | 16 GB | 12 GB | Recommended for OCR-enabled production |
|
||||
| Hetzner CX32 | 8 GB | 6 GB | Accept reduced batch sizes and slower throughput |
|
||||
| Hetzner CX22 | 4 GB | — | Disable the OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||
|
||||
A CX32 cannot honour a `mem_limit: 12g` — set it to `6g` in the prod overlay or use CX42.
|
||||
|
||||
### Dev vs production differences
|
||||
|
||||
| Concern | Dev compose | Prod overlay |
|
||||
|---|---|---|
|
||||
| MinIO image tag | `minio/minio:latest` (unpinned) | Pinned in prod overlay |
|
||||
| Data persistence | Bind mounts `./data/postgres`, `./data/minio` | Named Docker volumes |
|
||||
| Bucket creation | `create-buckets` helper container | Pre-created in Hetzner console |
|
||||
| Spring profile | `dev,e2e` (enables OpenAPI + Swagger UI) | `prod` |
|
||||
| Mail | Mailpit (local catcher) | Real SMTP |
|
||||
|
||||
Full prod overlay: [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md).
|
||||
|
||||
---
|
||||
|
||||
## 2. Environment variables
|
||||
|
||||
All vars are set in `.env` at the repo root (copy from `.env.example`). The backend resolves them via `application.yaml`; the Docker Compose file wires them into each container.
|
||||
|
||||
**Any var found in `docker-compose.yml` or `application*.yaml` that is not in this table is a blocking review comment on any PR that changes those files.**
|
||||
|
||||
### Backend
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|---|---|---|---|---|
|
||||
| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC URL | — | YES | — |
|
||||
| `SPRING_DATASOURCE_USERNAME` | DB username | — | YES | — |
|
||||
| `SPRING_DATASOURCE_PASSWORD` | DB password | — | YES | YES |
|
||||
| `S3_ENDPOINT` | MinIO / OBS endpoint URL | — | YES | — |
|
||||
| `S3_ACCESS_KEY` | MinIO access key (use service account, not root in prod) | — | YES | YES |
|
||||
| `S3_SECRET_KEY` | MinIO secret key | — | YES | YES |
|
||||
| `S3_BUCKET_NAME` | Target bucket name | — | YES | — |
|
||||
| `S3_REGION` | S3 region string | `us-east-1` | YES | — |
|
||||
| `APP_ADMIN_USERNAME` | Bootstrap admin username (⚠ not in .env.example) | `admin` | YES | — |
|
||||
| `APP_ADMIN_PASSWORD` | Bootstrap admin password (⚠ ships as `admin123`) | `admin123` | YES | YES |
|
||||
| `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — |
|
||||
| `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — |
|
||||
| `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES |
|
||||
| `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — |
|
||||
| `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — |
|
||||
| `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES |
|
||||
| `MAIL_PASSWORD` | SMTP password | — | YES (prod) | YES |
|
||||
| `APP_MAIL_FROM` | From address for outbound mail | `noreply@familienarchiv.local` | YES (prod) | — |
|
||||
| `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — |
|
||||
| `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — |
|
||||
| `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — |
|
||||
|
||||
### PostgreSQL container
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|---|---|---|---|---|
|
||||
| `POSTGRES_USER` | DB superuser | `archive_user` | YES | — |
|
||||
| `POSTGRES_PASSWORD` | DB password | `change-me` | YES | YES |
|
||||
| `POSTGRES_DB` | Database name | `family_archive_db` | YES | — |
|
||||
|
||||
### MinIO container
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|---|---|---|---|---|
|
||||
| `MINIO_ROOT_USER` | MinIO root username | `minio_admin` | YES | — |
|
||||
| `MINIO_ROOT_PASSWORD` | MinIO root password | `change-me` | YES | YES |
|
||||
| `MINIO_DEFAULT_BUCKETS` | Bucket name | `archive-documents` | YES | — |
|
||||
|
||||
### OCR service
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|---|---|---|---|---|
|
||||
| `TRAINING_TOKEN` | Guards `/train` and `/segtrain` endpoints (accepts file uploads) | — | YES (prod) | YES |
|
||||
| `ALLOWED_PDF_HOSTS` | SSRF protection — comma-separated list of allowed PDF source hosts. **Do not widen to `*`** | `minio,localhost,127.0.0.1` | YES | — |
|
||||
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
|
||||
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 3. Bootstrap from scratch
|
||||
|
||||
> Full VPS provisioning steps are in [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md). This section covers the sequence and the security-critical steps.
|
||||
|
||||
### Security checklist — complete before first boot
|
||||
|
||||
> ⚠️ **These defaults ship in `.env.example` and `application.yaml`. Change them or you will have an insecure installation.**
|
||||
|
||||
- [ ] Set `APP_ADMIN_PASSWORD` (default: `admin123` — change before starting the backend)
|
||||
- [ ] Set `APP_ADMIN_USERNAME` if you want a non-default admin login name (add to `.env` — not in `.env.example`)
|
||||
- [ ] Rotate `POSTGRES_PASSWORD` from `change-me`
|
||||
- [ ] Rotate `MINIO_ROOT_PASSWORD` from `change-me`
|
||||
- [ ] Set a strong `APP_OCR_TRAINING_TOKEN` (backend) and the matching `TRAINING_TOKEN` (OCR service) — both must be the same value (`python3 -c "import secrets; print(secrets.token_hex(32))"`)
|
||||
- [ ] Confirm `ALLOWED_PDF_HOSTS` is locked to your MinIO/S3 hostname — widening to `*` opens SSRF
|
||||
- [ ] Set `SPRING_PROFILES_ACTIVE=prod` in the prod overlay (not `dev,e2e` — that exposes Swagger UI and `/v3/api-docs`)
|
||||
- [ ] Use a dedicated MinIO service account for `S3_ACCESS_KEY` / `S3_SECRET_KEY`, not the root credentials
|
||||
|
||||
### Bootstrap sequence
|
||||
|
||||
```bash
|
||||
# 1. Copy and fill the env file
|
||||
cp .env.example .env
|
||||
# edit .env — complete the security checklist above first
|
||||
|
||||
# 2. (Production only) Create the MinIO / Hetzner OBS bucket in the console
|
||||
# The dev compose has a create-buckets helper; production does not.
|
||||
# Create the bucket named $MINIO_DEFAULT_BUCKETS with private access.
|
||||
|
||||
# 3. Start the stack (prod overlay — see docs/infrastructure/production-compose.md)
|
||||
# docker-compose.prod.yml is NOT committed — create it from the guide above
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
# 4. Flyway migrations run automatically on backend start.
|
||||
# Watch the backend log to confirm:
|
||||
docker compose logs --follow --tail=100 backend
|
||||
|
||||
# 5. Verify the stack is healthy
|
||||
curl http://localhost:8080/actuator/health
|
||||
# Expected: {"status":"UP"}
|
||||
|
||||
# 6. Open the app and log in with the admin credentials from .env
|
||||
```
|
||||
|
||||
> **Do not use `docker-compose.ci.yml` locally** — it disables bind mounts that the dev workflow depends on.
|
||||
|
||||
---
|
||||
|
||||
## 4. Logs + observability
|
||||
|
||||
### First-response commands
|
||||
|
||||
```bash
|
||||
# Stream backend logs (most useful first)
|
||||
docker compose logs --follow --tail=100 backend
|
||||
|
||||
# Stream all services
|
||||
docker compose logs --follow
|
||||
|
||||
# Single snapshot
|
||||
docker compose logs --tail=200 <service>
|
||||
# services: frontend, backend, db, minio, ocr-service
|
||||
```
|
||||
|
||||
### Log locations
|
||||
|
||||
- **Backend application log**: stdout (captured by Docker). Access inside the container at `/app/logs/` via `docker exec`.
|
||||
- **Spring Actuator health**: `http://localhost:8080/actuator/health` (internal only in prod — port 8081 for Prometheus scraping)
|
||||
- **Prometheus scraping**: management port 8081, path `/actuator/prometheus`. Internal only; Caddy blocks `/actuator/*` externally.
|
||||
|
||||
### Future observability
|
||||
|
||||
Phase 7 of the Production v1 milestone adds Prometheus + Loki + Grafana. No monitoring infrastructure is in place yet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backup + recovery
|
||||
|
||||
### Current state — no automated backup
|
||||
|
||||
No automated backup is configured. Manual procedure for a point-in-time backup:
|
||||
|
||||
```bash
|
||||
# PostgreSQL dump
|
||||
docker exec archive-db pg_dump -U ${POSTGRES_USER} ${POSTGRES_DB} > backup-$(date +%Y%m%d).sql
|
||||
|
||||
# MinIO data (bind-mounted in dev)
|
||||
# Copy ./data/minio/ to external storage
|
||||
```
|
||||
|
||||
Restoration:
|
||||
```bash
|
||||
# Restore Postgres
|
||||
docker exec -i archive-db psql -U ${POSTGRES_USER} ${POSTGRES_DB} < backup-YYYYMMDD.sql
|
||||
```
|
||||
|
||||
### Planned — phase 5 of Production v1 milestone
|
||||
|
||||
Automated backup (PostgreSQL WAL archiving + MinIO bucket replication) is planned in the Production v1 milestone phase 5. Until that ships: **manual backups are the only recovery option.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Common operational tasks
|
||||
|
||||
### Reset dev database (truncates data, keeps schema)
|
||||
|
||||
```bash
|
||||
bash scripts/reset-db.sh
|
||||
```
|
||||
|
||||
> Truncates all data but does **not** drop the schema or re-run Flyway. Use for E2E test resets, not full reinstalls.
|
||||
> ⚠️ Script hardcodes `DB_USER=archive_user` and `DB_NAME=family_archive_db` — if you customised these in `.env`, edit the script accordingly.
|
||||
|
||||
### Rebuild frontend container (clears node_modules volume)
|
||||
|
||||
```bash
|
||||
bash scripts/rebuild-frontend.sh
|
||||
```
|
||||
|
||||
> Assumes the Docker Compose volume is named `familienarchiv_frontend_node_modules`. If your project directory is not named `familienarchiv`, edit line 16 of the script.
|
||||
|
||||
### Download Kraken OCR models
|
||||
|
||||
```bash
|
||||
bash scripts/download-kraken-models.sh
|
||||
```
|
||||
|
||||
> Downloads the Kurrent/Sütterlin HTR models. Run once after a fresh clone or when models are updated.
|
||||
|
||||
### Trigger a mass import (Excel/ODS)
|
||||
|
||||
1. Place the import file in the `import/` bind mount on the backend container.
|
||||
2. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission).
|
||||
3. The import runs asynchronously — poll `GET /api/admin/import-status` or watch backend logs.
|
||||
|
||||
---
|
||||
|
||||
## 7. Known limitations
|
||||
|
||||
| Limitation | Reason | Reference |
|
||||
|---|---|---|
|
||||
| **Single-node OCR service** | The two required OCR engines (Surya + Kraken) exist only in the Python ecosystem; horizontal scaling would require a job queue not currently implemented | [ADR-001](adr/001-ocr-python-microservice.md) |
|
||||
| **No multi-tenancy** | Designed as a single-family private archive; all authenticated users share the same document space | Deliberate scope decision (family-only product frame) |
|
||||
| **No multi-region** | Single PostgreSQL + MinIO instance; no replication or failover | Deliberate scope decision |
|
||||
| **Max upload size** | 50 MB per file (500 MB per request for multi-file) | Configurable in `application.yaml` (`spring.servlet.multipart`) |
|
||||
| **No automated backup** | Phase 5 of Production v1 milestone is not yet implemented | See §5 above |
|
||||
116
docs/GLOSSARY.md
Normal file
116
docs/GLOSSARY.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Familienarchiv — Glossary
|
||||
|
||||
Domain-specific and overloaded terms used in this codebase.
|
||||
Each entry: **Term** — definition (≤ 2 sentences). Where two terms are easily confused, a _Not to be confused with_ note follows.
|
||||
|
||||
For architecture context see [`docs/architecture/c4-diagrams.md`](architecture/c4-diagrams.md).
|
||||
For domain package structure see [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) _(coming: DOC-2)_.
|
||||
|
||||
---
|
||||
|
||||
## Identity Terms
|
||||
|
||||
**AppUser** (`AppUser`) — a real person who can log into the system (a family member or administrator). `AppUser` records carry login credentials, group memberships, and notification history.
|
||||
_Not to be confused with [Person](#person-person)_ — an AppUser is never recorded as a document sender, receiver, or historical individual.
|
||||
|
||||
**Reader** — an `AppUser` whose effective permissions include `READ_ALL` but neither `WRITE_ALL` nor `ANNOTATE_ALL`. Readers see a dedicated dashboard (`isReader = !canWrite && !canAnnotate`) focused on browsing documents, persons, and stories rather than contribution tasks. A user who also holds `BLOG_WRITE` is still classified as a Reader and additionally sees a drafts module.
|
||||
_Not to be confused with [AppUser](#appuser-appuser)_ — Reader is a permission-derived role, not an entity.
|
||||
|
||||
**Permission** — a discrete capability string assigned to a `UserGroup` (e.g. `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`). Enforced via the `@RequirePermission` AOP annotation on controller methods, checked at runtime by `PermissionAspect`; not via Spring Security's `@PreAuthorize`.
|
||||
|
||||
**Person** (`Person`) — a historical individual in the family archive (sender, receiver of letters, person mentioned in transcriptions). NEVER has a login account and NEVER appears as an `AppUser`.
|
||||
_Not to be confused with [AppUser](#appuser-appuser)_ — `Person` is a historical record; `AppUser` is someone who can log in today.
|
||||
|
||||
**PersonNameAlias** (`PersonNameAlias`) — an alternate or historical name form associated with a `Person` (e.g. maiden name, nickname, abbreviated form). Used to locate `Person` records during mass import via `PersonNameAliasType`.
|
||||
|
||||
**UserGroup** (`UserGroup`) — a named permission bundle assigned to one or more `AppUser`s. A user's effective permissions are the union of all permissions across all groups they belong to.
|
||||
|
||||
---
|
||||
|
||||
## Document-Related Terms
|
||||
|
||||
**Annotation** (`DocumentAnnotation`) — a free-form polygon or shape drawn over a document page image to highlight a region of interest. Always scoped to a specific page of a `Document`; stored as a polygon (JSONB).
|
||||
_See also [TranscriptionBlock](#transcriptionblock-transcriptionblock)._
|
||||
|
||||
**Comment** (`DocumentComment`, table `document_comments`) — a threaded discussion message attached to a `Document`. Always scoped to a `Document`; optionally further contextualized by a specific `DocumentAnnotation` or `TranscriptionBlock`.
|
||||
|
||||
**Document** (`Document`) — a single archival item (letter, postcard, photograph) with a file stored in MinIO/S3 and associated metadata (sender, receivers, date, tags, transcription blocks).
|
||||
|
||||
**DocumentVersion** (`DocumentVersion`) — an append-only snapshot of a `Document`'s metadata at a point in time. Append-only by convention; no consumer-facing create or update endpoint exists. The entity uses Lombok `@Data` (which generates setters), so immutability is enforced by application convention, not at the Java level.
|
||||
|
||||
**Tag** (`Tag`) — a hierarchical category that can be applied to `Document`s. Tags are self-referencing via a `parent_id` foreign key, forming a tree structure.
|
||||
|
||||
**TranscriptionBlock** (`TranscriptionBlock`) — a paragraph-level segment of a `Document`'s transcribed text, with a polygon region (stored as JSONB) identifying its position on the page. One document can have many blocks across multiple pages.
|
||||
_See also [Annotation](#annotation-documentannotation)._
|
||||
|
||||
---
|
||||
|
||||
## Workflow Terms
|
||||
|
||||
**DocumentStatus lifecycle** — the ordered states a `Document` moves through:
|
||||
`PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
- `PLACEHOLDER`: created during mass import; no file attached yet.
|
||||
- `UPLOADED`: a file has been stored in MinIO/S3.
|
||||
- `TRANSCRIBED`: all transcription blocks have been marked done.
|
||||
- `REVIEWED`: a reviewer has approved the transcription.
|
||||
- `ARCHIVED`: the document is finalized and read-only.
|
||||
|
||||
**Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently).
|
||||
|
||||
**Transcription queue** — the set of `Document`s and `TranscriptionBlock`s awaiting work, computed on-the-fly from `Document`/`Block` status. Three views: segmentation queue, transcription queue, ready-to-read queue. NOT a persistent entity — no `transcription_queues` table exists.
|
||||
_See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
|
||||
|
||||
---
|
||||
|
||||
## OCR-Specific Terms
|
||||
|
||||
**HTR** — Handwritten Text Recognition. Recognizes cursive and historical handwriting (contrasted with OCR for printed/typewritten text). The primary mode used for letters in this archive.
|
||||
|
||||
**Kurrent** — Old German cursive handwriting style, the primary historical script appearing in letters from the 1899–1950 period covered by this archive.
|
||||
|
||||
**OCR** — Optical Character Recognition. Recognizes printed or typewritten text. Used for typed documents; HTR is used for handwritten ones.
|
||||
|
||||
**OcrJob** (`OcrJob`, table `ocr_jobs`) — a first-class persistent entity tracking a batch OCR run across one or more documents (`OcrJobDocument`, table `ocr_job_documents`). Distinct from the concept of "running OCR on a single document." Lifecycle: `PENDING → RUNNING → DONE / FAILED` (see `OcrJobStatus`).
|
||||
|
||||
**SenderModel** (`SenderModel`, table `sender_models`) — a fine-tuned Kraken HTR model trained on a specific historical correspondent's handwriting. Both an OCR-service concept (the model weights) and a persistent entity linking a `Person` to the path of their trained model file.
|
||||
|
||||
**Sütterlin** — A specific standardized style of Kurrent taught in German schools from 1915 to 1941.
|
||||
|
||||
---
|
||||
|
||||
## Other Domain Terms
|
||||
|
||||
**Aktivität / Aktivitäten** `[user-facing]` — the family activity feed accessible at `/aktivitaeten`. Shows recent documents, transcriptions, comments, and Geschichten as a chronological timeline.
|
||||
_See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Briefwechsel** `[user-facing]` — the bilateral conversation timeline between two `Person`s, derived from `Document` sender/receiver relationships. Accessible at `/briefwechsel`. Not a persistent entity — data is computed from existing `Document` records.
|
||||
_See also [Derived domain](#derived-domain)._
|
||||
|
||||
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
|
||||
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Terms
|
||||
|
||||
**Cross-cutting** — code that lives in `lib/shared/` (frontend) or cross-domain packages (backend) because it has no entity of its own, no user-facing CRUD, AND is used by two or more domains OR is framework infrastructure (error handling, API client, i18n utilities).
|
||||
|
||||
**Derived domain** — a Tier-2 frontend domain that has its own UI but no backend entities of its own. Data is computed from Tier-1 domain records. Current derived domains: `conversation` (from `Document` sender/receivers) and `activity` (from audit, notifications, document events).
|
||||
_See also [Briefwechsel](#briefwechsel-user-facing)._
|
||||
|
||||
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
|
||||
|
||||
---
|
||||
|
||||
## Pending Terms
|
||||
|
||||
_Terms flagged as potentially ambiguous that have not yet been formally defined here. Add an entry above and remove it from this list when resolved._
|
||||
|
||||
- Terms surfaced by Epic 1 audit findings (#388–#392) — review audit reports under `docs/audits/` when available and add any term flagged as ambiguous.
|
||||
- `OcrBatchService` vs `OcrAsyncRunner` — both handle async OCR orchestration; their division of responsibility should be clarified here.
|
||||
- `Stammbaum` — the genealogy tree view; relationship to `PersonRelationship` entity.
|
||||
85
docs/README.md
Normal file
85
docs/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# docs/
|
||||
|
||||
Project documentation organised into four categories: architecture decision records (ADRs), system architecture diagrams, infrastructure runbooks, and detailed UI/UX specifications.
|
||||
|
||||
## Folder structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── adr/ # Architecture Decision Records
|
||||
├── architecture/ # C4 model diagrams and system architecture docs
|
||||
├── infrastructure/ # Deployment, CI/CD, and ops guides
|
||||
├── specs/ # UI/UX feature specifications (HTML)
|
||||
├── ARCHITECTURE.md # Human-readable architecture overview (DOC-2)
|
||||
├── DEPLOYMENT.md # Day-1 checklist and operational reference (DOC-5)
|
||||
├── GLOSSARY.md # Domain terminology (DOC-3)
|
||||
├── security-guide.md # Security policies and hardening guide
|
||||
└── STYLEGUIDE.md # Coding and design style guide
|
||||
```
|
||||
|
||||
## ADR (`adr/`)
|
||||
|
||||
Architecture Decision Records capture major technical decisions and their rationale.
|
||||
|
||||
| ADR | Title | Status |
|
||||
| -------------------------------------- | ------------------------------------ | -------- |
|
||||
| `001-ocr-python-microservice.md` | OCR as a separate Python container | Accepted |
|
||||
| `002-polygon-jsonb-storage.md` | Polygon coordinates in JSONB columns | Accepted |
|
||||
| `003-chronik-unified-activity-feed.md` | Unified activity feed (Chronik) | Accepted |
|
||||
|
||||
When making a significant architectural change (new service, data model change, technology swap), write a new ADR:
|
||||
|
||||
- **Status** (Proposed / Accepted / Deprecated / Superseded)
|
||||
- **Context** (forces at play)
|
||||
- **Decision** (what we decided)
|
||||
- **Consequences** (trade-offs)
|
||||
- **Alternatives Considered** (table format)
|
||||
|
||||
ADRs are sequential (`NNN-descriptive-name.md`). Do not reuse numbers.
|
||||
|
||||
## Architecture (`architecture/`)
|
||||
|
||||
Contains C4 model diagrams describing the system at different zoom levels:
|
||||
|
||||
- **Context diagram** — How Familienarchiv fits into the user and system ecosystem
|
||||
- **Container diagram** — The high-level technology choices (Spring Boot, SvelteKit, PostgreSQL, MinIO, OCR service)
|
||||
- **Component diagram** — Major structural components within the backend
|
||||
|
||||
Written in Markdown with embedded Mermaid diagrams (`c4-diagrams.md`). Gitea renders these automatically.
|
||||
|
||||
For the human-readable architecture narrative, see [`docs/ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||
|
||||
## Infrastructure (`infrastructure/`)
|
||||
|
||||
Operational documentation for running Familienarchiv in production and CI.
|
||||
|
||||
| Document | Purpose |
|
||||
| -------------------------- | ---------------------------------------------------- |
|
||||
| `ci-gitea.md` | Gitea CI/CD pipeline configuration |
|
||||
| `production-compose.md` | Production Docker Compose setup and VPS provisioning |
|
||||
| `s3-migration.md` | Migrating documents between S3 buckets |
|
||||
| `self-hosted-catalogue.md` | Self-hosted software catalogue |
|
||||
|
||||
For the day-1 deployment checklist, see [`docs/DEPLOYMENT.md`](DEPLOYMENT.md).
|
||||
|
||||
## Specs (`specs/`)
|
||||
|
||||
High-fidelity UI/UX specifications written as standalone HTML files. These are design documents describing exact layout, interactions, and responsive behavior before implementation.
|
||||
|
||||
Each spec typically includes:
|
||||
|
||||
- Visual mockups with CSS-in-HTML styling
|
||||
- Interaction flows and state transitions
|
||||
- Responsive breakpoint behavior
|
||||
- Accessibility requirements
|
||||
|
||||
Before implementing a feature, check `specs/` for an existing specification.
|
||||
|
||||
## Style Guide
|
||||
|
||||
[`docs/STYLEGUIDE.md`](STYLEGUIDE.md) covers:
|
||||
|
||||
- Code formatting and linting rules
|
||||
- Component naming conventions
|
||||
- Color palette and typography
|
||||
- Accessibility standards (WCAG 2.1 AA)
|
||||
52
docs/adr/007-reader-dashboard-permission-discriminant.md
Normal file
52
docs/adr/007-reader-dashboard-permission-discriminant.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# ADR-007: Reader-dashboard permission discriminant
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #447 introduced two distinct user cohorts on the home page:
|
||||
|
||||
- **Contributors** — transcribe, annotate, upload. The existing `MissionControlStrip`, `EnrichmentBlock`, `DashboardResumeStrip`, `DashboardFamilyPulse`, `DashboardActivityFeed`, and `DropZone` are aimed at them.
|
||||
- **Readers** — browse and consume finished content. Older, less technical, on smaller devices. The contribution-focused widgets are noise to them.
|
||||
|
||||
`AppUser` permissions are already derived in `+layout.server.ts` and exposed via `$page.data` as `canWrite`, `canAnnotate`, and `canBlogWrite`. The home route needs a single boolean to switch its layout and its data fetch set, and that boolean has to be load-bearing — every future permission introduced has to be classified against it.
|
||||
|
||||
## Decision
|
||||
|
||||
```ts
|
||||
const isReader = !canWrite && !canAnnotate;
|
||||
```
|
||||
|
||||
Computed at the start of `+page.server.ts` `load()`. When true, the loader fetches a lean reader set (stats / top-4 persons / recent docs / recent stories — and drafts when `canBlogWrite`) via `Promise.allSettled` and returns a discriminated-union shape the page distinguishes via `data.isReader`.
|
||||
|
||||
`BLOG_WRITE` is **not** part of the discriminant. A `READ_ALL + BLOG_WRITE` user is still a reader and additionally sees the `ReaderDraftsModule`. Story writers are conceptually closer to readers than to transcribers: they consume the archive, occasionally publish narrative on top of it, and have no business with the transcription queue.
|
||||
|
||||
A `BLOG_WRITE`-only user (no `READ_ALL`) is also classified as a reader by this formula. Because every reader API requires `READ_ALL`, all four content tiles degrade to empty via `Promise.allSettled`. They see the empty reader shell plus the drafts module — acceptable behaviour, since this permission combination is degenerate by configuration. Documented in `docs/GLOSSARY.md`.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| New `/reader-home` route with a server-side redirect from `/` | Two routes that mostly answer the same product question (home page). Bookmarks split, breadcrumbs split, header `home` link has to know which to use. The conditional-render keeps a single canonical URL and lets the auth state drive the layout, matching how `canWrite` already gates the upload zone in the contributor branch. |
|
||||
| `AppUser.dashboardVariant` column persisted in the DB | Permissions already encode the relevant signal; a separate field has to be kept in sync with permission changes. Drift is a feature foot-gun: a user gets `WRITE_ALL` granted but their `dashboardVariant` field still says `reader` and they keep seeing the wrong UI. |
|
||||
| Middleware/handle hook redirecting based on permissions | Same logical issue as the dedicated route plus a network round-trip on every dashboard hit. The discriminant runs once inside the same `load()` that's already fetching the user. |
|
||||
| `isReader = !canWrite && !canAnnotate && !canBlogWrite` (exclude `BLOG_WRITE` from readers) | Treats blog writers as contributors. They would land on the `MissionControlStrip` they cannot meaningfully use (no `WRITE_ALL`, no `ANNOTATE_ALL`) and would have to scroll past the transcription queue to find their own drafts. The reader shell + drafts module fits their actual workflow. |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- Reader and contributor views share one canonical home URL — no redirect, no routing fork.
|
||||
- Adding a new content tile to the reader dashboard is a single-file change inside the `if (isReader)` branch of `load()` plus a new component import in `+page.svelte`.
|
||||
- Backend `@RequirePermission(READ_ALL)` on every reader API call remains the load-bearing security gate. `isReader` is purely a UX flag — manipulating it client-side serves a different layout to the same authenticated user with the same permissions.
|
||||
|
||||
**Harder:**
|
||||
- Every future `Permission` value has to be explicitly classified against this formula. Adding a permission that grants contribution rights but not `WRITE_ALL`/`ANNOTATE_ALL` would silently leave its bearers on the reader dashboard. Mitigation: keep this ADR linked from `+page.server.ts` and from the `Permission` enum's Javadoc.
|
||||
- The discriminated-union return type of `load()` (`{isReader: true} | {isReader: false}`) requires every consumer to narrow on `data.isReader` before accessing branch-specific fields. The current `+page.svelte` already does this with the top-level `{#if data.isReader}`; new consumers of the home loader must follow suit.
|
||||
|
||||
## Future Direction
|
||||
|
||||
If a third cohort emerges (e.g. an admin home with system-health tiles), promote the discriminant to a tagged-union: `dashboard: 'reader' | 'contributor' | 'admin'`. The discriminant computation moves from `+page.server.ts` into a small helper in `lib/shared/server/`, callable from any route that needs the same classification (e.g. a future `/welcome` onboarding flow).
|
||||
|
||||
If `BLOG_WRITE`-only access becomes a real product mode (rather than the degenerate combination it is today), revisit whether the formula should add a `canRead` precondition: `isReader = canRead && !canWrite && !canAnnotate`.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Familienarchiv — C4 Architecture Diagrams
|
||||
|
||||
> For domain terminology used in these diagrams, see [docs/GLOSSARY.md](../GLOSSARY.md).
|
||||
>
|
||||
> **Cross-diagram stubs:** Components placed outside a `System_Boundary` block with a "See diagram X" annotation are reference stubs — they represent a component fully defined in another sub-diagram and appear here only to show the cross-domain dependency without duplicating the full definition.
|
||||
|
||||
## Level 1 — System Context
|
||||
|
||||
Who uses the system and what external systems does it interact with.
|
||||
@@ -8,13 +12,15 @@ Who uses the system and what external systems does it interact with.
|
||||
C4Context
|
||||
title System Context: Familienarchiv
|
||||
|
||||
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews documents")
|
||||
Person(member, "Family Member", "Searches, browses, and reads archived documents")
|
||||
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews and transcribes documents")
|
||||
Person(member, "Family Member", "Access by administrator invite. Searches, browses, reads, and transcribes archived documents.")
|
||||
|
||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||
|
||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches and views via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
```
|
||||
|
||||
---
|
||||
@@ -28,13 +34,16 @@ C4Container
|
||||
title Container Diagram: Familienarchiv
|
||||
|
||||
Person(user, "User", "Admin or family member")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification and password-reset emails.")
|
||||
|
||||
System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles session cookies, search UI, document viewer, and admin panel.")
|
||||
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
|
||||
|
||||
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, and Excel import.")
|
||||
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications.")
|
||||
|
||||
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, and Spring Session data.")
|
||||
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
|
||||
|
||||
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
|
||||
|
||||
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Objects keyed as documents/{UUID}_{filename}.")
|
||||
|
||||
@@ -43,8 +52,12 @@ C4Container
|
||||
|
||||
Rel(user, frontend, "Uses", "HTTPS / Browser")
|
||||
Rel(frontend, backend, "API requests with Basic Auth token", "HTTP / REST / JSON")
|
||||
Rel(backend, user, "SSE notifications (server-sent events)", "HTTP / SSE — direct backend-to-browser")
|
||||
Rel(backend, db, "Reads and writes metadata and sessions", "JDBC / SQL")
|
||||
Rel(backend, storage, "Uploads and streams document files", "HTTP / S3 API (AWS SDK v2)")
|
||||
Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JSON")
|
||||
Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(mc, storage, "Creates bucket on startup", "MinIO Client CLI")
|
||||
```
|
||||
|
||||
@@ -52,149 +65,444 @@ C4Container
|
||||
|
||||
## Level 3 — Components: API Backend
|
||||
|
||||
The internal structure of the Spring Boot backend.
|
||||
The internal structure of the Spring Boot backend, split into seven focused sub-diagrams.
|
||||
|
||||
### 3a — Security & Authentication
|
||||
|
||||
How requests are authenticated and write operations are authorised.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend
|
||||
title Component Diagram: API Backend — Security & Authentication
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and constructs an Authentication token; delegates credential validation to DaoAuthenticationProvider via BCrypt. Permits password-reset, invite, and register endpoints without authentication.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects. Logs unknown permissions.")
|
||||
}
|
||||
|
||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods", "")
|
||||
Rel(secConf, userDetails, "Wires as UserDetailsService", "")
|
||||
Rel(userDetails, db, "Loads user by email", "JDBC")
|
||||
```
|
||||
|
||||
### 3b — Document Management & Import
|
||||
|
||||
Document management, file storage, and bulk Excel/ODS import.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Document Management & Import
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
ContainerDb(minio, "MinIO")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel/ODS mass import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
|
||||
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and validates credentials via BCrypt.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
|
||||
Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel/ODS files from /import mount. Tracks import state (IDLE/RUNNING/DONE/FAILED) and delegates to ExcelService. Returns immediately; processing runs asynchronously.")
|
||||
Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel/ODS workbooks (Apache POI). Column indices configurable via application.properties. Creates/updates document records per row.")
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents. Endpoints: search, get by ID, update metadata, upload file, download file, get conversation thread.")
|
||||
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Also returns all documents sent by a person.")
|
||||
Component(userCtrl, "UserController", "Spring MVC — /api/users", "Returns current user (/me). Creates and deletes users (requires ADMIN_USER permission).")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel mass import (requires ADMIN permission).")
|
||||
Component(groupCtrl, "GroupController", "Spring MVC — /api/groups", "Lists and manages permission groups.")
|
||||
Component(tagCtrl, "TagController", "Spring MVC — /api/tags", "Lists tags for typeahead.")
|
||||
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core business logic: store, update, search documents. Resolves persons and tags. Delegates file I/O to FileService. Builds JPA Specifications for dynamic search queries.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths. Downloads with content-type detection (PDF, JPEG, PNG, octet-stream).")
|
||||
Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel workbooks (Apache POI). Column indices are configurable via application.properties. Creates/updates document records per row.")
|
||||
Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel files from /import mount. Delegates to ExcelService. Runs asynchronously so the HTTP response returns immediately.")
|
||||
Component(userSvc, "UserService", "Spring Service", "User CRUD. Encodes passwords with BCrypt. Assigns users to permission groups.")
|
||||
Component(dataInit, "DataInitializer", "CommandLineRunner", "On startup: creates default admin user and groups if none exist. Seeds test data (persons, documents) if DB is empty.")
|
||||
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents. Supports Specification-based dynamic search, conversation thread queries (bidirectional sender/receiver), and filename lookups.")
|
||||
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable query predicates: hasText (full-text across title/filename/transcription/location), hasSender, hasReceiver (join), isBetween (date range), hasTags (subquery AND logic).")
|
||||
Component(personRepo, "PersonRepository", "Spring Data JPA", "Lists all persons sorted by last name. Supports name search for typeahead.")
|
||||
Component(userRepo, "AppUserRepository", "Spring Data JPA", "Finds users by username. Used by Spring Security and UserService.")
|
||||
Component(tagRepo, "TagRepository", "Spring Data JPA", "Finds or creates tags by name (case-insensitive).")
|
||||
Component(groupRepo, "UserGroupRepository", "Spring Data JPA", "Manages permission groups.")
|
||||
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client bean with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by username from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
|
||||
}
|
||||
|
||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
||||
Rel(secFilter, permAspect, "Authenticated requests proceed", "")
|
||||
|
||||
Rel(secFilter, docCtrl, "Routes to", "")
|
||||
Rel(secFilter, personCtrl, "Routes to", "")
|
||||
Rel(secFilter, userCtrl, "Routes to", "")
|
||||
Rel(secFilter, adminCtrl, "Routes to", "")
|
||||
|
||||
Rel(permAspect, docCtrl, "Guards", "AOP @Around")
|
||||
Rel(permAspect, userCtrl, "Guards", "AOP @Around")
|
||||
Rel(permAspect, adminCtrl, "Guards", "AOP @Around")
|
||||
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. Called by DocumentService to resolve sender / receiver persons by ID.")
|
||||
Component(tagSvc, "TagService", "Spring Service", "See diagram 3d. Called by DocumentService to find or create tags by name.")
|
||||
|
||||
Rel(frontend, docCtrl, "Document requests", "HTTP / JSON")
|
||||
Rel(frontend, adminCtrl, "Trigger import", "HTTP / JSON")
|
||||
Rel(docCtrl, docSvc, "Delegates to", "")
|
||||
Rel(adminCtrl, massImport, "Triggers", "")
|
||||
Rel(userCtrl, userSvc, "Delegates to", "")
|
||||
|
||||
Rel(docSvc, fileSvc, "Upload / download files", "")
|
||||
Rel(docSvc, docRepo, "Reads / writes documents", "")
|
||||
Rel(docSvc, docSpec, "Builds search predicates", "")
|
||||
Rel(docSvc, personRepo, "Resolves sender / receivers", "")
|
||||
Rel(docSvc, tagRepo, "Finds or creates tags", "")
|
||||
|
||||
Rel(massImport, excelSvc, "Parses Excel file", "")
|
||||
Rel(docSvc, personSvc, "Resolves sender / receivers", "")
|
||||
Rel(docSvc, tagSvc, "Finds or creates tags", "")
|
||||
Rel(massImport, excelSvc, "Parses Excel/ODS file", "")
|
||||
Rel(excelSvc, docSvc, "Creates / updates documents", "")
|
||||
Rel(minioConf, fileSvc, "Provides S3Client and S3Presigner beans", "")
|
||||
Rel(fileSvc, minio, "PUT / GET / presigned URL objects", "S3 API / HTTP")
|
||||
Rel(docRepo, db, "SQL queries", "JDBC")
|
||||
```
|
||||
|
||||
### 3c — Document Transcription Pipeline
|
||||
|
||||
Annotation-driven transcription: page markup, text blocks, versioning, and comment threads.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Document Transcription Pipeline
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(transcriptionCtrl, "TranscriptionBlockController", "Spring MVC — /api/transcription", "CRUD for transcription text blocks per document page. Manages sort order, review status, and block version history.")
|
||||
Component(annotationCtrl, "AnnotationController", "Spring MVC — /api/documents/{id}/annotations", "CRUD for free-form page annotations with polygon coordinates, colour coding, and file-hash tracking.")
|
||||
Component(commentCtrl, "CommentController", "Spring MVC — /api/documents/{id}/comments", "Threaded comment CRUD on transcription blocks with @mention support and notification triggers.")
|
||||
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "Creates and updates transcription blocks from annotation regions. Tracks block versions, sanitizes text with an HTML allow-list, and triggers mentions.")
|
||||
Component(transcriptionQueueSvc, "TranscriptionQueueService", "Spring Service", "Assembles segmentation, transcription, and review queue projections by delegating to DocumentService and AuditLogQueryService.")
|
||||
Component(annotationSvc, "AnnotationService", "Spring Service", "Manages document page annotations with polygon coordinates. Called by OcrAsyncRunner to persist OCR-generated block boundaries.")
|
||||
Component(commentSvc, "CommentService", "Spring Service", "Creates and manages threaded comments with @mention parsing. Triggers NotificationService for REPLY and MENTION events.")
|
||||
|
||||
Component(blockRepo, "TranscriptionBlockRepository", "Spring Data JPA", "Reads and writes TranscriptionBlock and TranscriptionBlockVersion records.")
|
||||
Component(annotationRepo, "AnnotationRepository", "Spring Data JPA", "Reads and writes DocumentAnnotation records.")
|
||||
Component(commentRepo, "CommentRepository", "Spring Data JPA", "Reads and writes DocumentComment records.")
|
||||
}
|
||||
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by TranscriptionQueueService to assemble pipeline queue projections.")
|
||||
Component(auditQuerySvc, "AuditLogQueryService", "Spring Service", "See diagram 3g. Called by TranscriptionQueueService for pipeline activity data.")
|
||||
|
||||
Rel(frontend, transcriptionCtrl, "Transcription block requests", "HTTP / JSON")
|
||||
Rel(frontend, annotationCtrl, "Annotation requests", "HTTP / JSON")
|
||||
Rel(frontend, commentCtrl, "Comment requests", "HTTP / JSON")
|
||||
Rel(transcriptionCtrl, transcriptionSvc, "Delegates to", "")
|
||||
Rel(transcriptionCtrl, transcriptionQueueSvc, "Queries pipeline queues", "")
|
||||
Rel(annotationCtrl, annotationSvc, "Delegates to", "")
|
||||
Rel(commentCtrl, commentSvc, "Delegates to", "")
|
||||
Rel(transcriptionSvc, blockRepo, "Reads / writes blocks and versions", "")
|
||||
Rel(annotationSvc, annotationRepo, "Reads / writes annotations", "")
|
||||
Rel(commentSvc, commentRepo, "Reads / writes comments", "")
|
||||
Rel(transcriptionQueueSvc, documentSvc, "Queries pipeline document state", "")
|
||||
Rel(transcriptionQueueSvc, auditQuerySvc, "Queries pipeline activity data", "")
|
||||
Rel(blockRepo, db, "SQL queries", "JDBC")
|
||||
Rel(annotationRepo, db, "SQL queries", "JDBC")
|
||||
Rel(commentRepo, db, "SQL queries", "JDBC")
|
||||
```
|
||||
|
||||
### 3d — Users, Groups & Administration
|
||||
|
||||
User lifecycle, permission groups, tag management, and authentication endpoints.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Users, Groups & Administration
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(userCtrl, "UserController", "Spring MVC — /api/users", "Returns current user (/me), creates and deletes users (requires ADMIN_USER), supports user search and profile updates.")
|
||||
Component(groupCtrl, "GroupController", "Spring MVC — /api/groups", "Lists and manages permission groups.")
|
||||
Component(tagCtrl, "TagController", "Spring MVC — /api/tags", "Lists tags for typeahead, supports tag merge, tree structure, and subtree deletion.")
|
||||
Component(inviteCtrl, "InviteController", "Spring MVC — /api/auth/invite", "Creates invite codes and validates them at registration time. Rate-limited via WebConfig interceptor.")
|
||||
Component(authCtrl, "AuthController", "Spring MVC — /api/auth", "Handles user registration (POST /register) and password reset token endpoints (/forgot-password, /reset-password).")
|
||||
|
||||
Component(userSvc, "UserService", "Spring Service", "User CRUD with BCrypt password encoding, group assignment, and audit logging. Orchestrates invite-based registration and password reset tokens.")
|
||||
Component(tagSvc, "TagService", "Spring Service", "Tag CRUD with name search, hierarchical tree structure, merge/reparent operations, and recursive subtree deletion.")
|
||||
Component(dataInit, "DataInitializer", "CommandLineRunner", "On startup: creates default admin user and groups if none exist. Seeds test data if DB is empty.")
|
||||
|
||||
Component(userRepo, "AppUserRepository", "Spring Data JPA", "Finds users by email. Supports search by email or display name.")
|
||||
Component(groupRepo, "UserGroupRepository", "Spring Data JPA", "Manages permission groups.")
|
||||
Component(tagRepo, "TagRepository", "Spring Data JPA", "Finds or creates tags by name (case-insensitive). Supports recursive ancestor/descendant CTE queries and merge/reparent helpers.")
|
||||
}
|
||||
|
||||
Rel(frontend, userCtrl, "User requests", "HTTP / JSON")
|
||||
Rel(frontend, groupCtrl, "Group requests", "HTTP / JSON")
|
||||
Rel(frontend, tagCtrl, "Tag requests", "HTTP / JSON")
|
||||
Rel(frontend, inviteCtrl, "Invite validation", "HTTP / JSON")
|
||||
Rel(frontend, authCtrl, "Registration and password reset", "HTTP / JSON")
|
||||
Rel(userCtrl, userSvc, "Delegates to", "")
|
||||
Rel(groupCtrl, userSvc, "Delegates to", "")
|
||||
Rel(tagCtrl, tagSvc, "Delegates to", "")
|
||||
Rel(tagSvc, tagRepo, "Reads / writes tags", "")
|
||||
Rel(inviteCtrl, userSvc, "Creates and validates invites", "")
|
||||
Rel(authCtrl, userSvc, "Registers users, resets passwords", "")
|
||||
Rel(userSvc, userRepo, "Reads / writes users", "")
|
||||
Rel(userSvc, groupRepo, "Assigns groups", "")
|
||||
Rel(userDetails, userRepo, "Loads user by username", "")
|
||||
|
||||
Rel(fileSvc, minio, "PUT / GET objects", "S3 API / HTTP")
|
||||
Rel(docRepo, db, "SQL queries", "JDBC")
|
||||
Rel(personRepo, db, "SQL queries", "JDBC")
|
||||
Rel(userRepo, db, "SQL queries", "JDBC")
|
||||
Rel(tagRepo, db, "SQL queries", "JDBC")
|
||||
Rel(groupRepo, db, "SQL queries", "JDBC")
|
||||
Rel(dataInit, db, "Seeds initial data", "JDBC")
|
||||
Rel(secConf, userDetails, "Wires", "")
|
||||
Rel(minioConf, fileSvc, "Provides S3Client bean", "")
|
||||
Rel(userRepo, db, "SQL queries", "JDBC")
|
||||
Rel(groupRepo, db, "SQL queries", "JDBC")
|
||||
Rel(tagRepo, db, "SQL queries", "JDBC")
|
||||
```
|
||||
|
||||
### 3e — Persons & Family Graph
|
||||
|
||||
Person management including family relationship modelling and transitive inference.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Persons & Family Graph
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Returns documents sent by or received by a person, correspondent suggestions, and person summary with document counts.")
|
||||
Component(relCtrl, "RelationshipController", "Spring MVC — /api/network, /api/persons/{id}/relationships", "CRUD for explicit person relationships and the full family network graph (nodes + edges) used by the Stammbaum view.")
|
||||
|
||||
Component(personSvc, "PersonService", "Spring Service", "Person CRUD, alias management, and merge operations (reassigns all document sender/receiver references before deleting duplicate persons).")
|
||||
Component(relSvc, "RelationshipService", "Spring Service", "Manages explicit directional family relationships (PARENT_OF, SPOUSE_OF, SIBLING_OF, etc.) with optional date ranges and notes.")
|
||||
Component(relInference, "RelationshipInferenceService", "Spring Service", "Computes transitive family relationships from explicit edges to infer grandparent/grandchild, aunt/uncle, and other extended-family links for the network graph.")
|
||||
|
||||
Component(personRepo, "PersonRepository", "Spring Data JPA", "Queries persons with name search (including aliases), correspondent discovery, person summaries with document counts, and merge/reassignment helpers.")
|
||||
Component(relRepo, "PersonRelationshipRepository", "Spring Data JPA", "Reads and writes PersonRelationship records. Supports lookup by person ID, by relation type, and existence checks for deduplication.")
|
||||
}
|
||||
|
||||
Rel(frontend, personCtrl, "Person requests", "HTTP / JSON")
|
||||
Rel(frontend, relCtrl, "Relationship and graph requests", "HTTP / JSON")
|
||||
Rel(personCtrl, personSvc, "Delegates to", "")
|
||||
Rel(relCtrl, relSvc, "Delegates to", "")
|
||||
Rel(relCtrl, relInference, "Queries inferred graph", "")
|
||||
Rel(personSvc, personRepo, "Reads / writes persons", "")
|
||||
Rel(relSvc, relRepo, "Reads / writes relationships", "")
|
||||
Rel(relInference, relRepo, "Reads relationships for inference", "")
|
||||
Rel(personRepo, db, "SQL queries", "JDBC")
|
||||
Rel(relRepo, db, "SQL queries", "JDBC")
|
||||
```
|
||||
|
||||
### 3f — OCR Orchestration
|
||||
|
||||
How the Spring Boot backend manages OCR jobs, streams results, and trains recognition models.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — OCR Orchestration
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
ContainerDb(minio, "MinIO")
|
||||
Container(ocrPy, "OCR Service", "Python FastAPI")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(ocrCtrl, "OcrController", "Spring MVC — /api/ocr", "REST entry point: trigger single or batch OCR jobs, stream progress via SSE, query job status, and manage training runs and per-sender models.")
|
||||
Component(ocrSvc, "OcrService", "Spring Service", "Creates OcrJob and OcrJobDocument records, checks Python service health, and delegates async execution to OcrAsyncRunner.")
|
||||
Component(ocrBatch, "OcrBatchService", "Spring Service", "Orchestrates multi-document OCR jobs, iterating documents and delegating each to OcrAsyncRunner.")
|
||||
Component(ocrAsync, "OcrAsyncRunner", "Spring Component — @Async", "Async worker that streams OCR results from Python page by page, persists transcription blocks and annotations via domain services, and emits progress via SSE.")
|
||||
Component(ocrClient, "RestClientOcrClient", "Spring Component", "HTTP client wrapping the Python service: POST /ocr/stream (NDJSON), /train, /segtrain, and /train-sender. Falls back from streaming to batch on 404.")
|
||||
Component(ocrTraining, "OcrTrainingService", "Spring Service", "Orchestrates model training: exports training data as ZIP, calls Python /train or /segtrain, persists training metrics in OcrTrainingRunRepository.")
|
||||
Component(ocrJobRepo, "OcrJobRepository, OcrJobDocumentRepository", "Spring Data JPA", "Reads and writes OcrJob and OcrJobDocument records. Tracks job status (RUNNING/DONE/FAILED), per-document progress, page counts, and error messages.")
|
||||
}
|
||||
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist transcription blocks per page.")
|
||||
Component(annotationSvc, "AnnotationService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist OCR-generated annotation regions per page.")
|
||||
|
||||
Rel(frontend, ocrCtrl, "OCR trigger, status, and progress requests", "HTTP / JSON / SSE")
|
||||
Rel(ocrCtrl, ocrSvc, "Single-document jobs", "")
|
||||
Rel(ocrCtrl, ocrBatch, "Batch jobs", "")
|
||||
Rel(ocrCtrl, ocrTraining, "Training runs", "")
|
||||
Rel(ocrSvc, ocrAsync, "Delegates async execution", "")
|
||||
Rel(ocrBatch, ocrAsync, "Delegates async execution", "")
|
||||
Rel(ocrAsync, ocrClient, "Streams OCR results page by page", "HTTP / NDJSON")
|
||||
Rel(ocrTraining, ocrClient, "Sends training data ZIP", "HTTP / multipart")
|
||||
Rel(ocrClient, ocrPy, "POST /ocr/stream, /train, /segtrain, /train-sender", "HTTP / REST")
|
||||
Rel(ocrAsync, transcriptionSvc, "Saves transcription blocks per page", "")
|
||||
Rel(ocrAsync, annotationSvc, "Saves annotation regions per page", "")
|
||||
Rel(ocrAsync, ocrJobRepo, "Reads / writes OCR job state", "")
|
||||
Rel(ocrJobRepo, db, "SQL queries", "JDBC")
|
||||
Rel(ocrAsync, minio, "Generates presigned URLs for PDF fetch", "S3 API")
|
||||
Rel(ocrPy, minio, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(ocrTraining, db, "Persists training run metrics", "JDBC")
|
||||
```
|
||||
|
||||
### 3g — Supporting Domains
|
||||
|
||||
Audit logging, dashboard stats, SSE notifications, stories (Geschichten), and cross-cutting exception handling.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: API Backend — Supporting Domains
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(auditSvc, "AuditService", "Spring Service — @Async", "Writes audit log entries asynchronously via a dedicated TaskExecutor, with transaction-aware logging to prevent deadlocks on concurrent saves.")
|
||||
Component(auditQuery, "AuditLogQueryService", "Spring Service", "Queries audit logs for activity feeds, pulse stats, recent contributors, and per-document history. Facade over AuditLogRepository.")
|
||||
|
||||
Component(dashCtrl, "DashboardController", "Spring MVC — /api/dashboard", "REST endpoints for the user dashboard: recent document resume (/resume), weekly transcription pulse stats (/pulse), and activity feed (/activity) with kind filtering and pagination.")
|
||||
Component(statsCtrl, "StatsController", "Spring MVC — /api/stats", "Returns aggregate counts (total persons, total documents) for the UI stats bar.")
|
||||
Component(statsSvc, "StatsService", "Spring Service", "Queries aggregate counts: total persons and total documents.")
|
||||
Component(dashSvc, "DashboardService", "Spring Service", "Assembles the user dashboard: recent document resume (calls DocumentService + TranscriptionService), weekly transcription pulse stats, and activity feed with contributor avatars.")
|
||||
|
||||
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
||||
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
|
||||
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by DashboardService to fetch document titles and resume data.")
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by DashboardService to fetch transcription block progress for resume.")
|
||||
|
||||
Rel(frontend, dashCtrl, "Dashboard requests", "HTTP / JSON")
|
||||
Rel(frontend, statsCtrl, "GET /api/stats", "HTTP / JSON")
|
||||
Rel(frontend, notifCtrl, "Notification stream and history", "HTTP / JSON / SSE")
|
||||
Rel(frontend, geschCtrl, "Story requests", "HTTP / JSON")
|
||||
Rel(dashCtrl, dashSvc, "Delegates to", "")
|
||||
Rel(statsCtrl, statsSvc, "Delegates to", "")
|
||||
Rel(statsSvc, db, "Reads aggregate counts", "JDBC")
|
||||
Rel(dashSvc, auditQuery, "Fetches activity feed and pulse stats", "")
|
||||
Rel(dashSvc, documentSvc, "Fetches document titles and resume data", "")
|
||||
Rel(dashSvc, transcriptionSvc, "Fetches transcription block progress for resume", "")
|
||||
Rel(notifCtrl, notifSvc, "Delegates to", "")
|
||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection", "")
|
||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients", "")
|
||||
Rel(geschCtrl, geschSvc, "Delegates to", "")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
Rel(geschSvc, db, "Reads / writes geschichten", "JDBC")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Level 3 — Components: Web Frontend
|
||||
|
||||
The internal structure of the SvelteKit frontend.
|
||||
The internal structure of the SvelteKit frontend, split into four focused views.
|
||||
|
||||
### 3a — Middleware, Auth & Layout
|
||||
|
||||
Per-request middleware: session validation, i18n, auth cookie handling, and auth pages.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: Web Frontend
|
||||
title Component Diagram: Web Frontend — Middleware, Auth & Layout
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
|
||||
Component(hooks, "hooks.server.ts", "SvelteKit Server Hook", "Two responsibilities: (1) userGroup handle — reads auth_token cookie, fetches /api/users/me, stores user in event.locals. (2) handleFetch — intercepts all outgoing fetch() calls, injects Authorization header from cookie. Redirects to /login if token absent.")
|
||||
Component(hooks, "hooks.server.ts", "SvelteKit Server Hook", "Four handle layers: (1) handleAuth — redirects unauthenticated users to /login; (2) userGroup — reads auth_token cookie, fetches /api/users/me, stores user in event.locals; (3) handleFetch — injects Authorization header on all outgoing /api/ calls; (4) handleLocaleDetection — sets language cookie from Accept-Language header.")
|
||||
Component(i18n, "hooks.ts (Paraglide)", "SvelteKit Client Hook", "Client-side i18n middleware. Detects language from URL and sets the active locale for Paraglide.js translation functions.")
|
||||
|
||||
Component(layout, "+layout.server.ts", "SvelteKit Layout Loader", "Passes event.locals.user down to all child pages so every route has access to the authenticated user.")
|
||||
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL search params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons, returns results. Page: renders search form with full-text, date range, sender/receiver typeahead, tag filters. Displays paginated document list.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: fetches /api/documents/{id}. Handles 401 redirect to login, 404 error. Page: shows document metadata, file viewer (PDF/image inline), transcription, tags.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Form with PersonTypeahead for sender/receiver, TagInput for tags, date/location fields. Submits PUT to /api/documents/{id}.")
|
||||
Component(persons, "/persons and /persons/[id]", "SvelteKit Routes", "Lists all persons. Detail page shows person metadata and all documents they sent.")
|
||||
Component(conversations, "/conversations", "SvelteKit Route", "Selects two persons via PersonTypeahead, fetches /api/documents/conversation, displays chronological exchange.")
|
||||
Component(loginPage, "/login", "SvelteKit Route", "Form action: encodes username:password as Base64 Basic Auth token, POSTs to /api/users/me to validate, sets auth_token httpOnly cookie (SameSite=strict, maxAge=86400), redirects to /.")
|
||||
Component(loginPage, "/login", "SvelteKit Route", "Form action: encodes email:password as Base64 Basic Auth token, POSTs to /api/users/me to validate, sets auth_token httpOnly cookie (SameSite=strict, maxAge=86400), redirects to /.")
|
||||
Component(logoutPage, "/logout", "SvelteKit Route (server-only)", "Clears the auth_token cookie and redirects to /login.")
|
||||
Component(adminPage, "/admin", "SvelteKit Route", "User management UI (create/delete users). Excel import trigger button (calls /api/admin/trigger-import).")
|
||||
|
||||
Component(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend. Used by PersonTypeahead for typeahead suggestions.")
|
||||
Component(apiTags, "/api/tags (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/tags to backend. Used by TagInput for autocomplete.")
|
||||
|
||||
Component(typeahead, "PersonTypeahead.svelte", "Svelte Component", "Async autocomplete for selecting a person. Debounces input, calls /api/persons?q=.")
|
||||
Component(tagInput, "TagInput.svelte", "Svelte Component", "Multi-tag input. Supports free-text entry and selecting existing tags from /api/tags.")
|
||||
Component(registerPage, "/register", "SvelteKit Route", "Loader validates invite code via GET /api/auth/invite/{code}. Form action: POST /api/auth/register to create the user account.")
|
||||
Component(forgotPw, "/forgot-password", "SvelteKit Route", "Form action: POST /api/auth/forgot-password. Always responds with success to prevent email enumeration.")
|
||||
Component(resetPw, "/reset-password", "SvelteKit Route", "Form action: POST /api/auth/reset-password with the token from the query string.")
|
||||
}
|
||||
|
||||
Rel(user, hooks, "Every browser request", "HTTPS")
|
||||
Rel(hooks, backend, "GET /api/users/me (session check)", "HTTP / Basic Auth")
|
||||
Rel(hooks, loginPage, "Redirect if no token", "")
|
||||
|
||||
Rel(layout, homePage, "Provides user context", "")
|
||||
Rel(layout, docDetail, "Provides user context", "")
|
||||
Rel(layout, adminPage, "Provides user context", "")
|
||||
|
||||
Rel(homePage, backend, "GET /api/documents/search", "HTTP / JSON")
|
||||
Rel(homePage, backend, "GET /api/persons", "HTTP / JSON")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}", "HTTP / JSON")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}/file", "HTTP / Binary stream")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
Rel(conversations, backend, "GET /api/documents/conversation", "HTTP / JSON")
|
||||
Rel(hooks, layout, "Stores authenticated user in event.locals", "")
|
||||
Rel(loginPage, backend, "POST /api/users/me (auth check)", "HTTP / Basic Auth")
|
||||
Rel(adminPage, backend, "GET/POST/DELETE /api/users", "HTTP / JSON")
|
||||
Rel(adminPage, backend, "POST /api/admin/trigger-import", "HTTP / JSON")
|
||||
Rel(registerPage, backend, "GET /api/auth/invite/{code}, POST /api/auth/register", "HTTP / JSON")
|
||||
Rel(forgotPw, backend, "POST /api/auth/forgot-password", "HTTP / JSON")
|
||||
Rel(resetPw, backend, "POST /api/auth/reset-password", "HTTP / JSON")
|
||||
```
|
||||
|
||||
Rel(apiPersons, backend, "GET /api/persons", "HTTP / JSON")
|
||||
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
|
||||
### 3b — Document Workflows
|
||||
|
||||
Document search, viewing, editing, enrichment, and the shared components that support them.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: Web Frontend — Document Workflows
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||
Component(docBulkEdit, "/documents/bulk-edit", "SvelteKit Route", "Multi-document metadata editor. Loader: GET /api/documents/incomplete. Requires WRITE_ALL (redirects otherwise). Action: PATCH /api/documents/bulk.")
|
||||
Component(enrichPage, "/enrich/[id]", "SvelteKit Route", "Guided enrichment workflow. Loader: GET /api/documents/{id}. Progressively saves annotations and transcription blocks.")
|
||||
|
||||
Component(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend for PersonTypeahead suggestions.")
|
||||
Component(apiTags, "/api/tags (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/tags to backend for TagInput autocomplete.")
|
||||
Component(typeahead, "PersonTypeahead.svelte", "Svelte Component", "Async autocomplete for selecting a person. Debounces input, calls /api/persons?q=.")
|
||||
Component(tagInput, "TagInput.svelte", "Svelte Component", "Multi-tag input. Supports free-text entry and selecting existing tags from /api/tags.")
|
||||
}
|
||||
|
||||
Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
Rel(docNew, backend, "GET /api/persons, POST /api/documents", "HTTP / JSON + Multipart")
|
||||
Rel(docBulkEdit, backend, "GET /api/documents/incomplete, PATCH /api/documents/bulk", "HTTP / JSON")
|
||||
Rel(enrichPage, backend, "GET/POST /api/transcription, POST /api/documents/{id}/annotations", "HTTP / JSON")
|
||||
Rel(homePage, typeahead, "Uses for sender/receiver filter", "")
|
||||
Rel(docEdit, typeahead, "Uses for sender/receiver selection", "")
|
||||
Rel(docNew, typeahead, "Uses for sender selection", "")
|
||||
Rel(docEdit, tagInput, "Uses for tag management", "")
|
||||
Rel(typeahead, apiPersons, "Fetches suggestions", "HTTP")
|
||||
Rel(tagInput, apiTags, "Fetches existing tags", "HTTP")
|
||||
Rel(apiPersons, backend, "GET /api/persons", "HTTP / JSON")
|
||||
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
|
||||
```
|
||||
|
||||
### 3c — People, Stories & Discovery
|
||||
|
||||
Person directory, bilateral conversations, activity feed, stories, family tree, and user profiles.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: Web Frontend — People, Stories & Discovery
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.")
|
||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||
Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||
}
|
||||
|
||||
Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
|
||||
Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON")
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||
```
|
||||
|
||||
### 3d — Administration & Help
|
||||
|
||||
Admin panel sub-routes and the transcription help guide.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Component Diagram: Web Frontend — Administration & Help
|
||||
|
||||
Person(admin, "Administrator")
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(adminUsers, "/admin/users, /admin/users/[id], /admin/users/new, /admin/invites", "SvelteKit Routes", "User directory, create/update/delete users, and manage invite codes. Requires ADMIN_USER permission.")
|
||||
Component(adminGroups, "/admin/groups, /admin/groups/[id], /admin/groups/new", "SvelteKit Routes", "Permission group management: create/edit groups and their permission sets.")
|
||||
Component(adminTags, "/admin/tags and /admin/tags/[id]", "SvelteKit Routes", "Tag administration: edit tag hierarchy, merge tags, delete subtrees.")
|
||||
Component(adminOcr, "/admin/ocr and /admin/ocr/[personId]", "SvelteKit Routes", "Global and per-person OCR configuration. Manages script types and triggers sender model training.")
|
||||
Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers Excel/ODS mass import (POST /api/admin/trigger-import). Displays import state.")
|
||||
Component(hilfe, "/hilfe/transkription", "SvelteKit Route", "Static transcription style guide for Kurrent and Sütterlin character recognition. No backend calls.")
|
||||
}
|
||||
|
||||
Rel(admin, adminUsers, "Manages users and invites", "HTTPS / Browser")
|
||||
Rel(user, hilfe, "Views transcription style guide", "HTTPS / Browser")
|
||||
Rel(adminUsers, backend, "GET/POST/DELETE /api/users, POST /api/auth/invite", "HTTP / JSON")
|
||||
Rel(adminGroups, backend, "GET/POST/PUT/DELETE /api/groups", "HTTP / JSON")
|
||||
Rel(adminTags, backend, "GET/PUT/DELETE /api/tags", "HTTP / JSON")
|
||||
Rel(adminOcr, backend, "GET/POST /api/ocr (global config and sender training)", "HTTP / JSON")
|
||||
Rel(adminSystem, backend, "POST /api/admin/trigger-import, GET /api/admin/import-status", "HTTP / JSON")
|
||||
```
|
||||
|
||||
---
|
||||
@@ -211,12 +519,12 @@ sequenceDiagram
|
||||
participant Backend as Backend (Spring Boot)
|
||||
participant DB as PostgreSQL
|
||||
|
||||
User->>Browser: Enter username + password
|
||||
User->>Browser: Enter email + password
|
||||
Browser->>Frontend: POST /login (form action)
|
||||
Frontend->>Frontend: Base64 encode "user:password"
|
||||
Frontend->>Frontend: Base64 encode "email:password"
|
||||
Frontend->>Backend: GET /api/users/me<br/>Authorization: Basic <token>
|
||||
Backend->>Backend: Spring Security parses Basic Auth
|
||||
Backend->>DB: SELECT user WHERE username=?
|
||||
Backend->>DB: SELECT user WHERE email=?
|
||||
DB-->>Backend: AppUser + groups + permissions
|
||||
Backend->>Backend: BCrypt.matches(password, hash)
|
||||
Backend-->>Frontend: 200 OK — UserDTO
|
||||
@@ -264,3 +572,14 @@ sequenceDiagram
|
||||
Backend-->>Frontend: 200 OK — Document JSON
|
||||
Frontend-->>User: Refreshed document view
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
Entity-relationship and full column reference for the PostgreSQL schema (30 tables, 7 domain groups). Source files in `docs/architecture/db/`.
|
||||
|
||||
- **[db-relationships.puml](db/db-relationships.puml)** — Entity relationships: all tables and foreign-key connections, grouped by domain. Start here for an overview.
|
||||
- **[db-orm.puml](db/db-orm.puml)** — Full schema reference: all columns and types for all 30 tables. Use this when mapping Java entities to database columns.
|
||||
|
||||
> Schema as of Flyway V60 (2026-05-06). Open in VS Code with the PlantUML extension (server: `http://heim-nas:8500`).
|
||||
|
||||
39
docs/architecture/c4/README.md
Normal file
39
docs/architecture/c4/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# C4-PlantUML Diagrams
|
||||
|
||||
Architecture diagrams in C4-PlantUML format. These are the authoritative source for layout-accurate diagrams. The companion `c4-diagrams.md` in the parent directory keeps Mermaid versions for inline Gitea rendering.
|
||||
|
||||
## Render in Gitea
|
||||
|
||||
Gitea is configured to render `.puml` files as diagrams. Open any `.puml` file in the Gitea UI to see the rendered diagram.
|
||||
|
||||
> **Note:** `plantuml` code fences inside Markdown files do **not** render inline in Gitea — this is a Gitea limitation unrelated to the server configuration. The `.md` files in this repo use Mermaid for that reason.
|
||||
|
||||
## Render in VS Code
|
||||
|
||||
Install the [PlantUML extension](https://marketplace.visualstudio.com/items?itemName=jebbs.plantuml) (`jebbs.plantuml`). The project's `.vscode/settings.json` already points at the shared server:
|
||||
|
||||
```
|
||||
plantuml.server = http://heim-nas:8500
|
||||
```
|
||||
|
||||
Open any `.puml` file and press `Alt+D` to preview.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Diagram |
|
||||
|---|---|
|
||||
| `l1-context.puml` | Level 1 — System Context |
|
||||
| `l2-containers.puml` | Level 2 — Containers |
|
||||
| `l3-backend-3a-security.puml` | L3 Backend: Security & Authentication |
|
||||
| `l3-backend-3b-document-management.puml` | L3 Backend: Document Management & Import |
|
||||
| `l3-backend-3c-transcription.puml` | L3 Backend: Document Transcription Pipeline |
|
||||
| `l3-backend-3d-users-groups.puml` | L3 Backend: Users, Groups & Administration |
|
||||
| `l3-backend-3e-persons.puml` | L3 Backend: Persons & Family Graph |
|
||||
| `l3-backend-3f-ocr.puml` | L3 Backend: OCR Orchestration |
|
||||
| `l3-backend-3g-supporting.puml` | L3 Backend: Supporting Domains |
|
||||
| `l3-frontend-3a-middleware-auth.puml` | L3 Frontend: Middleware, Auth & Layout |
|
||||
| `l3-frontend-3b-document-workflows.puml` | L3 Frontend: Document Workflows |
|
||||
| `l3-frontend-3c-people-stories.puml` | L3 Frontend: People, Stories & Discovery |
|
||||
| `l3-frontend-3d-administration.puml` | L3 Frontend: Administration & Help |
|
||||
| `seq-auth-flow.puml` | Sequence: Authentication Flow |
|
||||
| `seq-document-upload.puml` | Sequence: Document Upload Flow |
|
||||
16
docs/architecture/c4/l1-context.puml
Normal file
16
docs/architecture/c4/l1-context.puml
Normal file
@@ -0,0 +1,16 @@
|
||||
@startuml
|
||||
!include <C4/C4_Context>
|
||||
|
||||
title System Context: Familienarchiv
|
||||
|
||||
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews and transcribes documents")
|
||||
Person(member, "Family Member", "Access by administrator invite. Searches, browses, reads, and transcribes archived documents.")
|
||||
|
||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||
|
||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
|
||||
@enduml
|
||||
28
docs/architecture/c4/l2-containers.puml
Normal file
28
docs/architecture/c4/l2-containers.puml
Normal file
@@ -0,0 +1,28 @@
|
||||
@startuml
|
||||
!include <C4/C4_Container>
|
||||
|
||||
title Container Diagram: Familienarchiv
|
||||
|
||||
Person(user, "User", "Admin or family member")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification and password-reset emails.")
|
||||
|
||||
System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
|
||||
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications.")
|
||||
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
|
||||
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
|
||||
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Objects keyed as documents/{UUID}_{filename}.")
|
||||
Container(mc, "Bucket Init Helper", "MinIO Client (mc)", "One-shot container on startup. Creates the archive bucket with private access policy.")
|
||||
}
|
||||
|
||||
Rel(user, frontend, "Uses", "HTTPS / Browser")
|
||||
Rel(frontend, backend, "API requests with Basic Auth token", "HTTP / REST / JSON")
|
||||
Rel(backend, user, "SSE notifications (server-sent events)", "HTTP / SSE — direct backend-to-browser")
|
||||
Rel(backend, db, "Reads and writes metadata and sessions", "JDBC / SQL")
|
||||
Rel(backend, storage, "Uploads and streams document files", "HTTP / S3 API (AWS SDK v2)")
|
||||
Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JSON")
|
||||
Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(mc, storage, "Creates bucket on startup", "MinIO Client CLI")
|
||||
|
||||
@enduml
|
||||
21
docs/architecture/c4/l3-backend-3a-security.puml
Normal file
21
docs/architecture/c4/l3-backend-3a-security.puml
Normal file
@@ -0,0 +1,21 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Security & Authentication
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and constructs an Authentication token; delegates credential validation to DaoAuthenticationProvider via BCrypt. Permits password-reset, invite, and register endpoints without authentication.")
|
||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
|
||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects. Logs unknown permissions.")
|
||||
}
|
||||
|
||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
||||
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
||||
Rel(userDetails, db, "Loads user by email", "JDBC")
|
||||
|
||||
@enduml
|
||||
40
docs/architecture/c4/l3-backend-3b-document-management.puml
Normal file
40
docs/architecture/c4/l3-backend-3b-document-management.puml
Normal file
@@ -0,0 +1,40 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Document Management & Import
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, batch metadata updates, and per-month density aggregation for the timeline filter widget.")
|
||||
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel/ODS mass import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
|
||||
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
|
||||
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
|
||||
Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel/ODS files from /import mount. Tracks import state (IDLE/RUNNING/DONE/FAILED) and delegates to ExcelService. Returns immediately; processing runs asynchronously.")
|
||||
Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel/ODS workbooks (Apache POI). Column indices configurable via application.properties. Creates/updates document records per row.")
|
||||
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
|
||||
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
|
||||
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
|
||||
}
|
||||
|
||||
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. Called by DocumentService to resolve sender / receiver persons by ID.")
|
||||
Component(tagSvc, "TagService", "Spring Service", "See diagram 3d. Called by DocumentService to find or create tags by name.")
|
||||
|
||||
Rel(frontend, docCtrl, "Document requests", "HTTP / JSON")
|
||||
Rel(frontend, adminCtrl, "Trigger import", "HTTP / JSON")
|
||||
Rel(docCtrl, docSvc, "Delegates to")
|
||||
Rel(adminCtrl, massImport, "Triggers")
|
||||
Rel(docSvc, fileSvc, "Upload / download files")
|
||||
Rel(docSvc, docRepo, "Reads / writes documents")
|
||||
Rel(docSvc, docSpec, "Builds search predicates")
|
||||
Rel(docSvc, personSvc, "Resolves sender / receivers")
|
||||
Rel(docSvc, tagSvc, "Finds or creates tags")
|
||||
Rel(massImport, excelSvc, "Parses Excel/ODS file")
|
||||
Rel(excelSvc, docSvc, "Creates / updates documents")
|
||||
Rel(minioConf, fileSvc, "Provides S3Client and S3Presigner beans")
|
||||
Rel(fileSvc, minio, "PUT / GET / presigned URL objects", "S3 API / HTTP")
|
||||
Rel(docRepo, db, "SQL queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
41
docs/architecture/c4/l3-backend-3c-transcription.puml
Normal file
41
docs/architecture/c4/l3-backend-3c-transcription.puml
Normal file
@@ -0,0 +1,41 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Document Transcription Pipeline
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(transcriptionCtrl, "TranscriptionBlockController", "Spring MVC — /api/transcription", "CRUD for transcription text blocks per document page. Manages sort order, review status, and block version history.")
|
||||
Component(annotationCtrl, "AnnotationController", "Spring MVC — /api/documents/{id}/annotations", "CRUD for free-form page annotations with polygon coordinates, colour coding, and file-hash tracking.")
|
||||
Component(commentCtrl, "CommentController", "Spring MVC — /api/documents/{id}/comments", "Threaded comment CRUD on transcription blocks with @mention support and notification triggers.")
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "Creates and updates transcription blocks from annotation regions. Tracks block versions, sanitizes text with an HTML allow-list, and triggers mentions.")
|
||||
Component(transcriptionQueueSvc, "TranscriptionQueueService", "Spring Service", "Assembles segmentation, transcription, and review queue projections by delegating to DocumentService and AuditLogQueryService.")
|
||||
Component(annotationSvc, "AnnotationService", "Spring Service", "Manages document page annotations with polygon coordinates. Called by OcrAsyncRunner to persist OCR-generated block boundaries.")
|
||||
Component(commentSvc, "CommentService", "Spring Service", "Creates and manages threaded comments with @mention parsing. Triggers NotificationService for REPLY and MENTION events.")
|
||||
Component(blockRepo, "TranscriptionBlockRepository", "Spring Data JPA", "Reads and writes TranscriptionBlock and TranscriptionBlockVersion records.")
|
||||
Component(annotationRepo, "AnnotationRepository", "Spring Data JPA", "Reads and writes DocumentAnnotation records.")
|
||||
Component(commentRepo, "CommentRepository", "Spring Data JPA", "Reads and writes DocumentComment records.")
|
||||
}
|
||||
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by TranscriptionQueueService to assemble pipeline queue projections.")
|
||||
Component(auditQuerySvc, "AuditLogQueryService", "Spring Service", "See diagram 3g. Called by TranscriptionQueueService for pipeline activity data.")
|
||||
|
||||
Rel(frontend, transcriptionCtrl, "Transcription block requests", "HTTP / JSON")
|
||||
Rel(frontend, annotationCtrl, "Annotation requests", "HTTP / JSON")
|
||||
Rel(frontend, commentCtrl, "Comment requests", "HTTP / JSON")
|
||||
Rel(transcriptionCtrl, transcriptionSvc, "Delegates to")
|
||||
Rel(transcriptionCtrl, transcriptionQueueSvc, "Queries pipeline queues")
|
||||
Rel(annotationCtrl, annotationSvc, "Delegates to")
|
||||
Rel(commentCtrl, commentSvc, "Delegates to")
|
||||
Rel(transcriptionSvc, blockRepo, "Reads / writes blocks and versions")
|
||||
Rel(annotationSvc, annotationRepo, "Reads / writes annotations")
|
||||
Rel(commentSvc, commentRepo, "Reads / writes comments")
|
||||
Rel(transcriptionQueueSvc, documentSvc, "Queries pipeline document state")
|
||||
Rel(transcriptionQueueSvc, auditQuerySvc, "Queries pipeline activity data")
|
||||
Rel(blockRepo, db, "SQL queries", "JDBC")
|
||||
Rel(annotationRepo, db, "SQL queries", "JDBC")
|
||||
Rel(commentRepo, db, "SQL queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
41
docs/architecture/c4/l3-backend-3d-users-groups.puml
Normal file
41
docs/architecture/c4/l3-backend-3d-users-groups.puml
Normal file
@@ -0,0 +1,41 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Users, Groups & Administration
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(userCtrl, "UserController", "Spring MVC — /api/users", "Returns current user (/me), creates and deletes users (requires ADMIN_USER), supports user search and profile updates.")
|
||||
Component(groupCtrl, "GroupController", "Spring MVC — /api/groups", "Lists and manages permission groups.")
|
||||
Component(tagCtrl, "TagController", "Spring MVC — /api/tags", "Lists tags for typeahead, supports tag merge, tree structure, and subtree deletion.")
|
||||
Component(inviteCtrl, "InviteController", "Spring MVC — /api/auth/invite", "Creates invite codes and validates them at registration time. Rate-limited via WebConfig interceptor.")
|
||||
Component(authCtrl, "AuthController", "Spring MVC — /api/auth", "Handles user registration (POST /register) and password reset token endpoints (/forgot-password, /reset-password).")
|
||||
Component(userSvc, "UserService", "Spring Service", "User CRUD with BCrypt password encoding, group assignment, and audit logging. Orchestrates invite-based registration and password reset tokens.")
|
||||
Component(tagSvc, "TagService", "Spring Service", "Tag CRUD with name search, hierarchical tree structure, merge/reparent operations, and recursive subtree deletion.")
|
||||
Component(dataInit, "DataInitializer", "CommandLineRunner", "On startup: creates default admin user and groups if none exist. Seeds test data if DB is empty.")
|
||||
Component(userRepo, "AppUserRepository", "Spring Data JPA", "Finds users by email. Supports search by email or display name.")
|
||||
Component(groupRepo, "UserGroupRepository", "Spring Data JPA", "Manages permission groups.")
|
||||
Component(tagRepo, "TagRepository", "Spring Data JPA", "Finds or creates tags by name (case-insensitive). Supports recursive ancestor/descendant CTE queries and merge/reparent helpers.")
|
||||
}
|
||||
|
||||
Rel(frontend, userCtrl, "User requests", "HTTP / JSON")
|
||||
Rel(frontend, groupCtrl, "Group requests", "HTTP / JSON")
|
||||
Rel(frontend, tagCtrl, "Tag requests", "HTTP / JSON")
|
||||
Rel(frontend, inviteCtrl, "Invite validation", "HTTP / JSON")
|
||||
Rel(frontend, authCtrl, "Registration and password reset", "HTTP / JSON")
|
||||
Rel(userCtrl, userSvc, "Delegates to")
|
||||
Rel(groupCtrl, userSvc, "Delegates to")
|
||||
Rel(tagCtrl, tagSvc, "Delegates to")
|
||||
Rel(tagSvc, tagRepo, "Reads / writes tags")
|
||||
Rel(inviteCtrl, userSvc, "Creates and validates invites")
|
||||
Rel(authCtrl, userSvc, "Registers users, resets passwords")
|
||||
Rel(userSvc, userRepo, "Reads / writes users")
|
||||
Rel(userSvc, groupRepo, "Assigns groups")
|
||||
Rel(dataInit, db, "Seeds initial data", "JDBC")
|
||||
Rel(userRepo, db, "SQL queries", "JDBC")
|
||||
Rel(groupRepo, db, "SQL queries", "JDBC")
|
||||
Rel(tagRepo, db, "SQL queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
30
docs/architecture/c4/l3-backend-3e-persons.puml
Normal file
30
docs/architecture/c4/l3-backend-3e-persons.puml
Normal file
@@ -0,0 +1,30 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Persons & Family Graph
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Returns documents sent by or received by a person, correspondent suggestions, and person summary with document counts.")
|
||||
Component(relCtrl, "RelationshipController", "Spring MVC — /api/network, /api/persons/{id}/relationships", "CRUD for explicit person relationships and the full family network graph (nodes + edges) used by the Stammbaum view.")
|
||||
Component(personSvc, "PersonService", "Spring Service", "Person CRUD, alias management, and merge operations (reassigns all document sender/receiver references before deleting duplicate persons).")
|
||||
Component(relSvc, "RelationshipService", "Spring Service", "Manages explicit directional family relationships (PARENT_OF, SPOUSE_OF, SIBLING_OF, etc.) with optional date ranges and notes.")
|
||||
Component(relInference, "RelationshipInferenceService", "Spring Service", "Computes transitive family relationships from explicit edges to infer grandparent/grandchild, aunt/uncle, and other extended-family links for the network graph.")
|
||||
Component(personRepo, "PersonRepository", "Spring Data JPA", "Queries persons with name search (including aliases), correspondent discovery, person summaries with document counts, and merge/reassignment helpers.")
|
||||
Component(relRepo, "PersonRelationshipRepository", "Spring Data JPA", "Reads and writes PersonRelationship records. Supports lookup by person ID, by relation type, and existence checks for deduplication.")
|
||||
}
|
||||
|
||||
Rel(frontend, personCtrl, "Person requests", "HTTP / JSON")
|
||||
Rel(frontend, relCtrl, "Relationship and graph requests", "HTTP / JSON")
|
||||
Rel(personCtrl, personSvc, "Delegates to")
|
||||
Rel(relCtrl, relSvc, "Delegates to")
|
||||
Rel(relCtrl, relInference, "Queries inferred graph")
|
||||
Rel(personSvc, personRepo, "Reads / writes persons")
|
||||
Rel(relSvc, relRepo, "Reads / writes relationships")
|
||||
Rel(relInference, relRepo, "Reads relationships for inference")
|
||||
Rel(personRepo, db, "SQL queries", "JDBC")
|
||||
Rel(relRepo, db, "SQL queries", "JDBC")
|
||||
|
||||
@enduml
|
||||
41
docs/architecture/c4/l3-backend-3f-ocr.puml
Normal file
41
docs/architecture/c4/l3-backend-3f-ocr.puml
Normal file
@@ -0,0 +1,41 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — OCR Orchestration
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
ContainerDb(minio, "Object Storage", "MinIO (S3-compatible)")
|
||||
Container(ocrPy, "OCR Service", "Python FastAPI")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(ocrCtrl, "OcrController", "Spring MVC — /api/ocr", "REST entry point: trigger single or batch OCR jobs, stream progress via SSE, query job status, and manage training runs and per-sender models.")
|
||||
Component(ocrSvc, "OcrService", "Spring Service", "Creates OcrJob and OcrJobDocument records, checks Python service health, and delegates async execution to OcrAsyncRunner.")
|
||||
Component(ocrBatch, "OcrBatchService", "Spring Service", "Orchestrates multi-document OCR jobs, iterating documents and delegating each to OcrAsyncRunner.")
|
||||
Component(ocrAsync, "OcrAsyncRunner", "Spring Component — @Async", "Async worker that streams OCR results from Python page by page, persists transcription blocks and annotations via domain services, and emits progress via SSE.")
|
||||
Component(ocrClient, "RestClientOcrClient", "Spring Component", "HTTP client wrapping the Python service: POST /ocr/stream (NDJSON), /train, /segtrain, and /train-sender. Falls back from streaming to batch on 404.")
|
||||
Component(ocrTraining, "OcrTrainingService", "Spring Service", "Orchestrates model training: exports training data as ZIP, calls Python /train or /segtrain, persists training metrics in OcrTrainingRunRepository.")
|
||||
Component(ocrJobRepo, "OcrJobRepository, OcrJobDocumentRepository", "Spring Data JPA", "Reads and writes OcrJob and OcrJobDocument records. Tracks job status (RUNNING/DONE/FAILED), per-document progress, page counts, and error messages.")
|
||||
}
|
||||
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist transcription blocks per page.")
|
||||
Component(annotationSvc, "AnnotationService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist OCR-generated annotation regions per page.")
|
||||
|
||||
Rel(frontend, ocrCtrl, "OCR trigger, status, and progress requests", "HTTP / JSON / SSE")
|
||||
Rel(ocrCtrl, ocrSvc, "Single-document jobs")
|
||||
Rel(ocrCtrl, ocrBatch, "Batch jobs")
|
||||
Rel(ocrCtrl, ocrTraining, "Training runs")
|
||||
Rel(ocrSvc, ocrAsync, "Delegates async execution")
|
||||
Rel(ocrBatch, ocrAsync, "Delegates async execution")
|
||||
Rel(ocrAsync, ocrClient, "Streams OCR results page by page", "HTTP / NDJSON")
|
||||
Rel(ocrTraining, ocrClient, "Sends training data ZIP", "HTTP / multipart")
|
||||
Rel(ocrClient, ocrPy, "POST /ocr/stream, /train, /segtrain, /train-sender", "HTTP / REST")
|
||||
Rel(ocrAsync, transcriptionSvc, "Saves transcription blocks per page")
|
||||
Rel(ocrAsync, annotationSvc, "Saves annotation regions per page")
|
||||
Rel(ocrAsync, ocrJobRepo, "Reads / writes OCR job state")
|
||||
Rel(ocrJobRepo, db, "SQL queries", "JDBC")
|
||||
Rel(ocrAsync, minio, "Generates presigned URLs for PDF fetch", "S3 API")
|
||||
Rel(ocrPy, minio, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(ocrTraining, db, "Persists training run metrics", "JDBC")
|
||||
|
||||
@enduml
|
||||
46
docs/architecture/c4/l3-backend-3g-supporting.puml
Normal file
46
docs/architecture/c4/l3-backend-3g-supporting.puml
Normal file
@@ -0,0 +1,46 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Supporting Domains
|
||||
|
||||
Container(frontend, "Web Frontend", "SvelteKit")
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(auditSvc, "AuditService", "Spring Service — @Async", "Writes audit log entries asynchronously via a dedicated TaskExecutor, with transaction-aware logging to prevent deadlocks on concurrent saves.")
|
||||
Component(auditQuery, "AuditLogQueryService", "Spring Service", "Queries audit logs for activity feeds, pulse stats, recent contributors, and per-document history. Facade over AuditLogRepository.")
|
||||
Component(dashCtrl, "DashboardController", "Spring MVC — /api/dashboard", "REST endpoints for the user dashboard: recent document resume (/resume), weekly transcription pulse stats (/pulse), and activity feed (/activity) with kind filtering and pagination.")
|
||||
Component(statsCtrl, "StatsController", "Spring MVC — /api/stats", "Returns aggregate counts (total persons, total documents) for the UI stats bar.")
|
||||
Component(statsSvc, "StatsService", "Spring Service", "Queries aggregate counts: total persons and total documents.")
|
||||
Component(dashSvc, "DashboardService", "Spring Service", "Assembles the user dashboard: recent document resume (calls DocumentService + TranscriptionService), weekly transcription pulse stats, and activity feed with contributor avatars.")
|
||||
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
||||
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by DashboardService to fetch document titles and resume data.")
|
||||
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by DashboardService to fetch transcription block progress for resume.")
|
||||
|
||||
Rel(frontend, dashCtrl, "Dashboard requests", "HTTP / JSON")
|
||||
Rel(frontend, statsCtrl, "GET /api/stats", "HTTP / JSON")
|
||||
Rel(frontend, notifCtrl, "Notification stream and history", "HTTP / JSON / SSE")
|
||||
Rel(frontend, geschCtrl, "Story requests", "HTTP / JSON")
|
||||
Rel(dashCtrl, dashSvc, "Delegates to")
|
||||
Rel(statsCtrl, statsSvc, "Delegates to")
|
||||
Rel(statsSvc, db, "Reads aggregate counts", "JDBC")
|
||||
Rel(dashSvc, auditQuery, "Fetches activity feed and pulse stats")
|
||||
Rel(dashSvc, documentSvc, "Fetches document titles and resume data")
|
||||
Rel(dashSvc, transcriptionSvc, "Fetches transcription block progress for resume")
|
||||
Rel(notifCtrl, notifSvc, "Delegates to")
|
||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
|
||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
|
||||
Rel(geschCtrl, geschSvc, "Delegates to")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
Rel(geschSvc, db, "Reads / writes geschichten", "JDBC")
|
||||
|
||||
@enduml
|
||||
29
docs/architecture/c4/l3-frontend-3a-middleware-auth.puml
Normal file
29
docs/architecture/c4/l3-frontend-3a-middleware-auth.puml
Normal file
@@ -0,0 +1,29 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: Web Frontend — Middleware, Auth & Layout
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(hooks, "hooks.server.ts", "SvelteKit Server Hook", "Four handle layers: (1) handleAuth — redirects unauthenticated users to /login; (2) userGroup — reads auth_token cookie, fetches /api/users/me, stores user in event.locals; (3) handleFetch — injects Authorization header on all outgoing /api/ calls; (4) handleLocaleDetection — sets language cookie from Accept-Language header.")
|
||||
Component(i18n, "hooks.ts (Paraglide)", "SvelteKit Client Hook", "Client-side i18n middleware. Detects language from URL and sets the active locale for Paraglide.js translation functions.")
|
||||
Component(layout, "+layout.server.ts", "SvelteKit Layout Loader", "Passes event.locals.user down to all child pages so every route has access to the authenticated user.")
|
||||
Component(loginPage, "/login", "SvelteKit Route", "Form action: encodes email:password as Base64 Basic Auth token, POSTs to /api/users/me to validate, sets auth_token httpOnly cookie (SameSite=strict, maxAge=86400), redirects to /.")
|
||||
Component(logoutPage, "/logout", "SvelteKit Route (server-only)", "Clears the auth_token cookie and redirects to /login.")
|
||||
Component(registerPage, "/register", "SvelteKit Route", "Loader validates invite code via GET /api/auth/invite/{code}. Form action: POST /api/auth/register to create the user account.")
|
||||
Component(forgotPw, "/forgot-password", "SvelteKit Route", "Form action: POST /api/auth/forgot-password. Always responds with success to prevent email enumeration.")
|
||||
Component(resetPw, "/reset-password", "SvelteKit Route", "Form action: POST /api/auth/reset-password with the token from the query string.")
|
||||
}
|
||||
|
||||
Rel(user, hooks, "Every browser request", "HTTPS")
|
||||
Rel(hooks, backend, "GET /api/users/me (session check)", "HTTP / Basic Auth")
|
||||
Rel(hooks, loginPage, "Redirect if no token")
|
||||
Rel(hooks, layout, "Stores authenticated user in event.locals")
|
||||
Rel(loginPage, backend, "POST /api/users/me (auth check)", "HTTP / Basic Auth")
|
||||
Rel(registerPage, backend, "GET /api/auth/invite/{code}, POST /api/auth/register", "HTTP / JSON")
|
||||
Rel(forgotPw, backend, "POST /api/auth/forgot-password", "HTTP / JSON")
|
||||
Rel(resetPw, backend, "POST /api/auth/reset-password", "HTTP / JSON")
|
||||
|
||||
@enduml
|
||||
43
docs/architecture/c4/l3-frontend-3b-document-workflows.puml
Normal file
43
docs/architecture/c4/l3-frontend-3b-document-workflows.puml
Normal file
@@ -0,0 +1,43 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: Web Frontend — Document Workflows
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
||||
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||
Component(docBulkEdit, "/documents/bulk-edit", "SvelteKit Route", "Multi-document metadata editor. Loader: GET /api/documents/incomplete. Requires WRITE_ALL (redirects otherwise). Action: PATCH /api/documents/bulk.")
|
||||
Component(enrichPage, "/enrich/[id]", "SvelteKit Route", "Guided enrichment workflow. Loader: GET /api/documents/{id}. Progressively saves annotations and transcription blocks.")
|
||||
Component(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend for PersonTypeahead suggestions.")
|
||||
Component(apiTags, "/api/tags (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/tags to backend for TagInput autocomplete.")
|
||||
Component(typeahead, "PersonTypeahead.svelte", "Svelte Component", "Async autocomplete for selecting a person. Debounces input, calls /api/persons?q=.")
|
||||
Component(tagInput, "TagInput.svelte", "Svelte Component", "Multi-tag input. Supports free-text entry and selecting existing tags from /api/tags.")
|
||||
}
|
||||
|
||||
Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
||||
Rel(homePage, timelineFilter, "Mounts above the result list")
|
||||
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
Rel(docNew, backend, "GET /api/persons, POST /api/documents", "HTTP / JSON + Multipart")
|
||||
Rel(docBulkEdit, backend, "GET /api/documents/incomplete, PATCH /api/documents/bulk", "HTTP / JSON")
|
||||
Rel(enrichPage, backend, "GET/POST /api/transcription, POST /api/documents/{id}/annotations", "HTTP / JSON")
|
||||
Rel(homePage, typeahead, "Uses for sender/receiver filter")
|
||||
Rel(docEdit, typeahead, "Uses for sender/receiver selection")
|
||||
Rel(docNew, typeahead, "Uses for sender selection")
|
||||
Rel(docEdit, tagInput, "Uses for tag management")
|
||||
Rel(typeahead, apiPersons, "Fetches suggestions", "HTTP")
|
||||
Rel(tagInput, apiTags, "Fetches existing tags", "HTTP")
|
||||
Rel(apiPersons, backend, "GET /api/persons", "HTTP / JSON")
|
||||
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
|
||||
|
||||
@enduml
|
||||
32
docs/architecture/c4/l3-frontend-3c-people-stories.puml
Normal file
32
docs/architecture/c4/l3-frontend-3c-people-stories.puml
Normal file
@@ -0,0 +1,32 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: Web Frontend — People, Stories & Discovery
|
||||
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.")
|
||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||
Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||
}
|
||||
|
||||
Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
|
||||
Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON")
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||
|
||||
@enduml
|
||||
27
docs/architecture/c4/l3-frontend-3d-administration.puml
Normal file
27
docs/architecture/c4/l3-frontend-3d-administration.puml
Normal file
@@ -0,0 +1,27 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: Web Frontend — Administration & Help
|
||||
|
||||
Person(admin, "Administrator")
|
||||
Person(user, "User")
|
||||
Container(backend, "API Backend", "Spring Boot")
|
||||
|
||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(adminUsers, "/admin/users, /admin/users/[id], /admin/users/new, /admin/invites", "SvelteKit Routes", "User directory, create/update/delete users, and manage invite codes. Requires ADMIN_USER permission.")
|
||||
Component(adminGroups, "/admin/groups, /admin/groups/[id], /admin/groups/new", "SvelteKit Routes", "Permission group management: create/edit groups and their permission sets.")
|
||||
Component(adminTags, "/admin/tags and /admin/tags/[id]", "SvelteKit Routes", "Tag administration: edit tag hierarchy, merge tags, delete subtrees.")
|
||||
Component(adminOcr, "/admin/ocr and /admin/ocr/[personId]", "SvelteKit Routes", "Global and per-person OCR configuration. Manages script types and triggers sender model training.")
|
||||
Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers Excel/ODS mass import (POST /api/admin/trigger-import). Displays import state.")
|
||||
Component(hilfe, "/hilfe/transkription", "SvelteKit Route", "Static transcription style guide for Kurrent and Sütterlin character recognition. No backend calls.")
|
||||
}
|
||||
|
||||
Rel(admin, adminUsers, "Manages users and invites", "HTTPS / Browser")
|
||||
Rel(user, hilfe, "Views transcription style guide", "HTTPS / Browser")
|
||||
Rel(adminUsers, backend, "GET/POST/DELETE /api/users, POST /api/auth/invite", "HTTP / JSON")
|
||||
Rel(adminGroups, backend, "GET/POST/PUT/DELETE /api/groups", "HTTP / JSON")
|
||||
Rel(adminTags, backend, "GET/PUT/DELETE /api/tags", "HTTP / JSON")
|
||||
Rel(adminOcr, backend, "GET/POST /api/ocr (global config and sender training)", "HTTP / JSON")
|
||||
Rel(adminSystem, backend, "POST /api/admin/trigger-import, GET /api/admin/import-status", "HTTP / JSON")
|
||||
|
||||
@enduml
|
||||
26
docs/architecture/c4/seq-auth-flow.puml
Normal file
26
docs/architecture/c4/seq-auth-flow.puml
Normal file
@@ -0,0 +1,26 @@
|
||||
@startuml
|
||||
title Authentication Flow
|
||||
|
||||
actor User
|
||||
participant Browser
|
||||
participant "Frontend (SvelteKit)" as Frontend
|
||||
participant "Backend (Spring Boot)" as Backend
|
||||
participant PostgreSQL as DB
|
||||
|
||||
User -> Browser: Enter email + password
|
||||
Browser -> Frontend: POST /login (form action)
|
||||
Frontend -> Frontend: Base64 encode "email:password"
|
||||
Frontend -> Backend: GET /api/users/me\nAuthorization: Basic <token>
|
||||
Backend -> Backend: Spring Security parses Basic Auth
|
||||
Backend -> DB: SELECT user WHERE email=?
|
||||
DB --> Backend: AppUser + groups + permissions
|
||||
Backend -> Backend: BCrypt.matches(password, hash)
|
||||
Backend --> Frontend: 200 OK — UserDTO
|
||||
Frontend -> Browser: Set-Cookie: auth_token=<base64>\n(httpOnly, SameSite=strict, maxAge=86400)
|
||||
Browser -> Frontend: GET / (next request)
|
||||
Frontend -> Frontend: hooks.server.ts reads auth_token cookie
|
||||
Frontend -> Backend: GET /api/users/me\nAuthorization: Basic <token>
|
||||
Backend --> Frontend: 200 OK — user in event.locals
|
||||
Frontend --> Browser: Render page with user context
|
||||
|
||||
@enduml
|
||||
32
docs/architecture/c4/seq-document-upload.puml
Normal file
32
docs/architecture/c4/seq-document-upload.puml
Normal file
@@ -0,0 +1,32 @@
|
||||
@startuml
|
||||
title Document Upload Flow
|
||||
|
||||
actor User
|
||||
participant "Frontend (SvelteKit)" as Frontend
|
||||
participant "Backend (Spring Boot)" as Backend
|
||||
participant "PermissionAspect (AOP)" as Aspect
|
||||
participant DocumentService as DocSvc
|
||||
participant FileService as FileSvc
|
||||
participant MinIO
|
||||
participant PostgreSQL as DB
|
||||
|
||||
User -> Frontend: Submit edit form (file + metadata)
|
||||
Frontend -> Backend: PUT /api/documents/{id}\nmultipart/form-data + Authorization header
|
||||
Backend -> Aspect: @RequirePermission(WRITE_ALL) check
|
||||
Aspect -> Aspect: Verify user has WRITE_ALL authority
|
||||
Aspect --> Backend: Proceed
|
||||
Backend -> DocSvc: updateDocument(id, dto, file)
|
||||
DocSvc -> DocSvc: Resolve sender Person by ID
|
||||
DocSvc -> DocSvc: Resolve/create Tags
|
||||
DocSvc -> FileSvc: uploadFile(file, filename)
|
||||
FileSvc -> FileSvc: Generate key: documents/{UUID}_{filename}
|
||||
FileSvc -> MinIO: PutObject(bucket, key, stream)
|
||||
MinIO --> FileSvc: Success
|
||||
FileSvc --> DocSvc: S3 key
|
||||
DocSvc -> DB: UPDATE documents SET file_path=?, status='UPLOADED', ...
|
||||
DB --> DocSvc: OK
|
||||
DocSvc --> Backend: Updated Document entity
|
||||
Backend --> Frontend: 200 OK — Document JSON
|
||||
Frontend --> User: Refreshed document view
|
||||
|
||||
@enduml
|
||||
432
docs/architecture/db/db-orm.puml
Normal file
432
docs/architecture/db/db-orm.puml
Normal file
@@ -0,0 +1,432 @@
|
||||
@startuml db-orm
|
||||
' Schema source: Flyway V1–V60 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V60 (2026-05-06)
|
||||
' ⚠ 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_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_year : INTEGER
|
||||
death_year : INTEGER
|
||||
family_member : 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_year : INTEGER
|
||||
to_year : INTEGER
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
' ── 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
|
||||
--
|
||||
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
|
||||
status : 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 geschichten_documents {
|
||||
geschichte_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
|
||||
document_receivers }o--|| documents : document_id
|
||||
document_receivers }o--|| persons : person_id
|
||||
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
|
||||
|
||||
' 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
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
|
||||
@enduml
|
||||
132
docs/architecture/db/db-relationships.puml
Normal file
132
docs/architecture/db/db-relationships.puml
Normal file
@@ -0,0 +1,132 @@
|
||||
@startuml db-relationships
|
||||
' Schema source: Flyway V1–V60 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V60 (2026-05-06)
|
||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||
|
||||
hide circle
|
||||
skinparam linetype ortho
|
||||
|
||||
left to right direction
|
||||
|
||||
' ── Auth ──
|
||||
package "Auth" {
|
||||
entity app_users
|
||||
entity user_groups
|
||||
entity app_users_groups
|
||||
entity group_permissions
|
||||
entity password_reset_tokens
|
||||
entity invite_tokens
|
||||
entity invite_token_group_ids
|
||||
}
|
||||
|
||||
' ── Documents ──
|
||||
package "Documents" {
|
||||
entity documents
|
||||
entity document_receivers
|
||||
entity document_tags
|
||||
entity document_versions
|
||||
entity document_annotations
|
||||
entity document_comments
|
||||
entity document_training_labels
|
||||
entity comment_mentions
|
||||
}
|
||||
|
||||
' ── Persons ──
|
||||
package "Persons" {
|
||||
entity persons
|
||||
entity person_name_aliases
|
||||
entity person_relationships
|
||||
}
|
||||
|
||||
' ── Tags ──
|
||||
package "Tags" {
|
||||
entity tag
|
||||
}
|
||||
|
||||
' ── Transcription ──
|
||||
package "Transcription" {
|
||||
entity transcription_blocks
|
||||
entity transcription_block_versions
|
||||
entity transcription_block_mentioned_persons
|
||||
}
|
||||
|
||||
' ── OCR ──
|
||||
package "OCR" {
|
||||
entity ocr_jobs
|
||||
entity ocr_job_documents
|
||||
entity ocr_training_runs
|
||||
entity sender_models
|
||||
}
|
||||
|
||||
' ── Supporting ──
|
||||
package "Supporting" {
|
||||
entity notifications
|
||||
entity audit_log
|
||||
entity geschichten
|
||||
entity geschichten_persons
|
||||
entity geschichten_documents
|
||||
}
|
||||
|
||||
' 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
|
||||
document_receivers }o--|| documents : document_id
|
||||
document_receivers }o--|| persons : person_id
|
||||
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
|
||||
|
||||
' 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
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
|
||||
@enduml
|
||||
1224
docs/specs/reader-dashboard-final.html
Normal file
1224
docs/specs/reader-dashboard-final.html
Normal file
File diff suppressed because it is too large
Load Diff
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -36,6 +36,10 @@ src/lib/paraglide_bak*
|
||||
e2e/.auth/
|
||||
|
||||
**/test-results/**
|
||||
**/test-results.locked/
|
||||
|
||||
# Stale SvelteKit build artifacts
|
||||
**/.svelte-kit.old/
|
||||
|
||||
# Proofshot browser verification artifacts
|
||||
proofshot-artifacts/
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type * as Kit from '@sveltejs/kit';
|
||||
|
||||
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
|
||||
type RouteParams = { };
|
||||
type RouteId = '/stammbaum';
|
||||
type MaybeWithVoid<T> = {} extends T ? T | void : T;
|
||||
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
|
||||
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
|
||||
type EnsureDefined<T> = T extends null | undefined ? {} : T;
|
||||
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
|
||||
export type Snapshot<T = any> = Kit.Snapshot<T>;
|
||||
type PageServerParentData = EnsureDefined<import('../$types.js').LayoutServerData>;
|
||||
type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
|
||||
|
||||
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
|
||||
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
|
||||
export type ActionData = unknown;
|
||||
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('../../../../../src/routes/stammbaum/+page.server.js').load>>>>>>;
|
||||
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
|
||||
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
|
||||
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
|
||||
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
|
||||
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
|
||||
@@ -30,11 +30,18 @@ src/
|
||||
│ ├── documents/ # Document CRUD, detail, edit, upload
|
||||
│ ├── persons/ # Person directory, detail, edit, merge
|
||||
│ ├── briefwechsel/ # Bilateral conversation timeline
|
||||
│ ├── chronik/ # Unified activity feed
|
||||
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
|
||||
│ ├── admin/ # User, group, tag, OCR, system management
|
||||
│ ├── api/ # Internal API proxies (server-side only)
|
||||
│ ├── login/ logout/ # Auth pages
|
||||
│ └── ...
|
||||
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
|
||||
│ ├── stammbaum/ # Family tree
|
||||
│ ├── enrich/ # Enrichment workflow ([id], done)
|
||||
│ ├── hilfe/transkription/ # Transcription help page
|
||||
│ ├── profile/ # User profile settings
|
||||
│ ├── users/[id]/ # Public user profile page
|
||||
│ ├── login/ logout/ register/
|
||||
│ ├── forgot-password/ reset-password/
|
||||
│ └── demo/ # Dev-only demos
|
||||
├── lib/ # Domain-based package structure (mirrors backend)
|
||||
│ ├── document/ # Document domain: components, stores, services, utils
|
||||
│ │ ├── annotation/ # Annotation overlay components
|
||||
@@ -71,29 +78,13 @@ src/
|
||||
└── ... # Other SvelteKit config files
|
||||
```
|
||||
|
||||
For per-domain component inventories, see the domain READMEs in `src/lib/<domain>/README.md`.
|
||||
|
||||
## API Client Pattern
|
||||
|
||||
All server-side API calls use the typed client from `$lib/api.server.ts`:
|
||||
→ See [CONTRIBUTING.md §Frontend API client](../CONTRIBUTING.md#frontend-api-client)
|
||||
|
||||
```typescript
|
||||
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.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
|
||||
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
|
||||
- Use `result.data!` after an ok check
|
||||
|
||||
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
|
||||
**LLM reminder:** check `!result.response.ok` (not `result.error` — breaks when spec has no error responses); cast errors as `result.error as unknown as { code?: string }`; use `result.data!` after an ok check. For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
|
||||
|
||||
## Form Actions Pattern
|
||||
|
||||
@@ -102,7 +93,7 @@ For multipart/form-data (file uploads), bypass the typed client and use raw `fet
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
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
|
||||
@@ -112,49 +103,39 @@ export const actions = {
|
||||
|
||||
## Date Handling
|
||||
|
||||
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend.
|
||||
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
|
||||
```typescript
|
||||
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
||||
new Date(doc.documentDate + 'T12:00:00')
|
||||
);
|
||||
```
|
||||
→ 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. Forms use German `dd.mm.yyyy` format via `handleDateInput()` with a hidden ISO input.
|
||||
|
||||
## Styling Conventions (Tailwind CSS 4)
|
||||
|
||||
Brand color utilities (defined in `layout.css`):
|
||||
Brand color tokens (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 |
|
||||
| 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` (Merriweather) — body text, document titles, names
|
||||
- `font-serif` (Tinos) — body text, document titles, names
|
||||
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||
|
||||
Card pattern for content sections:
|
||||
|
||||
```svelte
|
||||
<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</h2>
|
||||
<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</h2>
|
||||
<!-- content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Key UI Components
|
||||
|
||||
| Component | Location | Props | Description |
|
||||
| -------------------- | ------------------------------ | --------------------------------------- | ------------------------------------------ |
|
||||
| `PersonTypeahead` | `$lib/person/` | `name`, `label`, `value`, `initialName` | Single-person selector with typeahead |
|
||||
| `PersonMultiSelect` | `$lib/person/` | `selectedPersons` (bind) | Chip-based multi-person selector |
|
||||
| `TagInput` | `$lib/tag/` | `tags` (bind), `allowCreation?` | Tag chip input with typeahead |
|
||||
| `PdfViewer` | `$lib/document/` | `url`, `annotations` | PDF rendering with annotation overlay |
|
||||
| `TranscriptionBlock` | `$lib/document/transcription/` | `block`, `mode` | Read/edit transcription block |
|
||||
| `DocumentTopBar` | `$lib/document/` | `document` | Responsive document metadata header |
|
||||
| `BackButton` | `$lib/shared/primitives/` | — | Calls `history.back()`; 44 px touch target |
|
||||
→ See per-domain READMEs: [`src/lib/person/README.md`](src/lib/person/README.md), [`src/lib/tag/README.md`](src/lib/tag/README.md), [`src/lib/document/README.md`](src/lib/document/README.md), [`src/lib/shared/README.md`](src/lib/shared/README.md)
|
||||
|
||||
**LLM reminder:** `BackButton` is at `$lib/shared/primitives/BackButton.svelte` — use it for all back navigation; never a static `<a href>`. API client is at `$lib/shared/api.server`.
|
||||
|
||||
## How to Run
|
||||
|
||||
@@ -163,7 +144,7 @@ Card pattern for content sections:
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Dev server on port 5173 (or 3000 if --port 3000)
|
||||
npm run dev # Dev server on port 5173
|
||||
```
|
||||
|
||||
### Build & Preview
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "PARAGLIDE_LOCALE",
|
||||
"value": "de",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1812352142.362504,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "auth_token",
|
||||
"value": "Basic%20YWRtaW5AZmFtaWx5YXJjaGl2ZS5sb2NhbDphZG1pbjEyMw%3D%3D",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1777878542.943668,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Strict"
|
||||
}
|
||||
],
|
||||
"origins": []
|
||||
}
|
||||
@@ -448,6 +448,28 @@
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_stats_documents": "Dokumente",
|
||||
"dashboard_stats_persons": "Personen",
|
||||
"dashboard_reader_stats_documents": "Dokumente",
|
||||
"dashboard_reader_stats_persons": "Personen",
|
||||
"dashboard_reader_stats_stories": "Geschichten",
|
||||
"dashboard_reader_person_chips_heading": "Personen",
|
||||
"dashboard_reader_no_persons": "Noch keine Personen im Archiv.",
|
||||
"dashboard_reader_all_persons": "Alle Personen →",
|
||||
"dashboard_reader_drafts_heading": "Meine Entwürfe",
|
||||
"dashboard_reader_drafts_empty": "Keine Entwürfe",
|
||||
"dashboard_reader_recent_docs_heading": "Zuletzt aktualisiert",
|
||||
"dashboard_reader_recent_stories_heading": "Neue Geschichten",
|
||||
"dashboard_badge_new": "Neu",
|
||||
"dashboard_reader_all_stories": "Alle Geschichten →",
|
||||
"dashboard_reader_doc_count_suffix": "Dok.",
|
||||
"dashboard_all_documents": "Alle Dokumente",
|
||||
"dashboard_greeting_time_morning": "Morgen",
|
||||
"dashboard_greeting_time_afternoon": "Mittag",
|
||||
"dashboard_greeting_time_evening": "Abend",
|
||||
"dashboard_welcome": "Herzlich willkommen, {name}.",
|
||||
"dashboard_reader_stats_documents_short": "Dok.",
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Gesch.",
|
||||
"dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||
"doc_status_placeholder": "Platzhalter",
|
||||
@@ -1045,5 +1067,12 @@
|
||||
"relation_form_year_placeholder": "z.B. 1920",
|
||||
|
||||
"person_relationships_heading": "Beziehungen",
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt."
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||
|
||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||
"timeline_clear_selection": "Auswahl zurücksetzen",
|
||||
"timeline_zoom_reset": "Zurück zur Übersicht",
|
||||
"timeline_bar_aria_singular": "{when}, 1 Dokument",
|
||||
"timeline_bar_aria_plural": "{when}, {count} Dokumente",
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt"
|
||||
}
|
||||
|
||||
@@ -448,6 +448,28 @@
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_stats_documents": "Documents",
|
||||
"dashboard_stats_persons": "Persons",
|
||||
"dashboard_reader_stats_documents": "Documents",
|
||||
"dashboard_reader_stats_persons": "Persons",
|
||||
"dashboard_reader_stats_stories": "Stories",
|
||||
"dashboard_reader_person_chips_heading": "Persons",
|
||||
"dashboard_reader_no_persons": "No persons in the archive yet.",
|
||||
"dashboard_reader_all_persons": "All Persons →",
|
||||
"dashboard_reader_drafts_heading": "My Drafts",
|
||||
"dashboard_reader_drafts_empty": "No drafts",
|
||||
"dashboard_reader_recent_docs_heading": "Recently Updated",
|
||||
"dashboard_reader_recent_stories_heading": "New Stories",
|
||||
"dashboard_badge_new": "New",
|
||||
"dashboard_reader_all_stories": "All Stories →",
|
||||
"dashboard_reader_doc_count_suffix": "docs.",
|
||||
"dashboard_all_documents": "All Documents",
|
||||
"dashboard_greeting_time_morning": "Morning",
|
||||
"dashboard_greeting_time_afternoon": "Afternoon",
|
||||
"dashboard_greeting_time_evening": "Evening",
|
||||
"dashboard_welcome": "Welcome, {name}.",
|
||||
"dashboard_reader_stats_documents_short": "Docs.",
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Stor.",
|
||||
"dashboard_reader_draft_meta": "Draft · last edited {relative}",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_fallback": "Unknown document",
|
||||
"doc_status_placeholder": "Placeholder",
|
||||
@@ -1045,5 +1067,12 @@
|
||||
"relation_form_year_placeholder": "e.g. 1920",
|
||||
|
||||
"person_relationships_heading": "Relationships",
|
||||
"person_relationships_empty": "No relationships known yet."
|
||||
"person_relationships_empty": "No relationships known yet.",
|
||||
|
||||
"timeline_aria_label": "Document density timeline",
|
||||
"timeline_clear_selection": "Clear selection",
|
||||
"timeline_zoom_reset": "Reset zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 document",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documents",
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected"
|
||||
}
|
||||
|
||||
@@ -448,6 +448,28 @@
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_stats_documents": "Documentos",
|
||||
"dashboard_stats_persons": "Personas",
|
||||
"dashboard_reader_stats_documents": "Documentos",
|
||||
"dashboard_reader_stats_persons": "Personas",
|
||||
"dashboard_reader_stats_stories": "Historias",
|
||||
"dashboard_reader_person_chips_heading": "Personas",
|
||||
"dashboard_reader_no_persons": "Todavía no hay personas en el archivo.",
|
||||
"dashboard_reader_all_persons": "Todas las personas →",
|
||||
"dashboard_reader_drafts_heading": "Mis borradores",
|
||||
"dashboard_reader_drafts_empty": "Sin borradores",
|
||||
"dashboard_reader_recent_docs_heading": "Actualizados recientemente",
|
||||
"dashboard_reader_recent_stories_heading": "Nuevas historias",
|
||||
"dashboard_badge_new": "Nuevo",
|
||||
"dashboard_reader_all_stories": "Todas las historias →",
|
||||
"dashboard_reader_doc_count_suffix": "docs.",
|
||||
"dashboard_all_documents": "Todos los documentos",
|
||||
"dashboard_greeting_time_morning": "Mañana",
|
||||
"dashboard_greeting_time_afternoon": "Tarde",
|
||||
"dashboard_greeting_time_evening": "Noche",
|
||||
"dashboard_welcome": "Bienvenido, {name}.",
|
||||
"dashboard_reader_stats_documents_short": "Docs.",
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Hist.",
|
||||
"dashboard_reader_draft_meta": "Borrador · editado hace {relative}",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_fallback": "Documento desconocido",
|
||||
"doc_status_placeholder": "Marcador",
|
||||
@@ -1045,5 +1067,12 @@
|
||||
"relation_form_year_placeholder": "ej. 1920",
|
||||
|
||||
"person_relationships_heading": "Relaciones",
|
||||
"person_relationships_empty": "Aún no se conocen relaciones."
|
||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||
|
||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||
"timeline_clear_selection": "Borrar selección",
|
||||
"timeline_zoom_reset": "Restablecer zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 documento",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documentos",
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import ChronikErrorCard from './ChronikErrorCard.svelte';
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('ChronikErrorCard', () => {
|
||||
it('renders the default error message', async () => {
|
||||
render(ChronikErrorCard, { onRetry: vi.fn() });
|
||||
await expect
|
||||
.element(page.getByText('Die Chronik konnte nicht geladen werden.'))
|
||||
.element(page.getByText('Die Aktivitäten konnten nicht geladen werden.'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -27,7 +27,8 @@ describe('ChronikErrorCard', () => {
|
||||
it('calls onRetry when the retry button is clicked', async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(ChronikErrorCard, { onRetry });
|
||||
await userEvent.click(page.getByText('Erneut versuchen'));
|
||||
const btn = (await page.getByText('Erneut versuchen').element()) as HTMLElement;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -108,6 +108,11 @@ const rowHref: string = $derived(
|
||||
<a
|
||||
href={rowHref}
|
||||
data-variant={variant}
|
||||
aria-label={variant === 'comment'
|
||||
? item.commentPreview
|
||||
? `${m.chronik_comment_added({ actor: actorName, doc: docTitle })} — ${item.commentPreview}`
|
||||
: m.chronik_comment_added({ actor: actorName, doc: docTitle })
|
||||
: undefined}
|
||||
class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
|
||||
{variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}"
|
||||
>
|
||||
@@ -159,20 +164,11 @@ const rowHref: string = $derived(
|
||||
</p>
|
||||
|
||||
{#if variant === 'comment'}
|
||||
<!--
|
||||
TODO: the backend does not yet expose a comment body preview on
|
||||
ActivityFeedItemDTO. Render an ellipsis placeholder until it does —
|
||||
duplicating the document title here looks like the comment is
|
||||
quoting itself (Leonie, PR #288 review).
|
||||
SECURITY: once item.commentPreview lands, render via {text}, never
|
||||
{@html}. The backend must truncate and strip tags server-side (Nora,
|
||||
issue #285 comment #3552).
|
||||
-->
|
||||
<p
|
||||
data-testid="chronik-comment-preview"
|
||||
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
|
||||
>
|
||||
„…“
|
||||
{item.commentPreview ?? '„…"'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -186,6 +186,61 @@ describe('ChronikRow', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
// --- commentPreview content ---
|
||||
it('renders commentPreview text when variant is comment and commentPreview is present', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentPreview: 'Hello family, great letter!'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview?.textContent).toContain('Hello family, great letter!');
|
||||
});
|
||||
|
||||
it('renders placeholder ellipsis when variant is comment and commentPreview is null', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentPreview: undefined
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview?.textContent?.trim()).toBe('„…"');
|
||||
});
|
||||
|
||||
it('does not render preview paragraph for non-comment variants', async () => {
|
||||
const item: ActivityFeedItemDTO = { ...baseItem, kind: 'TEXT_SAVED' };
|
||||
render(ChronikRow, { item });
|
||||
expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('link has aria-label containing preview text for comment variant with preview', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentPreview: 'A wonderful letter from grandma'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector('a[aria-label]');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute('aria-label')).toContain('A wonderful letter from grandma');
|
||||
});
|
||||
|
||||
it('link still has aria-label for comment variant when commentPreview is absent', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentPreview: undefined
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector('a[aria-label]');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute('aria-label')).not.toBeNull();
|
||||
});
|
||||
|
||||
// --- robustness: title rendering for edge cases ---
|
||||
it('still renders the row link when documentTitle is an empty string', async () => {
|
||||
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span
|
||||
|
||||
@@ -34,6 +34,6 @@ describe('BulkDropZone', () => {
|
||||
|
||||
it('shows drop hint text', async () => {
|
||||
render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Dateien ablegen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
36
frontend/src/lib/document/README.md
Normal file
36
frontend/src/lib/document/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# document (frontend)
|
||||
|
||||
UI for the archive's core concept: viewing, uploading, editing, searching, bulk-selecting, and transcribing documents.
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Components: `DocumentRow`, `DocumentThumbnail`, `DocumentTopBar`, `DocumentViewer`, `DocumentMetadataDrawer`, `DocumentEditLayout`, `DocumentStatusChip`, `UploadZone`, `BulkSelectionBar`, `BulkDropZone`.
|
||||
Utilities: `search.ts` (search-param helpers), `filename.ts` (filename formatting), `documentStatusLabel.ts` (i18n label mapping), `validateFile.ts` (upload validation), `groupDocuments.ts` (list grouping).
|
||||
Sub-folders: `annotation/`, `transcription/`, `viewer/`.
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- Person typeahead — `person/PersonTypeahead.svelte` (cross-domain import, allowed by ESLint rule)
|
||||
- Tag input — `tag/TagInput.svelte` (cross-domain import, allowed)
|
||||
- Shared discussion — `shared/discussion/` (comment/mention editor)
|
||||
|
||||
## Key components
|
||||
|
||||
| Component | Route used in | Notes |
|
||||
| --------------------------- | ---------------------------------- | ------------------------------------ |
|
||||
| `DocumentRow.svelte` | `/` (search results), admin queues | Compact document card with thumbnail |
|
||||
| `DocumentViewer.svelte` | `/documents/[id]` | PDF/image inline viewer |
|
||||
| `DocumentEditLayout.svelte` | `/documents/[id]/edit` | Full edit form with sticky save bar |
|
||||
| `UploadZone.svelte` | `/documents/new`, bulk upload | Drag-and-drop file drop area |
|
||||
| `BulkSelectionBar.svelte` | `/documents` bulk mode | Multi-select action bar |
|
||||
|
||||
## Cross-domain imports
|
||||
|
||||
- `person/PersonTypeahead.svelte` — sender / receiver selection
|
||||
- `tag/TagInput.svelte` — tag chip input
|
||||
- `ocr/OcrProgress.svelte` — job status indicator in the document header
|
||||
- `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI
|
||||
|
||||
## Backend counterpart
|
||||
|
||||
`backend/src/main/java/org/raddatz/familienarchiv/document/README.md`
|
||||
128
frontend/src/lib/document/TimelineBars.svelte
Normal file
128
frontend/src/lib/document/TimelineBars.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { formatTickLabel } from '$lib/document/timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
const ZERO_COUNT_BAR_HEIGHT = 2; // px — minimum visible signal for empty months
|
||||
|
||||
let {
|
||||
filled,
|
||||
maxCount,
|
||||
barAreaHeight,
|
||||
isSelected,
|
||||
isInDragPreview,
|
||||
isDragging,
|
||||
dragWindowLeftPct,
|
||||
dragWindowRightPct,
|
||||
rowEl = $bindable(),
|
||||
onbarpointerdown,
|
||||
onbarpointerenter,
|
||||
onbarclick
|
||||
}: {
|
||||
filled: MonthBucket[];
|
||||
maxCount: number;
|
||||
barAreaHeight: number;
|
||||
isSelected: (label: string) => boolean;
|
||||
isInDragPreview: (index: number) => boolean;
|
||||
isDragging: boolean;
|
||||
dragWindowLeftPct: number;
|
||||
dragWindowRightPct: number;
|
||||
rowEl?: HTMLDivElement;
|
||||
onbarpointerdown: (event: PointerEvent, index: number) => void;
|
||||
onbarpointerenter: (index: number) => void;
|
||||
onbarclick: (index: number) => void;
|
||||
} = $props();
|
||||
|
||||
function barHeight(count: number): number {
|
||||
if (count === 0) return ZERO_COUNT_BAR_HEIGHT;
|
||||
return Math.max(ZERO_COUNT_BAR_HEIGHT, (count / maxCount) * barAreaHeight);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={rowEl}
|
||||
class="relative flex items-end border-b border-line"
|
||||
style="height: {barAreaHeight}px;"
|
||||
>
|
||||
{#each filled as bucket, i (bucket.month)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-bar"
|
||||
aria-label={bucket.count === 1
|
||||
? m.timeline_bar_aria_singular({ when: formatTickLabel(bucket.month, getLocale()) })
|
||||
: m.timeline_bar_aria_plural({
|
||||
when: formatTickLabel(bucket.month, getLocale()),
|
||||
count: bucket.count
|
||||
})}
|
||||
aria-pressed={isSelected(bucket.month)}
|
||||
onpointerdown={(e) => onbarpointerdown(e, i)}
|
||||
onpointerenter={() => onbarpointerenter(i)}
|
||||
onclick={() => onbarclick(i)}
|
||||
class="bar group flex h-full min-w-px flex-1 cursor-pointer items-end justify-center bg-transparent p-0 transition-colors focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
class:selected={isSelected(bucket.month)}
|
||||
class:in-drag-preview={isInDragPreview(i)}
|
||||
>
|
||||
<span
|
||||
class="bar-fill block w-full rounded-t-[2px]"
|
||||
style="height: {barHeight(bucket.count)}px;"
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if isDragging}
|
||||
<div
|
||||
class="drag-window"
|
||||
data-testid="timeline-drag-window"
|
||||
style="left: {dragWindowLeftPct}%; right: {dragWindowRightPct}%;"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Timeline-specific tokens (--timeline-bar-idle, --timeline-bar-outside) live
|
||||
in layout.css next to the rest of the design tokens; this <style> only
|
||||
consumes them. */
|
||||
.bar .bar-fill {
|
||||
background-color: var(--timeline-bar-idle);
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bar .bar-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bar.selected .bar-fill,
|
||||
.bar.in-drag-preview .bar-fill {
|
||||
background-color: var(--palette-mint, #a1dcd8);
|
||||
}
|
||||
|
||||
.bar.in-drag-preview .bar-fill {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Gate hover under (hover: hover) so emulated mouse events on touch devices
|
||||
don't leave a tapped bar stuck in :hover until the next tap elsewhere. */
|
||||
@media (hover: hover) {
|
||||
.bar:hover .bar-fill {
|
||||
background-color: var(--palette-mint, #a1dcd8);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
/* Graylog-style range selector window: left/right borders mark the dragged
|
||||
range, tinted body fills the area. pointer-events:none keeps the bars below
|
||||
reachable so pointermove still fires their pointerenter. */
|
||||
.drag-window {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(161, 220, 216, 0.22);
|
||||
border-left: 2px solid var(--palette-mint, #a1dcd8);
|
||||
border-right: 2px solid var(--palette-mint, #a1dcd8);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/lib/document/TimelineControls.svelte
Normal file
41
frontend/src/lib/document/TimelineControls.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
isZoomed,
|
||||
hasSelection,
|
||||
onresetzoom,
|
||||
onclearselection
|
||||
}: {
|
||||
isZoomed: boolean;
|
||||
hasSelection: boolean;
|
||||
onresetzoom: () => void;
|
||||
onclearselection: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute top-2 right-2 flex items-center gap-1">
|
||||
{#if isZoomed}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-zoom-reset"
|
||||
aria-label={m.timeline_zoom_reset()}
|
||||
title={m.timeline_zoom_reset()}
|
||||
onclick={onresetzoom}
|
||||
class="hover:text-ink-1 inline-flex h-11 min-w-[44px] items-center justify-center gap-1 rounded-sm px-3 text-xs text-ink-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasSelection}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="timeline-clear"
|
||||
aria-label={m.timeline_clear_selection()}
|
||||
onclick={onclearselection}
|
||||
class="hover:text-ink-1 inline-flex h-11 w-11 items-center justify-center rounded-full text-ink-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
204
frontend/src/lib/document/TimelineDensityFilter.svelte
Normal file
204
frontend/src/lib/document/TimelineDensityFilter.svelte
Normal file
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import {
|
||||
fillDensityGaps,
|
||||
aggregateToYears,
|
||||
clipBucketsToRange,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
formatTickLabel
|
||||
} from '$lib/document/timeline';
|
||||
import { createTimelineDrag } from '$lib/document/useTimelineDrag.svelte';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import TimelineBars from '$lib/document/TimelineBars.svelte';
|
||||
import TimelineYAxis from '$lib/document/TimelineYAxis.svelte';
|
||||
import TimelineXAxis from '$lib/document/TimelineXAxis.svelte';
|
||||
import TimelineControls from '$lib/document/TimelineControls.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
// Drag emits filter + zoom atomically (Graylog-style range selector).
|
||||
// Single click and clear emit filter only — zoom fields are absent.
|
||||
type SelectionEvent = {
|
||||
from: string;
|
||||
to: string;
|
||||
zoomFrom?: string | null;
|
||||
zoomTo?: string | null;
|
||||
};
|
||||
type ZoomEvent = { zoomFrom: string; zoomTo: string };
|
||||
|
||||
const BAR_AREA_HEIGHT = 80; // px — Leonie spec h-20
|
||||
// Above this threshold, month bars compress to sub-pixel widths in the flex
|
||||
// row; we collapse to year granularity so each bar stays clickable.
|
||||
const MONTH_GRANULARITY_LIMIT = 240;
|
||||
|
||||
let {
|
||||
density,
|
||||
minDate,
|
||||
maxDate,
|
||||
from,
|
||||
to,
|
||||
zoomFrom = null,
|
||||
zoomTo = null,
|
||||
onchange,
|
||||
onzoomchange
|
||||
}: {
|
||||
density: MonthBucket[] | null;
|
||||
minDate: string | null;
|
||||
maxDate: string | null;
|
||||
from: string;
|
||||
to: string;
|
||||
zoomFrom?: string | null;
|
||||
zoomTo?: string | null;
|
||||
onchange: (event: SelectionEvent) => void;
|
||||
onzoomchange?: (event: ZoomEvent | null) => void;
|
||||
} = $props();
|
||||
|
||||
const monthBuckets = $derived.by(() => {
|
||||
if (density === null) return [];
|
||||
const full = fillDensityGaps(density, minDate, maxDate);
|
||||
return clipBucketsToRange(full, zoomFrom, zoomTo);
|
||||
});
|
||||
|
||||
const filled = $derived(
|
||||
monthBuckets.length > MONTH_GRANULARITY_LIMIT ? aggregateToYears(monthBuckets) : monthBuckets
|
||||
);
|
||||
|
||||
const isZoomed = $derived(zoomFrom !== null && zoomTo !== null);
|
||||
|
||||
function resetZoom() {
|
||||
onzoomchange?.(null);
|
||||
}
|
||||
|
||||
const maxCount = $derived(Math.max(...filled.map((b) => b.count), 1));
|
||||
|
||||
const hasSelection = $derived(from !== '' || to !== '');
|
||||
|
||||
let rowEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function clearSelection() {
|
||||
onchange({ from: '', to: '' });
|
||||
}
|
||||
|
||||
function isSelected(label: string): boolean {
|
||||
if (!hasSelection) return false;
|
||||
const labelFrom = selectionBoundaryFrom(label);
|
||||
return labelFrom >= from && labelFrom <= to;
|
||||
}
|
||||
|
||||
function isYearLabel(label: string): boolean {
|
||||
return label.length === 4;
|
||||
}
|
||||
|
||||
function emitSelection(startIndex: number, endIndex: number, includeZoom: boolean) {
|
||||
const lo = Math.min(startIndex, endIndex);
|
||||
const hi = Math.max(startIndex, endIndex);
|
||||
const startLabel = filled[lo]?.month;
|
||||
const endLabel = filled[hi]?.month;
|
||||
if (!startLabel || !endLabel) return;
|
||||
const selFrom = selectionBoundaryFrom(startLabel);
|
||||
const selTo = selectionBoundaryTo(endLabel);
|
||||
if (includeZoom) {
|
||||
onchange({ from: selFrom, to: selTo, zoomFrom: selFrom, zoomTo: selTo });
|
||||
} else {
|
||||
onchange({ from: selFrom, to: selTo });
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a viewport X-coordinate to a bar index by measuring the row, so
|
||||
// pointermove during drag works even when the cursor leaves the original bar
|
||||
// or the graph entirely. Pointer capture isn't usable here because it would
|
||||
// re-target click and suppress pointerenter on sibling bars.
|
||||
function indexFromClientX(clientX: number): number | null {
|
||||
if (!rowEl || filled.length === 0) return null;
|
||||
const rect = rowEl.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
if (x < 0) return 0;
|
||||
if (x >= rect.width) return filled.length - 1;
|
||||
const barWidth = rect.width / filled.length;
|
||||
return Math.min(filled.length - 1, Math.max(0, Math.floor(x / barWidth)));
|
||||
}
|
||||
|
||||
const drag = createTimelineDrag({
|
||||
indexFromClientX,
|
||||
labelAt: (i) => filled[i]?.month,
|
||||
isYearLabel,
|
||||
emit: emitSelection
|
||||
});
|
||||
|
||||
// Strip any in-flight document listeners if the component unmounts mid-drag
|
||||
// (route change, view toggle, breakpoint drop). Without this they survive on
|
||||
// document and keep writing to torn-down state cells.
|
||||
$effect(() => drag.cleanup);
|
||||
|
||||
function isInDragPreview(index: number): boolean {
|
||||
if (!drag.isDragging) return false;
|
||||
if (drag.lowIndex === null || drag.highIndex === null) return false;
|
||||
return index >= drag.lowIndex && index <= drag.highIndex;
|
||||
}
|
||||
|
||||
const dragWindowLeftPct = $derived.by(() => {
|
||||
if (!drag.isDragging || drag.lowIndex === null || filled.length === 0) return 0;
|
||||
return (drag.lowIndex / filled.length) * 100;
|
||||
});
|
||||
const dragWindowRightPct = $derived.by(() => {
|
||||
if (!drag.isDragging || drag.highIndex === null || filled.length === 0) return 100;
|
||||
return ((filled.length - drag.highIndex - 1) / filled.length) * 100;
|
||||
});
|
||||
|
||||
// While dragging, expose the live preview range to assistive tech via a
|
||||
// polite live region. Empty text outside drag avoids announcing residual state.
|
||||
const dragLiveMessage = $derived.by(() => {
|
||||
if (!drag.isDragging || drag.lowIndex === null || drag.highIndex === null) return '';
|
||||
const fromLabel = filled[drag.lowIndex]?.month;
|
||||
const toLabel = filled[drag.highIndex]?.month;
|
||||
if (!fromLabel || !toLabel) return '';
|
||||
return m.timeline_dragging_aria_live({
|
||||
from: formatTickLabel(fromLabel, getLocale()),
|
||||
to: formatTickLabel(toLabel, getLocale())
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if density !== null}
|
||||
<div
|
||||
data-testid="timeline-density-filter"
|
||||
role="group"
|
||||
aria-label={m.timeline_aria_label()}
|
||||
class="relative rounded-sm border border-line bg-surface px-3 pt-3 pb-2 shadow-sm"
|
||||
>
|
||||
<div class="flex">
|
||||
<TimelineYAxis maxCount={maxCount} barAreaHeight={BAR_AREA_HEIGHT} />
|
||||
|
||||
<div class="flex-1">
|
||||
<TimelineBars
|
||||
filled={filled}
|
||||
maxCount={maxCount}
|
||||
barAreaHeight={BAR_AREA_HEIGHT}
|
||||
isSelected={isSelected}
|
||||
isInDragPreview={isInDragPreview}
|
||||
isDragging={drag.isDragging}
|
||||
dragWindowLeftPct={dragWindowLeftPct}
|
||||
dragWindowRightPct={dragWindowRightPct}
|
||||
bind:rowEl={rowEl}
|
||||
onbarpointerdown={drag.pointerDown}
|
||||
onbarpointerenter={drag.pointerEnter}
|
||||
onbarclick={drag.click}
|
||||
/>
|
||||
|
||||
<TimelineXAxis filled={filled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sr-only" aria-live="polite" data-testid="timeline-aria-live">
|
||||
{dragLiveMessage}
|
||||
</div>
|
||||
|
||||
<TimelineControls
|
||||
isZoomed={isZoomed}
|
||||
hasSelection={hasSelection}
|
||||
onresetzoom={resetZoom}
|
||||
onclearselection={clearSelection}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
659
frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
Normal file
659
frontend/src/lib/document/TimelineDensityFilter.svelte.spec.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import TimelineDensityFilter from './TimelineDensityFilter.svelte';
|
||||
import { formatTickLabel } from './timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const NOOP = () => undefined;
|
||||
|
||||
function makeProps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
] satisfies MonthBucket[],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
from: '',
|
||||
to: '',
|
||||
onchange: NOOP,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('TimelineDensityFilter — visibility', () => {
|
||||
it('renders nothing when density is null', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ density: null, minDate: null, maxDate: null }));
|
||||
expect(document.querySelector('[data-testid="timeline-density-filter"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the widget when density is populated', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes an accessible group label on the widget', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const widget = document.querySelector('[data-testid="timeline-density-filter"]') as HTMLElement;
|
||||
expect(widget.getAttribute('role')).toBe('group');
|
||||
expect(widget.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — axes', () => {
|
||||
it('renders a Y-axis showing the maximum bar count and zero', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 12 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const yAxis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(yAxis).not.toBeNull();
|
||||
expect(yAxis.textContent).toContain('12');
|
||||
expect(yAxis.textContent).toContain('0');
|
||||
});
|
||||
|
||||
it('renders X-axis ticks at January boundaries for long month ranges', async () => {
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let m = 8; m <= 12; m++)
|
||||
buckets.push({ month: `1914-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
for (let m = 1; m <= 12; m++)
|
||||
buckets.push({ month: `1915-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
for (let m = 1; m <= 2; m++)
|
||||
buckets.push({ month: `1916-${String(m).padStart(2, '0')}`, count: 1 });
|
||||
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1914-08-01', maxDate: '1916-02-29' })
|
||||
);
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
expect(ticks.length).toBe(2);
|
||||
expect(Array.from(ticks).map((t) => t.textContent?.trim())).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('1915'), expect.stringContaining('1916')])
|
||||
);
|
||||
});
|
||||
|
||||
it('renders X-axis ticks for year-aggregated bars (every 10 years for ~50yr range)', async () => {
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1949; year++) {
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
buckets.push({ month: `${year}-${String(month).padStart(2, '0')}`, count: 1 });
|
||||
}
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1949-12-31' })
|
||||
);
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
const labels = Array.from(ticks).map((t) => t.textContent?.trim());
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — bars', () => {
|
||||
it('renders one bar per month within the range, including zero-count gaps', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
|
||||
it('zero-count months get the minimum visible bar height of 2px', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 4 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-09-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"] .bar-fill'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
expect(bars.length).toBe(2);
|
||||
expect(bars[1].style.height).toBe('2px');
|
||||
});
|
||||
|
||||
it('renders an empty widget without crashing when density is empty array and no range', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ density: [], minDate: null, maxDate: null }));
|
||||
await expect.element(page.getByTestId('timeline-density-filter')).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('[data-testid="timeline-bar"]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — selection', () => {
|
||||
it('clicking a bar emits the boundary dates of that month via onchange', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(TimelineDensityFilter, makeProps({ onchange }));
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
bars[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1915-08-01', to: '1915-08-31' });
|
||||
});
|
||||
|
||||
it('shows a clear button when from/to are set', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
await expect.element(page.getByTestId('timeline-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the clear button when from/to are empty', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
expect(document.querySelector('[data-testid="timeline-clear"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('clicking the clear button emits empty dates via onchange', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30', onchange }));
|
||||
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLButtonElement;
|
||||
clearBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '', to: '' });
|
||||
});
|
||||
|
||||
it('clear button is a real <button> with aria-label (Nora a11y review)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
|
||||
expect(clearBtn.tagName).toBe('BUTTON');
|
||||
expect(clearBtn.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — year-granularity fallback', () => {
|
||||
it('collapses to year buckets when the month sequence exceeds the limit', async () => {
|
||||
// 21 years × 12 months = 252 entries — above the 240 month limit.
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
buckets.push({ month: `${year}-06`, count: year - 1899 });
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ density: buckets, minDate: '1900-01-01', maxDate: '1920-12-31' })
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
expect(bars.length).toBe(21);
|
||||
const firstLabel = bars[0].getAttribute('aria-label') ?? '';
|
||||
// Localised, not the raw machine string "1900 · 1".
|
||||
expect(firstLabel).not.toMatch(/^\d{4} · \d+$/);
|
||||
expect(firstLabel).toContain('1900');
|
||||
expect(firstLabel).toContain('1');
|
||||
});
|
||||
|
||||
it('clicking a year bar zooms into that year (filter + zoom atomic)', async () => {
|
||||
const onchange = vi.fn();
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
buckets.push({ month: `${year}-06`, count: 5 });
|
||||
}
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: buckets,
|
||||
minDate: '1900-01-01',
|
||||
maxDate: '1920-12-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
bars[5].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1905-01-01',
|
||||
to: '1905-12-31',
|
||||
zoomFrom: '1905-01-01',
|
||||
zoomTo: '1905-12-31'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — zoom', () => {
|
||||
it('does not show the zoom-in button (drag replaces it as the zoom gesture)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
expect(document.querySelector('[data-testid="timeline-zoom-in"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the reset-zoom button only when zoomed', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }));
|
||||
await expect.element(page.getByTestId('timeline-zoom-reset')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking reset-zoom emits onzoomchange(null)', async () => {
|
||||
const onzoomchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30', onzoomchange })
|
||||
);
|
||||
|
||||
const resetBtn = document.querySelector(
|
||||
'[data-testid="timeline-zoom-reset"]'
|
||||
) as HTMLButtonElement;
|
||||
resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(onzoomchange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('when zoomed, only bars within the zoom range are rendered', async () => {
|
||||
// 21-year span normally collapses to year mode (>240 months handled
|
||||
// elsewhere). Zooming in to a 3-month window should restore month bars.
|
||||
const buckets: MonthBucket[] = [];
|
||||
for (let year = 1900; year <= 1920; year++) {
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
buckets.push({
|
||||
month: `${year}-${String(month).padStart(2, '0')}`,
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: buckets,
|
||||
minDate: '1900-01-01',
|
||||
maxDate: '1920-12-31',
|
||||
zoomFrom: '1910-06-01',
|
||||
zoomTo: '1910-08-31'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
// 3 months in zoom range
|
||||
expect(bars.length).toBe(3);
|
||||
const firstLabel = bars[0].getAttribute('aria-label') ?? '';
|
||||
const lastLabel = bars[2].getAttribute('aria-label') ?? '';
|
||||
// Localised — must NOT contain the raw "YYYY-MM" machine string.
|
||||
expect(firstLabel).not.toMatch(/\d{4}-\d{2}/);
|
||||
expect(lastLabel).not.toMatch(/\d{4}-\d{2}/);
|
||||
expect(firstLabel).toContain(formatTickLabel('1910-06', getLocale()));
|
||||
expect(lastLabel).toContain(formatTickLabel('1910-08', getLocale()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — touch targets', () => {
|
||||
it('reset-zoom button is at least 44×44 (WCAG 2.5.8)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }));
|
||||
const resetBtn = document.querySelector('[data-testid="timeline-zoom-reset"]') as HTMLElement;
|
||||
expect(resetBtn.classList.contains('h-11')).toBe(true);
|
||||
expect(resetBtn.className).toMatch(/min-w-\[44px\]/);
|
||||
expect(resetBtn.className).not.toMatch(/(?:^|\s)h-6(?:$|\s)/);
|
||||
});
|
||||
|
||||
it('clear-selection button is at least 44×44 (WCAG 2.5.8)', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
|
||||
expect(clearBtn.classList.contains('h-11')).toBe(true);
|
||||
expect(clearBtn.classList.contains('w-11')).toBe(true);
|
||||
expect(clearBtn.className).not.toMatch(/(?:^|\s)h-6(?:$|\s)/);
|
||||
});
|
||||
|
||||
it('bar buttons render a focus-visible ring so keyboard users can see focus', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
expect(bar.className).toMatch(/focus-visible:ring-2/);
|
||||
expect(bar.className).toMatch(/focus-visible:ring-brand-navy/);
|
||||
expect(bar.className).toMatch(/focus-visible:ring-offset-2/);
|
||||
});
|
||||
|
||||
it('reset-zoom button renders the same focus-visible ring as the bars', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ zoomFrom: '1915-08-01', zoomTo: '1915-09-30' }));
|
||||
const resetBtn = document.querySelector('[data-testid="timeline-zoom-reset"]') as HTMLElement;
|
||||
expect(resetBtn.className).toMatch(/focus-visible:ring-2/);
|
||||
expect(resetBtn.className).toMatch(/focus-visible:ring-brand-navy/);
|
||||
expect(resetBtn.className).toMatch(/focus-visible:ring-offset-2/);
|
||||
});
|
||||
|
||||
it('clear-selection button renders the same focus-visible ring as the bars', async () => {
|
||||
render(TimelineDensityFilter, makeProps({ from: '1915-08-01', to: '1915-09-30' }));
|
||||
const clearBtn = document.querySelector('[data-testid="timeline-clear"]') as HTMLElement;
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-2/);
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-brand-navy/);
|
||||
expect(clearBtn.className).toMatch(/focus-visible:ring-offset-2/);
|
||||
});
|
||||
|
||||
it('bar hover style is gated by @media (hover: hover) to avoid touch-device hover-stick', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const cssText = Array.from(document.styleSheets)
|
||||
.flatMap((sheet) => {
|
||||
try {
|
||||
return Array.from(sheet.cssRules);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n');
|
||||
expect(cssText).toMatch(/@media \(hover: hover\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — accessibility', () => {
|
||||
it('Y-axis labels meet the 12px minimum font floor (Tailwind text-xs)', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const yAxis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(yAxis.classList.contains('text-xs')).toBe(true);
|
||||
expect(yAxis.className).not.toMatch(/text-\[10px\]/);
|
||||
});
|
||||
|
||||
it('X-axis row uses text-xs and h-4 to fit the 12px line height', async () => {
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const xAxis = document.querySelector('[data-testid="timeline-x-axis"]') as HTMLElement;
|
||||
expect(xAxis.classList.contains('text-xs')).toBe(true);
|
||||
expect(xAxis.classList.contains('h-4')).toBe(true);
|
||||
expect(xAxis.className).not.toMatch(/text-\[10px\]/);
|
||||
});
|
||||
|
||||
it('bar aria-label uses the singular noun form when count is 1', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 1 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
// "documents", "Dokumente", "documentos" are the plural-only forms in our 3 locales.
|
||||
expect(label).not.toMatch(/\b(?:documents|Dokumente|documentos)\b/);
|
||||
expect(label).toMatch(/\b1 (?:document|Dokument|documento)\b/);
|
||||
});
|
||||
|
||||
it('bar aria-label uses the plural noun form when count is not 1', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 5 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
expect(label).toMatch(/\b5 (?:documents|Dokumente|documentos)\b/);
|
||||
});
|
||||
|
||||
it('bar aria-label is built from a localised template, never the raw YYYY-MM', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [{ month: '1915-08', count: 5 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-08-31'
|
||||
})
|
||||
);
|
||||
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
const label = bar.getAttribute('aria-label') ?? '';
|
||||
expect(label).not.toMatch(/1915-08/);
|
||||
expect(label).toContain(formatTickLabel('1915-08', getLocale()));
|
||||
expect(label).toContain('5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — aria-live during drag', () => {
|
||||
function pointerDown(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
function pointerEnter(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1 }));
|
||||
}
|
||||
function pointerUp(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
|
||||
it('renders a polite aria-live region whose text reflects the dragged range', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31'
|
||||
})
|
||||
);
|
||||
|
||||
const live = document.querySelector('[data-testid="timeline-aria-live"]') as HTMLElement;
|
||||
expect(live).not.toBeNull();
|
||||
expect(live.getAttribute('aria-live')).toBe('polite');
|
||||
expect(live.textContent?.trim() ?? '').toBe('');
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
const text = live.textContent ?? '';
|
||||
expect(text).toContain(formatTickLabel('1915-08', getLocale()));
|
||||
expect(text).toContain(formatTickLabel('1915-10', getLocale()));
|
||||
|
||||
pointerUp(bars[2]);
|
||||
await tick();
|
||||
expect(live.textContent?.trim() ?? '').toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — listener cleanup on unmount', () => {
|
||||
it('removes document pointer listeners when unmounted mid-drag', async () => {
|
||||
const removed: string[] = [];
|
||||
const realRemove = document.removeEventListener.bind(document);
|
||||
const removeSpy = vi
|
||||
.spyOn(document, 'removeEventListener')
|
||||
.mockImplementation((type: string, listener, options) => {
|
||||
removed.push(type);
|
||||
return realRemove(type, listener as EventListener, options);
|
||||
});
|
||||
|
||||
render(TimelineDensityFilter, makeProps());
|
||||
const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLElement;
|
||||
bar.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(removed).toContain('pointermove');
|
||||
expect(removed).toContain('pointerup');
|
||||
expect(removed).toContain('pointercancel');
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimelineDensityFilter — drag-to-select-range', () => {
|
||||
function pointerDown(el: HTMLElement) {
|
||||
const event = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0 });
|
||||
// jsdom-style stub for setPointerCapture (the real DOM has it but vitest-browser
|
||||
// uses Playwright-driven Chromium so it works natively too).
|
||||
el.dispatchEvent(event);
|
||||
}
|
||||
function pointerEnter(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1 }));
|
||||
}
|
||||
function pointerUp(el: HTMLElement) {
|
||||
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1, button: 0 }));
|
||||
}
|
||||
|
||||
it('dragging from bar A to bar B emits a single onchange with filter + zoom (atomic)', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
pointerUp(bars[2]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1915-08-01',
|
||||
to: '1915-10-31',
|
||||
zoomFrom: '1915-08-01',
|
||||
zoomTo: '1915-10-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('dragging from a later bar to an earlier bar still emits ascending boundaries', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-10-31',
|
||||
onchange
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[2]);
|
||||
pointerEnter(bars[0]);
|
||||
pointerUp(bars[0]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
from: '1915-08-01',
|
||||
to: '1915-10-31',
|
||||
zoomFrom: '1915-08-01',
|
||||
zoomTo: '1915-10-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('pressing+releasing on the same bar selects that single month without zoom', async () => {
|
||||
const onchange = vi.fn();
|
||||
render(TimelineDensityFilter, makeProps({ onchange }));
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[1]);
|
||||
pointerUp(bars[1]);
|
||||
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith({ from: '1915-09-01', to: '1915-09-30' });
|
||||
});
|
||||
|
||||
it('renders a drag-window overlay between drag start and current position', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-11', count: 4 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-11-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
expect(document.querySelector('[data-testid="timeline-drag-window"]')).toBeNull();
|
||||
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
const win = document.querySelector('[data-testid="timeline-drag-window"]') as HTMLElement;
|
||||
expect(win).not.toBeNull();
|
||||
// 4 bars total, drag covers indices 0..2 → left 0%, right 25%.
|
||||
expect(win.style.left).toBe('0%');
|
||||
expect(win.style.right).toBe('25%');
|
||||
|
||||
pointerUp(bars[2]);
|
||||
await tick();
|
||||
expect(document.querySelector('[data-testid="timeline-drag-window"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks bars in the active drag range with the in-drag-preview class', async () => {
|
||||
render(
|
||||
TimelineDensityFilter,
|
||||
makeProps({
|
||||
density: [
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-11', count: 4 }
|
||||
],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1915-11-30'
|
||||
})
|
||||
);
|
||||
|
||||
const bars = document.querySelectorAll(
|
||||
'[data-testid="timeline-bar"]'
|
||||
) as NodeListOf<HTMLElement>;
|
||||
pointerDown(bars[0]);
|
||||
pointerEnter(bars[2]);
|
||||
await tick();
|
||||
|
||||
expect(bars[0].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[1].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[2].classList.contains('in-drag-preview')).toBe(true);
|
||||
expect(bars[3].classList.contains('in-drag-preview')).toBe(false);
|
||||
|
||||
pointerUp(bars[2]);
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/document/TimelineXAxis.svelte
Normal file
40
frontend/src/lib/document/TimelineXAxis.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { formatTickLabel, tickIndicesFor } from '$lib/document/timeline';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
|
||||
let {
|
||||
filled
|
||||
}: {
|
||||
filled: MonthBucket[];
|
||||
} = $props();
|
||||
|
||||
const tickIndices = $derived(tickIndicesFor(filled));
|
||||
|
||||
// When all visible buckets share a year, the X-axis omits the year so a
|
||||
// 12-month zoom reads as "Jan Feb Mär…" without repetition.
|
||||
const omitTickYear = $derived.by(() => {
|
||||
if (filled.length === 0 || filled[0].month.length === 4) return false;
|
||||
const firstYear = filled[0].month.slice(0, 4);
|
||||
return filled.every((b) => b.month.slice(0, 4) === firstYear);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative mt-1 h-4 font-sans text-xs leading-none text-ink-3"
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-x-axis"
|
||||
>
|
||||
{#each tickIndices as idx (filled[idx]?.month)}
|
||||
{@const tickLeftPct = ((idx + 0.5) / filled.length) * 100}
|
||||
<span
|
||||
class="absolute -translate-x-1/2 whitespace-nowrap"
|
||||
data-testid="timeline-x-tick"
|
||||
style="left: {tickLeftPct}%;"
|
||||
>
|
||||
{formatTickLabel(filled[idx].month, getLocale(), omitTickYear)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
19
frontend/src/lib/document/TimelineYAxis.svelte
Normal file
19
frontend/src/lib/document/TimelineYAxis.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
maxCount,
|
||||
barAreaHeight
|
||||
}: {
|
||||
maxCount: number;
|
||||
barAreaHeight: number;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col justify-between pr-1.5 text-right font-sans text-xs leading-none text-ink-3"
|
||||
style="height: {barAreaHeight}px;"
|
||||
aria-hidden="true"
|
||||
data-testid="timeline-y-axis"
|
||||
>
|
||||
<span>{maxCount}</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
392
frontend/src/lib/document/timeline.spec.ts
Normal file
392
frontend/src/lib/document/timeline.spec.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
monthBoundaryFrom,
|
||||
monthBoundaryTo,
|
||||
buildMonthSequence,
|
||||
fillDensityGaps,
|
||||
fetchDensity,
|
||||
buildDensityUrl,
|
||||
aggregateToYears,
|
||||
selectionBoundaryFrom,
|
||||
selectionBoundaryTo,
|
||||
clipBucketsToRange,
|
||||
tickIndicesFor,
|
||||
formatTickLabel
|
||||
} from './timeline';
|
||||
|
||||
describe('monthBoundaryFrom', () => {
|
||||
it('returns the first day of the given month', () => {
|
||||
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
});
|
||||
|
||||
it('handles January', () => {
|
||||
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthBoundaryTo', () => {
|
||||
it('returns the last day of a 31-day month', () => {
|
||||
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('returns the last day of a 30-day month', () => {
|
||||
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
|
||||
});
|
||||
|
||||
it('returns 28 for February in a non-leap year', () => {
|
||||
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
|
||||
});
|
||||
|
||||
it('returns 29 for February in a leap year', () => {
|
||||
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMonthSequence', () => {
|
||||
it('returns a single month when min and max are in the same month', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
|
||||
});
|
||||
|
||||
it('returns months from minDate through maxDate inclusive', () => {
|
||||
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
|
||||
'1915-08',
|
||||
'1915-09',
|
||||
'1915-10',
|
||||
'1915-11'
|
||||
]);
|
||||
});
|
||||
|
||||
it('crosses year boundaries correctly', () => {
|
||||
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
|
||||
'1915-11',
|
||||
'1915-12',
|
||||
'1916-01',
|
||||
'1916-02'
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
|
||||
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
|
||||
expect(buildMonthSequence(null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillDensityGaps', () => {
|
||||
it('returns empty array when minDate or maxDate is null', () => {
|
||||
expect(fillDensityGaps([], null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 },
|
||||
{ month: '1915-11', count: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all-zero sequence when buckets array is empty', () => {
|
||||
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ month: '1915-08', count: 0 },
|
||||
{ month: '1915-09', count: 0 },
|
||||
{ month: '1915-10', count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-10', count: 3 },
|
||||
{ month: '1915-08', count: 1 }
|
||||
];
|
||||
|
||||
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
|
||||
|
||||
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateToYears', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(aggregateToYears([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums counts within the same year', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
||||
});
|
||||
|
||||
it('produces one bucket per distinct year, sorted chronologically', () => {
|
||||
const result = aggregateToYears([
|
||||
{ month: '1916-01', count: 3 },
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1916-04', count: 7 },
|
||||
{ month: '1914-12', count: 1 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ month: '1914', count: 1 },
|
||||
{ month: '1915', count: 5 },
|
||||
{ month: '1916', count: 10 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clipBucketsToRange', () => {
|
||||
const buckets = [
|
||||
{ month: '1915-08', count: 5 },
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 },
|
||||
{ month: '1915-11', count: 3 }
|
||||
];
|
||||
|
||||
it('returns the original buckets when range bounds are null', () => {
|
||||
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
|
||||
});
|
||||
|
||||
it('keeps only buckets whose month falls within the range', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the range excludes everything', () => {
|
||||
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats partial dates correctly when bounds cross month boundaries', () => {
|
||||
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
|
||||
{ month: '1915-09', count: 2 },
|
||||
{ month: '1915-10', count: 8 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionBoundaryFrom / To', () => {
|
||||
it('handles month labels (YYYY-MM)', () => {
|
||||
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
||||
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
||||
});
|
||||
|
||||
it('handles year labels (YYYY)', () => {
|
||||
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
||||
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDensityUrl', () => {
|
||||
it('returns the bare endpoint when no filters provided', () => {
|
||||
expect(buildDensityUrl()).toBe('/api/documents/density');
|
||||
});
|
||||
|
||||
it('forwards single-value filters as query params', () => {
|
||||
expect(buildDensityUrl({ q: 'Brief', senderId: 's-1' })).toBe(
|
||||
'/api/documents/density?q=Brief&senderId=s-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('repeats the tag param for multi-value tag filters', () => {
|
||||
const url = buildDensityUrl({ tags: ['Familie', 'Urlaub'], tagOp: 'OR' });
|
||||
expect(url).toContain('tag=Familie');
|
||||
expect(url).toContain('tag=Urlaub');
|
||||
expect(url).toContain('tagOp=OR');
|
||||
});
|
||||
|
||||
it('omits tagOp when it is AND (default on backend)', () => {
|
||||
const url = buildDensityUrl({ tags: ['Familie'], tagOp: 'AND' });
|
||||
expect(url).not.toContain('tagOp=');
|
||||
});
|
||||
|
||||
it('does not forward from/to even if a caller mistakenly adds them', () => {
|
||||
// Intentional: density is the surface for picking from/to, so it must always
|
||||
// span the broader space the user is selecting within.
|
||||
// @ts-expect-error - from/to are explicitly absent from DensityFilters
|
||||
const url = buildDensityUrl({ q: 'Brief', from: '1915-01-01', to: '1916-12-31' });
|
||||
expect(url).not.toContain('from=');
|
||||
expect(url).not.toContain('to=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDensity', () => {
|
||||
it('skips fetch and returns null density on mobile', async () => {
|
||||
const fetch = vi.fn();
|
||||
|
||||
const result = await fetchDensity(fetch, null, false);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ density: null, minDate: null, maxDate: null });
|
||||
});
|
||||
|
||||
it('skips fetch when view is calendar', async () => {
|
||||
const fetch = vi.fn();
|
||||
|
||||
const result = await fetchDensity(fetch, 'calendar', true);
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ density: null, minDate: null, maxDate: null });
|
||||
});
|
||||
|
||||
it('calls /api/documents/density and returns body on desktop, list view', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
buckets: [{ month: '1915-08', count: 3 }],
|
||||
minDate: '1915-08-01',
|
||||
maxDate: '1916-12-31'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/documents/density');
|
||||
expect(result.density).toEqual([{ month: '1915-08', count: 3 }]);
|
||||
expect(result.minDate).toBe('1915-08-01');
|
||||
expect(result.maxDate).toBe('1916-12-31');
|
||||
});
|
||||
|
||||
it('forwards active filters as query params', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ buckets: [], minDate: null, maxDate: null })
|
||||
});
|
||||
|
||||
await fetchDensity(fetch, null, true, { senderId: 's-1', tags: ['Familie'] });
|
||||
|
||||
const calledWith = fetch.mock.calls[0][0] as string;
|
||||
expect(calledWith).toContain('senderId=s-1');
|
||||
expect(calledWith).toContain('tag=Familie');
|
||||
});
|
||||
|
||||
it('returns empty density and null bounds when the API responds non-ok', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
const result = await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(result).toEqual({ density: [], minDate: null, maxDate: null });
|
||||
});
|
||||
|
||||
it('treats fetch rejection as a graceful degradation, not an error', async () => {
|
||||
const fetch = vi.fn().mockRejectedValue(new TypeError('Network down'));
|
||||
|
||||
const result = await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(result).toEqual({ density: [], minDate: null, maxDate: null });
|
||||
});
|
||||
|
||||
it('emits console.warn with the status when the response is non-ok', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0][0]).toContain('503');
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('emits console.warn with the caught error when fetch rejects', async () => {
|
||||
const error = new TypeError('Network down');
|
||||
const fetch = vi.fn().mockRejectedValue(error);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await fetchDensity(fetch, null, true);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]).toContain(error);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickIndicesFor', () => {
|
||||
it('returns no indices for an empty bucket list', () => {
|
||||
expect(tickIndicesFor([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
||||
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
||||
month: String(1875 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
||||
});
|
||||
|
||||
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
||||
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
||||
month: String(1900 + i),
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
const labels = ticks.map((i) => buckets[i].month);
|
||||
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
||||
});
|
||||
|
||||
it('picks January boundaries for long month ranges', () => {
|
||||
const buckets = [
|
||||
{ month: '1914-08', count: 1 },
|
||||
{ month: '1914-09', count: 1 },
|
||||
{ month: '1914-10', count: 1 },
|
||||
{ month: '1914-11', count: 1 },
|
||||
{ month: '1914-12', count: 1 },
|
||||
{ month: '1915-01', count: 1 },
|
||||
{ month: '1915-02', count: 1 },
|
||||
{ month: '1915-03', count: 1 },
|
||||
{ month: '1915-04', count: 1 },
|
||||
{ month: '1915-05', count: 1 },
|
||||
{ month: '1915-06', count: 1 },
|
||||
{ month: '1915-07', count: 1 },
|
||||
{ month: '1915-08', count: 1 },
|
||||
{ month: '1915-09', count: 1 },
|
||||
{ month: '1915-10', count: 1 },
|
||||
{ month: '1915-11', count: 1 },
|
||||
{ month: '1915-12', count: 1 },
|
||||
{ month: '1916-01', count: 1 },
|
||||
{ month: '1916-02', count: 1 }
|
||||
];
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
||||
});
|
||||
|
||||
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
||||
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
||||
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
||||
count: 1
|
||||
}));
|
||||
const ticks = tickIndicesFor(buckets);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
||||
expect(ticks.length).toBeLessThanOrEqual(7);
|
||||
expect(ticks[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTickLabel', () => {
|
||||
it('returns the year string unchanged for year labels', () => {
|
||||
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
||||
});
|
||||
|
||||
it('formats month labels with the year by default', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US');
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).toMatch(/1905/);
|
||||
});
|
||||
|
||||
it('omits the year when omitYear is true', () => {
|
||||
const result = formatTickLabel('1905-06', 'en-US', true);
|
||||
expect(result).toMatch(/Jun/);
|
||||
expect(result).not.toMatch(/1905/);
|
||||
});
|
||||
});
|
||||
231
frontend/src/lib/document/timeline.ts
Normal file
231
frontend/src/lib/document/timeline.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type MonthBucket = components['schemas']['MonthBucket'];
|
||||
type DocumentDensityResult = components['schemas']['DocumentDensityResult'];
|
||||
|
||||
export type DensityState = {
|
||||
density: MonthBucket[] | null;
|
||||
minDate: string | null;
|
||||
maxDate: string | null;
|
||||
};
|
||||
|
||||
const SKIP: DensityState = { density: null, minDate: null, maxDate: null };
|
||||
const EMPTY: DensityState = { density: [], minDate: null, maxDate: null };
|
||||
|
||||
export function monthBoundaryFrom(yearMonth: string): string {
|
||||
return `${yearMonth}-01`;
|
||||
}
|
||||
|
||||
export function monthBoundaryTo(yearMonth: string): string {
|
||||
const [year, month] = yearMonth.split('-').map(Number);
|
||||
// Day 0 of `month + 1` rolls back to the last day of `month` — so passing
|
||||
// `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
|
||||
// of that month. Handles 28/29/30/31 and leap years without a lookup table.
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
|
||||
if (!minDate || !maxDate) return [];
|
||||
|
||||
const [minY, minM] = minDate.split('-').map(Number);
|
||||
const [maxY, maxM] = maxDate.split('-').map(Number);
|
||||
|
||||
const sequence: string[] = [];
|
||||
let year = minY;
|
||||
let month = minM;
|
||||
|
||||
while (year < maxY || (year === maxY && month <= maxM)) {
|
||||
sequence.push(`${year}-${String(month).padStart(2, '0')}`);
|
||||
month += 1;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
export function fillDensityGaps(
|
||||
buckets: MonthBucket[],
|
||||
minDate: string | null,
|
||||
maxDate: string | null
|
||||
): MonthBucket[] {
|
||||
const sequence = buildMonthSequence(minDate, maxDate);
|
||||
if (sequence.length === 0) return [];
|
||||
|
||||
const counts = new Map(buckets.map((b) => [b.month, b.count]));
|
||||
return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the month buckets whose YYYY-MM falls inside the provided
|
||||
* `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
|
||||
* input array is returned unchanged. Used by the timeline's zoom-in tool to
|
||||
* narrow the visible bars without refetching data.
|
||||
*
|
||||
* @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
|
||||
* unit suite (`timeline.spec.ts`) can pin the boundary semantics directly.
|
||||
*/
|
||||
export function clipBucketsToRange(
|
||||
buckets: MonthBucket[],
|
||||
fromInclusive: string | null,
|
||||
toInclusive: string | null
|
||||
): MonthBucket[] {
|
||||
if (!fromInclusive || !toInclusive) return buckets;
|
||||
const fromMonth = fromInclusive.slice(0, 7);
|
||||
const toMonth = toInclusive.slice(0, 7);
|
||||
return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates month-granular buckets into one entry per year. Month strings are
|
||||
* truncated to "YYYY" and counts are summed. Used when the date span is too
|
||||
* long for month-granular bars to render at a clickable size.
|
||||
*/
|
||||
export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
|
||||
const totals = new Map<string, number>();
|
||||
for (const b of buckets) {
|
||||
const year = b.month.slice(0, 4);
|
||||
totals.set(year, (totals.get(year) ?? 0) + b.count);
|
||||
}
|
||||
return Array.from(totals.entries())
|
||||
.map(([year, count]) => ({ month: year, count }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
|
||||
* (year) and return the matching LocalDate string.
|
||||
*/
|
||||
export function selectionBoundaryFrom(label: string): string {
|
||||
return label.length === 4 ? `${label}-01-01` : `${label}-01`;
|
||||
}
|
||||
|
||||
export function selectionBoundaryTo(label: string): string {
|
||||
if (label.length === 4) return `${label}-12-31`;
|
||||
return monthBoundaryTo(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks bucket indices that should get an X-axis tick label. The strategy adapts
|
||||
* to whether bars are years or months and how many are visible:
|
||||
* - Year bars: pick years divisible by a step that scales with range length
|
||||
* (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
|
||||
* - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
|
||||
* one year zoomed in to months), fall back to evenly spaced ticks so we
|
||||
* show ~6 labels even when no January boundary exists.
|
||||
*/
|
||||
export function tickIndicesFor(filled: MonthBucket[]): number[] {
|
||||
if (filled.length === 0) return [];
|
||||
const isYearMode = filled[0].month.length === 4;
|
||||
const indices: number[] = [];
|
||||
|
||||
if (isYearMode) {
|
||||
const years = filled.length;
|
||||
const step =
|
||||
years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const year = parseInt(filled[i].month, 10);
|
||||
if (year % step === 0) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
if (filled.length <= 18) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Long month range — pick January boundaries (year breaks).
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
if (filled[i].month.endsWith('-01')) indices.push(i);
|
||||
}
|
||||
// Fallback if there's no January in the visible range (rare): even spacing.
|
||||
if (indices.length === 0) {
|
||||
const step = Math.max(1, Math.round(filled.length / 6));
|
||||
for (let i = 0; i < filled.length; i += step) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
|
||||
* `omitYear` is true the year is dropped so a 12-month zoomed view reads as
|
||||
* "Jan", "Feb", … without repetition.
|
||||
*/
|
||||
export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
|
||||
if (label.length === 4) return label;
|
||||
const [yearStr, monthStr] = label.split('-');
|
||||
const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
|
||||
const opts: Intl.DateTimeFormatOptions = omitYear
|
||||
? { month: 'short' }
|
||||
: { month: 'short', year: 'numeric' };
|
||||
return new Intl.DateTimeFormat(locale, opts).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of /documents URL params that should narrow the density chart.
|
||||
* Date bounds (`from`/`to`) are intentionally excluded — see
|
||||
* {@link fetchDensity} for why.
|
||||
*/
|
||||
export type DensityFilters = {
|
||||
q?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tags?: string[];
|
||||
tagQ?: string;
|
||||
status?: string;
|
||||
tagOp?: 'AND' | 'OR';
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the density endpoint URL, including the active non-date filters
|
||||
* so the chart matches the document list it sits above.
|
||||
*/
|
||||
export function buildDensityUrl(filters: DensityFilters = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.q) params.set('q', filters.q);
|
||||
if (filters.senderId) params.set('senderId', filters.senderId);
|
||||
if (filters.receiverId) params.set('receiverId', filters.receiverId);
|
||||
for (const tag of filters.tags ?? []) params.append('tag', tag);
|
||||
if (filters.tagQ) params.set('tagQ', filters.tagQ);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
|
||||
const qs = params.toString();
|
||||
return qs ? `/api/documents/density?${qs}` : '/api/documents/density';
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the density data for the timeline widget. Tablet and below (lg breakpoint,
|
||||
* <1024px) and calendar view both skip the request entirely — the widget isn't
|
||||
* rendered there. A non-ok response or network failure degrades to an empty
|
||||
* bucket list instead of throwing, so the document list page keeps rendering.
|
||||
*/
|
||||
export async function fetchDensity(
|
||||
fetch: typeof globalThis.fetch,
|
||||
view: string | null,
|
||||
isDesktop: boolean,
|
||||
filters: DensityFilters = {}
|
||||
): Promise<DensityState> {
|
||||
if (!isDesktop || view === 'calendar') return SKIP;
|
||||
|
||||
try {
|
||||
const response = await fetch(buildDensityUrl(filters));
|
||||
if (!response.ok) {
|
||||
console.warn(`[timeline] density fetch responded with ${response.status}`);
|
||||
return EMPTY;
|
||||
}
|
||||
const body = (await response.json()) as DocumentDensityResult;
|
||||
return {
|
||||
density: body.buckets,
|
||||
minDate: body.minDate ?? null,
|
||||
maxDate: body.maxDate ?? null
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[timeline] density fetch failed', error);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ describe('TranscriptionBlock — interactions', () => {
|
||||
it('calls onFocus when textarea is focused', async () => {
|
||||
const onFocus = vi.fn();
|
||||
renderBlock({ onFocus });
|
||||
const textarea = page.getByRole('textbox');
|
||||
await textarea.click();
|
||||
const textboxEl = (await page.getByRole('textbox').element()) as HTMLElement;
|
||||
textboxEl.dispatchEvent(new FocusEvent('focus', { bubbles: false }));
|
||||
expect(onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -152,16 +152,20 @@ describe('TranscriptionBlock — reorder controls', () => {
|
||||
it('calls onMoveUp when up arrow clicked', async () => {
|
||||
const onMoveUp = vi.fn();
|
||||
renderBlock({ onMoveUp, isFirst: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach oben' });
|
||||
await btn.click();
|
||||
const btnEl = (await page
|
||||
.getByRole('button', { name: 'Nach oben' })
|
||||
.element()) as HTMLButtonElement;
|
||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(onMoveUp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onMoveDown when down arrow clicked', async () => {
|
||||
const onMoveDown = vi.fn();
|
||||
renderBlock({ onMoveDown, isLast: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach unten' });
|
||||
await btn.click();
|
||||
const btnEl = (await page
|
||||
.getByRole('button', { name: 'Nach unten' })
|
||||
.element()) as HTMLButtonElement;
|
||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(onMoveDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -227,16 +231,17 @@ describe('TranscriptionBlock — delete confirmation', () => {
|
||||
describe('TranscriptionBlock — quote selection', () => {
|
||||
it('shows quote hint after text is selected in the editor', async () => {
|
||||
renderBlock({ text: 'Breslau, den 12. August' });
|
||||
await page.getByRole('textbox').click();
|
||||
// Select all text in the contenteditable via the native Selection API.
|
||||
// Tiptap fires selectionUpdate which the block forwards as onSelectionChange.
|
||||
const editorEl = document.querySelector('[role="textbox"]') as HTMLElement;
|
||||
// Native .focus() activates ProseMirror's DOMObserver so it listens for selectionchange.
|
||||
const editorEl = (await page.getByRole('textbox').element()) as HTMLElement;
|
||||
editorEl.focus();
|
||||
// Let ProseMirror's focus handler complete before we overwrite the selection.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editorEl);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import TranscriptionEditView from './TranscriptionEditView.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||
|
||||
@@ -148,24 +148,28 @@ describe('TranscriptionEditView — auto-save debounce', () => {
|
||||
});
|
||||
|
||||
it('passes the block mentionedPersons array as the 3rd save argument', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
|
||||
const blockWithMention = {
|
||||
...block1,
|
||||
// text must contain the @displayName token so deserialize() creates a mention node;
|
||||
// fill() replaces the whole content with plain text and would destroy the node
|
||||
text: '@Auguste Raddatz',
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
renderView({ blocks: [blockWithMention], onSaveBlock });
|
||||
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('Hallo @Auguste Raddatz');
|
||||
// type() focuses the element (cursor at position 0) then inserts without replacing the
|
||||
// existing mention node. Fake timers interfere with keyboard CDP so use real timers
|
||||
// + vi.waitFor to catch the 1500 ms debounce.
|
||||
await userEvent.type(page.getByRole('textbox').first(), 'Hallo ');
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]),
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('resets debounce timer on rapid successive changes', async () => {
|
||||
@@ -238,8 +242,9 @@ describe('TranscriptionEditView — flush on blur', () => {
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('Blur text');
|
||||
|
||||
// Blur before 1500ms debounce fires — locator.blur() not available, use native DOM
|
||||
const el = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
// Blur before 1500ms debounce fires — locator.blur() not available, use native DOM.
|
||||
// PersonMentionEditor uses a contenteditable div (role=textbox), not a <textarea>.
|
||||
const el = document.querySelector('[role="textbox"]') as HTMLElement;
|
||||
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -335,7 +340,12 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
|
||||
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
|
||||
// handlers when a TipTap editor is mounted in the same component tree.
|
||||
const btn = (await page
|
||||
.getByRole('button', { name: /Alle als fertig markieren/ })
|
||||
.element()) as HTMLButtonElement;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
@@ -349,9 +359,14 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
|
||||
await btn.click();
|
||||
await expect.element(btn).toBeDisabled();
|
||||
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
|
||||
const btnEl = (await page
|
||||
.getByRole('button', { name: /Alle als fertig markieren/ })
|
||||
.element()) as HTMLButtonElement;
|
||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeDisabled();
|
||||
resolveMarkAll();
|
||||
});
|
||||
});
|
||||
|
||||
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { flushSync } from 'svelte';
|
||||
|
||||
const { createTimelineDrag } = await import('./useTimelineDrag.svelte');
|
||||
|
||||
type EmitCall = { start: number; end: number; includeZoom: boolean };
|
||||
|
||||
function makeOpts(overrides: Partial<Parameters<typeof createTimelineDrag>[0]> = {}) {
|
||||
const labels = ['1915-08', '1915-09', '1915-10', '1915']; // last entry is a year label
|
||||
const calls: EmitCall[] = [];
|
||||
const opts = {
|
||||
indexFromClientX: vi.fn(() => 0),
|
||||
labelAt: (i: number) => labels[i],
|
||||
isYearLabel: (l: string) => l.length === 4,
|
||||
emit: (start: number, end: number, includeZoom: boolean) => {
|
||||
calls.push({ start, end, includeZoom });
|
||||
},
|
||||
...overrides
|
||||
};
|
||||
return { opts, calls };
|
||||
}
|
||||
|
||||
function pointerDownEvent(button = 0): PointerEvent {
|
||||
return { button } as unknown as PointerEvent;
|
||||
}
|
||||
|
||||
describe('createTimelineDrag', () => {
|
||||
beforeEach(() => {
|
||||
// Reset listeners attached to the JSDOM document between tests
|
||||
document.removeEventListener('pointermove', () => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts with no drag in progress and null low/high indices', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
expect(drag.lowIndex).toBeNull();
|
||||
expect(drag.highIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('pointerDown on a primary button enters drag state at that index', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 1);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(true);
|
||||
expect(drag.lowIndex).toBe(1);
|
||||
expect(drag.highIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('pointerDown on a non-primary button is ignored', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(2), 1);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('pointerEnter during drag widens the range high boundary', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
drag.pointerEnter(2);
|
||||
flushSync();
|
||||
expect(drag.lowIndex).toBe(0);
|
||||
expect(drag.highIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('pointerEnter outside drag is a no-op', () => {
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerEnter(2);
|
||||
flushSync();
|
||||
expect(drag.isDragging).toBe(false);
|
||||
expect(drag.lowIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('click on a month bar emits filter only (no zoom)', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.click(0);
|
||||
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
|
||||
});
|
||||
|
||||
it('click on a year bar emits filter + zoom atomically', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.click(3); // labels[3] = '1915' (year label)
|
||||
expect(calls).toEqual([{ start: 3, end: 3, includeZoom: true }]);
|
||||
});
|
||||
|
||||
it('range drag commits emit with zoom in ascending order', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 2) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 999 }));
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([{ start: 0, end: 2, includeZoom: true }]);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('reverse-direction drag still emits ascending boundaries via emit ordering', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 2);
|
||||
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 }));
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
// emit receives raw start/end — orchestrator's emit() handles ordering
|
||||
expect(calls).toEqual([{ start: 2, end: 0, includeZoom: true }]);
|
||||
});
|
||||
|
||||
it('pointerup on the same bar (no-range) on a month label emits filter without zoom', () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
|
||||
});
|
||||
|
||||
it('pointercancel resets state without emitting', () => {
|
||||
const { opts, calls } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 1);
|
||||
document.dispatchEvent(new PointerEvent('pointercancel'));
|
||||
flushSync();
|
||||
expect(calls).toEqual([]);
|
||||
expect(drag.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it('click after pointerup is suppressed (no double emit)', async () => {
|
||||
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
document.dispatchEvent(new PointerEvent('pointerup'));
|
||||
flushSync();
|
||||
drag.click(0); // synthesized click after pointerup
|
||||
expect(calls.length).toBe(1); // only the pointerup-driven emit, not the click
|
||||
});
|
||||
|
||||
it('cleanup removes document pointer listeners', () => {
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const { opts } = makeOpts();
|
||||
const drag = createTimelineDrag(opts);
|
||||
drag.pointerDown(pointerDownEvent(0), 0);
|
||||
drag.cleanup();
|
||||
const removed = removeSpy.mock.calls.map((c) => c[0]);
|
||||
expect(removed).toEqual(expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']));
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user