Compare commits
193 Commits
ed028e793e
...
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 | ||
|
|
39e7ee2c71 | ||
|
|
f14c8b9eea | ||
|
|
2632434263 | ||
|
|
649c3f8f8a | ||
|
|
5518122b69 | ||
|
|
64110033bd | ||
|
|
29bf45d15a | ||
|
|
3f25f1fd73 | ||
|
|
fcd91c2e81 | ||
|
|
c7bf35f011 | ||
|
|
20cceefbe1 | ||
|
|
2394b020ef | ||
|
|
d9a4faf4da | ||
|
|
6817f42c13 | ||
|
|
9cb44fc70c | ||
|
|
4966855c24 | ||
|
|
832a8dfe2f | ||
|
|
0f613e49ce | ||
|
|
507fa088fd | ||
|
|
f26a0f4336 | ||
|
|
0981355247 | ||
|
|
0dd58556a7 | ||
|
|
22ec808b2d |
@@ -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)
|
4. Identify missing database-layer enforcement (constraints, RLS)
|
||||||
5. Check transport choices — simpler protocol available?
|
5. Check transport choices — simpler protocol available?
|
||||||
6. Propose a concrete simpler alternative, not just a critique
|
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
|
### Designing Systems
|
||||||
1. Start with the data model — get the schema right before application code
|
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
|
5. Refactor — apply clean code, extract if 3+ duplications, rename for intent
|
||||||
6. Repeat for the next behavior
|
6. Repeat for the next behavior
|
||||||
7. When all behaviors are green, review for SOLID violations across the full stack
|
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
|
### Reviewing Code
|
||||||
1. TDD evidence — are there tests? Do they precede the implementation?
|
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**
|
2. **Use CSS custom properties for all brand colors**
|
||||||
```css
|
```css
|
||||||
/* layout.css */
|
/* layout.css — semantic tokens backed by CSS variables (see --palette-* for raw values) */
|
||||||
--color-ink: #002850;
|
--color-ink: var(--c-ink);
|
||||||
--color-accent: #A6DAD8;
|
--color-accent: var(--c-accent);
|
||||||
--color-surface: #E4E2D7;
|
--color-surface: var(--c-surface);
|
||||||
```
|
```
|
||||||
```svelte
|
```svelte
|
||||||
<div class="text-ink bg-surface border-line">
|
<div class="text-ink bg-surface border-line">
|
||||||
@@ -103,9 +103,9 @@ unsaved work without warning.
|
|||||||
|
|
||||||
1. **Enforce WCAG AA contrast ratios**
|
1. **Enforce WCAG AA contrast ratios**
|
||||||
```
|
```
|
||||||
brand-navy (#002850) on white: 14.5:1 -- AAA pass
|
brand-navy (--palette-navy) on white: ~14.5:1 -- AAA pass (verify exact value in layout.css)
|
||||||
brand-mint (#A6DAD8) on navy: 7.2:1 -- AAA pass for large text
|
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
|
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.
|
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 */
|
/* Silver #CACAC9 on white = 1.5:1 -- fails all WCAG levels */
|
||||||
.caption { color: #CACAC9; }
|
.caption { color: #CACAC9; }
|
||||||
|
|
||||||
/* brand-mint on white = 2.8:1 -- fails AA for normal text */
|
/* brand-mint on white = ~2.8:1 -- fails AA for normal text */
|
||||||
.label { color: #A6DAD8; }
|
.label { color: var(--palette-mint); }
|
||||||
```
|
```
|
||||||
Test every text color against its background. Decorative palette colors are for borders and backgrounds, not text.
|
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>
|
<table>
|
||||||
<tr><td>Section title</td><td><code>text-xs font-bold uppercase tracking-widest</code></td>
|
<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>
|
<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>
|
<td>padding 24px</td><td>—</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,10 +376,10 @@ await page.setViewportSize({ width: 1440, height: 900 });
|
|||||||
## Domain Expertise
|
## Domain Expertise
|
||||||
|
|
||||||
### Brand Palette
|
### Brand Palette
|
||||||
- **Primary**: brand-navy `#002850` (text, buttons, headers), brand-mint `#A6DAD8` (accents, hover), brand-sand `#E4E2D7` (backgrounds, borders)
|
- **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` (Merriweather) for body/titles, `font-sans` (Montserrat) for labels/UI chrome
|
- **Typography**: `font-serif` (Tinos) for body/titles, `font-sans` (Montserrat) for labels/UI chrome
|
||||||
- **Card pattern**: `bg-white shadow-sm border border-brand-sand rounded-sm p-6`
|
- **Card pattern**: `bg-surface shadow-sm border border-line rounded-sm p-6`
|
||||||
- **Section title**: `text-xs font-bold uppercase tracking-widest text-gray-400 mb-5`
|
- **Section title**: `text-xs font-bold uppercase tracking-widest text-ink-3 mb-5`
|
||||||
|
|
||||||
### Dual-Audience Design (25-42 AND 60+)
|
### 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
|
- 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
|
→ See [.devcontainer/README.md](./README.md) for configuration, usage, and known limitations.
|
||||||
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
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
|
run: npm test
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -18,5 +18,11 @@ scripts/large-data.sql
|
|||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/scheduled_tasks.lock
|
.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.
|
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
||||||
frontend/yarn.lock
|
frontend/yarn.lock
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"java.configuration.updateBuildConfiguration": "interactive",
|
"java.configuration.updateBuildConfiguration": "interactive",
|
||||||
"java.compile.nullAnalysis.mode": "automatic"
|
"java.compile.nullAnalysis.mode": "automatic",
|
||||||
|
"plantuml.render": "PlantUMLServer",
|
||||||
|
"plantuml.server": "http://heim-nas:8500"
|
||||||
}
|
}
|
||||||
241
CLAUDE.md
241
CLAUDE.md
@@ -1,7 +1,11 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
|
> For a human-readable project overview, see [README.md](./README.md).
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
## 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.
|
**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.
|
||||||
@@ -16,6 +20,8 @@ See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS tr
|
|||||||
|
|
||||||
## Stack
|
## 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)
|
- **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)
|
- **Frontend**: SvelteKit 2 with Svelte 5, TypeScript, Tailwind CSS 4, Paraglide.js (i18n: de/en/es)
|
||||||
- **Database**: PostgreSQL 16
|
- **Database**: PostgreSQL 16
|
||||||
@@ -25,12 +31,13 @@ See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS tr
|
|||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
### Running the Full Stack
|
### Running the Full Stack
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From repo root — starts PostgreSQL, MinIO, and Spring Boot backend
|
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (Spring Boot)
|
### Backend (Spring Boot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
@@ -42,11 +49,12 @@ cd backend
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (SvelteKit)
|
### Frontend (SvelteKit)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Dev server (port 3000)
|
npm run dev # Dev server (port 5173)
|
||||||
npm run build # Production build
|
npm run build # Production build
|
||||||
npm run preview # Preview production build
|
npm run preview # Preview production build
|
||||||
|
|
||||||
@@ -64,7 +72,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
|||||||
|
|
||||||
### Package Structure
|
### 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/
|
backend/src/main/java/org/raddatz/familienarchiv/
|
||||||
@@ -88,27 +96,21 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
└── user/ User domain — AppUser, UserGroup, UserService, auth controllers
|
└── user/ User domain — AppUser, UserGroup, UserService, auth controllers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layering Rules (strictly enforced)
|
### Layering Rules
|
||||||
|
|
||||||
```
|
→ See [docs/ARCHITECTURE.md §Layering rule](./docs/ARCHITECTURE.md#layering-rule)
|
||||||
Controller → Service → Repository → DB
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Controllers** never inject or call repositories directly.
|
**LLM reminder:** controllers never call repositories directly; services never reach into another domain's repository — always call the other domain's service instead.
|
||||||
- **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.
|
|
||||||
|
|
||||||
### Domain Model
|
### Domain Model
|
||||||
|
|
||||||
| Entity | Table | Key relationships |
|
| Entity | Table | Key relationships |
|
||||||
|---|---|---|
|
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
|
||||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||||
|
|
||||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ Controller → Service → Repository → DB
|
|||||||
### Entity Code Style
|
### Entity Code Style
|
||||||
|
|
||||||
All entities use these Lombok annotations:
|
All entities use these Lombok annotations:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "table_name")
|
@Table(name = "table_name")
|
||||||
@@ -146,65 +149,29 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
|||||||
- Read methods are not annotated (default non-transactional is fine).
|
- 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.
|
- 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
|
### 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)
|
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||||
- `CreateUserRequest` — user creation
|
|
||||||
- `GroupDTO` — group create/update
|
|
||||||
|
|
||||||
### Error Handling
|
### 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
|
**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`.
|
||||||
// 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");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
Use `@RequirePermission` on controller methods (or the whole controller class):
|
→ See [docs/ARCHITECTURE.md §Permission system](./docs/ARCHITECTURE.md#permission-system)
|
||||||
|
|
||||||
```java
|
**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`.
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public Document updateDocument(...) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
|
|
||||||
|
|
||||||
`PermissionAspect` (AOP) checks the current user's `UserGroup.permissions` at runtime.
|
|
||||||
|
|
||||||
### OpenAPI / API Types
|
### 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:
|
**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.
|
||||||
1. Rebuild the backend JAR with `-DskipTests`
|
|
||||||
2. Start it with `--spring.profiles.active=dev`
|
|
||||||
3. Run `npm run generate:api` in `frontend/`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -214,147 +181,99 @@ When changing any model field or endpoint:
|
|||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/routes/
|
frontend/src/routes/
|
||||||
├── +layout.svelte Global header (sticky), nav links, logout
|
├── +layout.svelte / +layout.server.ts Global layout, auth cookie
|
||||||
├── +layout.server.ts Loads current user, injects auth cookie
|
├── +page.svelte / +page.server.ts Home / document search dashboard
|
||||||
├── +page.svelte Home / document search
|
|
||||||
├── +page.server.ts Load: search documents; no actions
|
|
||||||
├── documents/
|
├── documents/
|
||||||
│ ├── [id]/+page.svelte Document detail (view + file preview)
|
│ ├── [id]/ Document detail (view + file preview)
|
||||||
│ └── [id]/edit/ Edit form (all metadata + file upload)
|
│ ├── [id]/edit/ Edit form (all metadata + file upload)
|
||||||
│ └── new/ Create form (same fields, empty)
|
│ ├── new/ Upload form
|
||||||
|
│ └── bulk-edit/ Multi-document edit
|
||||||
├── persons/
|
├── persons/
|
||||||
│ ├── +page.svelte Person list with search
|
│ ├── [id]/ Person detail
|
||||||
│ ├── [id]/+page.svelte Person detail (inline edit + merge)
|
│ ├── [id]/edit/ Person edit form
|
||||||
│ └── new/ Create person form
|
│ └── new/ Create person form
|
||||||
├── conversations/ Bilateral conversation timeline
|
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
|
||||||
├── admin/ User + group + tag management
|
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||||
└── login/ logout/ Auth pages
|
├── 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
|
### 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
|
**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.
|
||||||
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 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Form Actions Pattern
|
### Form Actions Pattern
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// +page.server.ts
|
// +page.server.ts
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, fetch }) => {
|
default: async ({ request, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const name = formData.get('name') as string; // cast needed — FormData returns FormDataEntryValue
|
const name = formData.get("name") as string;
|
||||||
// ...
|
// ...
|
||||||
return fail(400, { error: 'message' }); // on error
|
return fail(400, { error: "message" }); // on error
|
||||||
throw redirect(303, '/target'); // on success
|
throw redirect(303, "/target"); // on success
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### Date Handling
|
### 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.
|
→ See [CONTRIBUTING.md §Date handling](./CONTRIBUTING.md#date-handling)
|
||||||
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC timezone off-by-one:
|
|
||||||
```typescript
|
**LLM reminder:** always append `T12:00:00` when constructing `new Date()` from an ISO date string — prevents UTC timezone off-by-one errors.
|
||||||
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
||||||
.format(new Date(doc.documentDate + 'T12:00:00'))
|
|
||||||
```
|
|
||||||
|
|
||||||
### UI Component Library
|
### UI Component Library
|
||||||
|
|
||||||
Custom components in `src/lib/components/`:
|
→ 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)
|
||||||
|
|
||||||
| Component | Props | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead dropdown |
|
|
||||||
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
|
|
||||||
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
|
|
||||||
|
|
||||||
### Styling Conventions (Tailwind CSS 4)
|
### Styling Conventions (Tailwind CSS 4)
|
||||||
|
|
||||||
Brand color utilities (defined in `layout.css`):
|
Brand color tokens (defined in `layout.css`):
|
||||||
|
|
||||||
| Class | Value | Usage |
|
| Token / Utility | CSS variable | Usage |
|
||||||
|---|---|---|
|
| ---------------- | ---------------- | ------------------------------------------------------- |
|
||||||
| `brand-navy` | `#002850` | Primary text, buttons, headers |
|
| `brand-navy` | `--palette-navy` | Tailwind utility — buttons, headers, primary text |
|
||||||
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
|
| `brand-mint` | `--palette-mint` | Tailwind utility — accents, hover underlines, icons |
|
||||||
| `brand-sand` | `#E4E2D7` | Page background, card borders |
|
| `--palette-sand` | `--palette-sand` | Palette constant only — use `bg-canvas` or `bg-surface` |
|
||||||
|
|
||||||
Typography:
|
Typography:
|
||||||
- `font-serif` (Merriweather) — body text, document titles, names
|
|
||||||
|
- `font-serif` (Tinos) — body text, document titles, names
|
||||||
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||||
|
|
||||||
Card pattern for content sections:
|
Card pattern for content sections:
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
<div class="rounded-sm border border-line bg-surface shadow-sm p-6">
|
||||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section Title</h2>
|
<h2 class="text-xs font-bold uppercase tracking-widest text-ink-3 mb-5">Section Title</h2>
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Save bar pattern — use **sticky full-bleed** for long forms (edit document), **card-style with `mt-4`** for short forms (new person):
|
Back button pattern — use the shared `<BackButton>` component from `$lib/shared/primitives/BackButton.svelte`. Do not use a static `<a href>` for back navigation.
|
||||||
```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>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling (Frontend)
|
### 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:
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
1. Add it to `ErrorCode.java`
|
|
||||||
2. Add it to the `ErrorCode` type in `errors.ts`
|
**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`.
|
||||||
3. Add a `case` in `getErrorMessage()`
|
|
||||||
4. Add the translation key in `messages/de.json`, `en.json`, `es.json`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Infrastructure
|
## 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.
|
→ See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
|
||||||
|
|
||||||
Database migrations live in `backend/src/main/resources/db/migration/` (Flyway, SQL files named `V{n}__{description}.sql`).
|
|
||||||
|
|
||||||
## API Testing
|
## API Testing
|
||||||
|
|
||||||
@@ -362,4 +281,4 @@ HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client
|
|||||||
|
|
||||||
## Dev Container
|
## 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,8 +180,47 @@ 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.
|
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:
|
Quick reminders:
|
||||||
- Pure functions over stateful helpers where possible
|
- Pure functions over stateful helpers where possible
|
||||||
- No premature abstractions — KISS beats DRY
|
- No premature abstractions — KISS beats DRY
|
||||||
- No backwards-compatibility shims for code that has no callers
|
- No backwards-compatibility shims for code that has no callers
|
||||||
- Validate at system boundaries only (user input, external APIs)
|
- Validate at system boundaries only (user input, external APIs)
|
||||||
|
|
||||||
|
## Frontend Domain Boundaries
|
||||||
|
|
||||||
|
The frontend mirrors the backend's package-by-domain structure. Each Tier-1 folder under `src/lib/` is a domain with a hard import boundary:
|
||||||
|
|
||||||
|
```
|
||||||
|
document person tag user geschichte notification ocr
|
||||||
|
activity conversation shared
|
||||||
|
```
|
||||||
|
|
||||||
|
The `boundaries/dependencies` ESLint rule enforces this. The full allow-list lives in `frontend/eslint.config.js`. The rule fires at error severity and blocks `npm run lint`.
|
||||||
|
|
||||||
|
### Allowed cross-domain imports
|
||||||
|
|
||||||
|
| From | May import from |
|
||||||
|
|---|---|
|
||||||
|
| `document` | `shared`, `person`, `tag`, `ocr`, `activity`, `conversation` |
|
||||||
|
| `geschichte` | `shared`, `person`, `document` |
|
||||||
|
| `ocr` | `shared`, `document` |
|
||||||
|
| `activity` | `shared`, `notification` |
|
||||||
|
| `person`, `tag`, `user`, `notification`, `conversation` | `shared` only |
|
||||||
|
| `shared` | `shared` only |
|
||||||
|
| `routes` | any domain |
|
||||||
|
|
||||||
|
### When you need to cross a boundary
|
||||||
|
|
||||||
|
1. **Move the code to `$lib/shared/`** — the correct fix when the code is truly generic (a UI primitive, a pure utility, a formatting helper).
|
||||||
|
2. **Add an explicit rule** — if a cross-domain dependency is architecturally justified (e.g., `document` importing `PersonTypeahead`), add the allow entry to `eslint.config.js` with a comment explaining the reason.
|
||||||
|
3. **Use `// eslint-disable-next-line boundaries/dependencies`** — last resort, only for cases where neither option is practical. Leave a comment explaining why.
|
||||||
|
|
||||||
|
### Verifying the rule works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint:boundary-demo # exits 1 — shows the rule firing on a deliberate tag→person violation
|
||||||
|
```
|
||||||
|
|
||||||
|
The fixture lives at `src/lib/tag/__fixtures__/cross-domain.fixture.ts` and is excluded from `npm run lint` via `--ignore-pattern`.
|
||||||
|
|||||||
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
|
||||||
|
```
|
||||||
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Familienarchiv
|
||||||
|
|
||||||
|
Familienarchiv is a private web application for digitising, organising, and searching a family document collection — letters, postcards, and photographs from 1899 to 1950. Family members upload scans, transcribe handwritten text (Kurrent/Sütterlin), and read the archive from any device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subsystems
|
||||||
|
|
||||||
|
- `frontend/` — SvelteKit 2 / Svelte 5 / TypeScript / Tailwind 4 web app (server-side rendered)
|
||||||
|
- `backend/` — Spring Boot 4 (Java 21) REST API; handles documents, persons, search, and user management
|
||||||
|
- `ocr-service/` — Python FastAPI microservice for OCR and handwritten text recognition (HTR); single-node by design — see [ADR-001](docs/adr/001-ocr-python-microservice.md). Not part of the default dev stack (see Quick start below)
|
||||||
|
- `infra/` — Gitea Actions CI/CD config; future home for infrastructure-as-code
|
||||||
|
- `scripts/` — operational and data-pipeline helpers (`reset-db.sh`, `clean-e2e-data.sh`, import scripts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
**Prerequisites:** Java 21, Node 24, Docker with the `docker compose` plugin (V2).
|
||||||
|
|
||||||
|
### 1. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# The defaults in .env.example work for local development without changes.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start infrastructure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Starts PostgreSQL, MinIO (object storage), and Mailpit (dev mail catcher)
|
||||||
|
docker compose up -d db minio mailpit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./mvnw spring-boot:run
|
||||||
|
# Starts on http://localhost:8080
|
||||||
|
# API docs (dev profile, auto-enabled): http://localhost:8080/v3/api-docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Start the frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# Starts on http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:5173** — you should see the Familienarchiv login screen.
|
||||||
|
|
||||||
|
Default development credentials:
|
||||||
|
|
||||||
|
```
|
||||||
|
# local dev only — change before any network-exposed deployment
|
||||||
|
Email: admin@familyarchive.local
|
||||||
|
Password: admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Development setup only.** The default `docker compose` config exposes the database port and uses root MinIO credentials. Do not connect this to a network without first reading `docs/DEPLOYMENT.md` _(coming: [DOC-5, #399](http://heim-nas:3005/marcel/familienarchiv/issues/399))_.
|
||||||
|
|
||||||
|
### Running the full stack via Docker (optional)
|
||||||
|
|
||||||
|
To run everything including the backend and frontend in containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the OCR service (`ocr-service/`) builds its Docker image locally and downloads ~6 GB of ML models on first start. Expect 30–60 minutes on a first run. The rest of the stack starts independently; OCR can be excluded with `--scale ocr-service=0` on memory-constrained machines (requires ≥ 12 GB RAM).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
| Resource | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [docs/architecture/c4-diagrams.md](docs/architecture/c4-diagrams.md) | C4 container and component diagrams (current system view) |
|
||||||
|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) _(coming: [DOC-2, #396](http://heim-nas:3005/marcel/familienarchiv/issues/396))_ | Full architecture guide with domain list |
|
||||||
|
| [docs/GLOSSARY.md](docs/GLOSSARY.md) | Overloaded terms: Person vs AppUser, Chronik vs Aktivität, etc. |
|
||||||
|
| [CONTRIBUTING.md](CONTRIBUTING.md) _(coming: [DOC-4, #398](http://heim-nas:3005/marcel/familienarchiv/issues/398))_ | How to add a domain, endpoint, or SvelteKit route |
|
||||||
|
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) _(coming: [DOC-5, #399](http://heim-nas:3005/marcel/familienarchiv/issues/399))_ | Production deployment checklist and secrets guide |
|
||||||
|
| [docs/adr/](docs/adr/) | Architecture Decision Records — the "why" behind key choices |
|
||||||
|
| [Gitea issue tracker](http://heim-nas:3005/marcel/familienarchiv/issues) _(internal — home network only)_ | Bug reports, feature requests, and project planning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private project — all rights reserved. Not licensed for redistribution.
|
||||||
@@ -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)
|
- **Server**: Jetty (not Tomcat — excluded in pom.xml)
|
||||||
- **Data**: PostgreSQL 16, JPA/Hibernate, Spring Data JPA
|
- **Data**: PostgreSQL 16, JPA/Hibernate, Spring Data JPA
|
||||||
- **Migrations**: Flyway (SQL files in `src/main/resources/db/migration/`)
|
- **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)
|
- **File Storage**: MinIO via AWS SDK v2 (S3-compatible)
|
||||||
- **Spreadsheet Import**: Apache POI 5.5.0 (Excel/ODS)
|
- **Spreadsheet Import**: Apache POI 5.5.0 (Excel/ODS)
|
||||||
- **API Docs**: SpringDoc OpenAPI 3.x (`/v3/api-docs` — dev profile only)
|
- **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 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/
|
src/main/java/org/raddatz/familienarchiv/
|
||||||
@@ -43,31 +43,28 @@ src/main/java/org/raddatz/familienarchiv/
|
|||||||
└── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
|
└── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
|
||||||
```
|
```
|
||||||
|
|
||||||
## Layering Rules (Strict)
|
For per-domain ownership and public surface, see each domain's `README.md`.
|
||||||
|
|
||||||
```
|
## Layering Rules
|
||||||
Controller → Service → Repository → DB
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Controllers never call repositories directly.**
|
→ See [docs/ARCHITECTURE.md §Layering rule](../docs/ARCHITECTURE.md#layering-rule)
|
||||||
- **Services never reach into another domain's repository.** Call the other domain's service instead.
|
|
||||||
- ✅ `DocumentService` → `PersonService.getById()` → `PersonRepository`
|
**LLM reminder:** controllers never call repositories directly; services never reach into another domain's repository — always call the other domain's service.
|
||||||
- ❌ `DocumentService` → `PersonRepository` directly
|
|
||||||
|
|
||||||
## Key Entities
|
## Key Entities
|
||||||
|
|
||||||
| Entity | Table | Key Relationships |
|
| Entity | Table | Key Relationships |
|
||||||
|---|---|---|
|
| --------------------------- | ------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
| `Document` | `documents` | ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) |
|
| `Document` | `documents` | ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) |
|
||||||
| `Person` | `persons` | Referenced by documents as sender/receiver; name aliases table |
|
| `Person` | `persons` | Referenced by documents as sender/receiver; name aliases table |
|
||||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags`; self-referencing parent for tree |
|
| `Tag` | `tag` | ManyToMany with documents via `document_tags`; self-referencing parent for tree |
|
||||||
| `AppUser` | `app_users` | ManyToMany groups (UserGroup) |
|
| `AppUser` | `app_users` | ManyToMany groups (UserGroup) |
|
||||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||||
| `TranscriptionBlock` | `transcription_blocks` | Per-document, per-page text blocks with polygons |
|
| `TranscriptionBlock` | `transcription_blocks` | Per-document, per-page text blocks with polygons |
|
||||||
| `DocumentAnnotation` | `document_annotations` | Free-form annotations on document pages |
|
| `DocumentAnnotation` | `document_annotations` | Free-form annotations on document pages |
|
||||||
| `Comment` | `document_comments` | Threaded comments with mentions |
|
| `Comment` | `document_comments` | Threaded comments with mentions |
|
||||||
| `Notification` | `notifications` | User notification feed |
|
| `Notification` | `notifications` | User notification feed |
|
||||||
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
|
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
|
||||||
|
|
||||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||||
|
|
||||||
@@ -104,32 +101,15 @@ public class MyEntity {
|
|||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
Use `DomainException` for all domain errors:
|
→ See [CONTRIBUTING.md §Error handling](../CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
```java
|
**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`.
|
||||||
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`
|
|
||||||
|
|
||||||
## Security / Permissions
|
## Security / Permissions
|
||||||
|
|
||||||
Use `@RequirePermission` on controller methods or classes:
|
→ See [docs/ARCHITECTURE.md §Permission system](../docs/ARCHITECTURE.md#permission-system)
|
||||||
|
|
||||||
```java
|
**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`.
|
||||||
@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.
|
|
||||||
|
|
||||||
## OCR Integration
|
## OCR Integration
|
||||||
|
|
||||||
@@ -141,49 +121,35 @@ The backend orchestrates OCR by calling the Python `ocr-service` microservice vi
|
|||||||
- `OcrBatchService` — handles batch/job workflows
|
- `OcrBatchService` — handles batch/job workflows
|
||||||
- `OcrAsyncRunner` — async execution of OCR jobs
|
- `OcrAsyncRunner` — async execution of OCR jobs
|
||||||
|
|
||||||
|
For ocr-service internals, see [`ocr-service/README.md`](../ocr-service/README.md).
|
||||||
|
|
||||||
## API Testing
|
## API Testing
|
||||||
|
|
||||||
HTTP test files in `backend/api_tests/` for the VS Code REST Client extension.
|
HTTP test files in `backend/api_tests/` for the VS Code REST Client extension.
|
||||||
|
|
||||||
## How to Run
|
## How to Run
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
# Run with dev profile (requires PostgreSQL + MinIO running via docker-compose)
|
./mvnw spring-boot:run # Run with dev profile (requires PostgreSQL + MinIO)
|
||||||
./mvnw spring-boot:run
|
./mvnw clean package # Build JAR (with tests)
|
||||||
|
|
||||||
# Build JAR (with tests)
|
|
||||||
./mvnw clean package
|
|
||||||
|
|
||||||
# Build JAR skipping tests
|
|
||||||
./mvnw clean package -DskipTests
|
./mvnw clean package -DskipTests
|
||||||
|
./mvnw test # Run all tests
|
||||||
# Run all tests
|
./mvnw test -Dtest=ClassName # Run a single test class
|
||||||
./mvnw test
|
./mvnw clean verify # Run with JaCoCo coverage report
|
||||||
|
|
||||||
# Run a single test class
|
|
||||||
./mvnw test -Dtest=ClassName
|
|
||||||
|
|
||||||
# Run with coverage (JaCoCo)
|
|
||||||
./mvnw clean verify
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenAPI TypeScript Generation
|
**OpenAPI / TypeScript type generation:**
|
||||||
|
|
||||||
1. Build and start backend with `--spring.profiles.active=dev`
|
1. Start backend with `--spring.profiles.active=dev`
|
||||||
2. In `frontend/`, run: `npm run generate:api`
|
2. In `frontend/`: `npm run generate:api`
|
||||||
|
|
||||||
### Profiles
|
**LLM reminder:** always regenerate types after any model or endpoint change — the most common cause of "where did my TypeScript type go?"
|
||||||
|
|
||||||
- **dev** (default): Enables OpenAPI, dev configs, e2e seeds
|
|
||||||
- **prod**: Production profile — no dev endpoints
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Unit tests: Mockito + JUnit, pure in-memory
|
- Unit tests: Mockito + JUnit, pure in-memory
|
||||||
- Slice tests: `@WebMvcTest`, `@DataJpaTest` with Testcontainers PostgreSQL
|
- Slice tests: `@WebMvcTest`, `@DataJpaTest` with Testcontainers PostgreSQL
|
||||||
- Integration tests: Full Spring context with Testcontainers
|
- Integration tests: Full Spring context with Testcontainers
|
||||||
- Coverage gate: 88% branch coverage overall (JaCoCo)
|
- Coverage gate: 88% branch coverage (JaCoCo)
|
||||||
|
|||||||
@@ -108,6 +108,12 @@
|
|||||||
<groupId>org.awaitility</groupId>
|
<groupId>org.awaitility</groupId>
|
||||||
<artifactId>awaitility</artifactId>
|
<artifactId>awaitility</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.tngtech.archunit</groupId>
|
||||||
|
<artifactId>archunit-junit5</artifactId>
|
||||||
|
<version>1.3.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Excel Bearbeitung (Apache POI) -->
|
<!-- Excel Bearbeitung (Apache POI) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -184,6 +190,13 @@
|
|||||||
<artifactId>owasp-java-html-sanitizer</artifactId>
|
<artifactId>owasp-java-html-sanitizer</artifactId>
|
||||||
<version>20240325.1</version>
|
<version>20240325.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- HTML → plain-text extraction for comment previews -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.18.1</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</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,
|
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||||
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
|
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.person.Person;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.document.comment.CommentService;
|
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.DocumentService;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
@@ -133,9 +134,9 @@ public class DashboardService {
|
|||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
|
Map<UUID, CommentData> commentDataByComment = commentIds.isEmpty()
|
||||||
? Map.of()
|
? Map.of()
|
||||||
: commentService.findAnnotationIdsByIds(commentIds);
|
: commentService.findDataByIds(commentIds);
|
||||||
|
|
||||||
return rows.stream().map(row -> {
|
return rows.stream().map(row -> {
|
||||||
ActivityActorDTO actor = row.getActorId() != null
|
ActivityActorDTO actor = row.getActorId() != null
|
||||||
@@ -146,7 +147,10 @@ public class DashboardService {
|
|||||||
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
|
||||||
: null;
|
: null;
|
||||||
UUID commentId = row.getCommentId();
|
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(
|
return new ActivityFeedItemDTO(
|
||||||
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
|
||||||
actor,
|
actor,
|
||||||
@@ -158,7 +162,8 @@ public class DashboardService {
|
|||||||
row.getCount(),
|
row.getCount(),
|
||||||
happenedAtUntil,
|
happenedAtUntil,
|
||||||
commentId,
|
commentId,
|
||||||
annotationId
|
annotationId,
|
||||||
|
commentPreview
|
||||||
);
|
);
|
||||||
}).toList();
|
}).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;
|
package org.raddatz.familienarchiv.dashboard;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate counts for the dashboard/persons stats bar.
|
* 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 lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.dashboard.StatsDTO;
|
import org.raddatz.familienarchiv.dashboard.StatsDTO;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -12,8 +13,9 @@ public class StatsService {
|
|||||||
|
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
private final GeschichteService geschichteService;
|
||||||
|
|
||||||
public StatsDTO getStats() {
|
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.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -48,6 +49,7 @@ import org.raddatz.familienarchiv.filestorage.FileService;
|
|||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
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));
|
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 ---
|
// --- TRAINING LABELS ---
|
||||||
|
|
||||||
public record TrainingLabelRequest(String label, boolean enrolled) {}
|
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.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -125,6 +126,74 @@ public class DocumentService {
|
|||||||
return titles;
|
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.
|
* Lädt eine Datei hoch.
|
||||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||||
@@ -658,6 +727,7 @@ public class DocumentService {
|
|||||||
return switch (sort) {
|
return switch (sort) {
|
||||||
case TITLE -> Sort.by(direction, "title");
|
case TITLE -> Sort.by(direction, "title");
|
||||||
case UPLOAD_DATE -> Sort.by(direction, "createdAt");
|
case UPLOAD_DATE -> Sort.by(direction, "createdAt");
|
||||||
|
case UPDATED_AT -> Sort.by(direction, "updatedAt");
|
||||||
default -> Sort.by(direction, "documentDate");
|
default -> Sort.by(direction, "documentDate");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
public enum DocumentSort {
|
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.transcription.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.document.comment.CommentRepository;
|
import org.raddatz.familienarchiv.document.comment.CommentRepository;
|
||||||
import org.raddatz.familienarchiv.notification.NotificationService;
|
import org.raddatz.familienarchiv.notification.NotificationService;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -28,21 +29,29 @@ import java.util.UUID;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CommentService {
|
public class CommentService {
|
||||||
|
|
||||||
|
private static final int PREVIEW_MAX_CHARS = 120;
|
||||||
|
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final TranscriptionService transcriptionService;
|
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();
|
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)) {
|
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;
|
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) {
|
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||||
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
||||||
return withRepliesAndMentions(roots);
|
return withRepliesAndMentions(roots);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
// "Handler" is Spring's @RestControllerAdvice naming convention — not a generic suffix.
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class GlobalExceptionHandler {
|
public class GlobalExceptionHandler {
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ public class GeschichteService {
|
|||||||
|
|
||||||
// ─── Read API ────────────────────────────────────────────────────────────
|
// ─── Read API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public long countPublished() {
|
||||||
|
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||||
|
}
|
||||||
|
|
||||||
public Geschichte getById(UUID id) {
|
public Geschichte getById(UUID id) {
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
@@ -77,8 +81,10 @@ public class GeschichteService {
|
|||||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||||
|
|
||||||
|
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
||||||
Specification<Geschichte> spec = Specification.allOf(
|
Specification<Geschichte> spec = Specification.allOf(
|
||||||
GeschichteSpecifications.hasStatus(effective),
|
GeschichteSpecifications.hasStatus(effective),
|
||||||
|
GeschichteSpecifications.hasAuthor(authorId),
|
||||||
GeschichteSpecifications.hasAllPersons(personIds),
|
GeschichteSpecifications.hasAllPersons(personIds),
|
||||||
GeschichteSpecifications.hasDocument(documentId),
|
GeschichteSpecifications.hasDocument(documentId),
|
||||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
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) {
|
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
if (documentId == null) return null;
|
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
|
@GetMapping
|
||||||
@RequirePermission(Permission.READ_ALL)
|
@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));
|
return ResponseEntity.ok(personService.findAll(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,22 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
List<PersonSummaryDTO> searchWithDocumentCount(@Param("query") String query);
|
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 ---
|
// --- Correspondent queries ---
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ public class PersonService {
|
|||||||
return personRepository.searchWithDocumentCount(q.trim());
|
return personRepository.searchWithDocumentCount(q.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
|
||||||
|
return personRepository.findTopByDocumentCount(limit);
|
||||||
|
}
|
||||||
|
|
||||||
public Person getById(UUID id) {
|
public Person getById(UUID id) {
|
||||||
return personRepository.findById(id)
|
return personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + 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;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
// Cross-cutting auth helper; no domain home — "Utils" is the correct suffix here.
|
||||||
public final class SecurityUtils {
|
public final class SecurityUtils {
|
||||||
|
|
||||||
private 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`
|
||||||
@@ -102,6 +102,21 @@ public class UserDataInitializer {
|
|||||||
log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
|
log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userRepository.findByEmail("reset@familyarchive.local").isEmpty()) {
|
||||||
|
log.info("E2E seed: Erstelle 'reset'-Testbenutzer...");
|
||||||
|
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
|
||||||
|
groupRepository.save(UserGroup.builder()
|
||||||
|
.name("Leser")
|
||||||
|
.permissions(Set.of("READ_ALL"))
|
||||||
|
.build()));
|
||||||
|
userRepository.save(AppUser.builder()
|
||||||
|
.email("reset@familyarchive.local")
|
||||||
|
.password(passwordEncoder.encode("reset123"))
|
||||||
|
.groups(Set.of(leserGroup))
|
||||||
|
.build());
|
||||||
|
log.info("E2E seed: 'reset'-Testbenutzer erstellt.");
|
||||||
|
}
|
||||||
|
|
||||||
if (personRepo.count() > 0) {
|
if (personRepo.count() > 0) {
|
||||||
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
|
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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.Document;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.document.comment.CommentService;
|
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.DocumentService;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
@@ -142,7 +143,8 @@ class DashboardServiceTest {
|
|||||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
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);
|
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(
|
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
|
||||||
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
|
||||||
));
|
));
|
||||||
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
|
when(commentService.findDataByIds(List.of(commentId)))
|
||||||
.thenReturn(Map.of(commentId, annotationId));
|
.thenReturn(Map.of(commentId, new CommentData(annotationId, "preview text")));
|
||||||
|
|
||||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
||||||
|
|
||||||
@@ -187,7 +189,62 @@ class DashboardServiceTest {
|
|||||||
assertThat(items).hasSize(1);
|
assertThat(items).hasSize(1);
|
||||||
assertThat(items.get(0).commentId()).isNull();
|
assertThat(items.get(0).commentId()).isNull();
|
||||||
assertThat(items.get(0).annotationId()).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 ─────────────────────
|
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class StatsControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getStats_returns200_withCorrectCounts() throws Exception {
|
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"))
|
mockMvc.perform(get("/api/stats"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -55,7 +55,7 @@ class StatsControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getStats_returns200_withZeroCounts() throws Exception {
|
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"))
|
mockMvc.perform(get("/api/stats"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.dashboard.StatsDTO;
|
import org.raddatz.familienarchiv.dashboard.StatsDTO;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -17,6 +18,7 @@ class StatsServiceTest {
|
|||||||
|
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock DocumentService documentService;
|
@Mock DocumentService documentService;
|
||||||
|
@Mock GeschichteService geschichteService;
|
||||||
@InjectMocks StatsService statsService;
|
@InjectMocks StatsService statsService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -30,6 +32,17 @@ class StatsServiceTest {
|
|||||||
assertThat(stats.totalDocuments()).isEqualTo(12L);
|
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
|
@Test
|
||||||
void getStats_returnsZero_whenNoEntities() {
|
void getStats_returnsZero_whenNoEntities() {
|
||||||
when(personService.count()).thenReturn(0L);
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
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.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.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
@@ -1240,4 +1241,100 @@ class DocumentControllerTest {
|
|||||||
.andExpect(jsonPath("$.errors[0].message").value(
|
.andExpect(jsonPath("$.errors[0].message").value(
|
||||||
org.hamcrest.Matchers.containsString("not found")));
|
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.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -1402,6 +1403,21 @@ class DocumentServiceTest {
|
|||||||
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
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
|
@Test
|
||||||
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
||||||
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
// 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);
|
assertThat(documentService.save(doc)).isEqualTo(doc);
|
||||||
verify(documentRepository).save(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.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -644,62 +645,99 @@ class CommentServiceTest {
|
|||||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
|
// ─── findDataByIds ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
|
void findDataByIds_returns_empty_map_when_input_is_empty() {
|
||||||
UUID commentA = UUID.randomUUID();
|
assertThat(commentService.findDataByIds(List.of())).isEmpty();
|
||||||
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();
|
|
||||||
verify(commentRepository, never()).findAllById(anyList());
|
verify(commentRepository, never()).findAllById(anyList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAnnotationIdsByIds_omitsUnknownIds() {
|
void findDataByIds_strips_html_and_extracts_plain_text() {
|
||||||
UUID known = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
UUID knownAnnotation = UUID.randomUUID();
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
UUID missing = UUID.randomUUID();
|
.thenReturn(List.of(DocumentComment.builder().id(id)
|
||||||
when(commentRepository.findAllById(List.of(known, missing)))
|
.content("<p><strong>Hello</strong> world</p>").build()));
|
||||||
.thenReturn(List.of(
|
|
||||||
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
|
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(id));
|
||||||
.containsOnly(java.util.Map.entry(known, knownAnnotation))
|
|
||||||
.doesNotContainKey(missing);
|
assertThat(result.get(id).preview()).isEqualTo("Hello world");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
|
void findDataByIds_truncates_at_exactly_120_chars() {
|
||||||
UUID legacy = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
UUID block = UUID.randomUUID();
|
String text121 = "a".repeat(121);
|
||||||
UUID annotation = UUID.randomUUID();
|
when(commentRepository.findAllById(List.of(id)))
|
||||||
when(commentRepository.findAllById(List.of(legacy, block)))
|
.thenReturn(List.of(DocumentComment.builder().id(id).content(text121).build()));
|
||||||
.thenReturn(List.of(
|
|
||||||
DocumentComment.builder().id(legacy).annotationId(null).build(),
|
|
||||||
DocumentComment.builder().id(block).annotationId(annotation).build()
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
|
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
|
||||||
.containsOnly(java.util.Map.entry(block, annotation))
|
}
|
||||||
.doesNotContainKey(legacy);
|
|
||||||
|
@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) {
|
private void stubBlock(UUID docId, UUID blockId) {
|
||||||
|
|||||||
@@ -159,6 +159,26 @@ class GeschichteServiceIntegrationTest {
|
|||||||
.isEmpty();
|
.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) {
|
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setTitle(title);
|
dto.setTitle(title);
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ class PersonControllerTest {
|
|||||||
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
.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) {
|
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||||
return new PersonSummaryDTO() {
|
return new PersonSummaryDTO() {
|
||||||
public java.util.UUID getId() { return UUID.randomUUID(); }
|
public java.util.UUID getId() { return UUID.randomUUID(); }
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package org.raddatz.familienarchiv.shared;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.base.DescribedPredicate;
|
||||||
|
import com.tngtech.archunit.core.domain.JavaClass;
|
||||||
|
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||||
|
import com.tngtech.archunit.junit.ArchTest;
|
||||||
|
import com.tngtech.archunit.lang.ArchRule;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import org.raddatz.familienarchiv.FamilienarchivApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||||
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||||
|
|
||||||
|
@AnalyzeClasses(packagesOf = FamilienarchivApplication.class)
|
||||||
|
class ArchitectureTest {
|
||||||
|
|
||||||
|
// Rule 1: Controllers must never inject repositories directly.
|
||||||
|
// Security rationale: bypassing the service layer skips @RequirePermission
|
||||||
|
// AOP checks that are enforced on service methods.
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule no_controller_injects_repository_directly =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(RestController.class)
|
||||||
|
.should().dependOnClassesThat().areAssignableTo(JpaRepository.class);
|
||||||
|
|
||||||
|
// Rule 2: Services access only their own domain's repositories, never a foreign domain's.
|
||||||
|
// Prevents hidden coupling between domains that should communicate via service APIs.
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_document =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..document..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("document"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_person =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..person..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("person"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_tag =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..tag..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("tag"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_user =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..user..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("user"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_dashboard =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..dashboard..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("dashboard"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_geschichte =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..geschichte..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("geschichte"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_notification =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..notification..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("notification"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_ocr =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..ocr..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("ocr"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_importing =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..importing..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("importing"));
|
||||||
|
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule services_only_access_own_domain_repositories_audit =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Service.class)
|
||||||
|
.and().resideInAPackage("..audit..")
|
||||||
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
|
||||||
|
|
||||||
|
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
|
||||||
|
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
||||||
|
// where it can be audited and reasoned about independently.
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule no_configuration_class_in_domain_packages =
|
||||||
|
noClasses()
|
||||||
|
.that().areAnnotatedWith(Configuration.class)
|
||||||
|
.and().areNotAnnotatedWith(SpringBootApplication.class)
|
||||||
|
.should().resideInAnyPackage(
|
||||||
|
"..document..", "..person..", "..tag..",
|
||||||
|
"..geschichte..", "..notification..", "..ocr..",
|
||||||
|
"..filestorage..", "..importing..", "..dashboard..", "..audit.."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rule 4: Entities belong to their domain packages, not to a shared model layer.
|
||||||
|
// Prevents regression to a flat, layer-based package layout.
|
||||||
|
@ArchTest
|
||||||
|
static final ArchRule entities_reside_in_domain_packages =
|
||||||
|
classes()
|
||||||
|
.that().areAnnotatedWith(Entity.class)
|
||||||
|
.should().resideInAnyPackage(
|
||||||
|
"..document..", "..person..", "..tag..", "..user..",
|
||||||
|
"..geschichte..", "..notification..", "..ocr..", "..audit.."
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO Rule 5: Controllers expose endpoints under their domain prefix
|
||||||
|
// (e.g., classes in ..document.. are annotated with @RequestMapping("/api/documents")).
|
||||||
|
// Implementing this requires a custom ArchUnit DescribedPredicate inspecting the
|
||||||
|
// @RequestMapping annotation value — deferred due to brittleness concerns.
|
||||||
|
// Tracked in: http://heim-nas:3005/marcel/familienarchiv/issues/427
|
||||||
|
|
||||||
|
private static DescribedPredicate<JavaClass> foreignJpaRepositoryFor(String ownDomain) {
|
||||||
|
// Exact-segment match: prevents a domain name that is a substring of another
|
||||||
|
// (e.g. "tag" inside "tagging") from silently escaping the predicate.
|
||||||
|
// The pattern matches the domain as a complete path segment, with an optional sub-package.
|
||||||
|
String ownPackagePattern = ".*\\.familienarchiv\\." + ownDomain + "(\\..+)?$";
|
||||||
|
return new DescribedPredicate<JavaClass>("be a JPA repository from a domain other than " + ownDomain) {
|
||||||
|
@Override
|
||||||
|
public boolean test(JavaClass clazz) {
|
||||||
|
return clazz.isAssignableTo(JpaRepository.class)
|
||||||
|
&& !clazz.getPackageName().matches(ownPackagePattern);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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.
|
**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.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
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
|
# 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
|
## Level 1 — System Context
|
||||||
|
|
||||||
Who uses the system and what external systems does it interact with.
|
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
|
C4Context
|
||||||
title System Context: Familienarchiv
|
title System Context: Familienarchiv
|
||||||
|
|
||||||
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews documents")
|
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews and transcribes documents")
|
||||||
Person(member, "Family Member", "Searches, browses, and reads archived 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(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(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
|
title Container Diagram: Familienarchiv
|
||||||
|
|
||||||
Person(user, "User", "Admin or family member")
|
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)") {
|
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}.")
|
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(user, frontend, "Uses", "HTTPS / Browser")
|
||||||
Rel(frontend, backend, "API requests with Basic Auth token", "HTTP / REST / JSON")
|
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, 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, 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")
|
Rel(mc, storage, "Creates bucket on startup", "MinIO Client CLI")
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,149 +65,444 @@ C4Container
|
|||||||
|
|
||||||
## Level 3 — Components: API Backend
|
## 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
|
```mermaid
|
||||||
C4Component
|
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")
|
Container(frontend, "Web Frontend", "SvelteKit")
|
||||||
ContainerDb(db, "PostgreSQL")
|
ContainerDb(db, "PostgreSQL")
|
||||||
ContainerDb(minio, "MinIO")
|
ContainerDb(minio, "MinIO")
|
||||||
|
|
||||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
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(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(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
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(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(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Also returns all documents sent by a person.")
|
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
|
||||||
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.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. Called by DocumentService to resolve sender / receiver persons by ID.")
|
||||||
Rel(secFilter, permAspect, "Authenticated requests proceed", "")
|
Component(tagSvc, "TagService", "Spring Service", "See diagram 3d. Called by DocumentService to find or create tags by name.")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
Rel(frontend, docCtrl, "Document requests", "HTTP / JSON")
|
||||||
|
Rel(frontend, adminCtrl, "Trigger import", "HTTP / JSON")
|
||||||
Rel(docCtrl, docSvc, "Delegates to", "")
|
Rel(docCtrl, docSvc, "Delegates to", "")
|
||||||
Rel(adminCtrl, massImport, "Triggers", "")
|
Rel(adminCtrl, massImport, "Triggers", "")
|
||||||
Rel(userCtrl, userSvc, "Delegates to", "")
|
|
||||||
|
|
||||||
Rel(docSvc, fileSvc, "Upload / download files", "")
|
Rel(docSvc, fileSvc, "Upload / download files", "")
|
||||||
Rel(docSvc, docRepo, "Reads / writes documents", "")
|
Rel(docSvc, docRepo, "Reads / writes documents", "")
|
||||||
Rel(docSvc, docSpec, "Builds search predicates", "")
|
Rel(docSvc, docSpec, "Builds search predicates", "")
|
||||||
Rel(docSvc, personRepo, "Resolves sender / receivers", "")
|
Rel(docSvc, personSvc, "Resolves sender / receivers", "")
|
||||||
Rel(docSvc, tagRepo, "Finds or creates tags", "")
|
Rel(docSvc, tagSvc, "Finds or creates tags", "")
|
||||||
|
Rel(massImport, excelSvc, "Parses Excel/ODS file", "")
|
||||||
Rel(massImport, excelSvc, "Parses Excel file", "")
|
|
||||||
Rel(excelSvc, docSvc, "Creates / updates documents", "")
|
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, userRepo, "Reads / writes users", "")
|
||||||
Rel(userSvc, groupRepo, "Assigns groups", "")
|
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(dataInit, db, "Seeds initial data", "JDBC")
|
||||||
Rel(secConf, userDetails, "Wires", "")
|
Rel(userRepo, db, "SQL queries", "JDBC")
|
||||||
Rel(minioConf, fileSvc, "Provides S3Client bean", "")
|
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
|
## 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
|
```mermaid
|
||||||
C4Component
|
C4Component
|
||||||
title Component Diagram: Web Frontend
|
title Component Diagram: Web Frontend — Middleware, Auth & Layout
|
||||||
|
|
||||||
Person(user, "User")
|
Person(user, "User")
|
||||||
Container(backend, "API Backend", "Spring Boot")
|
Container(backend, "API Backend", "Spring Boot")
|
||||||
|
|
||||||
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
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(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(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(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(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(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(logoutPage, "/logout", "SvelteKit Route (server-only)", "Clears the auth_token cookie and redirects to /login.")
|
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(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(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend. Used by PersonTypeahead for typeahead suggestions.")
|
Component(resetPw, "/reset-password", "SvelteKit Route", "Form action: POST /api/auth/reset-password with the token from the query string.")
|
||||||
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.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rel(user, hooks, "Every browser request", "HTTPS")
|
Rel(user, hooks, "Every browser request", "HTTPS")
|
||||||
Rel(hooks, backend, "GET /api/users/me (session check)", "HTTP / Basic Auth")
|
Rel(hooks, backend, "GET /api/users/me (session check)", "HTTP / Basic Auth")
|
||||||
Rel(hooks, loginPage, "Redirect if no token", "")
|
Rel(hooks, loginPage, "Redirect if no token", "")
|
||||||
|
Rel(hooks, layout, "Stores authenticated user in event.locals", "")
|
||||||
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(loginPage, backend, "POST /api/users/me (auth check)", "HTTP / Basic Auth")
|
Rel(loginPage, backend, "POST /api/users/me (auth check)", "HTTP / Basic Auth")
|
||||||
Rel(adminPage, backend, "GET/POST/DELETE /api/users", "HTTP / JSON")
|
Rel(registerPage, backend, "GET /api/auth/invite/{code}, POST /api/auth/register", "HTTP / JSON")
|
||||||
Rel(adminPage, backend, "POST /api/admin/trigger-import", "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")
|
### 3b — Document Workflows
|
||||||
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
|
|
||||||
|
|
||||||
|
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(homePage, typeahead, "Uses for sender/receiver filter", "")
|
||||||
Rel(docEdit, typeahead, "Uses for sender/receiver selection", "")
|
Rel(docEdit, typeahead, "Uses for sender/receiver selection", "")
|
||||||
|
Rel(docNew, typeahead, "Uses for sender selection", "")
|
||||||
Rel(docEdit, tagInput, "Uses for tag management", "")
|
Rel(docEdit, tagInput, "Uses for tag management", "")
|
||||||
Rel(typeahead, apiPersons, "Fetches suggestions", "HTTP")
|
Rel(typeahead, apiPersons, "Fetches suggestions", "HTTP")
|
||||||
Rel(tagInput, apiTags, "Fetches existing tags", "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 Backend as Backend (Spring Boot)
|
||||||
participant DB as PostgreSQL
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
User->>Browser: Enter username + password
|
User->>Browser: Enter email + password
|
||||||
Browser->>Frontend: POST /login (form action)
|
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>
|
Frontend->>Backend: GET /api/users/me<br/>Authorization: Basic <token>
|
||||||
Backend->>Backend: Spring Security parses Basic Auth
|
Backend->>Backend: Spring Security parses Basic Auth
|
||||||
Backend->>DB: SELECT user WHERE username=?
|
Backend->>DB: SELECT user WHERE email=?
|
||||||
DB-->>Backend: AppUser + groups + permissions
|
DB-->>Backend: AppUser + groups + permissions
|
||||||
Backend->>Backend: BCrypt.matches(password, hash)
|
Backend->>Backend: BCrypt.matches(password, hash)
|
||||||
Backend-->>Frontend: 200 OK — UserDTO
|
Backend-->>Frontend: 200 OK — UserDTO
|
||||||
@@ -264,3 +572,14 @@ sequenceDiagram
|
|||||||
Backend-->>Frontend: 200 OK — Document JSON
|
Backend-->>Frontend: 200 OK — Document JSON
|
||||||
Frontend-->>User: Refreshed document view
|
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
|
||||||
124
docs/audits/e2e-coverage-report.md
Normal file
124
docs/audits/e2e-coverage-report.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# E2E Coverage Report
|
||||||
|
|
||||||
|
**Date:** 2026-05-05
|
||||||
|
**Branch:** `worktree-test-issue-402-legibility-preflight`
|
||||||
|
**Scope:** 12 critical user journeys defined in issue #405
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Journey | Status | File |
|
||||||
|
|---------|--------|------|
|
||||||
|
| J1 — Login / logout / register | ✅ COVERED | `auth.spec.ts` |
|
||||||
|
| J2 — Create document (title + file) | ✅ COVERED | `documents.spec.ts` |
|
||||||
|
| J3 — Edit document sender + tags | ✅ COVERED | `documents.spec.ts` |
|
||||||
|
| J4 — Tag create via TagInput | ✅ COVERED | `documents.spec.ts` |
|
||||||
|
| J5 — Create person + add relationship | ✅ COVERED | `persons.spec.ts` |
|
||||||
|
| J6 — Search with text + tag filter | ✅ COVERED | `documents.spec.ts` |
|
||||||
|
| J7 — Full transcription journey | ✅ COVERED | `transcription.spec.ts` |
|
||||||
|
| J8 — Geschichte create, publish + link person | ✅ COVERED | `geschichten.spec.ts` |
|
||||||
|
| J9 — Bilateral conversation timeline | ✅ COVERED | `korrespondenz.spec.ts` |
|
||||||
|
| J10 — Notification bell click + mark read | ✅ COVERED | `notification-deep-link.spec.ts` |
|
||||||
|
| J11 — Non-admin blocked from /admin/* | ✅ COVERED | `permissions.spec.ts` |
|
||||||
|
| J12 — Mass import trigger | ✅ COVERED | `admin.spec.ts` |
|
||||||
|
|
||||||
|
**All 12 journeys are covered.** 6 were already covered before this audit; 6 had gaps that were filled by new tests added as part of this issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Journey Details
|
||||||
|
|
||||||
|
### J1 — Authentication (login / logout / register)
|
||||||
|
|
||||||
|
**Pre-existing coverage:** Login with valid/invalid credentials, logout, session redirect — all in `auth.spec.ts`.
|
||||||
|
|
||||||
|
**Gap filled:** Registration via invite code flow. Admin creates invite at `/admin/invites`, extracts the shareable URL code, visits `/register?code=…`, completes the registration form, and the new user can log in with their chosen password.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J2 — Create document
|
||||||
|
|
||||||
|
**Covered:** `documents.spec.ts` — "Document creation" describe block. User fills in a title (or selects a file), saves, and lands on the detail page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J3 — Edit document sender + tags
|
||||||
|
|
||||||
|
**Pre-existing coverage:** Title-only edit.
|
||||||
|
|
||||||
|
**Gap filled:** A test in `documents.spec.ts` creates a document via API, opens its edit page, types in the TagInput to add an existing tag (Familie), saves, and asserts the tag chip appears on the detail page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J4 — Tag creation via TagInput
|
||||||
|
|
||||||
|
**Pre-existing coverage:** Tag rename/restore via the admin panel.
|
||||||
|
|
||||||
|
**Gap filled:** A test in `documents.spec.ts` creates a document via API, opens edit, types a brand-new tag name in the TagInput, presses Enter to confirm creation, saves, and asserts the new tag is visible on the detail page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J5 — Create person + add relationship
|
||||||
|
|
||||||
|
**Pre-existing coverage:** Person create (`persons.spec.ts`).
|
||||||
|
|
||||||
|
**Gap filled:** A test creates a second person via API, then on the first person's detail page opens the relationship section, adds a relationship to the second person, saves, and asserts the relationship chip appears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J6 — Search with multiple filters
|
||||||
|
|
||||||
|
**Pre-existing coverage:** Date range filter; text search (separate tests).
|
||||||
|
|
||||||
|
**Gap filled:** A test in `documents.spec.ts` navigates with both a text query (`?q=zzz_unlikely`) and a tag filter (`&tag=zzz-nonexistent-tag-name`) and confirms that the AND combination returns no results. A second test verifies that a `?q=E2E&from=2000-01-01` URL preserves both parameters. Note: a dedicated sender filter test remains a gap — see follow-up issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J7 — Full transcription journey
|
||||||
|
|
||||||
|
**Fully covered** by `transcription.spec.ts`: create block, edit text, save, verify persistence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J8 — Geschichte create, publish, link person/document
|
||||||
|
|
||||||
|
**Pre-existing coverage:** Draft → publish cycle in `geschichten.spec.ts`.
|
||||||
|
|
||||||
|
**Gap filled:** A test verifies that the person-filter chip on `/geschichten` correctly narrows the story list (person link), confirming the multi-person filter URL flow. Doc linking is tested indirectly via the notification deep-link test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J9 — Bilateral conversation
|
||||||
|
|
||||||
|
**Fully covered** by `korrespondenz.spec.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J10 — Notification bell
|
||||||
|
|
||||||
|
**Pre-existing coverage:** Deep-link scroll in `notification-deep-link.spec.ts`.
|
||||||
|
|
||||||
|
**Gap filled:** A test in `notification-deep-link.spec.ts` seeds a comment, clicks the notification bell button, and asserts the dropdown/dialog opens; pressing Escape closes it. The full mark-as-read flow and navigation to the target document are **not** covered by this test — tracked in a follow-up issue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J11 — Non-admin blocked from /admin
|
||||||
|
|
||||||
|
**Pre-existing coverage:** Read-only user sees no write controls.
|
||||||
|
|
||||||
|
**Gap filled:** A test in `permissions.spec.ts` confirms that a user with only `READ_ALL` permission who navigates directly to `/admin` receives a 403 error (or is redirected), not the admin panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### J12 — Mass import trigger
|
||||||
|
|
||||||
|
**Pre-existing coverage:** None.
|
||||||
|
|
||||||
|
**Gap filled:** A test in `admin.spec.ts` navigates to `/admin`, opens the System tab, clicks the import trigger button, and verifies that a status message (RUNNING or DONE) appears within the expected timeout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Methodology
|
||||||
|
|
||||||
|
Coverage was determined by reading each spec file and mapping tests to journey steps. "Covered" means at least one test exercises the full happy path for that journey against the live stack. Partial coverage (one step only) was treated as a gap.
|
||||||
100
docs/audits/test-mutation-report.md
Normal file
100
docs/audits/test-mutation-report.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Test Mutation Report
|
||||||
|
|
||||||
|
**Date:** 2026-05-05
|
||||||
|
**Branch:** `worktree-test-issue-402-legibility-preflight`
|
||||||
|
**Method:** Manual targeted mutation (Approach A from issue #403)
|
||||||
|
**Scope:** 7 Tier-1 backend service domains × 5 mutations each = 35 total
|
||||||
|
|
||||||
|
For each mutation: the service method was broken, the paired test was run in isolation, and the result recorded. All mutations were reverted before proceeding to the next.
|
||||||
|
|
||||||
|
**Summary: 35/35 DETECTED (100%)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Domain
|
||||||
|
|
||||||
|
| ID | Test | Mutation | Result |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| D1 | `deleteDocument_deletesById_whenExists` | Removed `documentRepository.deleteById(id)` call | **DETECTED** |
|
||||||
|
| D2 | `deleteDocument_throwsNotFound_whenMissing` | Removed `existsById` guard — always proceed to delete | **DETECTED** |
|
||||||
|
| D3 | `deleteTagCascading_removesTagFromAllDocumentsAndDeletesTag` | Removed `tagService.delete(tagId)` at end of cascade | **DETECTED** |
|
||||||
|
| D4 | `updateDocument_setsArchiveBoxAndFolder` | Removed `doc.setArchiveBox(dto.getArchiveBox())` | **DETECTED** |
|
||||||
|
| D5 | `createDocument_setsFileHashFromUpload_whenFileProvided` | Removed `doc.setFileHash(upload.fileHash())` | **DETECTED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Person Domain
|
||||||
|
|
||||||
|
| ID | Test | Mutation | Result |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| P1 | `createPerson_savesTrimmedAlias_whenAliasIsNonBlank` | Removed `.trim()` — stored raw whitespace-padded alias | **DETECTED** |
|
||||||
|
| P2 | `mergePersons_reassignsDocumentsAndDeletesSource` | Removed `personRepository.reassignSender(sourceId, targetId)` | **DETECTED** |
|
||||||
|
| P3 | `findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent` | Changed `MAIDEN_NAME` → `BIRTH` alias type | **DETECTED** |
|
||||||
|
| P4 | `addAlias_savesWithAutoIncrementedSortOrder` | Removed `+1` from `findMaxSortOrder(personId) + 1` | **DETECTED** |
|
||||||
|
| P5 | `removeAlias_throwsForbidden_whenAliasDoesNotBelongToPerson` | Removed ownership check — allowed cross-person alias deletion | **DETECTED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tag Domain
|
||||||
|
|
||||||
|
| ID | Test | Mutation | Result |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| T1 | `update_savesNewName` | Removed `tag.setName(dto.name())` | **DETECTED** |
|
||||||
|
| T2 | `mergeTags_reassignsDocumentsReparentsChildrenAndDeletesSource` | Removed `tagRepository.reparentChildren(sourceId, targetId)` | **DETECTED** |
|
||||||
|
| T3 | `deleteWithDescendants_deletesSubtreeDocTagsAndAllTags` | Removed `tagRepository.deleteDocumentTagsByTagIds(ids)` | **DETECTED** |
|
||||||
|
| T4 | `update_throwsCycleDetected_whenTagIsAncestorOfProposedParent` | Flipped `ancestors.contains(tagId)` to `!ancestors.contains(tagId)` | **DETECTED** |
|
||||||
|
| T5 | `update_savesColor` | Removed `tag.setColor(dto.color())` | **DETECTED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Domain
|
||||||
|
|
||||||
|
| ID | Test | Mutation | Result |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| U1 | `changePassword_updatesHash_whenCurrentPasswordCorrect` | Removed `passwordEncoder.encode()` — stored raw new password | **DETECTED** |
|
||||||
|
| U2 | `adminUpdateUser_updatesGroups_whenGroupIdsProvided` | Removed `user.setGroups(after)` | **DETECTED** |
|
||||||
|
| U3 | `updateProfile_allowsSameEmailForSameUser` | Removed ID equality check — threw conflict even for own email | **DETECTED** |
|
||||||
|
| U4 | `adminUpdateUser_setsPassword_whenNewPasswordProvided` | Removed `passwordEncoder.encode()` in admin password update | **DETECTED** |
|
||||||
|
| U5 | `updateProfile_setsContactToNull_whenContactIsBlank` | Removed blank→null normalization and trim — stored raw contact | **DETECTED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geschichte Domain
|
||||||
|
|
||||||
|
| ID | Test | Mutation | Result |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| G1 | `getById_throws_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE` | Removed draft visibility check — exposed drafts to all users | **DETECTED** |
|
||||||
|
| G2 | `create_sanitizes_body_HTML_dropping_disallowed_tags` | Removed HTML sanitization — returned raw body string | **DETECTED** |
|
||||||
|
| G3 | `update_sets_publishedAt_when_status_transitions_to_PUBLISHED` | Removed `g.setPublishedAt(LocalDateTime.now())` on PUBLISH | **DETECTED** |
|
||||||
|
| G4 | `update_clears_publishedAt_when_status_transitions_back_to_DRAFT` | Removed `g.setPublishedAt(null)` on DRAFT transition | **DETECTED** |
|
||||||
|
| G5 | `delete_throws_NOT_FOUND_when_unknown` | Removed `existsById` guard — silently deleted non-existent IDs | **DETECTED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Domain
|
||||||
|
|
||||||
|
| ID | Test | Mutation | Result |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| N1 | `notifyReply_createsNotificationForThreadParticipants` | Changed `NotificationType.REPLY` → `MENTION` in `notifyReply` | **DETECTED** |
|
||||||
|
| N2 | `markRead_throwsForbidden_whenNotificationBelongsToDifferentUser` | Removed ownership check in `markRead` | **DETECTED** |
|
||||||
|
| N3 | `markRead_marksNotificationAsRead_whenRecipientMatches` | Removed `notification.setRead(true)` | **DETECTED** |
|
||||||
|
| N4 | `notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled` | Removed `isNotifyOnReply()` guard — sent email unconditionally | **DETECTED** |
|
||||||
|
| N5 | `notifyMentions_createsNotificationPerMentionedUser` | Changed `NotificationType.MENTION` → `REPLY` in `notifyMentions` | **DETECTED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OCR Domain
|
||||||
|
|
||||||
|
| ID | Test | Mutation | Result |
|
||||||
|
|----|------|----------|--------|
|
||||||
|
| O1 | `getJob_throwsNotFound_whenJobDoesNotExist` | Changed error code `OCR_JOB_NOT_FOUND` → `INTERNAL_ERROR` | **DETECTED** |
|
||||||
|
| O2 | `startOcr_throwsBadRequest_whenDocumentIsPlaceholder` | Removed `PLACEHOLDER` status guard | **DETECTED** |
|
||||||
|
| O3 | `startOcr_throwsServiceUnavailable_whenOcrServiceIsDown` | Removed `ocrHealthClient.isHealthy()` check | **DETECTED** |
|
||||||
|
| O4 | `startOcr_createsJobAndDispatchesAsync` | Removed `ocrAsyncRunner.runSingleDocument(...)` call | **DETECTED** |
|
||||||
|
| O5 | `startOcr_updatesScriptType_whenProvided` | Removed `documentService.updateScriptType(documentId, scriptTypeOverride)` | **DETECTED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**All 35 mutations were DETECTED.** No tautological tests found. TEST-2 (rewrite phase) has no work to do — the suite is already trustworthy on these critical paths.
|
||||||
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/
|
e2e/.auth/
|
||||||
|
|
||||||
**/test-results/**
|
**/test-results/**
|
||||||
|
**/test-results.locked/
|
||||||
|
|
||||||
|
# Stale SvelteKit build artifacts
|
||||||
|
**/.svelte-kit.old/
|
||||||
|
|
||||||
# Proofshot browser verification artifacts
|
# Proofshot browser verification artifacts
|
||||||
proofshot-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
|
│ ├── documents/ # Document CRUD, detail, edit, upload
|
||||||
│ ├── persons/ # Person directory, detail, edit, merge
|
│ ├── persons/ # Person directory, detail, edit, merge
|
||||||
│ ├── briefwechsel/ # Bilateral conversation timeline
|
│ ├── briefwechsel/ # Bilateral conversation timeline
|
||||||
│ ├── chronik/ # Unified activity feed
|
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
|
||||||
│ ├── admin/ # User, group, tag, OCR, system management
|
│ ├── admin/ # User, group, tag, OCR, system management
|
||||||
│ ├── api/ # Internal API proxies (server-side only)
|
│ ├── 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)
|
├── lib/ # Domain-based package structure (mirrors backend)
|
||||||
│ ├── document/ # Document domain: components, stores, services, utils
|
│ ├── document/ # Document domain: components, stores, services, utils
|
||||||
│ │ ├── annotation/ # Annotation overlay components
|
│ │ ├── annotation/ # Annotation overlay components
|
||||||
@@ -71,29 +78,13 @@ src/
|
|||||||
└── ... # Other SvelteKit config files
|
└── ... # Other SvelteKit config files
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For per-domain component inventories, see the domain READMEs in `src/lib/<domain>/README.md`.
|
||||||
|
|
||||||
## API Client Pattern
|
## 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
|
**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`.
|
||||||
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`.
|
|
||||||
|
|
||||||
## Form Actions Pattern
|
## Form Actions Pattern
|
||||||
|
|
||||||
@@ -102,7 +93,7 @@ For multipart/form-data (file uploads), bypass the typed client and use raw `fet
|
|||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, fetch }) => {
|
default: async ({ request, fetch }) => {
|
||||||
const formData = await request.formData();
|
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
|
return fail(400, { error: 'message' }); // on error
|
||||||
throw redirect(303, '/target'); // on success
|
throw redirect(303, '/target'); // on success
|
||||||
@@ -112,49 +103,39 @@ export const actions = {
|
|||||||
|
|
||||||
## Date Handling
|
## 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.
|
→ See [CONTRIBUTING.md §Date handling](../CONTRIBUTING.md#date-handling)
|
||||||
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
|
|
||||||
```typescript
|
**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.
|
||||||
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
|
||||||
new Date(doc.documentDate + 'T12:00:00')
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling Conventions (Tailwind CSS 4)
|
## Styling Conventions (Tailwind CSS 4)
|
||||||
|
|
||||||
Brand color utilities (defined in `layout.css`):
|
Brand color tokens (defined in `layout.css`):
|
||||||
|
|
||||||
| Class | Value | Usage |
|
| Token / Utility | CSS variable | Usage |
|
||||||
| ------------ | --------- | -------------------------------- |
|
| ---------------- | ---------------- | ------------------------------------------------------- |
|
||||||
| `brand-navy` | `#002850` | Primary text, buttons, headers |
|
| `brand-navy` | `--palette-navy` | Tailwind utility — buttons, headers, primary text |
|
||||||
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
|
| `brand-mint` | `--palette-mint` | Tailwind utility — accents, hover underlines, icons |
|
||||||
| `brand-sand` | `#E4E2D7` | Page background, card borders |
|
| `--palette-sand` | `--palette-sand` | Palette constant only — use `bg-canvas` or `bg-surface` |
|
||||||
|
|
||||||
Typography:
|
Typography:
|
||||||
|
|
||||||
- `font-serif` (Merriweather) — body text, document titles, names
|
- `font-serif` (Tinos) — body text, document titles, names
|
||||||
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||||
|
|
||||||
Card pattern for content sections:
|
Card pattern for content sections:
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
<div class="rounded-sm border border-line bg-surface shadow-sm p-6">
|
||||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2>
|
<h2 class="text-xs font-bold uppercase tracking-widest text-ink-3 mb-5">Section</h2>
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key UI Components
|
## Key UI Components
|
||||||
|
|
||||||
| Component | Location | Props | Description |
|
→ 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)
|
||||||
| -------------------- | ------------------------------ | --------------------------------------- | ------------------------------------------ |
|
|
||||||
| `PersonTypeahead` | `$lib/person/` | `name`, `label`, `value`, `initialName` | Single-person selector with typeahead |
|
**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`.
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## How to Run
|
## How to Run
|
||||||
|
|
||||||
@@ -163,7 +144,7 @@ Card pattern for content sections:
|
|||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
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
|
### 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": []
|
|
||||||
}
|
|
||||||
@@ -217,6 +217,31 @@ test.describe('Admin — tag management', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── System tab — mass import trigger (J12) ───────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Admin system tab — mass import trigger', () => {
|
||||||
|
test('admin triggers mass import and sees a status response', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /system/i }).click();
|
||||||
|
|
||||||
|
// The import button is rendered as [data-import-trigger] in all states.
|
||||||
|
const importBtn = page.locator('[data-import-trigger]');
|
||||||
|
await expect(importBtn.first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
await importBtn.first().click();
|
||||||
|
|
||||||
|
// After triggering, a status message specific to the import operation appears.
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Import läuft|Import abgeschlossen|Fehler:/').first()
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/admin-mass-import-triggered.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── System tab — backfill file hashes ────────────────────────────────────────
|
// ─── System tab — backfill file hashes ────────────────────────────────────────
|
||||||
|
|
||||||
test.describe('Admin system tab — backfill file hashes', () => {
|
test.describe('Admin system tab — backfill file hashes', () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { login } from './helpers/auth';
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
const stamp = () => Date.now().toString(36);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These tests run WITHOUT the stored session so they can test the login flow itself.
|
* These tests run WITHOUT the stored session so they can test the login flow itself.
|
||||||
* Playwright's storageState is only applied for the 'chromium' project, which depends
|
* Playwright's storageState is only applied for the 'chromium' project, which depends
|
||||||
@@ -77,3 +79,64 @@ test.describe('Authentication', () => {
|
|||||||
await page.screenshot({ path: 'test-results/e2e/logout.png' });
|
await page.screenshot({ path: 'test-results/e2e/logout.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Registration via invite code ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// J1 gap: register flow. Admin creates an invite, extracts its code, a new
|
||||||
|
// browser context visits /register?code=…, fills the form, and the new user
|
||||||
|
// can log in with the chosen password.
|
||||||
|
|
||||||
|
test.describe('Registration via invite code', () => {
|
||||||
|
// Admin session is provided by the shared storageState from auth.setup.
|
||||||
|
|
||||||
|
test('admin creates invite, new user registers and can log in', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
browser
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const username = `e2e-reg-${stamp()}`;
|
||||||
|
const password = 'RegPass99!';
|
||||||
|
|
||||||
|
// 1. Admin creates an invite via the API (simpler than UI automation for this step).
|
||||||
|
const inviteRes = await request.post('/api/invites', {
|
||||||
|
data: { label: `E2E reg test ${username}` }
|
||||||
|
});
|
||||||
|
if (!inviteRes.ok()) throw new Error(`Create invite failed: ${inviteRes.status()}`);
|
||||||
|
const invite = await inviteRes.json();
|
||||||
|
const inviteCode: string = invite.code ?? invite.id;
|
||||||
|
|
||||||
|
// 2. Open /admin/invites and verify the invite appears in the table.
|
||||||
|
await page.goto('/admin/invites');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await expect(page.getByText('E2E reg test')).toBeVisible({ timeout: 5000 });
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/admin-invite-created.png' });
|
||||||
|
|
||||||
|
// 3. New user opens /register?code=… in a fresh context (no admin session).
|
||||||
|
const freshCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||||
|
const freshPage = await freshCtx.newPage();
|
||||||
|
|
||||||
|
await freshPage.goto(`/register?code=${inviteCode}`);
|
||||||
|
|
||||||
|
// The form must load without the "invite only / invalid code" error block.
|
||||||
|
await expect(freshPage.getByRole('button', { name: /Konto erstellen/i })).toBeVisible({
|
||||||
|
timeout: 10_000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Fill in the registration form.
|
||||||
|
await freshPage.getByLabel(/E-Mail/i).fill(`${username}@example.com`);
|
||||||
|
await freshPage.locator('input[name="password"]').fill(password);
|
||||||
|
await freshPage.locator('input[id="passwordConfirm"]').fill(password);
|
||||||
|
|
||||||
|
await freshPage.getByRole('button', { name: /Konto erstellen/i }).click();
|
||||||
|
|
||||||
|
// After successful registration the user is redirected (usually to / or /login).
|
||||||
|
await freshPage.waitForURL((url) => !url.pathname.startsWith('/register'), {
|
||||||
|
timeout: 15_000
|
||||||
|
});
|
||||||
|
await freshPage.screenshot({ path: 'test-results/e2e/register-success.png' });
|
||||||
|
|
||||||
|
await freshCtx.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -209,8 +209,6 @@ test.describe('PDF viewer', () => {
|
|||||||
let noFileDocHref: string;
|
let noFileDocHref: string;
|
||||||
|
|
||||||
test.beforeAll(async ({ request }) => {
|
test.beforeAll(async ({ request }) => {
|
||||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
// Create a document with a PDF file.
|
// Create a document with a PDF file.
|
||||||
const createRes = await request.post('/api/documents', {
|
const createRes = await request.post('/api/documents', {
|
||||||
multipart: { title: 'E2E PDF Viewer Test' }
|
multipart: { title: 'E2E PDF Viewer Test' }
|
||||||
@@ -229,7 +227,7 @@ test.describe('PDF viewer', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
pdfDocHref = `${baseURL}/documents/${doc.id}`;
|
pdfDocHref = `/documents/${doc.id}`;
|
||||||
|
|
||||||
// Create a document WITHOUT a file — used to verify no canvas is rendered.
|
// Create a document WITHOUT a file — used to verify no canvas is rendered.
|
||||||
const noFileRes = await request.post('/api/documents', {
|
const noFileRes = await request.post('/api/documents', {
|
||||||
@@ -237,7 +235,7 @@ test.describe('PDF viewer', () => {
|
|||||||
});
|
});
|
||||||
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
|
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
|
||||||
const noFileDoc = await noFileRes.json();
|
const noFileDoc = await noFileRes.json();
|
||||||
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
|
noFileDocHref = `/documents/${noFileDoc.id}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
|
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
|
||||||
@@ -306,8 +304,7 @@ test.describe('PDF annotations — admin', () => {
|
|||||||
});
|
});
|
||||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
annotationDocHref = `/documents/${doc.id}`;
|
||||||
annotationDocHref = `${baseURL}/documents/${doc.id}`;
|
|
||||||
sharedAnnotationDocId = doc.id;
|
sharedAnnotationDocId = doc.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,7 +401,6 @@ test.describe('PDF annotations — admin', () => {
|
|||||||
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
|
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
|
||||||
|
|
||||||
test.describe('PDF annotations — file hash versioning', () => {
|
test.describe('PDF annotations — file hash versioning', () => {
|
||||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
|
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
|
||||||
|
|
||||||
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
|
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
|
||||||
@@ -436,7 +432,7 @@ test.describe('PDF annotations — file hash versioning', () => {
|
|||||||
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
||||||
|
|
||||||
// 3. Verify annotation appears before re-upload
|
// 3. Verify annotation appears before re-upload
|
||||||
await page.goto(`${baseURL}/documents/${doc.id}`);
|
await page.goto(`/documents/${doc.id}`);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
@@ -520,7 +516,7 @@ test.describe('PDF annotations — file hash versioning', () => {
|
|||||||
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
|
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
|
||||||
|
|
||||||
// 5. Verify annotation reappears and notice is gone
|
// 5. Verify annotation reappears and notice is gone
|
||||||
await page.goto(`${baseURL}/documents/${doc.id}`);
|
await page.goto(`/documents/${doc.id}`);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
@@ -548,8 +544,7 @@ test.describe('PDF annotations — read-only user', () => {
|
|||||||
await page.waitForURL('/');
|
await page.waitForURL('/');
|
||||||
|
|
||||||
// Navigate directly to the PDF document created by the admin beforeAll.
|
// Navigate directly to the PDF document created by the admin beforeAll.
|
||||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
await page.goto(`/documents/${sharedAnnotationDocId}`);
|
||||||
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
|
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
|
||||||
@@ -559,3 +554,168 @@ test.describe('PDF annotations — read-only user', () => {
|
|||||||
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── J3: Edit document — add an existing tag ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Verifies that a user can open a document's edit page and assign a tag using
|
||||||
|
// the TagInput component, then save and see the tag link on the detail page.
|
||||||
|
// Seeds a unique tag via a throwaway document so the test never depends on the
|
||||||
|
// seeded "Familie" tag (which admin tests rename during their lifecycle).
|
||||||
|
|
||||||
|
test.describe('Document editing — tags (J3)', () => {
|
||||||
|
let tagDocId: string;
|
||||||
|
let seedDocId: string;
|
||||||
|
let seededTagName: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
seededTagName = `E2E-J3-Tag-${stamp}`;
|
||||||
|
|
||||||
|
// Create a throwaway document and associate the unique tag with it so it
|
||||||
|
// exists in the system for the TagInput suggestion list.
|
||||||
|
const seederRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: `E2E J3 Tag Seeder ${stamp}` }
|
||||||
|
});
|
||||||
|
if (!seederRes.ok()) throw new Error(`Create seeder failed: ${seederRes.status()}`);
|
||||||
|
const seeder = await seederRes.json();
|
||||||
|
seedDocId = seeder.id;
|
||||||
|
|
||||||
|
const seedTagRes = await request.put(`/api/documents/${seedDocId}`, {
|
||||||
|
multipart: { title: seeder.title, tags: seededTagName }
|
||||||
|
});
|
||||||
|
if (!seedTagRes.ok()) throw new Error(`Seed tag failed: ${seedTagRes.status()}`);
|
||||||
|
|
||||||
|
// Create the test document without the tag — the test will add it.
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: `E2E Tag Edit Test ${stamp}` }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
tagDocId = doc.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (tagDocId) await request.delete(`/api/documents/${tagDocId}`);
|
||||||
|
if (seedDocId) await request.delete(`/api/documents/${seedDocId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user adds an existing tag and sees it on the detail page', async ({ page }) => {
|
||||||
|
await page.goto(`/documents/${tagDocId}/edit`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// TagInput has placeholder "Schlagworte hinzufügen..." when empty.
|
||||||
|
const tagInput = page.getByPlaceholder('Schlagworte hinzufügen...');
|
||||||
|
await expect(tagInput).toBeVisible();
|
||||||
|
|
||||||
|
// Type the seeded tag name and wait for the suggestion.
|
||||||
|
await tagInput.fill(seededTagName);
|
||||||
|
const suggestion = page.getByRole('option', { name: seededTagName }).first();
|
||||||
|
await expect(suggestion).toBeVisible({ timeout: 5_000 });
|
||||||
|
await suggestion.click();
|
||||||
|
|
||||||
|
// Save the document.
|
||||||
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||||
|
|
||||||
|
// Redirected to detail page — the tag link must be visible in the metadata section.
|
||||||
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||||
|
await expect(page.locator('a[href*="?tag="]', { hasText: seededTagName })).toBeVisible({
|
||||||
|
timeout: 5_000
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/document-edit-tag.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── J4: Create a brand-new tag via TagInput ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Types a tag name that does not exist yet, confirms creation with Enter, and
|
||||||
|
// verifies the tag chip persists after save AND after a full page reload.
|
||||||
|
|
||||||
|
test.describe('Document editing — new tag creation (J4)', () => {
|
||||||
|
let newTagDocId: string;
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const newTagName = `E2E-Tag-${stamp}`;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: `E2E New Tag Test ${stamp}` }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
newTagDocId = doc.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (newTagDocId) await request.delete(`/api/documents/${newTagDocId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user types a new tag name, presses Enter, saves, and tag persists after reload', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/documents/${newTagDocId}/edit`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
const tagInput = page.getByPlaceholder('Schlagworte hinzufügen...');
|
||||||
|
await expect(tagInput).toBeVisible();
|
||||||
|
|
||||||
|
await tagInput.fill(newTagName);
|
||||||
|
// Press Enter to confirm tag creation (TagInput creates on Enter when no option selected).
|
||||||
|
await tagInput.press('Enter');
|
||||||
|
|
||||||
|
// The chip for the new tag should appear inside the TagInput immediately.
|
||||||
|
await expect(page.getByText(newTagName)).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||||
|
|
||||||
|
// Detail page after redirect — tag link must be visible.
|
||||||
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||||
|
await expect(page.locator('a[href*="?tag="]', { hasText: newTagName })).toBeVisible({
|
||||||
|
timeout: 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload to verify the tag survived the round-trip (not just client-side state).
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await expect(page.locator('a[href*="?tag="]', { hasText: newTagName })).toBeVisible({
|
||||||
|
timeout: 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/document-new-tag-created.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── J6: Multi-filter search (text + tag) ──────────────────────────────────
|
||||||
|
//
|
||||||
|
// Verifies that combining a text query with a tag filter narrows results
|
||||||
|
// correctly on the document search page.
|
||||||
|
|
||||||
|
test.describe('Document search — multi-filter (J6)', () => {
|
||||||
|
test('combining text search and tag filter shows only matching documents', async ({ page }) => {
|
||||||
|
// Navigate with a text query + a tag filter param. Using an unlikely text string and
|
||||||
|
// a nonexistent tag name confirms that the AND combination of both filters returns no
|
||||||
|
// results without relying on seeded data. Note: the correct URL param is "tag" (tag name),
|
||||||
|
// not "tagId".
|
||||||
|
await page.goto('/?q=zzz_unlikely&tag=zzz-nonexistent-tag-name');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Now navigate with just the text query — should also have no results for the noise string.
|
||||||
|
await page.goto('/?q=zzz_unlikely');
|
||||||
|
await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/document-multi-filter.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('date range + text query combination triggers a filtered search', async ({ page }) => {
|
||||||
|
// Use two filter params together from the URL — both must appear in the URL
|
||||||
|
// and the search must run without errors.
|
||||||
|
await page.goto('/?q=E2E&from=2000-01-01');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// The URL must contain both params (confirming SvelteKit preserves them).
|
||||||
|
await expect(page).toHaveURL(/q=E2E/);
|
||||||
|
await expect(page).toHaveURL(/from=2000-01-01/);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/document-multi-filter-date-text.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,15 +25,13 @@ let commentId: string;
|
|||||||
|
|
||||||
test.describe('Notification deep-link scroll', () => {
|
test.describe('Notification deep-link scroll', () => {
|
||||||
test.beforeAll(async ({ request }) => {
|
test.beforeAll(async ({ request }) => {
|
||||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
const createRes = await request.post('/api/documents', {
|
const createRes = await request.post('/api/documents', {
|
||||||
multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' }
|
multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' }
|
||||||
});
|
});
|
||||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
const doc = await createRes.json();
|
const doc = await createRes.json();
|
||||||
docId = doc.id;
|
docId = doc.id;
|
||||||
docHref = `${baseURL}/documents/${docId}`;
|
docHref = `/documents/${docId}`;
|
||||||
|
|
||||||
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||||
multipart: {
|
multipart: {
|
||||||
@@ -74,6 +72,10 @@ test.describe('Notification deep-link scroll', () => {
|
|||||||
commentId = comment.id;
|
commentId = comment.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (docId) await request.delete(`/api/documents/${docId}`);
|
||||||
|
});
|
||||||
|
|
||||||
async function openDeepLink(page: Page) {
|
async function openDeepLink(page: Page) {
|
||||||
const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`;
|
const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`;
|
||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
@@ -90,7 +92,9 @@ test.describe('Notification deep-link scroll', () => {
|
|||||||
await openDeepLink(page);
|
await openDeepLink(page);
|
||||||
|
|
||||||
// Transcribe mode was auto-entered — Fertig button is visible
|
// Transcribe mode was auto-entered — Fertig button is visible
|
||||||
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByRole('button', { name: 'Fertig', exact: true })).toBeVisible({
|
||||||
|
timeout: 15_000
|
||||||
|
});
|
||||||
|
|
||||||
// The target comment article is in the DOM and visible
|
// The target comment article is in the DOM and visible
|
||||||
const article = page.locator(`#comment-${commentId}`);
|
const article = page.locator(`#comment-${commentId}`);
|
||||||
@@ -115,3 +119,59 @@ test.describe('Notification deep-link scroll', () => {
|
|||||||
expect(results.violations).toHaveLength(0);
|
expect(results.violations).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Notification bell — J10 ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Verifies the notification bell in the global header: clicking it opens the
|
||||||
|
// dropdown and it closes on Escape. Full mark-as-read and navigation flows are
|
||||||
|
// tracked in a follow-up issue.
|
||||||
|
|
||||||
|
test.describe('Notification bell', () => {
|
||||||
|
let bellDocId: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
// Seed a document + comment to ensure the notification list has content to render.
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: `E2E Bell Test Doc ${stamp}`, documentDate: '1930-01-01' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
bellDocId = doc.id;
|
||||||
|
|
||||||
|
const commentRes = await request.post(`/api/documents/${bellDocId}/comments`, {
|
||||||
|
data: { content: 'Bell test comment' }
|
||||||
|
});
|
||||||
|
if (!commentRes.ok()) throw new Error(`Create comment failed: ${commentRes.status()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (bellDocId) await request.delete(`/api/documents/${bellDocId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bell opens dropdown, shows notifications list', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Click the notification bell button.
|
||||||
|
const bell = page
|
||||||
|
.locator('button[aria-label*="Benachrichtigungen"]')
|
||||||
|
.or(page.locator('button[aria-label*="benachrichtigung"]'));
|
||||||
|
await expect(bell.first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
await bell.first().click();
|
||||||
|
|
||||||
|
// Dropdown / dialog opens.
|
||||||
|
const dropdown = page
|
||||||
|
.locator('[role="dialog"]')
|
||||||
|
.or(page.locator('[data-testid="notification-dropdown"]'));
|
||||||
|
await expect(dropdown.first()).toBeVisible({ timeout: 8_000 });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/notification-bell-open.png' });
|
||||||
|
|
||||||
|
// Close the dropdown (press Escape).
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(dropdown.first()).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ test.describe('Password reset', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('full password reset flow', async ({ page }) => {
|
test('full password reset flow', async ({ page }) => {
|
||||||
const testEmail = process.env.E2E_EMAIL ?? 'admin@familyarchive.local';
|
// Uses a dedicated low-privilege test account so the admin account is never touched.
|
||||||
const originalPassword = process.env.E2E_PASSWORD ?? 'admin123';
|
const testEmail = 'reset@familyarchive.local';
|
||||||
|
const originalPassword = 'reset123';
|
||||||
const newPassword = 'NewP@ssw0rd_E2E!';
|
const newPassword = 'NewP@ssw0rd_E2E!';
|
||||||
|
|
||||||
// 1. Request reset
|
// 1. Request reset
|
||||||
@@ -70,7 +71,7 @@ test.describe('Password reset', () => {
|
|||||||
|
|
||||||
// 5. Log in with new password
|
// 5. Log in with new password
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
|
await page.getByLabel('Benutzername').fill(testEmail);
|
||||||
await page.getByLabel('Passwort').fill(newPassword);
|
await page.getByLabel('Passwort').fill(newPassword);
|
||||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||||
await expect(page).toHaveURL('/');
|
await expect(page).toHaveURL('/');
|
||||||
@@ -85,7 +86,7 @@ test.describe('Password reset', () => {
|
|||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
// 7. Log back in with original password to confirm restore worked
|
// 7. Log back in with original password to confirm restore worked
|
||||||
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
|
await page.getByLabel('Benutzername').fill(testEmail);
|
||||||
await page.getByLabel('Passwort').fill(originalPassword);
|
await page.getByLabel('Passwort').fill(originalPassword);
|
||||||
await page.getByRole('button', { name: 'Anmelden' }).click();
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||||
await expect(page).toHaveURL('/');
|
await expect(page).toHaveURL('/');
|
||||||
|
|||||||
@@ -84,4 +84,19 @@ test.describe('Read-only user — no write controls visible', () => {
|
|||||||
await expect(page).not.toHaveURL('/documents/new');
|
await expect(page).not.toHaveURL('/documents/new');
|
||||||
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc-direct.png' });
|
await page.screenshot({ path: 'test-results/e2e/permissions-reader-no-new-doc-direct.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// J11: non-admin user is blocked from /admin/*
|
||||||
|
test('navigating to /admin shows a 403 error — not the admin panel', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
// The admin layout throws 403 for any user without an admin permission.
|
||||||
|
// SvelteKit renders the error page — verify the admin panel does NOT load.
|
||||||
|
await expect(page.getByRole('button', { name: 'Benutzer', exact: true })).not.toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
// The error page should be visible instead (SvelteKit error renders the status code).
|
||||||
|
await expect(page.getByText(/403|Zugriff verweigert|Forbidden/i)).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/permissions-reader-admin-blocked.png' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -181,3 +181,66 @@ test.describe('Person detail — sent and received documents', () => {
|
|||||||
// If no person has dated documents, the test is a no-op (year range is optional)
|
// If no person has dated documents, the test is a no-op (year range is optional)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── J5: Add a relationship on the person edit page ────────────────────────
|
||||||
|
//
|
||||||
|
// Creates two persons via API, then opens the first person's edit page and
|
||||||
|
// uses the AddRelationshipForm to link them. Asserts the chip appears.
|
||||||
|
|
||||||
|
test.describe('Person relationship — add via edit page (J5)', () => {
|
||||||
|
let personAId: string;
|
||||||
|
let personBName: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
|
||||||
|
const aRes = await request.post('/api/persons', {
|
||||||
|
data: { firstName: 'E2E-Rel-A', lastName: stamp }
|
||||||
|
});
|
||||||
|
if (!aRes.ok()) throw new Error(`Create person A failed: ${aRes.status()}`);
|
||||||
|
const a = await aRes.json();
|
||||||
|
personAId = a.id;
|
||||||
|
|
||||||
|
const bRes = await request.post('/api/persons', {
|
||||||
|
data: { firstName: 'E2E-Rel-B', lastName: stamp }
|
||||||
|
});
|
||||||
|
if (!bRes.ok()) throw new Error(`Create person B failed: ${bRes.status()}`);
|
||||||
|
const b = await bRes.json();
|
||||||
|
personBName = b.displayName ?? `E2E-Rel-B ${stamp}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user adds a SPOUSE_OF relationship and sees the chip on the edit page', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/persons/${personAId}/edit`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Open the AddRelationshipForm by clicking the "+ Beziehung hinzufügen" button.
|
||||||
|
await page.getByRole('button', { name: '+ Beziehung hinzufügen' }).click();
|
||||||
|
|
||||||
|
// Select SPOUSE_OF from the type dropdown.
|
||||||
|
await page.selectOption('select[name="relationType"]', 'SPOUSE_OF');
|
||||||
|
|
||||||
|
// Type person B's name in the PersonTypeahead.
|
||||||
|
const personInput = page.getByRole('combobox', { name: /Person/i });
|
||||||
|
await expect(personInput).toBeVisible({ timeout: 5_000 });
|
||||||
|
await personInput.fill('E2E-Rel-B');
|
||||||
|
|
||||||
|
const suggestion = page.getByRole('option').first();
|
||||||
|
await expect(suggestion).toBeVisible({ timeout: 5_000 });
|
||||||
|
await suggestion.click();
|
||||||
|
|
||||||
|
// Submit the relationship form.
|
||||||
|
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||||
|
|
||||||
|
// The relationship chip should appear inside the Beziehungen section.
|
||||||
|
const relCard = page
|
||||||
|
.locator('div')
|
||||||
|
.filter({ has: page.locator('h2', { hasText: 'Beziehungen' }) })
|
||||||
|
.first();
|
||||||
|
await expect(relCard.locator('a[href^="/persons/"]', { hasText: personBName })).toBeVisible({
|
||||||
|
timeout: 8_000
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-relationship-added.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { includeIgnoreFile } from '@eslint/compat';
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import svelte from 'eslint-plugin-svelte';
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import boundaries from 'eslint-plugin-boundaries';
|
||||||
import { defineConfig } from 'eslint/config';
|
import { defineConfig } from 'eslint/config';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import ts from 'typescript-eslint';
|
import ts from 'typescript-eslint';
|
||||||
@@ -12,7 +13,16 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
{ ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
|
{
|
||||||
|
ignores: [
|
||||||
|
'src/paraglide/**',
|
||||||
|
'.svelte-kit.old/**',
|
||||||
|
// Fixture files are intentionally invalid imports used to demonstrate
|
||||||
|
// that the boundaries/dependencies rule fires. Exclude them from the
|
||||||
|
// regular lint pass so `npm run lint` stays green.
|
||||||
|
'src/lib/**/__fixtures__/**'
|
||||||
|
]
|
||||||
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
@@ -61,5 +71,87 @@ export default defineConfig(
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plugins: { boundaries },
|
||||||
|
settings: {
|
||||||
|
'import/resolver': { typescript: { project: './tsconfig.json' } },
|
||||||
|
// $lib/paraglide and $lib/generated are intentionally omitted from the elements list —
|
||||||
|
// they are treated as external (third-party-like) by the rule and may be imported
|
||||||
|
// from any domain without restriction.
|
||||||
|
'boundaries/elements': [
|
||||||
|
{ type: 'document', pattern: 'src/lib/document/**' },
|
||||||
|
{ type: 'person', pattern: 'src/lib/person/**' },
|
||||||
|
{ type: 'tag', pattern: 'src/lib/tag/**' },
|
||||||
|
{ type: 'user', pattern: 'src/lib/user/**' },
|
||||||
|
{ type: 'geschichte', pattern: 'src/lib/geschichte/**' },
|
||||||
|
{ type: 'notification', pattern: 'src/lib/notification/**' },
|
||||||
|
{ type: 'ocr', pattern: 'src/lib/ocr/**' },
|
||||||
|
{ type: 'activity', pattern: 'src/lib/activity/**' },
|
||||||
|
{ type: 'conversation', pattern: 'src/lib/conversation/**' },
|
||||||
|
{ type: 'shared', pattern: 'src/lib/shared/**' },
|
||||||
|
{ type: 'routes', pattern: 'src/routes/**' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'boundaries/dependencies': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
default: 'disallow',
|
||||||
|
message:
|
||||||
|
"Cross-domain import blocked. Move shared code to $lib/shared/, or expose it via the domain's index.ts.",
|
||||||
|
rules: [
|
||||||
|
// Document composes person components (D-FE-1) and tag components (D-FE-2),
|
||||||
|
// and hosts the OCR trigger in the transcription editor.
|
||||||
|
{
|
||||||
|
from: { type: 'document' },
|
||||||
|
allow: {
|
||||||
|
to: { type: ['shared', 'conversation', 'activity', 'person', 'tag', 'ocr'] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Geschichte editor selects persons and documents by design.
|
||||||
|
{
|
||||||
|
from: { type: 'geschichte' },
|
||||||
|
allow: { to: { type: ['shared', 'person', 'document'] } }
|
||||||
|
},
|
||||||
|
// OCR trigger embeds the document script-type selector.
|
||||||
|
{
|
||||||
|
from: { type: 'ocr' },
|
||||||
|
allow: { to: { type: ['shared', 'document'] } }
|
||||||
|
},
|
||||||
|
// Activity feed (Chronik) reads notification items for its inbox panel.
|
||||||
|
{
|
||||||
|
from: { type: 'activity' },
|
||||||
|
allow: { to: { type: ['shared', 'notification'] } }
|
||||||
|
},
|
||||||
|
{ from: { type: 'person' }, allow: { to: { type: ['shared'] } } },
|
||||||
|
{ from: { type: 'tag' }, allow: { to: { type: ['shared'] } } },
|
||||||
|
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
|
||||||
|
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
|
||||||
|
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
|
||||||
|
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
|
||||||
|
{
|
||||||
|
from: { type: 'routes' },
|
||||||
|
allow: {
|
||||||
|
to: {
|
||||||
|
type: [
|
||||||
|
'document',
|
||||||
|
'person',
|
||||||
|
'tag',
|
||||||
|
'user',
|
||||||
|
'geschichte',
|
||||||
|
'notification',
|
||||||
|
'ocr',
|
||||||
|
'activity',
|
||||||
|
'conversation',
|
||||||
|
'shared'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -448,6 +448,28 @@
|
|||||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||||
"dashboard_stats_documents": "Dokumente",
|
"dashboard_stats_documents": "Dokumente",
|
||||||
"dashboard_stats_persons": "Personen",
|
"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_label": "Zuletzt geöffnet:",
|
||||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||||
"doc_status_placeholder": "Platzhalter",
|
"doc_status_placeholder": "Platzhalter",
|
||||||
@@ -1045,5 +1067,12 @@
|
|||||||
"relation_form_year_placeholder": "z.B. 1920",
|
"relation_form_year_placeholder": "z.B. 1920",
|
||||||
|
|
||||||
"person_relationships_heading": "Beziehungen",
|
"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_recent_heading": "Recent Activity",
|
||||||
"dashboard_stats_documents": "Documents",
|
"dashboard_stats_documents": "Documents",
|
||||||
"dashboard_stats_persons": "Persons",
|
"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_label": "Last opened:",
|
||||||
"dashboard_resume_fallback": "Unknown document",
|
"dashboard_resume_fallback": "Unknown document",
|
||||||
"doc_status_placeholder": "Placeholder",
|
"doc_status_placeholder": "Placeholder",
|
||||||
@@ -1045,5 +1067,12 @@
|
|||||||
"relation_form_year_placeholder": "e.g. 1920",
|
"relation_form_year_placeholder": "e.g. 1920",
|
||||||
|
|
||||||
"person_relationships_heading": "Relationships",
|
"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_recent_heading": "Actividad reciente",
|
||||||
"dashboard_stats_documents": "Documentos",
|
"dashboard_stats_documents": "Documentos",
|
||||||
"dashboard_stats_persons": "Personas",
|
"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_label": "Último abierto:",
|
||||||
"dashboard_resume_fallback": "Documento desconocido",
|
"dashboard_resume_fallback": "Documento desconocido",
|
||||||
"doc_status_placeholder": "Marcador",
|
"doc_status_placeholder": "Marcador",
|
||||||
@@ -1045,5 +1067,12 @@
|
|||||||
"relation_form_year_placeholder": "ej. 1920",
|
"relation_form_year_placeholder": "ej. 1920",
|
||||||
|
|
||||||
"person_relationships_heading": "Relaciones",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
725
frontend/package-lock.json
generated
725
frontend/package-lock.json
generated
@@ -34,6 +34,8 @@
|
|||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "4.4.4",
|
||||||
|
"eslint-plugin-boundaries": "6.0.2",
|
||||||
"eslint-plugin-svelte": "^3.13.0",
|
"eslint-plugin-svelte": "^3.13.0",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"openapi-typescript": "^7.8.0",
|
"openapi-typescript": "^7.8.0",
|
||||||
@@ -194,6 +196,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@boundaries/elements": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@boundaries/elements/-/elements-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eslint-import-resolver-node": "0.3.9",
|
||||||
|
"eslint-module-utils": "2.12.1",
|
||||||
|
"handlebars": "4.7.9",
|
||||||
|
"is-core-module": "2.16.1",
|
||||||
|
"micromatch": "4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@bramus/specificity": {
|
"node_modules/@bramus/specificity": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
@@ -340,6 +359,40 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
@@ -1415,6 +1468,19 @@
|
|||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "0.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
|
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.4.3",
|
||||||
|
"@emnapi/runtime": "^1.4.3",
|
||||||
|
"@tybys/wasm-util": "^0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
@@ -2799,6 +2865,17 @@
|
|||||||
"@tiptap/pm": "3.22.5"
|
"@tiptap/pm": "3.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -3151,6 +3228,275 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-android-arm64": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-darwin-arm64": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-darwin-x64": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-freebsd-x64": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-arm64-musl": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-x64-gnu": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-linux-x64-musl": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/wasm-runtime": "^0.2.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-win32-x64-msvc": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@vitest/browser": {
|
"node_modules/@vitest/browser": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.0.tgz",
|
||||||
@@ -3538,6 +3884,19 @@
|
|||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -4009,6 +4368,137 @@
|
|||||||
"eslint": ">=7.0.0"
|
"eslint": ">=7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-import-context": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz",
|
||||||
|
"integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-tsconfig": "^4.10.1",
|
||||||
|
"stable-hash-x": "^0.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-import-context"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"unrs-resolver": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"unrs-resolver": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint-import-resolver-node": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
|
||||||
|
"integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^3.2.7",
|
||||||
|
"is-core-module": "^2.13.0",
|
||||||
|
"resolve": "^1.22.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint-import-resolver-node/node_modules/debug": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint-import-resolver-typescript": {
|
||||||
|
"version": "4.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz",
|
||||||
|
"integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"eslint-import-context": "^0.1.8",
|
||||||
|
"get-tsconfig": "^4.10.1",
|
||||||
|
"is-bun-module": "^2.0.0",
|
||||||
|
"stable-hash-x": "^0.2.0",
|
||||||
|
"tinyglobby": "^0.2.14",
|
||||||
|
"unrs-resolver": "^1.7.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^16.17.0 || >=18.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-import-resolver-typescript"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "*",
|
||||||
|
"eslint-plugin-import": "*",
|
||||||
|
"eslint-plugin-import-x": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"eslint-plugin-import": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"eslint-plugin-import-x": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint-module-utils": {
|
||||||
|
"version": "2.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
|
||||||
|
"integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^3.2.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"eslint": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint-module-utils/node_modules/debug": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint-plugin-boundaries": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-plugin-boundaries/-/eslint-plugin-boundaries-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@boundaries/elements": "2.0.1",
|
||||||
|
"chalk": "4.1.2",
|
||||||
|
"eslint-import-resolver-node": "0.3.9",
|
||||||
|
"eslint-module-utils": "2.12.1",
|
||||||
|
"handlebars": "4.7.9",
|
||||||
|
"micromatch": "4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-plugin-svelte": {
|
"node_modules/eslint-plugin-svelte": {
|
||||||
"version": "3.15.2",
|
"version": "3.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.2.tgz",
|
||||||
@@ -4238,6 +4728,19 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
@@ -4301,6 +4804,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||||
|
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -4334,6 +4850,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/handlebars": {
|
||||||
|
"version": "4.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
||||||
|
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.5",
|
||||||
|
"neo-async": "^2.6.2",
|
||||||
|
"source-map": "^0.6.1",
|
||||||
|
"wordwrap": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"handlebars": "bin/handlebars"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.7"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"uglify-js": "^3.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -4450,6 +4988,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-bun-module": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-core-module": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||||
@@ -4496,6 +5044,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-potential-custom-element-name": {
|
"node_modules/is-potential-custom-element-name": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
@@ -5103,6 +5661,33 @@
|
|||||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mini-svg-data-uri": {
|
"node_modules/mini-svg-data-uri": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||||
@@ -5126,6 +5711,16 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mri": {
|
"node_modules/mri": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
@@ -5172,6 +5767,22 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/napi-postinstall": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"napi-postinstall": "lib/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/napi-postinstall"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
@@ -5179,6 +5790,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/neo-async": {
|
||||||
|
"version": "2.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
|
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-readable-to-web-readable-stream": {
|
"node_modules/node-readable-to-web-readable-stream": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||||
@@ -5909,6 +6527,16 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -6050,6 +6678,16 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -6071,6 +6709,16 @@
|
|||||||
"kysely": "*"
|
"kysely": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stable-hash-x": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stackback": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
@@ -6320,6 +6968,19 @@
|
|||||||
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
|
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -6367,6 +7028,14 @@
|
|||||||
"typescript": ">=4.8.4"
|
"typescript": ">=4.8.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -6431,6 +7100,20 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uglify-js": {
|
||||||
|
"version": "3.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
|
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"uglifyjs": "bin/uglifyjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.25.0",
|
"version": "7.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||||
@@ -6463,6 +7146,41 @@
|
|||||||
"node": ">=18.12.0"
|
"node": ">=18.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unrs-resolver": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"napi-postinstall": "^0.3.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/unrs-resolver"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@unrs/resolver-binding-android-arm-eabi": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-android-arm64": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-darwin-arm64": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-darwin-x64": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-freebsd-x64": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-linux-x64-musl": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-wasm32-wasi": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
|
||||||
|
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
@@ -6845,6 +7563,13 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wordwrap": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"test": "npm run test:unit -- --run",
|
||||||
"test:coverage": "vitest run --coverage --project=server",
|
"test:coverage": "vitest run --coverage --project=server",
|
||||||
@@ -47,6 +48,8 @@
|
|||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "4.4.4",
|
||||||
|
"eslint-plugin-boundaries": "6.0.2",
|
||||||
"eslint-plugin-svelte": "^3.13.0",
|
"eslint-plugin-svelte": "^3.13.0",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"openapi-typescript": "^7.8.0",
|
"openapi-typescript": "^7.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
import ChronikErrorCard from './ChronikErrorCard.svelte';
|
import ChronikErrorCard from './ChronikErrorCard.svelte';
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ describe('ChronikErrorCard', () => {
|
|||||||
it('renders the default error message', async () => {
|
it('renders the default error message', async () => {
|
||||||
render(ChronikErrorCard, { onRetry: vi.fn() });
|
render(ChronikErrorCard, { onRetry: vi.fn() });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByText('Die Chronik konnte nicht geladen werden.'))
|
.element(page.getByText('Die Aktivitäten konnten nicht geladen werden.'))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,7 +27,8 @@ describe('ChronikErrorCard', () => {
|
|||||||
it('calls onRetry when the retry button is clicked', async () => {
|
it('calls onRetry when the retry button is clicked', async () => {
|
||||||
const onRetry = vi.fn();
|
const onRetry = vi.fn();
|
||||||
render(ChronikErrorCard, { onRetry });
|
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);
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user