Compare commits

...

244 Commits

Author SHA1 Message Date
Marcel
6ba7254344 test(ci): assert prerender output is only /hilfe/transkription
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
Addresses Sara's review request on #515.

Without this gate, a future regression that turns prerender.crawl
back on (or adds a new prerender entry whose nav links into
protected routes) would silently bake /, /documents, /persons etc.
to "redirect-to-login" HTML and re-introduce #514.

Verified the script catches the current broken build state:
  $ find build/prerendered ... -not -path 'hilfe/*' ...
  build/prerendered/{index,documents,persons,geschichten,stammbaum}.html

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 17:00:54 +02:00
Marcel
b2955fb695 fix(frontend): disable prerender crawl so /, /documents, /persons aren't baked
Closes #514.

The build was prerendering protected routes via crawl from
/hilfe/transkription. Their load functions throw redirect('/login')
during the build (no auth cookie), so SvelteKit captured the redirect
as static HTML and shipped /app/build/prerendered/{index,documents,
persons,geschichten,stammbaum}.html with a `location.href=/login`
script. In production these files are served BEFORE hooks.server.ts
runs, so an authenticated user with a valid cookie is still served
the baked bounce-back page.

Setting `crawl: false` keeps the explicit /hilfe/transkription entry
prerendered (needed for the public help page) without dragging the
nav targets along with it.

Verified locally: build now emits only `hilfe/transkription.html`
under build/prerendered/, no index.html or documents.html etc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 17:00:10 +02:00
5d2888e038 Merge pull request 'fix(compose): mark create-buckets as one-shot for up --wait (#510)' (#511) from fix/issue-510-compose-wait-oneshot-create-buckets into main
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
2026-05-11 16:59:59 +02:00
Marcel
3668555421 fix(compose): mark create-buckets as one-shot for up --wait
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m47s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m12s
CI / fail2ban Regex (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Successful in 56s
CI / Unit & Component Tests (pull_request) Failing after 2m49s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m13s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
Closes #510.

`docker compose up -d --wait` exits 1 even when every service is
healthy because the one-shot `create-buckets` exits 0 and --wait
expects "running". The whole stack came up fine on staging, but the
workflow gate failed before the smoke step could run.

Two changes:

1. create-buckets: `restart: "no"` declares one-shot intent.
2. backend.depends_on: add `create-buckets: service_completed_successfully`.

With both, compose v2.20+ understands create-buckets is a one-shot
that must complete successfully, and --wait treats exited(0) as the
target state. Backend startup now also correctly gates on bucket
bootstrap (closes a latent race where backend could start before
the archiv-app policy was bound).

Verified `docker compose config --quiet` parses and the resolved
config shows the right dependency graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:33:04 +02:00
Marcel
54a8f7f8e9 fix(workflows): match runner label — runs-on ubuntu-latest, not self-hosted
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Failing after 2m49s
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
Closes #508.

Our gitea-runner advertises labels ubuntu-latest / ubuntu-24.04 /
ubuntu-22.04. `runs-on: self-hosted` never matches → dispatched
deploy jobs sit in the queue forever. The runner is still
genuinely self-hosted (DooD socket, joined to gitea_gitea net,
single-tenant per ADR-011) — the `self-hosted` token was just an
unconfirmed assumption about the label name.

Unblocks #497 / #499 first deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:15:53 +02:00
Marcel
f8f0951bd5 fix(minio): bake bootstrap.sh into image instead of bind-mounting
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m9s
CI / fail2ban Regex (pull_request) Failing after 12s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
Closes #506.

Under Docker-out-of-Docker (the production Gitea Actions runner), the
host daemon resolves the relative bind-mount path against the host
filesystem — not the runner container's /workspace. The script is not
there, so Docker creates an empty directory at /bootstrap.sh and the
entrypoint fails with `/bootstrap.sh: Is a directory`.

Bake the script into a tiny derived image (infra/minio/Dockerfile) so
there is no runtime path resolution. Works in DooD, regular Docker,
and CI.

Unblocks the staging / production deploy pipelines from #497 / #499
and turns the Compose Bucket Idempotency CI job green.

Verified locally:
- `docker compose ... config --quiet` parses
- `docker compose ... build create-buckets` builds the image
- bootstrap.sh exists as a +x file at /bootstrap.sh inside the image

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:32:36 +02:00
c3c1efe5f1 Merge pull request 'fix(fail2ban): pin polling backend so jail actually reads Caddy access log (#503)' (#504) from fix/issue-503-fail2ban-polling-backend into main
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m47s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m12s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Failing after 50s
2026-05-11 15:08:58 +02:00
Marcel
e5363913ec fix(fail2ban): pin polling backend so jail actually reads Caddy access log
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m8s
CI / fail2ban Regex (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Failing after 53s
CI / Unit & Component Tests (pull_request) Failing after 2m46s
CI / OCR Service Tests (pull_request) Successful in 15s
CI / Backend Unit Tests (pull_request) Successful in 4m14s
CI / fail2ban Regex (pull_request) Successful in 37s
CI / Compose Bucket Idempotency (pull_request) Failing after 50s
Closes #503.

Debian's fail2ban package ships defaults-debian.conf with
`[DEFAULT] backend = systemd`. Without an explicit override, our
familienarchiv-auth jail inherits the systemd backend at runtime,
reads from journald, and never inspects /var/log/caddy/access.log.
A live login brute-force would not be banned.

Add `backend = polling` to the jail and a CI step that links the jail
into /etc/fail2ban/ and asserts `fail2ban-client -d` resolves it to
the polling backend, not the inherited systemd backend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:59:40 +02:00
Marcel
4d4d5793bb docs(glossary): add archiv-app service account entry
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m48s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m5s
CI / fail2ban Regex (pull_request) Successful in 37s
CI / Compose Bucket Idempotency (pull_request) Failing after 50s
CI / Unit & Component Tests (push) Failing after 2m46s
CI / OCR Service Tests (push) Successful in 15s
CI / Backend Unit Tests (push) Successful in 4m4s
CI / fail2ban Regex (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Failing after 50s
`archiv-app` is the bucket-scoped MinIO service account introduced
in PR #499 alongside the production deploy pipeline. Until now the
term only appeared in `infra/minio/bootstrap.sh` and the prod compose
file; a reader encountering `S3_ACCESS_KEY: archiv-app` had no
single-page reference distinguishing it from the MinIO root account.

Adds a new "Infrastructure Terms" section to docs/GLOSSARY.md so the
distinction (root account vs. application service account) and the
attached `archiv-app-policy` scope live in the canonical glossary
location. Cross-links to ADR-010 for the MinIO-stays-self-hosted
rationale. Addresses @elicit's round-2 recommendation on PR #499.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:11:46 +02:00
Marcel
9adde3cd89 refactor(compose): rename docker network archive-net to archiv-net
The docker network was the only `archive-*` identifier in either
compose file; everything else (user, db, bucket, service account,
project name) uses the `archiv-*` spelling. Reviewers' eyes stuttered
on it on the prod compose review (round 2 of PR #499 — Markus and
Tobi). Renamed in both prod and dev compose for consistency and
updated the single doc reference to the dev-project-prefixed
network name.

Operational note: applying this change to a running stack will
recreate the network on the next `docker compose up`; containers
restart, named volumes are unaffected.

`docker compose config --quiet` passes for both compose files and
for the staging profile. Sweep confirms zero `archive-net`
references remain in the tree.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:10:39 +02:00
Marcel
440a191138 infra(workflows): annotate env-file cleanup as load-bearing
The `if: always()` conditional on the env-file cleanup step in both
deploy workflows is what makes the ADR-011 single-tenant runner trust
model safe: secrets land on disk before each deploy and are wiped
unconditionally afterwards. A future workflow refactor that drops
`if: always()` would silently leave plaintext secrets on the runner
on any failed deploy.

The ADR documents this; the workflow file did not. Adds a prominent
inline comment so the next reader of the YAML sees the constraint
without having to cross-reference ADR-011. No behaviour change — both
workflows still parse. Addresses @nora's round-2 suggestion on PR
#499 — "linchpin of the ADR-011 trust model".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:09:12 +02:00
Marcel
1873f50f7f infra(mailpit): use nc -z healthcheck instead of wget
The mailpit service healthcheck previously assumed `wget` ships in
the axllent/mailpit image. That's true for v1.29.7 but is not part
of the image's contract — a future Alpine slim-down could drop wget
and silently disable the healthcheck. Switched to BusyBox `nc -z
localhost 8025`, which is a TCP-port open check with no dependency
beyond BusyBox itself.

Verified inside axllent/mailpit:v1.29.7 that `nc` is present
(/usr/bin/nc, BusyBox v1.37.0) and that the proposed command
returns 0 against an open port and non-zero against a closed one.
Compose still parses with `--profile staging`. Addresses @tobi's
round-2 suggestion on PR #499.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:08:23 +02:00
Marcel
a4f2047bcc security(ocr): pin ALLOWED_PDF_HOSTS=minio in prod ocr-service env
Production never sources PDFs from localhost or 127.0.0.1 — the OCR
service only reads from MinIO over the internal docker network. The
Python default (`minio,localhost,127.0.0.1`) was permissive on
purpose for local dev, but in production a future change to that
default — or a host-env override — would silently broaden the SSRF
surface. Pinning the env var explicitly here freezes the allowlist
to the one hostname production actually needs.

`docker compose config --quiet` and `--profile staging config
--quiet` both still pass. Verified the resolved config emits
`ALLOWED_PDF_HOSTS: minio`. Addresses @nora's round-2 suggestion on
PR #499 — "five characters of YAML, lifetime guarantee".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:07:16 +02:00
Marcel
09680557ef security(caddy): add Permissions-Policy header
Adds `Permissions-Policy: camera=(), microphone=(), geolocation=()` to
the shared (security_headers) snippet, so both archiv vhosts and the
git vhost deny browser APIs the app does not use. Reduces blast radius
of an XSS landing in a privileged origin.

The deploy smoke steps in nightly.yml and release.yml gain a matching
assertion against the canonical header value, so a future Caddyfile
edit that drops or loosens the header (e.g. `camera=(self)`) fails the
deploy instead of regressing silently.

`caddy validate` against caddy:2 passes; both workflow YAMLs parse.
Addresses @nora's round-2 suggestion on PR #499 — "lower-impact than
CSP but nearly free".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:06:13 +02:00
Marcel
8fcf653cb0 ci(smoke): pin HSTS to preload-list-eligible value
Replaces the presence-only `grep -qi strict-transport-security` smoke
assertion in both nightly.yml and release.yml with a value-pinning
regex that requires `max-age=31536000`, `includeSubDomains`, and
`preload`. A future Caddyfile edit that drops any of those three
parts now fails the deploy smoke step instead of passing silently.

Verified locally that the new pattern matches the preload-eligible
value and rejects three degraded forms (short max-age, missing
includeSubDomains, missing preload). Addresses @sara's round-2 note
on PR #499 — "presence check, not value check".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:05:02 +02:00
Marcel
a7a80f8c16 docs(deployment): route SSE through Caddy in topology mermaid
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m48s
CI / OCR Service Tests (push) Successful in 16s
CI / Unit & Component Tests (pull_request) Failing after 2m48s
CI / Backend Unit Tests (pull_request) Successful in 4m8s
CI / fail2ban Regex (pull_request) Successful in 37s
CI / Compose Bucket Idempotency (pull_request) Failing after 49s
CI / Backend Unit Tests (push) Successful in 4m7s
CI / fail2ban Regex (push) Successful in 36s
CI / Compose Bucket Idempotency (push) Failing after 1m15s
CI / OCR Service Tests (pull_request) Successful in 16s
The top-level deployment diagram lagged the C4 L2 diagram, which
correctly notes that SSE notifications are fronted by Caddy. The
mermaid showed Browser → Backend direct, which would only be true
if the backend port were exposed publicly (it is not — all docker
ports bind to 127.0.0.1).

Fixes the inconsistency Markus flagged on PR #499: the public
surface is Caddy and Caddy only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:18:11 +02:00
Marcel
03d478840b docs(arch): show Caddy + X-Forwarded-Proto in auth-flow diagram
Adds the Caddy hop to seq-auth-flow.puml and surfaces the two
production-relevant header behaviours:

  - Caddy terminates TLS and forwards X-Forwarded-Proto: https
  - Spring Boot trusts this header (server.forward-headers-strategy:
    native, ForwardedRequestCustomizer at the Jetty layer), so
    request.getScheme() returns "https"
  - The Set-Cookie response carries the Secure flag because the
    observed scheme is https — without forward-headers-strategy this
    would silently drop to plain http and the cookie would lose Secure

Closes the doc-currency gap flagged in the Markus review on PR #499:
"Auth flow change → docs/architecture/c4/seq-auth-flow.puml".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:17:12 +02:00
Marcel
6a6a1c4353 docs(adr): ADR-011 single-tenant Gitea runner with on-disk env-files
Records the operational assumption that nightly.yml and release.yml
bake in: the self-hosted runner is single-tenant, so writing secrets
to .env.staging / .env.production on disk and removing them via an
`if: always()` cleanup step is acceptable for v1.

Documents the three migration triggers (second repo on the runner,
untrusted PR execution, move to shared infrastructure) and the
one-step migration path (--env-file <(printf '%s' "$SECRET_BLOB"))
so the next operator does not silently break the trust assumption.

The in-comment notes at the top of both workflow files already point
at this ADR's content; this commit records the decision in the durable
location the doc-currency table demands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:16:20 +02:00
Marcel
b57afb9ad2 docs(adr): ADR-010 MinIO stays self-hosted, Hetzner OBS deferred
Records the reversal of the earlier "migrate to Hetzner Object Storage"
direction in docs/infrastructure/production-compose.md. Documents the
cost/benefit (current 13 GB fits trivially on the VPS; OBS billing is
dominated by base fee at this size; migration is a three-env-var swap
plus `mc mirror`, no application rewrite cost).

Captures the four triggers that should re-open the decision (50 GB
threshold, healthcheck latency, VPS upgrade cost, backup runtime) so
the deferral does not become an indefinite punt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:15:38 +02:00
Marcel
59bc81d353 docs(adr): ADR-009 standalone docker-compose.prod.yml, not overlay
Records the decision to make docker-compose.prod.yml a fully self-contained
file rather than an overlay over docker-compose.yml. Captures the cost
(env-var duplication across dev and prod files) and the benefit (single
file the reviewer can hold in their head, no Compose merge-rule
surprises, automatic project-name namespacing for cohabiting staging +
production on one host).

Surfaces the retirement of the earlier overlay narrative in
docs/infrastructure/production-compose.md so a future maintainer does
not reverse the choice out of ignorance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:14:58 +02:00
Marcel
33300e4ad9 chore(infra): drop aspirational Renovate comments from compose
The repo's renovate.json only configures TipTap grouping; Renovate is
not currently active against MinIO / mc / mailpit / Postgres / Node /
Caddy. The "Renovate keeps it current" comments were aspirational —
those tags will rot until Renovate is bootstrapped (tracked in a
follow-up issue).

The "Pinned mc release; Renovate keeps it current" comment is gone
already since the create-buckets entrypoint was extracted to a script
in the preceding MinIO-policy commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:12:55 +02:00
Marcel
fe1451f570 ci(smoke): pin curl to 127.0.0.1 via --resolve
The smoke step previously curled the public hostname unconditionally,
which routes the runner's request via DNS → router → back into the same
host. Many SOHO routers do not implement hairpin NAT (or do so only after
a firmware update), so the deploy may pass on day one and silently fail
on day 90.

--resolve "<host>:443:127.0.0.1" pins the hostname to the runner's
loopback while keeping SNI on the public name (so the cert validates
correctly and the Caddy vhost block matches). The smoke test now
verifies that the Caddy-on-the-same-host is serving the right
hostname end-to-end, with no router dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:12:05 +02:00
Marcel
f2ec81547b ci(deploy): add --pull to docker compose build for CVE pickup
Without --pull, the host's Docker layer cache wins: if a CVE drops in
node:20.19.0-alpine3.21 / postgres:16-alpine and the vendor re-publishes
the same tag, the runner keeps serving the cached layer until the cache
is manually cleared — a silent supply-chain blind spot.

Adding --pull to both `compose build` invocations costs a single
re-pull per run and lifts the base-image patch lag from "next host
prune" to "next nightly".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:10:59 +02:00
Marcel
7e430998b8 security(fail2ban): widen jail to /forgot-password and rate-limit 429
The filter only watched /api/auth/login 401 — leaving the forgot-password
endpoint open to:

  - email enumeration (slow brute-force probing which addresses exist)
  - password-reset brute-force against accounts whose addresses leak

Widens the failregex to /api/auth/(login|forgot-password) and adds 429 to
the status alternation so a future in-app rate-limiter response is also
caught by the jail (defense in depth).

CI assertions extended to cover both new dimensions plus a negative case
on an unrelated 401 endpoint (/api/documents) — pins that the widening
did not over-match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:10:08 +02:00
Marcel
156afa14a2 test(ci): add compose bucket-bootstrap idempotency job
The create-buckets service in docker-compose.prod.yml runs on every
`docker compose up` (one-shot, restart=no). A re-deploy that fails
because the user/bucket/policy already exists would block the whole
nightly/release pipeline — and the only way to find out today is to
run a second deploy.

This job runs the bootstrap twice against a throwaway minio stack and
asserts both invocations exit 0. Caught at PR time, not at the third
nightly deploy at 02:00.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:08:51 +02:00
Marcel
91f70e652d security(minio): scope archiv-app to bucket-only IAM policy
Replaces MinIO's built-in `readwrite` policy (which grants s3:* on
arn:aws:s3:::* — every bucket present and future) with a bucket-scoped
custom policy `archiv-app-policy`:

  - s3:GetObject / s3:PutObject / s3:DeleteObject on familienarchiv/*
  - s3:ListBucket / s3:GetBucketLocation on familienarchiv

The previous configuration silently regressed the least-privilege guarantee
that the service-account separation was supposed to provide: a future
second bucket (logs, backups, mc-mirror staging) would have been
read/write/delete-accessible to a compromised backend.

While at it, two follow-on fixes:

  1. Extract the entrypoint to infra/minio/bootstrap.sh. The previous
     inline `/bin/sh -c "..."` was already at the YAML-escaping ceiling;
     adding the policy-JSON heredoc would have made it unreadable.

  2. Replace the `| grep -q readwrite || exit 1` fatal-check with a
     POSIX `case` substring match. The minio/mc image ships coreutils +
     bash but NOT grep/awk/sed — the original check was a no-op that
     ALWAYS exited 1 (verified locally). The new check passes on the
     first invocation and on every subsequent re-deploy.

Idempotency verified locally: two consecutive `docker compose run --rm
create-buckets` invocations both exit 0 with the user bound to the
new policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:07:56 +02:00
Marcel
9652894aa4 test(ci): add fail2ban-regex regression job
Caddy 2.x emits JSON access logs; the failregex in
infra/fail2ban/filter.d/familienarchiv-auth.conf depends on the
"remote_ip" → "uri" → "status" key order being stable. A future Caddy
upgrade that reorders fields would break the jail silently (regex no
longer matches → fail2ban returns 0 hits → host stops banning
brute-force, discovered only at the next incident).

This job pins the contract: a sample /api/auth/login 401 line must
match (1 hit) and a /api/auth/login 200 line must not (0 hits).
Catches a regression at PR time instead of in production.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:03:04 +02:00
Marcel
e5d953dee8 test(config): rewrite ForwardHeadersConfigurationTest as context-less binder test
Drops @SpringBootTest + PostgresContainerConfig + @MockitoBean S3Client in
favour of Spring's Binder API against application.yaml. The new test binds
the property into the typed ServerProperties.ForwardHeadersStrategy enum,
so typos (`nativ`, `Native`, `framework `) and future enum renames fail
the build with BindException — addresses the silent-coercion concern that
the YAML-string assertion missed.

Verified the test goes red on a typo (BindException: Failed to convert
"nativ" → ForwardHeadersStrategy) and green on `native`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:01:06 +02:00
Marcel
ba5bd9cb11 docs(deployment): document fail2ban symlink, OCR_MEM_LIMIT, smoke test
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m53s
CI / OCR Service Tests (push) Failing after 1m58s
CI / Backend Unit Tests (push) Failing after 1m23s
CI / Unit & Component Tests (pull_request) Failing after 3m59s
CI / Backend Unit Tests (pull_request) Successful in 5m39s
CI / OCR Service Tests (pull_request) Successful in 1m14s
Updates DEPLOYMENT.md to match the infra changes in this PR:

§1 OCR memory — point operators at the new OCR_MEM_LIMIT env var instead
                of telling them to edit "the prod overlay".
§2 OCR env vars — add OCR_MEM_LIMIT to the table.
§3.1 server setup — replace fail2ban prose with concrete `ln -sf`
                    commands referencing the committed jail/filter.
                    Document the single-tenant runner assumption near
                    the runner-registration step.
§3.4 first deploy — describe the new automated smoke test step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:07:59 +02:00
Marcel
83565c6bb5 docs(ci): document workflow operational assumptions
The two deploy workflows make two non-obvious assumptions that future
maintainers should not have to rediscover by reading the diff:

  1. Single-tenant self-hosted runner — the .env.* file lands on disk
     during the deploy and is cleaned up unconditionally. Multi-tenant
     usage would require switching to stdin-piped env input.

  2. Host docker layer cache is authoritative — there is no
     actions/cache directive; a host-level `docker system prune` will
     cold-start the next build.

Both notes added as block comments at the top of each workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:06:48 +02:00
Marcel
a91a3e1f61 feat(ci): smoke test production deploy after up --wait
Mirrors the nightly.yml smoke step against archiv.raddatz.cloud. Catches
the same three failure modes (Caddy not reloaded, DNS missing, HSTS
dropped, /actuator block bypassed) on the prod path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:05:41 +02:00
Marcel
c523721ce8 feat(ci): smoke test staging deploy after up --wait
Healthchecks prove containers are healthy on the docker network; they
do not prove the public URL is reachable, HSTS still fires, or
/actuator is still blocked at the edge. Add a post-deploy smoke step
to nightly.yml that:

  1. GETs https://staging.raddatz.cloud/login (frontend reachable)
  2. asserts the response includes the Strict-Transport-Security header
  3. asserts /actuator/health returns 404 (defense-in-depth verified)

Failure aborts the workflow before the env-file cleanup step. The
cleanup step still runs because it is `if: always()`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:05:00 +02:00
Marcel
ad69d7cb83 feat(infra): commit fail2ban jail for /api/auth/login
Adds two files mirroring the on-host install layout:

  infra/fail2ban/filter.d/familienarchiv-auth.conf
  infra/fail2ban/jail.d/familienarchiv.conf

Filter parses the JSON access log emitted by Caddy (previous commit) and
matches 401 responses on /api/auth/login. Jail bans the offending IP for
30 min after 10 attempts in a 10-minute window.

Verified the failregex against four sample log lines via fail2ban-regex
in an alpine container:
  - 2 brute-force 401 attempts        → matched (ban)
  - 1 successful login (POST /api/auth/login 200) → not matched
  - 1 unrelated GET /login 200        → not matched
Date template "ts":{EPOCH} parses Caddy's Unix-epoch ts field.

The previous review iteration described this jail in DEPLOYMENT.md prose
only; committing it makes the security posture reproducible from a
fresh server build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:04:06 +02:00
Marcel
8d27c82e6d feat(infra): write Caddy JSON access logs for fail2ban
Adds an (access_log) snippet writing JSON-formatted access logs to
/var/log/caddy/access.log with 10mb rolling and 14-file retention. Both
archive vhosts (archiv.raddatz.cloud and staging.raddatz.cloud) import
it; the git vhost is intentionally excluded.

This is the prerequisite for the fail2ban jail committed in the next
commit — fail2ban tails this file looking for 401 responses on
/api/auth/login to defend against credential stuffing.

Validated with `caddy validate` against caddy:2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:02:28 +02:00
Marcel
4eb5eba347 feat(infra): parameterize OCR mem_limit via OCR_MEM_LIMIT
Hardcoded `mem_limit: 12g` only works on CX42+ (16 GB) hosts; a CX32 (8
GB) cannot honour it. Make both mem_limit and memswap_limit driven by
the OCR_MEM_LIMIT env var, defaulting to 12g so prod deploys on a CX42
keep current behaviour. Operators on smaller hosts override to 6g.

Verified compose interpolation produces 12 GiB by default and 6 GiB when
OCR_MEM_LIMIT=6g.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:01:23 +02:00
Marcel
47c5f77c81 fix(infra): fail loud when archiv-app is missing the readwrite policy
The previous `mc admin policy attach … || true` swallowed every failure
mode: a renamed policy, an mc CLI signature change, or a transient MinIO
error would leave the bootstrap container exiting zero with the service
account possessing no permissions, and the backend would then fail every
S3 call after a "successful" deploy.

Replace the silent fallback with verify-after: keep the attach (idempotent
in current mc, redundant in older versions), then assert via `mc admin
user info` that `readwrite` ends up on archiv-app. A genuine attach
failure now exits 1 and blocks the stack from starting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:00:34 +02:00
Marcel
a36f25cfc3 fix(infra): pin minio/mc client tag
Removes the implicit `:latest` from the create-buckets bootstrap
container. Pins to RELEASE.2025-08-13T08-35-41Z so a breaking change in
mc CLI syntax cannot silently brick deploys.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:59:18 +02:00
Marcel
c9ac83b2ba fix(infra): pin axllent/mailpit tag
Removes `:latest` from the mailpit service; pins to v1.29.7 so staging
deploys are reproducible. Renovate keeps the tag current.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:58:34 +02:00
Marcel
e4df17f308 docs: retire overlay narrative; add Caddy to C4 L2 diagram
Some checks failed
CI / Unit & Component Tests (push) Failing after 7m31s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m30s
CI / Unit & Component Tests (pull_request) Failing after 6m55s
CI / OCR Service Tests (pull_request) Successful in 51s
CI / Backend Unit Tests (pull_request) Failing after 3m31s
- docs/infrastructure/production-compose.md: trimmed to VPS sizing,
  cost breakdown, and Hetzner ecosystem rationale. The inline
  compose spec (overlay + Hetzner OBS in prod) is retired; the
  live file is now docker-compose.prod.yml at the repo root and
  the Caddyfile lives at infra/caddy/Caddyfile. Observability
  stack is called out as a not-yet-deployed gap (issue #498).

- docs/architecture/c4/l2-containers.puml: adds Caddy as a named
  reverse-proxy container with the two port paths and notes the
  archiv-app service-account split on MinIO access.

Refs #497.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 22:00:21 +02:00
Marcel
2eade2b78f docs(deployment): rewrite for Gitea Actions / Caddy / prod compose
Brings DEPLOYMENT.md in line with the production deployment landed
in #497:

- Topology diagram: frontend port 3000 (Node adapter), 127.0.0.1
  binding, project-name isolation between prod and staging
- Caddyfile now lives in-tree at infra/caddy/Caddyfile (symlinked
  onto the server)
- Dev vs prod table: documents the new deploy method (workflows +
  --wait) and the prod-compose specific differences
- Env vars: adds MINIO_APP_PASSWORD; notes that prod compose
  hardcodes the MinIO root user and the bucket name
- Bootstrap section: server hardening, fail2ban, Tailscale, the 16
  Gitea secrets, and the workflow_dispatch first-deploy step
- Admin password warning: first deploy locks the password, secret
  rotation after that point has no effect
- Rollback: TAG= override + docker compose up -d --wait

Refs #497.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:58:51 +02:00
Marcel
334b507476 feat(ci): add release production deploy workflow
Fires on `v*` tag push. Tags the built images with the git tag so
rollbacks are a one-liner (TAG=<previous> docker compose ... up -d).

`up -d --wait` blocks until every service healthcheck reports
healthy; a bad release fails the workflow rather than crash-looping
silently. The .env.production file containing all Gitea secrets is
removed in `if: always()` after the deploy step.

Refs #497.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:56:37 +02:00
Marcel
59349dfe93 feat(ci): add nightly staging deploy workflow
Runs daily at 02:00 (and on workflow_dispatch). Builds the prod
compose stack with BuildKit, writes a transient .env.staging from
Gitea secrets, then `docker compose up -d --wait` so the job fails
loudly if any service's healthcheck never reports healthy.

The --profile staging flag starts the mailpit catcher in place of
a real SMTP relay; no production SMTP credentials touch the staging
environment.

The .env.staging file is cleaned up in `if: always()` to avoid
leaving secrets in the runner workspace between runs.

Refs #497.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:55:41 +02:00
Marcel
56e55ff488 feat(infra): add production Caddyfile
Reverse proxy for the Familienarchiv host, validated against Caddy 2.
Includes both vhosts (production and staging), the Gitea vhost, and:

- HSTS, X-Content-Type-Options, Referrer-Policy headers on every site
- "-Server" header strip to hide the Caddy version
- /actuator/* responds 404 on both archive vhosts (defense in depth
  for Spring Boot's management endpoints)

X-Frame-Options is intentionally not set in Caddy: Spring Security
configures frame-options SAMEORIGIN for the in-app PDF preview
iframe; a DENY header here would conflict.

Refs #497.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:54:38 +02:00
Marcel
ecb930e5f9 feat(infra): add docker-compose.prod.yml for production/staging
Standalone production compose file (not an overlay) that runs the
full stack on a single host. Environment isolation is achieved via
the docker compose project name (-p archiv-production / -p
archiv-staging) so the two environments cohabit cleanly.

Key choices, resolved in #497 review:
- Named volumes for persistent data (no host bind mounts)
- MinIO pinned to a specific RELEASE tag (no :latest)
- Backend uses MinIO service account (S3_ACCESS_KEY=archiv-app),
  not root credentials; create-buckets bootstraps the account
- Mailpit lives under profiles: [staging] so no real SMTP secret
  is ever wired into the staging deploy
- OCR mem_limit 12g + healthcheck (start_period 120s) copied from
  the dev compose so docker compose up -d --wait works in CI
- Backend admin credentials wired through APP_ADMIN_USERNAME /
  APP_ADMIN_PASSWORD; first deploy locks the password in
  permanently because UserDataInitializer is idempotent on email
- All host ports bound to 127.0.0.1; Caddy fronts external traffic

Refs #497.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:53:19 +02:00
Marcel
8b109349c2 feat(frontend): add production stage to Dockerfile
Multi-stage Dockerfile with three targets:
- development (dev server on :5173, used by docker-compose.yml)
- build (runs npm run build, produces SvelteKit Node-adapter output)
- production (self-contained node build server on :3000)

Node base pinned to node:20.19.0-alpine3.21 for reproducible CI
builds (Renovate will keep it current).

docker-compose.yml now specifies target: development for the
frontend so dev continues to use the dev-server stage. Without
this, Docker would default to the last stage (production).

Refs #497.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:51:32 +02:00
Marcel
ebd0f671f9 fix(auth): mark /hilfe/transkription as public for prerender
The route exports prerender = true and is listed in
svelte.config.js's prerender.entries. Until now the auth hook
redirected unauthenticated requests to /login, so the prerender
crawler hit a 302 and the build failed with "marked as prerenderable,
but were not prerendered".

Adding the path to PUBLIC_PATHS lets the crawler render the static
HTML; consistent with the route's intent as a public help page.

Surfaced by #497 (the production Docker build is the first place
npm run build runs in CI).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:50:53 +02:00
Marcel
83f022ff4b feat(security): trust X-Forwarded-Proto behind reverse proxy
Adds server.forward-headers-strategy: native so that Jetty honours
X-Forwarded-{Proto,For,Host} from Caddy. Without this, getScheme(),
redirect URLs, and Spring Session "Secure" cookies reflect the
internal http hop instead of the original https client request.

Refs #497.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:33:39 +02:00
Marcel
80ccc0f3c6 fix(test): extend coverage thresholds to all four dimensions
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 6m18s
CI / OCR Service Tests (pull_request) Successful in 43s
CI / Backend Unit Tests (pull_request) Failing after 3m24s
CI / Unit & Component Tests (push) Failing after 6m10s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 3m22s
Add lines, functions, and statements at 80% alongside branches in both
the server (vite.config.ts) and client (vitest.client-coverage.config.ts)
coverage gates — branch-only thresholds allow misleadingly sparse tests to
pass the gate.

Also adds a plugin-sync comment to vitest.client-coverage.config.ts listing
the four Vite plugins mirrored from vite.config.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 18:56:44 +02:00
Marcel
eccecf35e3 ci: add combined coverage gate to unit-tests job
Some checks failed
CI / Unit & Component Tests (push) Failing after 5m54s
CI / Backend Unit Tests (push) Failing after 3m20s
CI / Unit & Component Tests (pull_request) Failing after 5m48s
CI / OCR Service Tests (push) Successful in 38s
CI / OCR Service Tests (pull_request) Successful in 33s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
Runs test:coverage (server v8 + client Istanbul) after tests, hard-gates
on both 80% branch thresholds, and uploads coverage/ as an artifact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 17:51:10 +02:00
Marcel
16f69fff33 feat(test): update test:coverage to run both server and client projects
Sequential && prevents the ENOTEMPTY race on coverage/.tmp. Server
uses v8 via --project=server; client uses the standalone Istanbul config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 17:50:13 +02:00
Marcel
bb374bf2cd feat(test): add Istanbul browser coverage via standalone client config
Vitest 4 silently ignores per-project coverage overrides in test.projects,
so a standalone vitest.client-coverage.config.ts provides the root-level
Istanbul coverage block that Vitest actually honours.

Root vite.config.ts retains the v8 coverage block (reportsDirectory:
coverage/server) for the server project. The client config writes to
coverage/client and instruments all .svelte and .svelte.ts files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 17:49:41 +02:00
Marcel
1a28e3114d build(deps): add @vitest/coverage-istanbul for browser-project coverage
Istanbul instruments code at transpile time and works inside Chromium's
sandbox; v8 coverage is silently a no-op in browser mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 17:21:00 +02:00
Marcel
915ad9f5c6 test(fts): add overflow guard and UUID-as-String regression tests
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m35s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m18s
- searchDocuments_relevance_returns_empty_when_offset_exceeds_maxInt:
  proves the long→int guard fires and findFtsPageRaw is never called
- searchDocuments_relevance_handles_string_uuid_from_jdbc_driver:
  exercises the toFtsPage String fallback branch for JDBC drivers that
  return UUID columns as String instead of java.util.UUID

Addresses Sara's review concerns on PR #488.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:35:01 +02:00
Marcel
143622bf27 refactor(fts): address PR #488 review concerns
- Extract isPureTextRelevance() private static method to replace the
  7-clause inline boolean in searchDocuments
- Guard long→int cast in relevanceSortedPageFromSql to prevent silent
  overflow at page ≥43M (CWE-190)
- resolvePersonName now uses the typed API client (createApiClient)
  instead of raw fetch, aligning with project conventions
- Update DocumentServiceTest stubs to match new FTS path (findFtsPageRaw
  + findAllById instead of findAllMatchingIdsByFts)
- Rewrite page.server.spec.ts person-name tests to mock via path-based
  API dispatch, matching the new api.GET call site

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:35:01 +02:00
Marcel
a3906976e8 test(fts): add integration tests and update unit tests for SQL-paginated relevance
- DocumentFtsPagedIntegrationTest: Testcontainers repo-level tests for
  findFtsPageRaw (page size, window total, last page, no matches, stopword)
- DocumentServiceSortTest: rewritten to stub findFtsPageRaw + findAllById
  for the pure-text RELEVANCE path; verifies filter-active path stays in-memory
- DocumentServiceTest: update two enrichment tests to use new SQL-path stubs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:35:01 +02:00
Marcel
b017da22c3 feat(fts): push FTS pagination into SQL via CTE window function
Pure-text RELEVANCE queries now use findFtsPageRaw (CTE + COUNT(*) OVER())
instead of loading all matching IDs into memory and sorting in-process.
Non-text paths (filters active, DATE sort) still use the in-memory path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:35:01 +02:00
Marcel
fea837b345 refactor(fts): add FtsHit/FtsPage records; rename findRankedIdsByFts -> findAllMatchingIdsByFts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:35:01 +02:00
Marcel
a364e3f69b docs(adr): ADR-008 SQL-level FTS pagination via window-function CTE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:35:01 +02:00
Marcel
7ca44d7df1 fix(db): add indexes on documents.sender_id and document_comments.author_id
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m26s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 3m16s
CI / Unit & Component Tests (pull_request) Failing after 4m33s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 3m16s
Flyway V62 adds idx_documents_sender_id and idx_comments_author_id to speed up
FK-driven queries on the persons page and briefwechsel view. Closes #470.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:31:30 +02:00
Marcel
e975642a4c fix(pdf-controls): add focus-visible ring to all PdfControls buttons (WCAG 2.1 §2.4.7)
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:09:15 +02:00
Marcel
72f422afe2 fix(a11y): increase all PdfControls buttons to 44×44px touch targets
Add min-h-[44px] min-w-[44px] to all five PDF viewer buttons (prev,
next, zoom in, zoom out, annotation toggle) and widen icon-only
padding from p-1 to p-2. Adds aria-pressed to the annotation toggle
for correct toggle semantics (WCAG 2.2 §2.5.8 + ARIA 1.2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:09:15 +02:00
Marcel
6074480482 ci: document Docker socket security trade-off in runner config
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m34s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 4m30s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 3m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:05:19 +02:00
Marcel
5512790d5a ci: track act_runner config with Docker socket mount
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m31s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
Documents the NAS runner configuration needed for Testcontainers.
Must be deployed to the runner host alongside the act_runner binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:03:36 +02:00
Marcel
a158048f45 fix(ci): expose Docker socket env vars for Testcontainers in backend job
DOCKER_HOST makes the socket explicit rather than relying on runner
config propagation; TESTCONTAINERS_RYUK_DISABLED=true avoids Ryuk
watchdog start failures in nested container environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:03:36 +02:00
Marcel
ac999066dd fix(ci): add TZ=Europe/Berlin to frontend test step
date-buckets.spec.ts midnight tests pass timezone-aware dates (+02:00)
which are 22:00 UTC the prior day; setHours(0,0,0,0) uses local TZ.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:03:36 +02:00
Marcel
8b25a5b940 fix(user): replace Math.abs(hashCode()) with Math.floorMod in computeColor
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
Math.abs(Integer.MIN_VALUE) overflows back to Integer.MIN_VALUE (negative),
making the old pattern unsafe for any palette size that doesn't evenly divide
MIN_VALUE. Math.floorMod always returns a non-negative residue in [0, n-1],
eliminating the overflow edge case entirely.

Fixes SpotBugs RV_ABSOLUTE_VALUE_OF_HASHCODE (priority 1, CORRECTNESS).
Closes #471

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:48:59 +02:00
Marcel
265b4f1484 fix(comment): declare missing @PathVariable params on block comment endpoints
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
getBlockComments was missing documentId; replyToBlockComment was missing
blockId. Spring silently ignored undeclared path variables — the segments
were parsed but never bound. Now both parameters are explicitly declared so
Spring rejects non-UUID values with 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:45:48 +02:00
Marcel
bfc3a17676 test(migration): guard cleanup in try-finally to ensure isolation
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m57s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
CI / Unit & Component Tests (push) Failing after 4m1s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 3m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:25:26 +02:00
Marcel
eb54a98ea2 fix(user): use builder in createGroup and guard against null permissions
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m2s
CI / OCR Service Tests (pull_request) Successful in 37s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
Null dto.permissions now produces an empty HashSet instead of propagating null
into the @ElementCollection — prevents a silent NPE after V64 adds NOT NULL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:19:20 +02:00
Marcel
3fcdfa85f1 fix(db): add PRIMARY KEY to group_permissions; promote tbmp UNIQUE to PK
V63 deduplicates any phantom (group_id, permission) rows accumulated since
the initial schema. V64 sets NOT NULL on permission and adds pk_group_permissions.
V65 renames uq_tbmp_block_person to pk_tbmp for naming-convention consistency.
Integration tests confirm each constraint via pg_catalog.pg_constraint. Closes #469 (partial).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:18:46 +02:00
Marcel
cd1c0b210e test(typeahead): note resetKey smoke-test limitation in spec comment
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m19s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 3m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
a239c16c31 fix(documents): sync filter display state with URL on navigation
Three root causes prevented filters from reflecting the URL after SvelteKit
client-side navigation:

1. +page.server.ts now resolves sender/receiver display names in parallel with
   the document search (UUID validation + silent 404 drop), so initialSenderName
   / initialReceiverName land in server data ready for the UI to use.

2. +page.svelte passes initialSenderName, initialReceiverName, and navKey
   (incremented via untrack on every navigation) down to SearchFilterBar.
   The untrack() prevents the effect from re-running due to its own navKey write.

3. SearchFilterBar forwards navKey as resetKey to each PersonTypeahead, which
   already had a void resetKey guard added in the previous commit.

Together these ensure that after navigating to /documents?senderId=<uuid> the
typeahead shows the person's display name, and clicking × reset clears it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
8a8205ad8d fix(person-typeahead): add resetKey prop to clear term on navigation reset
When the user types in the sender/receiver typeahead without selecting a
person and then clicks ×-reset (navigating back to /documents), the
manually-typed term was not cleared because initialName stayed '' between
navigations — the existing $effect tracking initialName never fired.

Adding `resetKey` (incremented by the page on every navigation) forces
the effect to re-run via `void resetKey`, clearing searchTerm=initialName
even when initialName is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
0430383e1c fix(date-input): re-derive display when value prop changes externally
`display` was initialised once and never updated, so the text box would
show a stale German date after the parent reset `value` (e.g. × reset
button or timeline drag). A guarded `$effect` re-derives `display` from
`value` whenever the two are out of sync while preserving mid-typing
partial dates (germanToIso returns '' for incomplete input, which matches
value='' during typing → no spurious re-derive).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:27:24 +02:00
Marcel
e2d74ff880 ci: add npm run build step to unit-tests job
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
The prerender fix only prevents regression if the build is actually run in
CI. Without this gate, a future prerendered route that becomes unreachable
behind auth would fail silently until someone runs the build manually.

Fits after the test step in the existing unit-tests job — no new job needed
since node_modules is already cached for the Playwright container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:25:32 +02:00
Marcel
586eea009b fix(build): add prerender entry for /hilfe/transkription
The SvelteKit prerender crawler cannot reach this route because
hooks.server.ts redirects all non-public paths to /login before the
crawler follows links. Explicitly listing the route in kit.prerender.entries
tells SvelteKit to render it directly without crawling.

Also removes a misleading comment that claimed the auth hook guards
prerendered static files — it does not. Prerendered HTML is served as a
static file by the reverse proxy; hooks.server.ts only runs for SSR requests.

Closes #472

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:25:32 +02:00
Marcel
7c2c4741ab refactor(dashboard): replace new CSS tokens with existing equivalents
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m0s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
mint-soft → accent-bg, line-soft → line-2, link-quiet → ink-2,
ink-4 removed (was never applied to any element).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:12:36 +02:00
Marcel
d464bca9f3 style(dashboard): increase doc row padding py-1.5 → py-3 in ReaderRecentDocs
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m57s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 3m58s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 3m18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:22:55 +02:00
Marcel
2283f733cc refactor(dashboard): align ReaderPersonChips cards with /persons overview style
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
CI / Unit & Component Tests (pull_request) Failing after 4m10s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m33s
- rounded, px-4 py-6, shadow-sm, gap-4 — matches overview card sizing
- hover: left accent border + shadow-md (matches overview hover)
- avatar: h-12 w-12, font-bold (djb2 palette colors kept)
- name: font-bold, group-hover:underline
- doc count: neutral bg-muted chip instead of mint pill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:21:24 +02:00
Marcel
cc20583ae6 fix(dashboard): replace text-brand-navy dark:text-brand-mint with text-ink
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m7s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 4m2s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m25s
text-ink uses --c-ink which is #012851 in light and #f0efe9 in dark, responding
to both @media and [data-theme='dark'] via CSS variable — no extra token needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:40:00 +02:00
Marcel
86d75d91be fix(dashboard): use bg-surface instead of bg-white in ReaderHeaderBar for dark mode
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m8s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
bg-white is hardcoded #fff and only flips via the Tailwind dark: media-query variant.
bg-surface uses a CSS variable (--c-surface) that responds to both the media query
and the [data-theme='dark'] attribute, matching how all other cards on the page work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:35:46 +02:00
Marcel
a98ca0e5d3 fix(dashboard): add dark:text-brand-mint to ReaderHeaderBar greeting and stat numbers
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m2s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:31:14 +02:00
Marcel
1c515a3145 style(dashboard): widen stat columns from px-3 to px-5 in ReaderHeaderBar
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m13s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 3m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:28:51 +02:00
Marcel
43d36c898c feat(dashboard): wire ReaderHeaderBar, grid content row, delete ReaderStatsStrip (#483)
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m46s
CI / OCR Service Tests (push) Successful in 52s
CI / Backend Unit Tests (push) Failing after 3m32s
CI / Unit & Component Tests (pull_request) Failing after 4m0s
CI / OCR Service Tests (pull_request) Successful in 43s
CI / Backend Unit Tests (pull_request) Failing after 3m32s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:13:00 +02:00
Marcel
60326cfb0a refactor(dashboard): ReaderDraftsModule mint left-border, card-head, row structure (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:10:20 +02:00
Marcel
e598f5a506 refactor(dashboard): ReaderRecentStories card-head link, touch targets (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:07:16 +02:00
Marcel
e1c78e3fbe refactor(dashboard): ReaderRecentDocs compact card-head, mint-pill badge (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:01:46 +02:00
Marcel
ae6355d206 refactor(dashboard): ReaderPersonChips → grid layout with mint-pill doc count (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:56:52 +02:00
Marcel
b5f9fcfdfd feat(dashboard): add ReaderHeaderBar with greeting + stat columns (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:54:02 +02:00
Marcel
2f48dfabd1 i18n: add reader header-bar keys, remove dashboard_badge_updated (#483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:50:36 +02:00
Marcel
495210052f style: add mint-soft, line-soft, link-quiet, ink-4 tokens (#483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:48:33 +02:00
Marcel
a072701632 docs(adr): ADR-007 reader-dashboard permission discriminant
Some checks failed
CI / Unit & Component Tests (push) Failing after 6m33s
CI / OCR Service Tests (push) Successful in 1m7s
CI / Backend Unit Tests (push) Failing after 4m31s
Captures the architectural decision behind isReader = !canWrite &&
!canAnnotate, why BLOG_WRITE intentionally lands on the reader
dashboard, the alternatives considered (separate route, AppUser
column, middleware redirect, BLOG_WRITE exclusion), and the
implications for future permission additions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
eac2356948 docs(dashboard): comment isReader discriminant with ADR-007 pointer
Felix and Elicit both flagged that the isReader formula had no
in-code explanation at the point of definition; future maintainers
adding a new permission level need a fast pointer to the architectural
rationale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
d554fc7e6b fix(a11y): darken avatar palette teal for AA contrast against white
#007596 with white initials hits ~4.5:1 — at the AA threshold for
small text. #005F74 lifts it comfortably above 5:1, matching the
contrast margin of the other four palette entries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
7bd477d24e feat(dashboard): empty-state message for ReaderPersonChips
When the top-persons fetch returns an empty list (or fails and
degrades to []), the chip area used to render the heading and the
view-all link with nothing in between, looking like a load failure.
Adds dashboard_reader_no_persons (de/en/es) and renders it above the
chip row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
b1c2132aa6 fix(a11y): view-all links meet 44px touch target
WCAG 2.2 §2.5.8 (Target Size, Minimum). The Alle Personen → and Alle
Geschichten → text links were inline elements with no enforced minimum
height — small tap targets on mobile. inline-flex + min-h-[44px] keeps
the visual layout while guaranteeing the 44px hit area.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
f7eefb525f fix(a11y): Aktualisiert badge passes WCAG AA contrast
text-ink-3 on bg-ink-3/10 (low-saturation grey on lighter grey) gave
roughly 2.8:1 contrast — below the 4.5:1 AA threshold for normal-weight
small text. Switching the foreground to text-ink-1 keeps the muted
background but lifts the text contrast well above 7:1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
500611925d fix(a11y): focus-visible ring on reader-dashboard view-all links
Both view-all links (Alle Personen → in ReaderPersonChips, Alle
Geschichten → in ReaderRecentStories) were missing the
focus-visible:ring-2 ring used by every other interactive element on
the reader dashboard, leaving keyboard users with no visible focus
indicator. WCAG 2.1 §2.4.7 (Focus Visible, Level AA).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
64bcc8d031 test(dashboard): cover the {#if data.isReader} render branch
Adds a readerData fixture and five render-level assertions: the three
ReaderStatsStrip totals, the recent-docs heading, the absent
contributor mission caption, and the drafts module appearing only when
canBlogWrite is true.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
5a8a1898f8 fix(dashboard): isNew compares timestamps numerically, not by ISO string
ISO strings differing only in millisecond precision or timezone
formatting represent the same instant but failed string equality, so
freshly created documents could miss the "Neu" badge depending on
whatever shape the backend serializer emitted.

Browser specs cannot run in the worktree (birpc WebSocket closure
crash documented in the PR description); the new vitest-browser test
must be verified from a normal checkout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
b4f24f4965 fix(api): mark StatsDTO totalPersons + totalDocuments as required
Mirrors what npm run generate:api would emit against the StatsDTO
record (all three @Schema(REQUIRED) annotations). Round-1 fix only
updated totalStories; this brings the other two into line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
9e1754bbb0 docs: add Reader glossary entry + clarifying comments on specs and query
- GLOSSARY.md: defines "Reader" as the permission-derived role
  (isReader = !canWrite && !canAnnotate) — addresses @Markus blocker
- GeschichteSpecifications.hasAuthor: comment explains null = no restriction
  (PUBLISHED path) — addresses @Markus suggestion
- PersonRepository.findTopByDocumentCount: comment explains alias-in-ORDER-BY
  is intentional PostgreSQL behaviour — addresses @Markus suggestion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
797852b494 test(dashboard): add partial-failure resilience test + fix i18n Dok. key
- page.server.spec.ts: new test verifies topPersons=[] when that fetch
  rejects, rest of reader data still loads — addresses @Sara concern
- ReaderPersonChips: replaces hardcoded "Dok." with
  dashboard_reader_doc_count_suffix Paraglide key (de/en/es)
  — addresses @Felix suggestion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
518334bc38 fix(a11y): fix WCAG AA contrast on reader dashboard "view all" links
brand-mint on white is ~2.8:1; brand-navy is ~10:1. Both "Alle Personen"
(ReaderPersonChips) and "Alle Geschichten" (ReaderRecentStories) links
updated: text-brand-navy underline hover:text-brand-mint.

Addresses @Leonie critical review finding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
c8b1a890be refactor(dashboard): extract settled<T>() helper; fix page.svelte.spec.ts types
Collapses 5x duplicated null-check pattern in the reader fetch branch into
a single typed helper — addresses @Felix review blocker.

Also adds isReader/incompleteDocs/incompleteTotal to page.svelte.spec.ts
baseData so it satisfies the discriminated PageData union introduced by this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
1f592958d7 feat(stats): wire totalStories stat tile in reader dashboard
Manually adds totalStories to generated StatsDTO type and wires it from
readerStats into ReaderStatsStrip — resolves @Elicit: stories tile was
permanently showing "—".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
9b5547757a fix(security): cap PersonController size param at 50 to prevent resource exhaustion
Addresses @Nora review: ?sort=documentCount&size=999999 could trigger a
full-table query and large serialization. Cap enforced at controller boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
92587b050e feat(stats): add totalStories to StatsDTO via GeschichteService.countPublished()
Addresses @Elicit review concern: stories stat tile was permanently showing
"—" because StatsDTO had no published-story count. Now wired end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
2be2087a95 feat(dashboard): wire reader layout to +page.svelte
Adds conditional {#if data.isReader} block that renders the 5-zone
reader layout (StatsStrip → DraftsModule → PersonChips → two-column
docs/stories row) for READ_ALL-only users, while preserving the
existing contributor layout for WRITE_ALL / ANNOTATE_ALL users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
4d9234244e feat(dashboard): add reader dashboard components
Adds 5 new components for the permission-gated reader layout:
- ReaderStatsStrip: stat tiles (documents / persons / stories) linking to list pages
- ReaderPersonChips: top-N persons by doc count with avatar + name
- ReaderDraftsModule: blog draft list for BLOG_WRITE users
- ReaderRecentDocs: 5 most-recently-updated docs with Neu/Aktualisiert badge
- ReaderRecentStories: 3 latest published stories with 150-char HTML-stripped excerpt

Each component ships with a vitest-browser spec covering the key assertions.
Avatar color/initials logic is inlined to satisfy $lib/shared → $lib/person
boundary rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
9b82621770 feat(i18n): add reader dashboard message keys (de/en/es)
New keys: reader stats strip, person chips, drafts module, recent docs,
recent stories, Neu/Aktualisiert badges, and all-items links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
a58e796ffa feat(dashboard): add isReader flag + reader branch to page load
Read-only users (no WRITE_ALL or ANNOTATE_ALL) now receive lean reader
data (stats, top-4 persons, 5 recent docs, 3 recent stories, and drafts
when BLOG_WRITE) instead of the contributor transcription queues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
6a46a1e3eb chore(api): update generated types — add UPDATED_AT sort and persons size/sort params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
5b645f6374 feat(person): add findTopByDocumentCount endpoint for reader dashboard
PersonController GET /api/persons?sort=documentCount&size=N returns the top N
persons by combined sender+receiver document count for the reader dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
d76ee5fa31 fix(security): restrict DRAFT list to author — prevent cross-user draft leak
GeschichteService.list() now applies hasAuthor(currentUser()) whenever
status == DRAFT, so BLOG_WRITE users cannot read other users' unpublished stories.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
5146aeb568 feat(document): add DocumentSort.UPDATED_AT for reader dashboard feed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:56:47 +02:00
Marcel
9fd1f3cde2 refactor(documents): extract timeline drag state into rune class (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m5s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m22s
CI / Unit & Component Tests (push) Failing after 3m57s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m22s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:50:34 +02:00
Marcel
5cd6ecc624 refactor(documents): split getDensity into resolve/load/aggregate (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:44:37 +02:00
Marcel
86de118d63 refactor(documents): bundle density filters into a record (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:42:38 +02:00
Marcel
00f35ab675 docs(documents): link density TODO to follow-up #481 (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:39:47 +02:00
Marcel
c0a1f04df5 chore(documents): density endpoint produces=application/json (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:38:29 +02:00
Marcel
7f99c64d45 docs(layout): note light-mode --timeline-bar-idle contrast ratio (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:34:03 +02:00
Marcel
18aaf1f3e8 style(documents): gate timeline bar hover under (hover: hover) (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:32:31 +02:00
Marcel
dd0a77a5a2 feat(documents): focus-visible ring on timeline controls (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:30:25 +02:00
Marcel
f68d16ef58 docs(documents): explain monthBoundaryTo day-zero idiom (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:28:58 +02:00
Marcel
301cfffd1a docs(c4): align density breakpoint with code (≥1024px) (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m4s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
CI / Unit & Component Tests (push) Failing after 4m3s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m22s
The widget hides below the Tailwind lg breakpoint to protect the
44×44 touch-target floor on tablet (Leonie's round-1 finding) but
the diagram still claimed 640px (sm). Update both the docsListPageTs
description, the timelineFilter description, and the relationship
label to match +page.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:07:16 +02:00
Marcel
bf501b7d62 docs(documents): mark clipBucketsToRange as @internal (#385)
The function has a single in-source call site (TimelineDensityFilter)
but is exported so timeline.spec.ts can pin its boundary semantics
without rendering the orchestrator. Note that explicitly so future
readers don't treat the export as a public API contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:05:46 +02:00
Marcel
5d749b2415 refactor(documents): move timeline CSS vars to layout.css (#385)
Defining --timeline-bar-idle / --timeline-bar-outside on :root from
inside a scoped <style> block leaks the contract into the global
namespace via component-local CSS, even though the selector itself
makes it work. Move both variables to layout.css next to the other
--palette / --c-* design tokens; the component <style> now only
consumes them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:05:06 +02:00
Marcel
1d6016cb19 fix(documents): pluralise timeline bar aria-label by count (#385)
The flat "{count} Dokumente / documents / documentos" keys read as
"1 Dokumente" / "1 documents" / "1 documentos" to a screen reader
when only one document falls in the month bucket. Splits each
locale into _singular + _plural keys and picks the form by count
in TimelineBars, mirroring the existing upload_banner_singular /
_plural pattern in this project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:03:19 +02:00
Marcel
48da819a54 feat(documents): focus-visible ring on timeline bar buttons (#385)
Bar buttons rendered with bg-transparent + p-0 fell back to the
default browser outline, which is invisible against bg-surface for
keyboard users. Adds the project-standard focus ring
(ring-2/brand-navy/offset-2) so the focused bar reads as focused.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:00:15 +02:00
Marcel
153752a901 fix(documents): bump timeline control buttons to 44x44 (#385)
WCAG 2.5.8 (target size, AA) requires 44×44 minimum, and the
project's senior persona makes that a hard floor on desktop too.
Reset-zoom: h-6 → h-11 + min-w-[44px] + px-3.
Clear-selection: h-6 w-6 → h-11 w-11.

Two regression tests on the TimelineDensityFilter spec assert the
sized classes so a future shrink can't slip through silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:58:59 +02:00
Marcel
3b6b117c75 fix(documents): cleanup timeline drag listeners on unmount (#385)
Pointerdown attaches three document-level listeners. Without an
explicit teardown, an unmount mid-drag (route change, view toggle,
viewport drops below lg) left them attached and they kept writing
to torn-down state cells.

Wrap the cleanup in $effect's return, which Svelte 5 invokes on
unmount. The listener-removal regression test pins this so the bug
cannot come back silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:56:58 +02:00
Marcel
2e9ce8e1da fix(documents): surface timeline density fetch failures via console.warn (#385)
Previously a 5xx, network blip, or JSON parse error all collapsed
into the same silent "no buckets" rendering. The widget still
degrades gracefully — failure should not block the document list —
but operators and Sentry now see the failure in browser devtools
instead of having to reverse-engineer a missing chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:55:04 +02:00
Marcel
c9be6cc165 test(documents): @Transactional rollback in DocumentDensityIntegrationTest (#385)
Replaces @DirtiesContext(AFTER_EACH_TEST_METHOD), which restarted
the full Spring context per test (≈10–15s × 7), with @Transactional
rollback. Each test still sees a clean slate via the spring-test
default rollback, but the context is shared across the class.

Wall time for this class dropped from 35s to 17.87s in local runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:53:48 +02:00
Marcel
ffe617dba8 docs(documents): note nullable minDate/maxDate on DocumentDensityResult (#385)
The empty-result case returns null for both bounds, which the TS
codegen surfaces as optional. Future contributors should not "fix"
the missing @Schema(REQUIRED) — it is deliberate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:52:03 +02:00
Marcel
47841b9110 refactor(documents): YearMonth.from(d).toString() for density key (#385)
YearMonth.from(d).toString() emits the same canonical YYYY-MM string
as the previous String.format("%04d-%02d", …) call but reads as a
single intent-revealing expression. Existing assertions on
"1915-08", "1916-01", … pin the output format unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:51:21 +02:00
Marcel
360db1ae33 chore(documents): drop V61 timeline density index migration (#385)
The index was added in anticipation of a SQL GROUP BY aggregation,
but DocumentService.getDensity aggregates in memory via
findAll(spec).stream(). The index is never touched by the current
query plan. Per Markus's round-2 review: drop the unused migration
to avoid mismatched rationale-vs-implementation debt. Revisit when
the archive crosses 50k rows (TODO already in getDensity Javadoc).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:49:24 +02:00
Marcel
e5739d7f8e refactor(documents): extract TimelineControls (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m10s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m16s
CI / Unit & Component Tests (push) Failing after 3m54s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m34s
Splits the reset-zoom and clear buttons out of the orchestrator into
their own component. Closes part 3 (final) of Felix's component-split
concern. Orchestrator now composes four single-purpose children
(TimelineBars, TimelineYAxis, TimelineXAxis, TimelineControls) and
keeps only the pointer choreography that links them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:07:45 +02:00
Marcel
219d9a816e refactor(documents): extract Y-axis and X-axis components (#385)
Felix's review named "TimelineAxes" as one of four split targets.
The Y-axis and X-axis don't sit adjacent in the DOM — Y is a flex
sibling of the bars+X column — so two single-purpose components
beats a discriminator-prop component. tickIndicesFor and the
omitTickYear derivation move to TimelineXAxis where they belong.
Closes part 2 of Felix's component-split concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:06:46 +02:00
Marcel
00682bac4f refactor(documents): extract TimelineBars from density filter (#385)
Splits the bar row + drag-window overlay + bar styling out of the
377-line orchestrator into a single-purpose component. The pointer
choreography (handle{PointerDown,DocumentMove,DocumentUp},
indexFromClientX, cleanupDragListeners) stays in the orchestrator
per Felix's note. Closes part 1 of Felix's component-split concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:04:38 +02:00
Marcel
77d282bbeb refactor(documents): split triggerSearch by zoom semantics (#385)
triggerSearch(zoomOverride?) made the call site read "depends on
whether the source event happened to include zoomFrom/zoomTo". Splits
into triggerSearchKeepZoom() and triggerSearchWithZoom(from, to) so
the contract is explicit at every call site. Closes Felix's review
nit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:02:02 +02:00
Marcel
52827ccc87 feat(documents): hide timeline density widget below lg (#385)
Tablet (640–1024px) is exactly the iPad audience for transcribers.
At 240 monthly bars on an 800px column the bars fall to ~3.3px wide,
well below the 44×44 touch-target floor. Bumps the visibility class
from hidden sm:block to hidden lg:block and matches the page.ts
matchMedia gate to (min-width: 1024px). Closes Leonie's tablet
touch-target finding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:00:47 +02:00
Marcel
61d1c1793b fix(documents): bump dark-mode timeline bar contrast to 3.33:1 (#385)
Previous #0d3358 measured 1.44:1 against the dark surface (#011526),
failing WCAG 1.4.11 (Non-text Contrast) for large UI elements.
#3a6e8c clears 3:1 with 3.33:1 while staying in the navy palette.
Closes Leonie's dark-mode contrast finding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:59:29 +02:00
Marcel
c06987da95 style(documents): respect prefers-reduced-motion on timeline bars (#385)
Disables the .bar-fill background-color transition for users who set
prefers-reduced-motion: reduce. Closes Leonie's vestibular-comfort
finding for users running the timeline alongside the live drag
cursor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:57:14 +02:00
Marcel
5028082da4 feat(documents): aria-live drag preview for screen readers (#385)
Adds a visually-hidden polite live region whose text reflects the
current drag range using the existing timeline_dragging_aria_live
i18n key. Closes Leonie's WCAG follow-the-drag-preview gap and turns
the previously orphaned i18n key into used markup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:56:20 +02:00
Marcel
ea106e9414 style(documents): timeline axis text to 12px / h-4 (#385)
text-[10px] failed Leonie's 12px font floor. Bumps Y-axis labels and
the X-axis tick row to text-xs (12px); the X-axis row grows to h-4 to
accommodate the line height. Regression-pinned via two new specs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:54:43 +02:00
Marcel
dfdcacdb85 feat(documents): localise timeline bar aria-label (#385)
Replaces the raw "1915-08 · 5" aria-label, which a screen reader
announces as "1915 dash 08 middle dot 5", with the i18n template
timeline_bar_aria("{when}, {count} ...") and a getLocale-formatted
month/year string. Closes Leonie's WCAG 1.3.1 / 4.1.2 finding and
Felix's localisation flag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:53:18 +02:00
Marcel
c9fb677499 chore(i18n): remove dead timeline keys (#385)
The timeline_count_label, timeline_loading, timeline_filtered_count,
and timeline_zoom_in keys were never referenced from src/. Felix's
review flagged them as 15 dead strings to translate. Removed across
de/en/es; the timeline_dragging_aria_live key is kept and will be
wired up in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:48:10 +02:00
Marcel
6aceafda8e docs(documents): TODO for SQL aggregation at 50k rows (#385)
Documents the in-memory aggregation trade-off in getDensity so the next
perf audit knows the row-count threshold at which to revisit. Addresses
Markus's review concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:20:49 +02:00
Marcel
5d92f5a32b refactor(documents): rework timeline UX after live testing (#385)
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m29s
CI / OCR Service Tests (push) Successful in 48s
CI / Backend Unit Tests (push) Failing after 3m46s
CI / Unit & Component Tests (pull_request) Failing after 4m31s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m31s
Replaces the discrete zoom-in button with a Graylog-style drag-to-zoom
range selector and adds X/Y axis labels so the chart is readable.

Drag interaction
- Pointerdown on a bar attaches document-level pointermove/pointerup/
  pointercancel listeners; pointermove maps clientX to a bar index via
  the row's bounding rect, so the mint-bordered window expands smoothly
  even when the cursor leaves the bar or the chart entirely.
- pointerup commits filter + zoom atomically. Same-bar release on a
  year bar (year-aggregated mode) zooms into that year's months;
  same-bar release on a month bar emits filter-only.
- setPointerCapture removed — it was suppressing pointerenter on
  sibling bars and preventing the drag window from expanding.
- Bar buttons are now h-full so the entire 80 px column is the hit
  target, not just the visible bar height.

Axis labels
- Y-axis: max-count and 0 labels left of the bar area.
- X-axis: tickIndicesFor() picks decadal years for long ranges, evenly
  spaced months for short year-zoom views, January boundaries for
  multi-year month ranges. formatTickLabel() drops the year when the
  visible range is a single year so 12-month zooms read "Jan Feb Mär…".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:54:48 +02:00
Marcel
a6123e1867 feat(documents): zoom-in tool for the timeline (#385)
Adds a zoom action that narrows the visible timeline range to the current
selection so the user can drill from year-level back into month-level
density. Zoom state lives in the URL (zoomFrom / zoomTo) so it survives
reload and is shareable.

- New `clipBucketsToRange(buckets, from, to)` helper applied before the
  >240-month year-aggregate decision, so a zoomed window flips back to
  month bars automatically when the clip narrows the range enough.
- `TimelineDensityFilter` gains `zoomFrom`, `zoomTo`, and `onzoomchange`
  props. Zoom button shown only when a selection exists and we aren't
  already zoomed; reset-zoom shown only when zoomed. Both placed in a
  shared right-edge action cluster alongside the × clear button.
- `+page.ts` reads zoomFrom/zoomTo from the URL and forwards them as
  props. `+page.svelte` extends FilterSnapshot + buildSearchParams, and
  triggerSearch accepts an optional zoom override so the onzoomchange
  callback can write the new pair (or clear them) atomically.
- 7 new component tests + 2 new page-integration tests cover the
  visibility rules and URL writes.
- 4 new unit tests for `clipBucketsToRange`.
- 3 new i18n keys (zoom in / zoom reset / drag aria-live) across de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:23:38 +02:00
Marcel
bd81ff81f9 feat(documents): drag-to-select-range on the timeline (#385)
The original AC required drag-to-select; the MVP shipped with click-only.
This adds pointer-driven range selection while preserving keyboard access:

- Pointer events (pointerdown / pointerenter / pointerup) drive the drag.
  Pointer capture on pointerdown so the cursor leaving the bar still
  produces drag-end events. Live preview class `in-drag-preview` highlights
  the spanning bars while dragging; the URL/list refetch only fires on
  pointerup (Felix R3).
- Click handler kept for keyboard activation (Enter/Space on focused bar).
  A `suppressClick` flag prevents the synthesized click after a mouse
  pointerup from double-emitting.
- Drag from later → earlier still emits ascending boundaries (drag direction
  doesn't matter).
- Existing single-click keyboard selection unchanged.

4 new component tests cover the drag paths plus the live-preview class.
Existing 13 tests (single click, year mode, clear, visibility) still green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:16:48 +02:00
Marcel
76023a99ed feat(documents): timeline density refetches when other filters change (#385)
The +page.ts client-side load now forwards the active /documents URL
filters (q, senderId, receiverId, tag, tagQ, status, tagOp) to
/api/documents/density so the bars recompute when the user narrows the
search. Date bounds (from/to) are deliberately omitted — the chart is
the surface for picking those.

- New `DensityFilters` type and `buildDensityUrl(filters)` helper.
- `fetchDensity` accepts a filter snapshot (defaulting to {} for
  back-compat in tests).
- 6 new unit tests cover URL building, multi-tag repetition, AND/OR
  forwarding, the explicit-no-from/to invariant, and filter-aware fetch.
- Generated API types refreshed against the new backend signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:10:12 +02:00
Marcel
e92e9e452e feat(documents): make density endpoint filter-reactive (#385)
Density bars now recompute when other filters change so the chart always
matches the list it sits above. Selectable filters: q, senderId, receiverId,
tag (multi), tagQ, status, tagOp. 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.

Architectural shift: drop the native SQL GROUP BY in favour of in-memory
grouping over the existing Specification-driven findAll. This composes for
free with all the search predicates (FTS-rank-then-filter, sender/receiver,
tag-with-descendants, tagQ partial match, status, tagOp) and keeps the
density implementation a thin layer on top of searchDocuments. At the
current archive size (~5k docs) this stays well under the p95 200ms target;
Cache-Control: max-age=300 absorbs repeated browse loads.

- Removes findDensityByMonth, findMinMaxDocumentDate, DocumentDateRangeProjection.
- Replaces DocumentService.getDensity(LocalDate, LocalDate) with the
  filter-aware overload.
- Endpoint accepts the same query params as /api/documents/search minus
  paging+sort+from+to.
- DocumentDensityIntegrationTest rewritten as @SpringBootTest covering
  no-filter / sender / tag / status / sender+tag combos via real PostgreSQL.
- DocumentServiceTest unit tests updated to the new signature.
- DocumentControllerTest tests forwarding of senderId+tag+tagOp and q+status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:06:47 +02:00
Marcel
59a2faa145 fix(documents): collapse timeline to year bars when range > 240 months (#385)
Surfaced during proofshot: the production archive spans 1873 → 2023
(≈1809 month bars). With flex-1 + gap-px on a 1280 px container, every
pixel was consumed by gaps and bars rendered at 0 px width — visible as
"empty box, no bars".

Fix:
- Add aggregateToYears(buckets) that sums month counts per year and
  returns YYYY-keyed entries.
- Add selectionBoundaryFrom/To that handle both YYYY and YYYY-MM labels
  (Jan 1 → Dec 31 for years, first → last day for months).
- Component switches to year granularity when the gap-filled month
  sequence exceeds 240 entries (~20 years), keeping each bar clickable.
- Drop the gap-px between bars and add min-w-px so sub-pixel rounding
  still leaves something visible.

5 new tests cover aggregation, boundary helpers, and the component-level
year-mode + click behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:54:02 +02:00
Marcel
8e29f428d7 fix(documents): merge +page.server data into +page.ts return (#385)
SvelteKit's PageData type generation only picks up +page.ts return values
when both files exist, so the runtime-merged server data was invisible to
TypeScript and svelte-check flagged every q/from/to/etc access in
+page.svelte. Spreading data into the +page.ts return restores the merge
at the type level. No runtime behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:29:24 +02:00
Marcel
e8fb8150b7 docs(c4): document timeline density widget across backend+frontend (#385)
- l3-backend-3b: extend DocumentController description to include the
  per-month density aggregation endpoint.
- l3-frontend-3b: add /documents/+page.ts (client-side gated loader) and
  TimelineDensityFilter component, plus relationships to the density
  endpoint and the search dashboard.

Per Markus' follow-up §5: both diagrams are mandatory before merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:17:29 +02:00
Marcel
6786c0112d feat(documents): wire TimelineDensityFilter into /documents/+page (#385)
Mounts the timeline above the result count, hidden on mobile via
\`hidden sm:block\` (defense-in-depth — +page.ts already gates the fetch).
The component's onchange callback updates local from/to and triggers
the existing search reload, so timeline selection composes with the
SearchFilterBar's other filters via AND semantics for free.

3 new page-level integration tests cover: widget renders when density
present, hides when null, and bar click navigates with correct
from/to URL params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:16:05 +02:00
Marcel
d43d73f231 feat(documents): add TimelineDensityFilter component (#385)
Density timeline widget: one bar per month within minDate/maxDate,
proportional heights, click-to-select-month with onchange callback,
and a clear button when a selection is active.

Notable details:
- Hidden entirely when density is null (mobile / calendar view; +page.ts
  controls the gating).
- Zero-count months render at 2 px so the time axis stays readable
  (Leonie's design intent overrides AC's literal "no bar" wording).
- Component-scoped --timeline-bar-idle CSS var for the dim idle color
  (light: mint-tinted rgba; dark: structural navy #0d3358 — meets
  WCAG 1.4.11 3:1 against surface, unlike the spec's #0E2535).
- Clear button is a real <button> with aria-label per Nora's a11y note.
- Bars are <button>s with aria-pressed selection state.
- Drag-range, tooltip, and year-tick labels are deferred for follow-ups —
  the AC-required behaviours (click filter, clear, AND-with-other-filters)
  are all in.

11 vitest-browser tests cover visibility gating, bar rendering with
gap-fill, zero-height floor, and selection/clear callback paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:13:58 +02:00
Marcel
ad82f2e1e2 feat(documents): add fetchDensity helper and /documents/+page.ts (#385)
The density data is fetched only on tablet/desktop (sm:+ breakpoint) and
when ?view=calendar is not set — mobile users and the future calendar view
(#386) skip the request entirely. Lives in +page.ts (client-side) so the
matchMedia gate can run in the browser; +page.server.ts continues to handle
the document search.

Non-ok responses and network failures degrade to an empty bucket list
rather than throwing, so the document list keeps rendering.

5 unit tests cover the gating + graceful degradation paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:06:57 +02:00
Marcel
5fdcc95c3d feat(documents): add timeline helpers (boundary + gap-fill) (#385)
Pure utilities backing the TimelineDensityFilter component:
- monthBoundaryFrom/To convert YYYY-MM into LocalDate strings the existing
  /api/documents/search accepts (first/last day of the month).
- buildMonthSequence enumerates months between minDate and maxDate, crossing
  year boundaries.
- fillDensityGaps merges sparse backend buckets with the full month sequence,
  producing zero-count entries for months that the API omitted.

14 unit tests cover leap years, year boundaries, null inputs, and out-of-order
buckets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:04:21 +02:00
Marcel
142459b916 feat(i18n): add timeline density widget keys (#385)
Five new keys across de/en/es for the upcoming TimelineDensityFilter:
aria label, clear selection, abbreviated count label, loading state, and
parametrised filtered-count message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:02:15 +02:00
Marcel
b31979c4f0 chore(frontend): regenerate API types after density endpoint (#385)
Adds DocumentDensityResult, MonthBucket and the /api/documents/density path
to the openapi-typescript output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:00:58 +02:00
Marcel
1060be7def feat(documents): add GET /api/documents/density endpoint (#385)
Authenticated read endpoint backing the timeline density widget. Optional
from/to LocalDate query params narrow the aggregation. Response carries
Cache-Control: private, max-age=300 so repeated browse sessions skip the
aggregation query (per Tobias' devops review). No @RequirePermission needed —
inherits the global anyRequest().authenticated() rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:52:36 +02:00
Marcel
fbf4725e97 feat(documents): add DocumentService.getDensity (#385)
Maps the repository's Object[] rows into a DocumentDensityResult and pairs
them with the archive-wide min/max meta_date range. Read-only, no
@Transactional needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:50:05 +02:00
Marcel
c90b42d045 feat(documents): add density and date-range repository queries (#385)
Native SQL aggregations backing GET /api/documents/density:
- findDensityByMonth groups documents by truncated meta_date with optional
  from/to bounds (frontend fills zero-count gaps).
- findMinMaxDocumentDate returns the earliest/latest meta_date via projection,
  null on empty archive.

Covered by DocumentDensityIntegrationTest (Testcontainers PostgreSQL): empty
archive, single+multi-month grouping, from/to bounds, null meta_date exclusion,
min/max edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:47:59 +02:00
Marcel
e61e3797d1 feat(documents): add DocumentDensityResult and MonthBucket records (#385)
Response shape for the upcoming GET /api/documents/density endpoint.
minDate and maxDate are nullable (null on empty archive); buckets is always
present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:43:34 +02:00
Marcel
ce0c013f0f feat(documents): add document_date index for density aggregation (#385)
Issue #385 introduces GET /api/documents/density which aggregates documents
by month via date_trunc. Adding the index now keeps the query cheap as the
archive grows and removes a future-investigation tax.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:43:28 +02:00
Marcel
baa0a9811c chore: merge main into branch; resolve ChronikRow conflict
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m21s
CI / OCR Service Tests (pull_request) Successful in 42s
CI / Backend Unit Tests (pull_request) Failing after 3m36s
CI / Unit & Component Tests (push) Failing after 3m46s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 3m17s
TODO/SECURITY placeholders from main are superseded by the #454 implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:05:12 +02:00
Marcel
9ef3c82398 fix(review): address review blockers from PR #475
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 3m51s
CI / OCR Service Tests (pull_request) Successful in 47s
CI / Backend Unit Tests (pull_request) Failing after 3m31s
- CommentData.java: add @Nullable on annotationId to match codebase convention
- DashboardService: isEmpty() → isBlank() for commentPreview null-guard
- ChronikRow.svelte: always set aria-label on comment rows (not only when preview present)
- ChronikRow.svelte.spec.ts: add test for aria-label on comment row without preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:54:56 +02:00
Marcel
708fd9d63e refactor(comment): promote CommentData to top-level record in comment package
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m32s
CI / OCR Service Tests (push) Successful in 47s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 3m32s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m25s
Moves the nested `CommentData` record out of `CommentService` into its own
`document/comment/CommentData.java` file, removing the cross-domain coupling
where `DashboardService` depended on an inner type of `CommentService`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:47:27 +02:00
Marcel
abe8ab8668 refactor(comment): remove dead findAnnotationIdsByIds; fix aria-label i18n; rename misleading test
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m36s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m24s
CI / Unit & Component Tests (push) Failing after 3m30s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 3m22s
- Remove `findAnnotationIdsByIds` from CommentService — no production caller exists now
  that DashboardService uses `findDataByIds` directly; along with its test coverage
- Fix aria-label construction in ChronikRow: pass actorName to i18n message function
  instead of manually prepending the actor, so all locales render correctly
- Rename `findDataByIds_does_not_truncate_at_exactly_120_chars` →
  `findDataByIds_preserves_content_at_exactly_120_chars` for accurate description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:05:46 +02:00
Marcel
e3a3f209f9 feat(chronik): render commentPreview in ChronikRow; add aria-label for screen readers
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m49s
CI / OCR Service Tests (push) Successful in 45s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 3m37s
CI / OCR Service Tests (pull_request) Successful in 48s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
Replace the „…" placeholder with {item.commentPreview ?? '„…"'}. Plain-text
binding — no {@html} — as specified in the security note from issue #285.
Adds aria-label to the <a> wrapper for COMMENT_ADDED rows that carry a preview,
giving screen reader users the full context in one announcement.

Generated api.ts updated manually to include commentPreview?:string; will be
regenerated by npm run generate:api once the backend is running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 18:53:26 +02:00
Marcel
e877847b7e feat(dashboard): add commentPreview to ActivityFeedItemDTO; wire via findDataByIds()
ActivityFeedItemDTO gains a nullable commentPreview field (plain-text, 120 chars max).
DashboardService.getActivity() now calls findDataByIds() once instead of
findAnnotationIdsByIds(), halving DB round-trips for the Chronik page load.
Empty-string previews are normalised to null so the frontend can use ?? cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:55:02 +02:00
Marcel
7c25d08506 feat(comment): add findDataByIds() — batch-fetch annotationId + plain-text preview in one query
Replaces the single-purpose findAnnotationIdsByIds() (kept as delegation shim).
Introduces CommentData record (annotationId + preview) and stripAndTruncate()
using Jsoup.parse().text() for DOM-safe HTML stripping. Truncates to 120 chars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:49:40 +02:00
Marcel
c10e8e8a3a fix(tests): replace flaky waitFor with synchronous dispatchEvent in edit-page delete spec
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m54s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 3m51s
CI / OCR Service Tests (push) Successful in 47s
CI / Backend Unit Tests (push) Failing after 3m19s
The Playwright CDP click latency occasionally pushed past vi.waitFor's 1000ms
deadline, making the "opens a confirm dialog" test flaky. Switched to
btn.dispatchEvent(new MouseEvent(...)) — the same synchronous in-browser pattern
already used in GeschichteEditor.svelte.spec.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:37:13 +02:00
Marcel
0c765d8112 fix(tests): fix 13 pre-existing vitest-browser spec failures
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m54s
CI / OCR Service Tests (pull_request) Successful in 33s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
CI / Unit & Component Tests (push) Failing after 3m48s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m24s
Fixes all remaining failing tests in the browser project. Root cause in
every case: Playwright CDP-based clicks/keyboard events do not reliably
trigger Svelte 5 onclick/onkeydown handlers. Pattern applied throughout:

- Buttons / result items: native `.element().click()` or
  `dispatchEvent(new MouseEvent('click', { bubbles: true }))`
- Keyboard events: `dispatchEvent(new KeyboardEvent('keydown', { key }))`
  on the target DOM element
- TipTap selection: `element.focus()` + Selection API +
  `document.dispatchEvent(new Event('selectionchange'))`
- ProseMirror focus for onFocus: `dispatchEvent(new FocusEvent('focus'))`

Also fixes pre-existing content/logic issues found during analysis:
- ChronikErrorCard, BulkDropZone, CorrespondenzHero: stale i18n strings
  and wrong ARIA role (combobox not textbox)
- RichtlinienRuleCard: beide beispielInput + beispielOutput required for
  arrow to render; querySelectorAll to get last code element
- admin/system/page: vi.unstubAllGlobals() in afterEach; strict-mode
  heading selector; per-call mockResolvedValueOnce for dual-card page
- DocumentList: add total prop + result count paragraph (test relied on it)
- PersonTypeahead keyboard navigation: pressKey() helper with native
  KeyboardEvent dispatch replaces userEvent.keyboard()
- PersonMultiSelect: native element clicks for result selection and
  chip removal; keydown dispatch on result div for Enter key test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:15:54 +02:00
Marcel
cdb54c7545 fix(tests): fix 2 more pre-existing vitest-browser spec failures
TranscriptionEditView: fix 4 failing tests:
- textarea → [role="textbox"] selector (editor is contenteditable, not <textarea>)
- button clicks → dispatchEvent(MouseEvent) for reliable Svelte 5 onclick with TipTap
- mentionedPersons test: init block with @mention token so deserialize() creates a
  mention node; use userEvent.type + vi.waitFor (real timers) instead of fill +
  fake timers, which prevents TipTap onUpdate from firing the debounce timer

EntityNavSection: anchor link click → add capture-phase preventDefault before
clicking to stop iframe navigation while allowing Svelte onclick handler to run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:22:06 +02:00
Marcel
6ab7abb9df fix(tests): fix 3 pre-existing vitest-browser spec failures
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m41s
CI / OCR Service Tests (push) Successful in 43s
CI / Backend Unit Tests (push) Failing after 3m30s
CI / Unit & Component Tests (pull_request) Failing after 3m32s
CI / OCR Service Tests (pull_request) Successful in 40s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
Three distinct root causes:

1. hilfe/transkription: Wikipedia link test was checking .textContent but
   the accessible text had moved to aria-label in a prior commit.

2. documents/[id]/edit: vi.spyOn on a Svelte 5 compiled .svelte.ts service
   object does not reliably track calls in vitest-browser mode; replaced
   with a plain closure-based mock.

3. GeschichteEditor: TipTap's onMount steals focus and its ProseMirror
   view interferes with Playwright CDP event dispatch. Three workarounds:
   - blur: dispatchEvent(new FocusEvent('blur')) bypasses focus-state check
   - save buttons: dispatchEvent(new MouseEvent('click')) from in-browser JS
     context reliably triggers Svelte 5 onclick vs. Playwright CDP click
   - trailing-space fill: input.value + dispatchEvent('input') works where
     userEvent.fill('value ') silently fails to update bind:value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 11:27:24 +02:00
Marcel
d28c455991 cleanup(legibility): repo hygiene — untrack artifacts, update gitignore
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m44s
CI / OCR Service Tests (push) Successful in 44s
CI / Backend Unit Tests (push) Failing after 3m43s
CI / Unit & Component Tests (pull_request) Failing after 3m42s
CI / OCR Service Tests (pull_request) Successful in 43s
CI / Backend Unit Tests (pull_request) Failing after 3m23s
CLEANUP-4 (#415):

Untracked from git (files stay on disk where appropriate):
- frontend/e2e/.auth/user.json — dev credential, already gitignored in
  frontend/.gitignore; git rm --cached so the rule takes effect
- proofshot-artifacts/ (44 files, ~7.6MB) — browser verification
  screenshots committed by mistake; added root .gitignore entry
- frontend/.svelte-kit.old/ — stale type stub from stammbaum route
  rename; deleted from disk
- frontend/test-results.locked/ — Playwright E2E artifacts; deleted
  from disk
- node_modules/.vite/vitest/.../results.json — Vite test cache committed
  by mistake

Deleted from repo:
- package.json / package-lock.json at root (3 testing-library devDeps
  with no justification for living outside frontend/)

.gitignore additions:
- root: proofshot-artifacts/, node_modules/
- frontend: **/test-results.locked/, **/.svelte-kit.old/

After this commit, git status on a fresh clone shows zero unexpected
items (only docs/superpowers/ and familienarchiv-408/ remain untracked,
both pre-existing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:27:09 +02:00
Marcel
0fa90d58cb cleanup(legibility): convert TODOs to issue refs; justify naming violators
CLEANUP-2 (#413): convert two actionable TODOs to issue-referenced stubs
- +layout.server.ts:29 → TODO(#453) for dedicated admin stats endpoint
- ChronikRow.svelte: TODO(#454) for commentPreview; keep SECURITY line
  as standalone comment (XSS guard stays co-located with the risk)

CLEANUP-3 (#414): add one-line justification comments to both naming
violators — SecurityUtils and GlobalExceptionHandler are both justified
by framework convention; no rename needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:25:55 +02:00
Marcel
172bafe202 docs(personas): add concrete doc-update trigger tables to Felix and Markus
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m49s
CI / OCR Service Tests (push) Successful in 44s
CI / Backend Unit Tests (push) Failing after 3m31s
Each persona now has a lookup table mapping specific code changes (new
Flyway migration, new route, new ErrorCode, etc.) to the exact doc files
that must be updated — DB diagrams, C4 diagrams, CLAUDE.md, ADRs, etc.
Markus treats missing updates as PR blockers, not concerns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:01:52 +02:00
Marcel
ba0bfc6a7e docs(db): add Database section to c4-diagrams.md
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m40s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m31s
CI / Unit & Component Tests (push) Failing after 3m52s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 3m34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 23:44:41 +02:00
Marcel
d4b5c14a26 docs(db): add full ORM diagram (db-orm.puml)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 23:44:00 +02:00
Marcel
e209d4877d docs(db): add relationship diagram (db-relationships.puml)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 23:42:46 +02:00
Marcel
66c1998d2f docs(c4): add VS Code PlantUML server config and diagram index
Some checks failed
CI / OCR Service Tests (push) Successful in 58s
CI / Backend Unit Tests (push) Failing after 3m24s
CI / Unit & Component Tests (push) Failing after 12m42s
2026-05-06 22:52:21 +02:00
Marcel
62bef1d267 docs(c4): add L3 frontend 3c/3d and sequence diagrams 2026-05-06 22:52:21 +02:00
Marcel
c3d4762ca0 docs(c4): add L3 frontend 3a middleware/auth and 3b document workflows 2026-05-06 22:52:21 +02:00
Marcel
421d7ffd37 docs(c4): add L3 backend 3e persons, 3f OCR, 3g supporting domains 2026-05-06 22:52:21 +02:00
Marcel
dbf19037fe docs(c4): add L3 backend 3c transcription and 3d users/groups 2026-05-06 22:52:21 +02:00
Marcel
9387fcc17b docs(c4): add L3 backend 3a security and 3b document management 2026-05-06 22:52:21 +02:00
Marcel
264db4e1c9 docs(c4): add L1 context and L2 containers as C4-PlantUML files 2026-05-06 22:52:21 +02:00
Marcel
12f0e21b21 fix(c4): flatten decimal sub-diagram numbering; note invite gate at L1
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m5s
CI / OCR Service Tests (push) Successful in 41s
CI / Backend Unit Tests (push) Failing after 3m33s
- Rename 3b.2→3c, 3c→3d, 3c.2→3e, 3d→3f, 3e→3g to eliminate
  decimal notation that read as version numbers rather than sub-levels
- Update all seven "See diagram X" cross-references to match
- Correct backend intro: "three focused views" → "seven focused sub-diagrams"
- Add "Access by administrator invite." to L1 Family Member description
  to surface the invite-only registration constraint at the context level

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
3e33021129 docs(c4): add cross-diagram stub convention note to header
The C4 standard doesn't define this pattern. Adding a one-sentence
explanation so readers unfamiliar with the project's rendering convention
understand what stub components outside System_Boundary blocks mean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
32396c6253 fix(c4): stammbaum — remove D3 library detail from component description
C4 L3 describes responsibility, not library choice. Removing the D3
reference keeps the description implementation-agnostic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
11b4206fe2 fix(c4): sequence diagram — username → email in auth flow
Three stale references: "Enter username + password", Base64 encode
"user:password", and SELECT WHERE username — all updated to email to
match AppUserRepository.findByEmail() and CustomUserDetailsService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
eede9f93a7 fix(c4): loginPage — username → email in component description
CustomUserDetailsService loads by email, not username. The component
description had a stale "encodes username:password" label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
260bb8e164 fix(c4): correct docBulkEdit endpoint /batch → /bulk
DocumentController has @PatchMapping("/bulk"); the component description
had the wrong path. The Rel in the same diagram was already correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
9b82d8e7dd docs(c4): add Email Service to L1 and L2 — NotificationService and PasswordResetService send SMTP
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
ab6117c87e docs(c4): fix 3e DashboardService — add documentSvc and transcriptionSvc cross-domain stubs
DashboardService.getResume() calls DocumentService.getDocumentById() and
TranscriptionService.listBlocks() — both missing from the diagram.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
b1f9f1603c docs(c4): add OcrJobRepository intermediary in 3d — route ocrAsync through repo, not bare db
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
f2a901eabf docs(c4): fix 3a secFilter description — BCrypt validation is in DaoAuthenticationProvider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
d6ca0f12c9 docs(c4): fix 3d frontend — add User actor for /hilfe/transkription
The help guide is used by all transcribers, not just administrators. Only
showing admin as the actor was misleading about who accesses this route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
537bfb79f0 docs(c4): fix 3a — remove AOP @Around from secFilter→permAspect rel label
The filter chain doesn't invoke the AOP aspect directly — Spring Security
hands off to the servlet and AOP intercepts at the method level. The label
implied a direct invocation chain that doesn't exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
f74b586f29 docs(c4): fix 3b frontend — correct docBulkEdit endpoint to /bulk
DocumentController maps the batch update to PATCH /api/documents/bulk,
not /api/documents/batch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
eb464b351a docs(c4): fix 3c.2 — add PersonRelationshipRepository, route through repo
Both RelationshipService and RelationshipInferenceService inject
PersonRelationshipRepository. The previous direct db arrows were inaccurate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
9ad172084a docs(c4): fix 3d OCR — route transcription/annotation through domain services
OcrAsyncRunner injects TranscriptionService and AnnotationService; it only
accesses the DB directly for OcrJob state (OcrJobRepository). The previous
Rel arrow incorrectly showed direct JDBC access for transcription blocks and
annotations, contradicting the component description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
0582edd840 docs(c4): fix service layer relationships in diagrams 3b and 3b.2
Diagram 3b: DocumentService calls PersonService and TagService, not
their repositories directly. Replace personRepo/tagRepo cross-ref
stubs with personSvc/tagSvc to accurately reflect the layering rule.

Diagram 3b.2: TranscriptionService, AnnotationService, and
CommentService each use a JPA repository, not JDBC directly. Add
TranscriptionBlockRepository, AnnotationRepository, and
CommentRepository components and route the service→repo→db chain.
TranscriptionQueueService delegates to DocumentService and
AuditLogQueryService (no repo of its own); replace the incorrect
→db arrow with cross-diagram stubs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
9986af7c3d docs: remove accidentally committed spec file
Spec file was pre-staged from a prior session and bundled into the previous commit. Specs belong in Gitea issues, not committed to the repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
a4bde0953e docs(c4): fix diagram 3c service layer and add missing 3e components
- diagram 3c: GroupController delegates to UserService (not groupRepo directly)
- diagram 3c: add TagService; TagController delegates to TagService (not tagRepo)
- diagram 3e: add DashboardController serving /api/dashboard/resume|pulse|activity
- diagram 3e: add StatsService; StatsController delegates to StatsService

Addresses blocker feedback from Markus, Felix, and Elicit in PR #448 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
1b55588aee docs(c4): rewrite frontend 3b, add 3c people/stories/discovery, add 3d admin/help
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
1c560289c8 docs(c4): update frontend 3a — hooks layers, add register/forgot/reset routes 2026-05-06 20:00:07 +02:00
Marcel
61e58e98ba docs(c4): add 3d OCR orchestration and 3e supporting domains 2026-05-06 20:00:07 +02:00
Marcel
3608a9723a docs(c4): restructure 3c users/groups, add 3c.2 persons and family graph 2026-05-06 20:00:07 +02:00
Marcel
63f00ce0a0 docs(c4): add 3b.2 transcription pipeline — annotations, blocks, comments 2026-05-06 20:00:07 +02:00
Marcel
0a5b290e6c docs(c4): update 3b document domain — descriptions, batch ops, FTS, presigned URLs 2026-05-06 20:00:07 +02:00
Marcel
ab1a1d1a3d docs(c4): fix 3a security — email field, permitted endpoints 2026-05-06 20:00:07 +02:00
Marcel
9d22a5134f docs(c4): update L1 personas and L2 frontend container description 2026-05-06 20:00:07 +02:00
Marcel
883c3381a7 docs(c4): split L3 monolith diagrams into five focused sub-diagrams
Backend L3 split into 3a (Security & Auth), 3b (Document/File/Import),
3c (People/Users/Groups). Frontend L3 split into 3a (Middleware/Auth/Layout)
and 3b (Pages & Shared Components). Each sub-diagram stays within dagre's
clean-layout range (5–10 components, 6–12 relationships).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:00:07 +02:00
Marcel
f34967f764 docs(spec): address review blockers and concerns in reader dashboard spec
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m35s
CI / OCR Service Tests (pull_request) Successful in 37s
CI / Backend Unit Tests (pull_request) Failing after 3m20s
CI / Unit & Component Tests (push) Failing after 3m30s
CI / OCR Service Tests (push) Successful in 42s
CI / Backend Unit Tests (push) Failing after 3m23s
- Fix 8 desktop + 8 mobile dark-mode CSS contrast failures (WCAG AA):
  muted #3A4568→#7080A8, labels #323850→#6070A0, dim #262E48→#5A6888
- Fix 4 light-mode contrast failures: HSTAT-LABEL/DOC-DATE/STORY-META
  #B8B4AE/#C8C4BE→#706C68; PERSONS-ALL opacity hack→direct #4A6E8A
- Fix 3 inline style="color:#262E48" dash elements in dark body HTML
- Add DK-→Tailwind dark: equivalent mapping to dark-mode CSS comment
- Add impl-ref table with exact Tailwind classes per UI region
- Add i18n key catalog annotation (10 new messages/*.json keys)
- Annotate stat link routes (/documents, /persons, /geschichten)
  and update all spec hrefs to real routes
- Update dark-mode annotation sidebar with corrected token values

Addresses Leonie's 3 blockers (WCAG contrast + impl-ref table) and
Felix's 4 suggestions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:26:31 +02:00
Marcel
12487d187f docs(spec): reader dashboard final spec (#447)
Some checks failed
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m13s
CI / Unit & Component Tests (pull_request) Failing after 3m30s
CI / Backend Unit Tests (pull_request) Failing after 3m18s
CI / Unit & Component Tests (push) Failing after 3m38s
CI / OCR Service Tests (push) Successful in 35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:42:57 +02:00
Marcel
d01b9a7508 docs(claude-md): replace hex values with CSS var refs, expand route trees
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m31s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m16s
CI / Unit & Component Tests (push) Failing after 3m23s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 3m19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:01:40 +02:00
Marcel
d69a3abc3b docs(personas): fix stale brand data in ui_expert persona
Update hex values → CSS var references, fix font (Merriweather→Tinos),
card pattern (border-brand-sand→border-line, bg-white→bg-surface),
and contrast table to remove hardcoded hex in favour of --palette-* names.

Addresses Leonie's review blocker on PR #446.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:58:39 +02:00
Marcel
5c72364899 docs: fix stale CLAUDE.md content after design-system refactoring
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m45s
CI / OCR Service Tests (push) Successful in 44s
CI / Backend Unit Tests (push) Failing after 3m25s
CI / Unit & Component Tests (pull_request) Failing after 3m29s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
Brand colors, font name, dev port, route tree, and card pattern were
all outdated relative to layout.css and the current route structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:49:47 +02:00
Marcel
50b18f0849 docs(legibility): fix three review blockers in DOC-7
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m29s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 3m29s
- docs/README.md: remove duplicate infrastructure/ entry at end of folder tree
- ocr-service/CLAUDE.md: add **LLM reminder:** prefix to ALLOWED_PDF_HOSTS
  SSRF warning (consistent with all other machine-readable instructions)
- backend/CLAUDE.md: restore ResponseStatusException note for simple controller
  validation — avoids LLMs reaching for DomainException for trivial checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:41:02 +02:00
Marcel
6cf5405b7a chore: remove accidentally staged familienarchiv-408 submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:41:02 +02:00
Marcel
86c13a230c docs(legibility): migrate CLAUDE.md rules into human docs — DOC-7
Processes all 7 CLAUDE.md files according to the 3-bucket classification.
Migration targets (CONTRIBUTING.md, docs/ARCHITECTURE.md, docs/DEPLOYMENT.md,
domain READMEs) are introduced by DOC-2/4/5/6 — this PR must merge last.

### scripts/CLAUDE.md → scripts/README.md
New `scripts/README.md` with full script documentation (preserving the
⚠️ destructive-operation warning on reset-db.sh). `scripts/CLAUDE.md`
reduced to a pointer + "document new scripts in README.md" reminder.

### .devcontainer/CLAUDE.md → .devcontainer/README.md
New `.devcontainer/README.md` with all configuration, usage, and limitations.
`devcontainer/CLAUDE.md` reduced to a single pointer line.

### docs/CLAUDE.md → docs/README.md
New `docs/README.md` covering the folder structure, ADR guide, infrastructure
docs, and specs folder. `docs/CLAUDE.md` reduced to pointer + ADR reminder.

### ocr-service/CLAUDE.md
Reduced to pointer to `ocr-service/README.md` (content migrated in DOC-6).
Kept LLM reminders: single-node constraint, ALLOWED_PDF_HOSTS SSRF risk.

### backend/CLAUDE.md
- Layering Rules → pointer to docs/ARCHITECTURE.md
- Error Handling → pointer to CONTRIBUTING.md + reminder
- Security/Permissions → pointer to docs/ARCHITECTURE.md + reminder
- Package Structure → tagged TODO post-REFACTOR-1
- Fixed errors.ts path to frontend/src/lib/shared/errors.ts
- Added ANNOTATE_ALL + BLOG_WRITE to permission list
- Key Entities, Entity Code Style, Services → kept (Bucket-2)

### root CLAUDE.md
- Stack, Infrastructure, Dev Container → pointers
- Layering Rules, Error Handling, Security, OpenAPI, API Client,
  Date Handling, UI Components, Frontend Error Handling → pointers + reminders
- Package Structure → tagged TODO post-REFACTOR-1
- Domain Model, Entity Code Style, Form Actions, Styling → kept (Bucket-2)

### frontend/CLAUDE.md
- API Client Pattern, Date Handling → pointers + reminders
- Key UI Components → pointer to domain READMEs
- Styling, Form Actions, How to Run, Vite Proxy, i18n → kept (Bucket-2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:41:02 +02:00
Marcel
513fda2888 fix(docs): correct person/notification domain README signatures
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
- person/README.md: findAll(String q) and findByName(String firstName, String lastName)
- notification/README.md: replace 'None inbound' with actual outbound dep on DocumentService.findTitlesByIds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:36:38 +02:00
Marcel
995c696c6a docs(legibility): fix four more signature/accuracy blockers in domain READMEs
- notification: remove phantom NotificationPreferenceRepository entity; fix
  notifyReply signature (DocumentComment + Set<UUID>, not parentComment/reply)
- tag: correct delete(UUID) description — TagService.delete() is called BY
  DocumentService.deleteTagCascading(), not the other way around
- person: fix findOrCreateByAlias to single-String signature; type classification
  is internal to PersonTypeClassifier
- dashboard: replace fabricated cross-domain calls with verified ones
  (removed NotificationService + GeschichteService; added TranscriptionService,
  UserService, CommentService per actual DashboardService imports)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:36:38 +02:00
Marcel
9b2ed48689 docs(legibility): fix two method signature blockers in domain READMEs
- notification/README.md: notifyMentions second param is DocumentComment, not String contextUrl
- document/README.md: transcription queue methods take int limit param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:36:38 +02:00
Marcel
a1b89670c0 docs(legibility): add 18 per-domain README.md files (DOC-6)
Backend (9): document, person, tag, user, geschichte, notification,
ocr, audit, dashboard.
Frontend (8): document, person, tag, user, geschichte, notification,
ocr, shared.
OCR service (1): ocr-service/README.md.

Each README covers: what the domain owns, explicit non-ownership,
public surface (verified by grep against the codebase), internal
layout, and cross-domain dependencies.

Closes #400
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:36:38 +02:00
Marcel
a3c17750cd fix(docs): correct DEPLOYMENT.md env var name and prod overlay note
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
- Security checklist: OCR_TRAINING_TOKEN → APP_OCR_TRAINING_TOKEN (backend)
  plus TRAINING_TOKEN (OCR service); both must share the same value
- Bootstrap: clarify docker-compose.prod.yml is not committed — must be
  created from docs/infrastructure/production-compose.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:35:23 +02:00
Marcel
83db80b867 docs(legibility): fix two blockers in DEPLOYMENT.md
- Use correct container name archive-db (not familienarchiv-db-1) in
  §5 backup/restore commands — verified against docker-compose.yml
- Add KRAKEN_MODEL_PATH to OCR service env vars table (was missing;
  set at docker-compose.yml:92 as /app/models/german_kurrent.mlmodel)

Refs #399
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:35:23 +02:00
Marcel
a944563560 docs(legibility): write docs/DEPLOYMENT.md — Day-1 checklist and operational reference
Covers: topology diagram (Mermaid), OCR memory/VPS sizing table,
dev-vs-prod differences, complete env vars table (all vars verified
against docker-compose.yml and application.yaml, including APP_ADMIN_*
and ALLOWED_PDF_HOSTS gaps not in .env.example), security checklist
before first boot, bootstrap sequence, logs, backup current state vs
planned, common operational tasks, and known limitations with ADR links.

Closes #399
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:35:23 +02:00
Marcel
8225baf578 docs(legibility): fix two blockers in CONTRIBUTING.md
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
- Clarify docs/ARCHITECTURE.md link with interim pointer to
  docs/architecture/c4-diagrams.md until DOC-2 PR merges
- Remove ./mvnw checkstyle:check — no checkstyle plugin in pom.xml;
  replace with ./mvnw test and ./mvnw clean package -DskipTests

Refs #398
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:31:55 +02:00
Marcel
bab30fe29c docs(legibility): write CONTRIBUTING.md with three concrete walkthroughs
Covers environment setup, daily workflow, three walkthroughs (add domain,
add endpoint, add frontend page), and a conventions reference. All file
paths verified against current main. Walkthroughs follow TDD order (Red
before Green). Resolves all persona feedback from issue #398.

Closes #398
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:31:55 +02:00
Marcel
69b564b34b docs(legibility): fix three factual errors in ARCHITECTURE.md
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
- Add ANNOTATE_ALL to the Permission enum listing (was missing)
- Fix transcription block autosave endpoint: PUT not PATCH,
  correct path /api/documents/{documentId}/transcription-blocks/{blockId}
- Clarify auth injection: hooks.server.ts handleFetch injects the
  Authorization header, not the SvelteKit action directly

Refs #396
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:30:48 +02:00
Marcel
fc53038af2 docs(legibility): write docs/ARCHITECTURE.md
Human-targeted architecture doc: high-level diagram, 7 Tier-1 + 2
Tier-2 domains, cross-cutting layer, stack-symmetry principle, 6 ADR
summaries, layering rule, permission system, and two data-flow
walkthroughs (document upload, transcription block autosave).

Closes #396
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:30:48 +02:00
Marcel
869885eb78 docs(legibility): update c4-diagrams.md L2 — add ocr-service, SSE, presigned URL
Refs #396
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:30:48 +02:00
Marcel
a9b8e19dea docs(legibility): add README reference line to root CLAUDE.md — DOC-1
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m2s
CI / OCR Service Tests (push) Successful in 1m9s
CI / Backend Unit Tests (push) Failing after 3m43s
Single pointer line at the top: humans read README.md, LLMs read CLAUDE.md.
No existing content removed — full migration is DOC-7's responsibility.

Refs #395

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:01:16 +02:00
Marcel
080e8eb55f docs(legibility): write human-targeted README.md at repo root — DOC-1
Five-section front door for new contributors: product description,
subsystem map, quick-start (local dev + full Docker variant), where-to-go-next
with TODO markers for DOC-2/4/5, and one-line private license.

Corrects stale port reference (3000→5173, per vite.config.ts).
Links docs/GLOSSARY.md, docs/adr/, docs/architecture/c4-diagrams.md,
and Gitea issue tracker with LAN qualifier.

Closes #395

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:01:16 +02:00
Marcel
a5f4b0df31 docs(legibility): link GLOSSARY.md from COLLABORATING.md — DOC-3
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m28s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m29s
CI / Unit & Component Tests (push) Failing after 3m26s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 3m17s
Adds a glossary pointer in the Code Style section so contributors
encounter domain terminology (Person vs AppUser, etc.) at the right moment.

Refs #397

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:29:07 +02:00
Marcel
9dae044eec docs(legibility): link GLOSSARY.md from c4-diagrams.md — DOC-3
Adds a temporary GLOSSARY link at the top of the C4 diagrams document.
DOC-2 (ARCHITECTURE.md) will own the permanent cross-reference when it lands.

Refs #397

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:28:10 +02:00
Marcel
5302075124 docs(legibility): write docs/GLOSSARY.md — DOC-3
Disambiguates all overloaded terms in the codebase: Person vs AppUser,
Chronik (internal) vs Aktivität (user-facing), TranscriptionBlock polygon
vs bounding box, DocumentVersion append-only convention, OcrJob lifecycle,
SenderModel as persistent entity, Audit log DB-layer caveat, and more.

Includes Pending Terms section for audit follow-ups (#388–#392).

Refs #397

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:27:13 +02:00
247 changed files with 12486 additions and 5552 deletions

View File

@@ -410,6 +410,23 @@ Never Kafka for teams under 10 or <100k events/day. Never gRPC inside a monolith
4. Identify missing database-layer enforcement (constraints, RLS)
5. Check transport choices — simpler protocol available?
6. Propose a concrete simpler alternative, not just a critique
7. Verify documentation currency. For each category below, check whether the PR triggered the update. Flag missing updates as blockers.
| PR contains | Required doc update |
|---|---|
| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` |
| New `@ManyToMany` join table or FK | Both DB diagrams |
| New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` |
| New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` |
| New SvelteKit route | `CLAUDE.md` route table + matching `docs/architecture/c4/l3-frontend-*.puml` |
| New Docker service or infrastructure component | `docs/architecture/c4/l2-containers.puml` + `docs/DEPLOYMENT.md` |
| New external system integrated | `docs/architecture/c4/l1-context.puml` |
| Auth or upload flow change | `docs/architecture/c4/seq-auth-flow.puml` or `docs/architecture/c4/seq-document-upload.puml` |
| New `ErrorCode` or `Permission` value | `CLAUDE.md` + `docs/ARCHITECTURE.md` |
| New domain concept or term | `docs/GLOSSARY.md` |
| Architectural decision with lasting consequences | New ADR in `docs/adr/` |
A doc omission is a blocker, not a concern — the PR does not merge until the diagram or text matches the code.
### Designing Systems
1. Start with the data model — get the schema right before application code

View File

@@ -980,6 +980,24 @@ Mark with `@pytest.mark.asyncio` so pytest runs the coroutine. Without it, the t
5. Refactor — apply clean code, extract if 3+ duplications, rename for intent
6. Repeat for the next behavior
7. When all behaviors are green, review for SOLID violations across the full stack
8. Update documentation before opening the PR. Use the table below to know which doc to touch.
| What changed in code | Doc(s) to update |
|---|---|
| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) |
| New `@ManyToMany` join table or FK relationship | Both DB diagrams above |
| New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain |
| New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain |
| New SvelteKit route (`+page.svelte`) | `CLAUDE.md` (route structure section) **and** the matching `docs/architecture/c4/l3-frontend-*.puml` diagram |
| New Docker service / infrastructure component | `docs/architecture/c4/l2-containers.puml` **and** `docs/DEPLOYMENT.md` |
| New external system integrated (new API, new S3 bucket, etc.) | `docs/architecture/c4/l1-context.puml` |
| Auth flow or document-upload flow changes | `docs/architecture/c4/seq-auth-flow.puml` or `docs/architecture/c4/seq-document-upload.puml` |
| New `ErrorCode` enum value | `CLAUDE.md` error handling section **and** `CONTRIBUTING.md` |
| New `Permission` enum value | `CLAUDE.md` security section **and** `docs/ARCHITECTURE.md` |
| New domain term introduced (entity name, status, concept) | `docs/GLOSSARY.md` |
| Architectural decision with lasting consequences (new tech, new transport protocol, new pattern) | New ADR in `docs/adr/` |
Skip a doc only if the change genuinely does not affect what that doc describes.
### Reviewing Code
1. TDD evidence — are there tests? Do they precede the implementation?

View File

@@ -38,10 +38,10 @@ Screen readers and search engines rely on landmarks to navigate. Every page need
2. **Use CSS custom properties for all brand colors**
```css
/* layout.css */
--color-ink: #002850;
--color-accent: #A6DAD8;
--color-surface: #E4E2D7;
/* layout.css — semantic tokens backed by CSS variables (see --palette-* for raw values) */
--color-ink: var(--c-ink);
--color-accent: var(--c-accent);
--color-surface: var(--c-surface);
```
```svelte
<div class="text-ink bg-surface border-line">
@@ -103,9 +103,9 @@ unsaved work without warning.
1. **Enforce WCAG AA contrast ratios**
```
brand-navy (#002850) on white: 14.5:1 -- AAA pass
brand-mint (#A6DAD8) on navy: 7.2:1 -- AAA pass for large text
Gray-500 on white: check >= 4.5:1 -- AA minimum for body text
brand-navy (--palette-navy) on white: ~14.5:1 -- AAA pass (verify exact value in layout.css)
brand-mint (--palette-mint) on navy: ~7.2:1 -- AAA pass for large text
Gray-500 on white: check >= 4.5:1 -- AA minimum for body text
```
Always verify contrast with a tool. AA is the floor (4.5:1 normal text, 3:1 large text). Target AAA (7:1) for body copy.
@@ -134,8 +134,8 @@ Color-blind users (8% of men) cannot distinguish status by color alone. Always p
/* Silver #CACAC9 on white = 1.5:1 -- fails all WCAG levels */
.caption { color: #CACAC9; }
/* brand-mint on white = 2.8:1 -- fails AA for normal text */
.label { color: #A6DAD8; }
/* brand-mint on white = ~2.8:1 -- fails AA for normal text */
.label { color: var(--palette-mint); }
```
Test every text color against its background. Decorative palette colors are for borders and backgrounds, not text.
@@ -338,7 +338,7 @@ Test at 320px (small phone), 768px (tablet), and 1440px (desktop). Review diffs
<table>
<tr><td>Section title</td><td><code>text-xs font-bold uppercase tracking-widest</code></td>
<td>12px / 700</td><td>Most commonly undersized</td></tr>
<tr><td>Card container</td><td><code>bg-white shadow-sm border border-brand-sand rounded-sm p-6</code></td>
<tr><td>Card container</td><td><code>bg-surface shadow-sm border border-line rounded-sm p-6</code></td>
<td>padding 24px</td><td></td></tr>
</table>
</div>
@@ -376,10 +376,10 @@ await page.setViewportSize({ width: 1440, height: 900 });
## Domain Expertise
### Brand Palette
- **Primary**: brand-navy `#002850` (text, buttons, headers), brand-mint `#A6DAD8` (accents, hover), brand-sand `#E4E2D7` (backgrounds, borders)
- **Typography**: `font-serif` (Merriweather) for body/titles, `font-sans` (Montserrat) for labels/UI chrome
- **Card pattern**: `bg-white shadow-sm border border-brand-sand rounded-sm p-6`
- **Section title**: `text-xs font-bold uppercase tracking-widest text-gray-400 mb-5`
- **Primary**: `brand-navy` (`--palette-navy`) — text, buttons, headers; `brand-mint` (`--palette-mint`) — accents, hover; sand (`--palette-sand`) — page background (use `bg-canvas` or `bg-surface` as Tailwind utilities, not `bg-brand-sand`)
- **Typography**: `font-serif` (Tinos) for body/titles, `font-sans` (Montserrat) for labels/UI chrome
- **Card pattern**: `bg-surface shadow-sm border border-line rounded-sm p-6`
- **Section title**: `text-xs font-bold uppercase tracking-widest text-ink-3 mb-5`
### Dual-Audience Design (25-42 AND 60+)
- Seniors: 16px minimum body text (prefer 18px), 44px touch targets (prefer 48px), redundant cues, calm layouts, persistent navigation, no timed interactions

View File

@@ -1,96 +1,3 @@
# Dev Container — Familienarchiv
# Dev Container
## Overview
VS Code Dev Container configuration for a pre-configured development environment. Includes Java 21, Maven, and Node.js 24 — everything needed to work on both backend and frontend.
## Configuration
File: `.devcontainer/devcontainer.json`
### Included Features
| Feature | Version | Purpose |
|---|---|---|
| Java | 21 | Spring Boot backend |
| Maven | bundled with Java feature | Build tool |
| Node.js | 24 | SvelteKit frontend |
### VS Code Extensions (Auto-installed)
| Extension | Purpose |
|---|---|
| `vscjava.vscode-java-pack` | Java language support, debugging, testing |
| `vmware.vscode-spring-boot` | Spring Boot tooling |
| `gabrielbb.vscode-lombok` | Lombok annotation support |
| `humao.rest-client` | HTTP request files (for `backend/api_tests/`) |
### Ports
- `8080` forwarded to host — access backend at `http://localhost:8080`
### User
Runs as `vscode` user (not root) for security.
## How to Use
### Prerequisites
- VS Code with the **Dev Containers** extension installed
- Docker running locally
### Open in Dev Container
1. Open the project in VS Code
2. Press `F1` → type "Dev Containers: Reopen in Container"
3. VS Code will:
- Build the container using the root `docker-compose.yml`
- Install Java 21, Maven, and Node 24
- Install the listed extensions
- Mount the workspace folder
### Working Inside the Container
Once inside the container, you have access to both stacks:
```bash
# Backend
cd backend
./mvnw spring-boot:run
# Frontend (in a new terminal)
cd frontend
npm install
npm run dev
```
The container reuses the `docker-compose.yml` services, so PostgreSQL and MinIO are available automatically.
### Forwarding Frontend Port
The devcontainer config only forwards port 8080 by default. To access the frontend dev server (port 5173 or 3000), either:
1. Add `5173` to `forwardPorts` in `devcontainer.json`, or
2. Use the VS Code "Ports" panel to forward it dynamically
## Limitations
- The devcontainer attaches to the `backend` service from `docker-compose.yml`, so it inherits those environment variables
- OCR service and other containers should be started separately via `docker-compose up -d`
- GPU passthrough for OCR training is not configured
## Customization
To add more tools or extensions, edit `.devcontainer/devcontainer.json`:
```json
{
"features": {
"ghcr.io/devcontainers/features/python:1": {
"version": "3.11"
}
},
"forwardPorts": [8080, 5173, 3000]
}
```
→ See [.devcontainer/README.md](./README.md) for configuration, usage, and known limitations.

94
.devcontainer/README.md Normal file
View 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]
}
```

View File

@@ -39,6 +39,48 @@ jobs:
- name: Run unit and component tests
run: npm test
working-directory: frontend
env:
TZ: Europe/Berlin
- name: Run coverage (server + client)
run: npm run test:coverage
working-directory: frontend
env:
TZ: Europe/Berlin
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-reports
path: frontend/coverage/
- name: Build frontend
run: npm run build
working-directory: frontend
# ── Prerender output is exactly the public help page ───────────────────
# SvelteKit prerender + crawl follows nav links and bakes "redirect to
# /login" HTML for every protected route, served BEFORE runtime hooks
# (see #514). With `crawl: false` only the explicit entry should land
# in build/prerendered/. Anything else is a regression — fail the build.
- name: Assert prerender output is only /hilfe/transkription
run: |
cd frontend
set -e
extra=$(find build/prerendered -type f \
-not -path 'build/prerendered/hilfe/*' \
-not -name '*.br' -not -name '*.gz' \
|| true)
if [ -n "$extra" ]; then
echo "FAIL: unexpected prerendered files (would shadow runtime hooks):"
echo "$extra"
exit 1
fi
# And the help page must still be there.
test -f build/prerendered/hilfe/transkription.html \
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
echo "PASS: only /hilfe/transkription.html prerendered."
- name: Upload screenshots
if: always()
@@ -74,6 +116,8 @@ jobs:
runs-on: ubuntu-latest
env:
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
DOCKER_HOST: unix:///var/run/docker.sock
TESTCONTAINERS_RYUK_DISABLED: "true"
steps:
- uses: actions/checkout@v4
@@ -93,4 +137,123 @@ jobs:
run: |
chmod +x mvnw
./mvnw clean test
working-directory: backend
working-directory: backend
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
# the JSON keys would silently break it (fail2ban-regex would return
# "0 matches", fail2ban would stop banning, no error surface). This job
# pins the contract against a deterministic sample line.
fail2ban-regex:
name: fail2ban Regex
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install fail2ban
run: |
sudo apt-get update
sudo apt-get install -y fail2ban
- name: Matches /api/auth/login 401
run: |
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":401}' > /tmp/sample.log
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
echo "$out"
echo "$out" | grep -qE '1 matched' \
|| { echo "expected 1 match for /api/auth/login 401"; exit 1; }
- name: Matches /api/auth/login 429
run: |
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":429}' > /tmp/sample.log
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
echo "$out"
echo "$out" | grep -qE '1 matched' \
|| { echo "expected 1 match for /api/auth/login 429"; exit 1; }
- name: Matches /api/auth/forgot-password 401
run: |
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/forgot-password"},"status":401}' > /tmp/sample.log
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
echo "$out"
echo "$out" | grep -qE '1 matched' \
|| { echo "expected 1 match for /api/auth/forgot-password 401"; exit 1; }
- name: Does not match /api/auth/login 200
run: |
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":200}' > /tmp/sample.log
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
echo "$out"
echo "$out" | grep -qE '0 matched' \
|| { echo "expected 0 matches for /api/auth/login 200"; exit 1; }
- name: Does not match /api/documents (unrelated 401)
run: |
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"GET","host":"archiv.raddatz.cloud","uri":"/api/documents"},"status":401}' > /tmp/sample.log
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
echo "$out"
echo "$out" | grep -qE '0 matched' \
|| { echo "expected 0 matches for /api/documents 401"; exit 1; }
# ── Backend resolves to file-polling, not systemd ─────────────────────
# The Debian/Ubuntu fail2ban package ships defaults-debian.conf with
# `[DEFAULT] backend = systemd`. Without `backend = polling` in our
# jail, the daemon loads the jail but reads from journald and never
# touches /var/log/caddy/access.log — i.e. the regex above passes in
# isolation while the live jail is inert. See issue #503.
- name: Jail resolves with polling backend (not inherited systemd)
run: |
sudo ln -sfn "$PWD/infra/fail2ban/jail.d/familienarchiv.conf" /etc/fail2ban/jail.d/familienarchiv.conf
sudo ln -sfn "$PWD/infra/fail2ban/filter.d/familienarchiv-auth.conf" /etc/fail2ban/filter.d/familienarchiv-auth.conf
dump=$(sudo fail2ban-client -d 2>&1)
echo "$dump" | grep -E "add.*familienarchiv-auth" || true
echo "$dump" | grep -qE "\['add', 'familienarchiv-auth', 'polling'\]" \
|| { echo "FAIL: familienarchiv-auth jail did not resolve to 'polling' backend"; exit 1; }
# ─── Compose Bucket-Bootstrap Idempotency ─────────────────────────────────────
# docker-compose.prod.yml's create-buckets service runs on every
# `docker compose up` (one-shot, no restart). Must be idempotent — a
# re-deploy must not fail just because the bucket / user / policy
# already exists. Validated by running create-buckets twice against a
# throwaway minio stack and asserting both invocations exit 0.
compose-idempotency:
name: Compose Bucket Idempotency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Write stub env file
run: |
cat > .env.test <<'EOF'
TAG=test
PORT_BACKEND=18080
PORT_FRONTEND=13000
APP_DOMAIN=localhost
POSTGRES_PASSWORD=stub
MINIO_PASSWORD=stubrootpassword
MINIO_APP_PASSWORD=stubapppassword
OCR_TRAINING_TOKEN=stub
APP_ADMIN_USERNAME=admin@local
APP_ADMIN_PASSWORD=stub
MAIL_HOST=mailpit
MAIL_PORT=1025
APP_MAIL_FROM=noreply@local
EOF
- name: Bring up minio
run: |
docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test up -d --wait minio
- name: First create-buckets run
run: |
docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test run --rm create-buckets
- name: Second create-buckets run (idempotency check)
run: |
docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test run --rm create-buckets
- name: Teardown
if: always()
run: |
docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test down -v
rm -f .env.test

View File

@@ -0,0 +1,138 @@
name: nightly
# Builds and deploys the staging environment from main every night.
# Runs on the self-hosted runner using Docker-out-of-Docker (the docker
# socket is mounted in), so `docker compose build` produces images on
# the host daemon and `docker compose up` consumes them directly — no
# registry hop.
#
# Operational assumptions (see docs/DEPLOYMENT.md §3 for the full setup):
#
# 1. Single-tenant self-hosted runner. The "Write staging env file" step
# writes every secret to .env.staging on the runner filesystem; the
# `if: always()` cleanup step removes it. A multi-tenant runner
# would need to switch to docker compose --env-file <(stdin) instead.
#
# 2. Host docker layer cache is authoritative. There is no
# actions/cache; we rely on the host daemon to keep Maven and npm
# layers warm between runs. A `docker system prune` on the host
# will cause the next nightly build to be cold (510 min slower).
#
# Staging environment isolation:
# - project name: archiv-staging
# - host ports: backend 8081, frontend 3001
# - profile: staging (starts mailpit instead of a real SMTP relay)
#
# Required Gitea secrets:
# STAGING_POSTGRES_PASSWORD
# STAGING_MINIO_PASSWORD
# STAGING_MINIO_APP_PASSWORD
# STAGING_OCR_TRAINING_TOKEN
# STAGING_APP_ADMIN_USERNAME
# STAGING_APP_ADMIN_PASSWORD
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
env:
# Ensures the backend Dockerfile's `RUN --mount=type=cache` lines are
# honoured (Maven cache survives between runs).
DOCKER_BUILDKIT: "1"
jobs:
deploy-staging:
# `ubuntu-latest` matches our self-hosted runner's advertised label
# (the runner has labels: ubuntu-latest / ubuntu-24.04 / ubuntu-22.04).
# `self-hosted` would never match — no runner advertises it — so the
# job parks in the queue forever. ADR-011's "single-tenant" promise
# is at the repo level; sharing this runner between CI and deploys
# for the same repo is within that boundary.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Write staging env file
run: |
cat > .env.staging <<EOF
TAG=nightly
PORT_BACKEND=8081
PORT_FRONTEND=3001
APP_DOMAIN=staging.raddatz.cloud
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
MINIO_PASSWORD=${{ secrets.STAGING_MINIO_PASSWORD }}
MINIO_APP_PASSWORD=${{ secrets.STAGING_MINIO_APP_PASSWORD }}
OCR_TRAINING_TOKEN=${{ secrets.STAGING_OCR_TRAINING_TOKEN }}
APP_ADMIN_USERNAME=${{ secrets.STAGING_APP_ADMIN_USERNAME }}
APP_ADMIN_PASSWORD=${{ secrets.STAGING_APP_ADMIN_PASSWORD }}
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_SMTP_AUTH=false
MAIL_STARTTLS_ENABLE=false
APP_MAIL_FROM=noreply@staging.raddatz.cloud
EOF
- name: Build images
# `--pull` forces re-fetching pinned base images so a CVE
# re-publication of the same tag (e.g. node:20.19.0-alpine3.21,
# postgres:16-alpine) is picked up instead of being served
# from the host's stale Docker layer cache.
run: |
docker compose \
-f docker-compose.prod.yml \
-p archiv-staging \
--env-file .env.staging \
--profile staging \
build --pull
- name: Deploy staging
run: |
docker compose \
-f docker-compose.prod.yml \
-p archiv-staging \
--env-file .env.staging \
--profile staging \
up -d --wait --remove-orphans
- name: Smoke test deployed environment
# Healthchecks confirm containers are healthy; they do NOT confirm the
# public surface works. This step catches: Caddy not reloaded, HSTS
# header dropped, /actuator block bypassed.
#
# --resolve pins staging.raddatz.cloud to the runner's loopback so we
# do NOT depend on the host router doing hairpin NAT (many SOHO
# routers do not, or do so only after a firmware update). SNI still
# uses the public hostname so the cert validates correctly.
run: |
set -e
HOST="staging.raddatz.cloud"
URL="https://$HOST"
RESOLVE="--resolve $HOST:443:127.0.0.1"
echo "Smoke test: $URL (pinned to 127.0.0.1)"
curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently.
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step.
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"
- name: Cleanup env file
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
# single-tenant runner trust model. Every secret in .env.staging
# is plain text on the runner filesystem until this step runs.
# If a future refactor drops `if: always()`, a failed deploy
# leaves the env-file behind. Do not remove this conditional
# without first re-evaluating ADR-011.
if: always()
run: rm -f .env.staging

View File

@@ -0,0 +1,128 @@
name: release
# Builds and deploys the production environment on `v*` tag push.
# Runs on the self-hosted runner via Docker-out-of-Docker; images are
# tagged with the actual git tag (e.g. v1.0.0) so rollback is
# `TAG=<previous> docker compose -f docker-compose.prod.yml -p archiv-production up -d --wait`
#
# Operational assumptions (see docs/DEPLOYMENT.md §3 for the full setup):
#
# 1. Single-tenant self-hosted runner. The "Write production env file"
# step writes every secret to .env.production on the runner
# filesystem; the `if: always()` cleanup step removes it. A
# multi-tenant runner would need to switch to
# `docker compose --env-file <(stdin)` instead.
#
# 2. Host docker layer cache is authoritative. There is no
# actions/cache; we rely on the host daemon to keep Maven and npm
# layers warm between runs. A `docker system prune` on the host
# will cause the next release build to be cold (510 min slower).
#
# Production environment:
# - project name: archiv-production
# - host ports: backend 8080, frontend 3000
# - profile: (none) — mailpit is excluded; real SMTP relay is used
#
# Required Gitea secrets:
# PROD_POSTGRES_PASSWORD
# PROD_MINIO_PASSWORD
# PROD_MINIO_APP_PASSWORD
# PROD_OCR_TRAINING_TOKEN
# PROD_APP_ADMIN_USERNAME (CRITICAL: see docs/DEPLOYMENT.md)
# PROD_APP_ADMIN_PASSWORD (CRITICAL: locked in on first deploy)
# MAIL_HOST
# MAIL_PORT
# MAIL_USERNAME
# MAIL_PASSWORD
on:
push:
tags:
- "v*"
env:
DOCKER_BUILDKIT: "1"
jobs:
deploy-production:
# See nightly.yml — same rationale: `ubuntu-latest` matches the
# advertised label of our single-tenant self-hosted runner.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Write production env file
run: |
cat > .env.production <<EOF
TAG=${{ gitea.ref_name }}
PORT_BACKEND=8080
PORT_FRONTEND=3000
APP_DOMAIN=archiv.raddatz.cloud
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
MINIO_PASSWORD=${{ secrets.PROD_MINIO_PASSWORD }}
MINIO_APP_PASSWORD=${{ secrets.PROD_MINIO_APP_PASSWORD }}
OCR_TRAINING_TOKEN=${{ secrets.PROD_OCR_TRAINING_TOKEN }}
APP_ADMIN_USERNAME=${{ secrets.PROD_APP_ADMIN_USERNAME }}
APP_ADMIN_PASSWORD=${{ secrets.PROD_APP_ADMIN_PASSWORD }}
MAIL_HOST=${{ secrets.MAIL_HOST }}
MAIL_PORT=${{ secrets.MAIL_PORT }}
MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}
MAIL_SMTP_AUTH=true
MAIL_STARTTLS_ENABLE=true
APP_MAIL_FROM=noreply@raddatz.cloud
EOF
- name: Build images
# `--pull` forces re-fetching pinned base images so a CVE
# re-publication of the same tag is picked up rather than served
# from the host's stale Docker layer cache.
run: |
docker compose \
-f docker-compose.prod.yml \
-p archiv-production \
--env-file .env.production \
build --pull
- name: Deploy production
run: |
docker compose \
-f docker-compose.prod.yml \
-p archiv-production \
--env-file .env.production \
up -d --wait --remove-orphans
- name: Smoke test deployed environment
# See nightly.yml — same three checks, against the prod vhost.
# --resolve pins archiv.raddatz.cloud to the runner's loopback so
# the smoke test does NOT depend on hairpin NAT on the host router.
run: |
set -e
HOST="archiv.raddatz.cloud"
URL="https://$HOST"
RESOLVE="--resolve $HOST:443:127.0.0.1"
echo "Smoke test: $URL (pinned to 127.0.0.1)"
curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently.
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step.
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"
- name: Cleanup env file
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
# single-tenant runner trust model. Every secret in
# .env.production is plain text on the runner filesystem until
# this step runs. If a future refactor drops `if: always()`, a
# failed deploy leaves the env-file behind. Do not remove this
# conditional without first re-evaluating ADR-011.
if: always()
run: rm -f .env.production

6
.gitignore vendored
View File

@@ -18,5 +18,11 @@ scripts/large-data.sql
.claude/worktrees/
.claude/scheduled_tasks.lock
# Run artifacts from verification tooling
proofshot-artifacts/
# Root-level Node.js tooling artifacts
node_modules/
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
frontend/yarn.lock

View File

@@ -1,4 +1,6 @@
{
"java.configuration.updateBuildConfiguration": "interactive",
"java.compile.nullAnalysis.mode": "automatic"
"java.compile.nullAnalysis.mode": "automatic",
"plantuml.render": "PlantUMLServer",
"plantuml.server": "http://heim-nas:8500"
}

241
CLAUDE.md
View File

@@ -1,7 +1,11 @@
# CLAUDE.md
> For a human-readable project overview, see [README.md](./README.md).
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> For a human-readable project overview, see [README.md](./README.md).
## Project Overview
**Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel/ODS batch import, full-text search, conversation threads between family members, and role-based access control.
@@ -16,6 +20,8 @@ See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS tr
## Stack
→ See [README.md §Tech Stack](./README.md#tech-stack)
- **Backend**: Spring Boot 4.0 (Java 21, Maven, Jetty, JPA/Hibernate, Flyway, Spring Security, Spring Session JDBC)
- **Frontend**: SvelteKit 2 with Svelte 5, TypeScript, Tailwind CSS 4, Paraglide.js (i18n: de/en/es)
- **Database**: PostgreSQL 16
@@ -25,12 +31,13 @@ See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS tr
## Common Commands
### Running the Full Stack
```bash
# From repo root — starts PostgreSQL, MinIO, and Spring Boot backend
docker-compose up -d
```
### Backend (Spring Boot)
```bash
cd backend
@@ -42,11 +49,12 @@ cd backend
```
### Frontend (SvelteKit)
```bash
cd frontend
npm install
npm run dev # Dev server (port 3000)
npm run dev # Dev server (port 5173)
npm run build # Production build
npm run preview # Preview production build
@@ -64,7 +72,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
### Package Structure
Package-by-domain: each domain owns its controller, service, repository, entities, and DTOs.
<!-- TODO: rewrite post-REFACTOR-1 — see Epic 4 -->
```
backend/src/main/java/org/raddatz/familienarchiv/
@@ -88,27 +96,21 @@ backend/src/main/java/org/raddatz/familienarchiv/
└── user/ User domain — AppUser, UserGroup, UserService, auth controllers
```
### Layering Rules (strictly enforced)
### Layering Rules
```
Controller → Service → Repository → DB
```
→ See [docs/ARCHITECTURE.md §Layering rule](./docs/ARCHITECTURE.md#layering-rule)
- **Controllers** never inject or call repositories directly.
- **Services** never reach into another domain's repository. Call the other domain's service instead.
-`DocumentService``PersonService.getById()``PersonRepository`
-`DocumentService``PersonRepository` directly
- This keeps domain boundaries clear and business logic testable in isolation.
**LLM reminder:** controllers never call repositories directly; services never reach into another domain's repository — always call the other domain's service instead.
### Domain Model
| Entity | Table | Key relationships |
|---|---|---|
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| Entity | Table | Key relationships |
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -118,6 +120,7 @@ Controller → Service → Repository → DB
### Entity Code Style
All entities use these Lombok annotations:
```java
@Entity
@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).
- Each service owns its domain's repository. Cross-domain data access goes through the other domain's service.
**Existing services:**
| Service | Responsibility |
|---|---|
| `DocumentService` | Document CRUD, search, tag cascade delete |
| `PersonService` | Person CRUD, find-or-create by alias |
| `TagService` | Tag find/create/update/delete |
| `UserService` | User and group CRUD |
| `FileService` | S3/MinIO upload and download |
| `MassImportService` | Async ODS/Excel import; delegates to PersonService and TagService |
### DTOs
Input DTOs live in `dto/`. Response types are the model entities themselves (no response DTOs).
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
- `DocumentUpdateDTO` — used for both create and update (all fields optional)
- `CreateUserRequest` — user creation
- `GroupDTO` — group create/update
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
### Error Handling
Use `DomainException` for all domain errors. Never throw raw exceptions from service methods.
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
```java
// Static factories match common HTTP status codes:
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)
DomainException.forbidden("Access denied")
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "Already running")
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Upload failed: " + e.getMessage())
```
`ErrorCode` is an enum in `exception/ErrorCode.java`. When adding a new error case, add the value there **and** mirror it in the frontend's `src/lib/errors.ts` + add a Paraglide translation key.
For simple validation in controllers (not domain logic), `ResponseStatusException` is acceptable:
```java
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "firstName is required");
```
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) mirror in `frontend/src/lib/shared/errors.ts`, (3) add i18n keys in `messages/{de,en,es}.json`.
### Security / Permissions
Use `@RequirePermission` on controller methods (or the whole controller class):
→ See [docs/ARCHITECTURE.md §Permission system](./docs/ARCHITECTURE.md#permission-system)
```java
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(...) { ... }
```
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
`PermissionAspect` (AOP) checks the current user's `UserGroup.permissions` at runtime.
**LLM reminder:** `@RequirePermission(Permission.WRITE_ALL)` is **required** on every `POST`, `PUT`, `PATCH`, `DELETE` endpoint — not optional. Do not mix with Spring Security's `@PreAuthorize`. Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`.
### OpenAPI / API Types
SpringDoc generates the spec at `/v3/api-docs` (only accessible when running with `--spring.profiles.active=dev`).
→ See [CONTRIBUTING.md §Walkthrough B — Add a new endpoint](./CONTRIBUTING.md#4-walkthrough-b--add-a-new-endpoint)
When changing any model field or endpoint:
1. Rebuild the backend JAR with `-DskipTests`
2. Start it with `--spring.profiles.active=dev`
3. Run `npm run generate:api` in `frontend/`
**LLM reminder:** always run `npm run generate:api` in `frontend/` after any backend model or endpoint change — this is the most common cause of TypeScript type errors.
---
@@ -214,147 +181,99 @@ When changing any model field or endpoint:
```
frontend/src/routes/
├── +layout.svelte Global header (sticky), nav links, logout
├── +layout.server.ts Loads current user, injects auth cookie
├── +page.svelte Home / document search
├── +page.server.ts Load: search documents; no actions
├── +layout.svelte / +layout.server.ts Global layout, auth cookie
├── +page.svelte / +page.server.ts Home / document search dashboard
├── documents/
│ ├── [id]/+page.svelte Document detail (view + file preview)
── [id]/edit/ Edit form (all metadata + file upload)
── new/ Create form (same fields, empty)
│ ├── [id]/ Document detail (view + file preview)
── [id]/edit/ Edit form (all metadata + file upload)
── new/ Upload form
│ └── bulk-edit/ Multi-document edit
├── persons/
│ ├── +page.svelte Person list with search
│ ├── [id]/+page.svelte Person detail (inline edit + merge)
│ ├── [id]/ Person detail
│ ├── [id]/edit/ Person edit form
│ └── new/ Create person form
├── conversations/ Bilateral conversation timeline
├── admin/ User + group + tag management
── login/ logout/ Auth pages
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
├── aktivitaeten/ Unified activity feed (Chronik)
── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management
├── hilfe/transkription/ Transcription help page
├── profile/ User profile settings
├── users/[id]/ Public user profile page
├── login/ logout/ register/
├── forgot-password/ reset-password/
└── demo/ Dev-only demos
```
### API Client Pattern
All server-side API calls use the typed client from `$lib/api.server.ts`:
→ See [CONTRIBUTING.md §Frontend API client](./CONTRIBUTING.md#frontend-api-client)
```typescript
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
```
Key rules:
- Use `!result.response.ok` for error checking (not `if (result.error)` — this breaks when the spec has no error responses defined)
- Cast errors as `result.error as unknown as { code?: string }` to extract the backend error code
- Use `result.data!` (non-null assertion) after an ok check — TypeScript knows it's present
For multipart/form-data endpoints (file uploads), bypass the typed client and use raw `fetch`:
```typescript
const res = await fetch(`${baseUrl}/api/documents`, { method: 'POST', body: formData });
```
**LLM reminder:** check `!result.response.ok` (not `result.error` — breaks when spec has no error responses defined); cast errors as `result.error as unknown as { code?: string }`; use `result.data!` after an ok check.
### Form Actions Pattern
```typescript
// +page.server.ts
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get('name') as string; // cast needed — FormData returns FormDataEntryValue
// ...
return fail(400, { error: 'message' }); // on error
throw redirect(303, '/target'); // on success
}
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = formData.get("name") as string;
// ...
return fail(400, { error: "message" }); // on error
throw redirect(303, "/target"); // on success
},
};
```
### Date Handling
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO format to the backend.
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC timezone off-by-one:
```typescript
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
.format(new Date(doc.documentDate + 'T12:00:00'))
```
→ See [CONTRIBUTING.md §Date handling](./CONTRIBUTING.md#date-handling)
**LLM reminder:** always append `T12:00:00` when constructing `new Date()` from an ISO date string — prevents UTC timezone off-by-one errors.
### UI Component Library
Custom components in `src/lib/components/`:
| Component | Props | Description |
|---|---|---|
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead dropdown |
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
→ See per-domain READMEs: [`frontend/src/lib/person/README.md`](./frontend/src/lib/person/README.md), [`frontend/src/lib/tag/README.md`](./frontend/src/lib/tag/README.md), [`frontend/src/lib/document/README.md`](./frontend/src/lib/document/README.md), [`frontend/src/lib/shared/README.md`](./frontend/src/lib/shared/README.md)
### Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in `layout.css`):
Brand color tokens (defined in `layout.css`):
| Class | Value | Usage |
|---|---|---|
| `brand-navy` | `#002850` | Primary text, buttons, headers |
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
| `brand-sand` | `#E4E2D7` | Page background, card borders |
| Token / Utility | CSS variable | Usage |
| ---------------- | ---------------- | ------------------------------------------------------- |
| `brand-navy` | `--palette-navy` | Tailwind utility — buttons, headers, primary text |
| `brand-mint` | `--palette-mint` | Tailwind utility — accents, hover underlines, icons |
| `--palette-sand` | `--palette-sand` | Palette constant only — use `bg-canvas` or `bg-surface` |
Typography:
- `font-serif` (Merriweather) — body text, document titles, names
- `font-serif` (Tinos) — body text, document titles, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome
Card pattern for content sections:
```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section Title</h2>
<div class="rounded-sm border border-line bg-surface shadow-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-ink-3 mb-5">Section Title</h2>
<!-- content -->
</div>
```
Save bar pattern — use **sticky full-bleed** for long forms (edit document), **card-style with `mt-4`** for short forms (new person):
```svelte
<!-- Long forms: sticky, full-bleed -->
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
<!-- Short forms: card, top margin -->
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
```
Back button pattern — use the shared `<BackButton>` component from `$lib/components/BackButton.svelte`:
```svelte
<script lang="ts">
import BackButton from '$lib/components/BackButton.svelte';
</script>
<BackButton />
```
The component calls `history.back()` so the user returns to wherever they came from. Label is always "Zurück" (no contextual suffix — destination is unknown). Touch target ≥ 44px and focus ring are built in. Do not use a static `<a href>` for back navigation.
Subtle action link (e.g. "new document/person"):
```svelte
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors">
<svg class="w-4 h-4" ...><!-- plus icon --></svg>
Neues Dokument
</a>
```
Back button pattern — use the shared `<BackButton>` component from `$lib/shared/primitives/BackButton.svelte`. Do not use a static `<a href>` for back navigation.
### Error Handling (Frontend)
`src/lib/errors.ts` mirrors the backend `ErrorCode` enum and maps codes to Paraglide translation keys. When adding a new `ErrorCode` on the backend:
1. Add it to `ErrorCode.java`
2. Add it to the `ErrorCode` type in `errors.ts`
3. Add a `case` in `getErrorMessage()`
4. Add the translation key in `messages/de.json`, `en.json`, `es.json`
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
---
## Infrastructure
The `docker-compose.yml` at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the `archive-documents` bucket. The backend container depends on both `db` and `minio` being healthy.
Database migrations live in `backend/src/main/resources/db/migration/` (Flyway, SQL files named `V{n}__{description}.sql`).
→ See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
## API Testing
@@ -362,4 +281,4 @@ HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client
## Dev Container
A `.devcontainer/` config is available (Java 21 + Node 24, ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" for a pre-configured environment.
→ See [.devcontainer/README.md](./.devcontainer/README.md)

View File

@@ -180,6 +180,8 @@ When in doubt, commit more often rather than less.
See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack.
For domain terminology (Person vs AppUser, DocumentStatus lifecycle, Chronik vs Aktivität, etc.) see [docs/GLOSSARY.md](./docs/GLOSSARY.md).
Quick reminders:
- Pure functions over stateful helpers where possible
- No premature abstractions — KISS beats DRY

305
CONTRIBUTING.md Normal file
View 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
View 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 3060 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.

View File

@@ -11,7 +11,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
- **Server**: Jetty (not Tomcat — excluded in pom.xml)
- **Data**: PostgreSQL 16, JPA/Hibernate, Spring Data JPA
- **Migrations**: Flyway (SQL files in `src/main/resources/db/migration/`)
- **Security**: Spring Security, Spring Session JDBC, JWT tokens
- **Security**: Spring Security, Spring Session JDBC
- **File Storage**: MinIO via AWS SDK v2 (S3-compatible)
- **Spreadsheet Import**: Apache POI 5.5.0 (Excel/ODS)
- **API Docs**: SpringDoc OpenAPI 3.x (`/v3/api-docs` — dev profile only)
@@ -19,7 +19,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
## Package Structure
Package-by-domain: each domain owns its controller, service, repository, entities, and DTOs.
<!-- TODO: rewrite post-REFACTOR-1 — see Epic 4 -->
```
src/main/java/org/raddatz/familienarchiv/
@@ -43,31 +43,28 @@ src/main/java/org/raddatz/familienarchiv/
└── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
```
## Layering Rules (Strict)
For per-domain ownership and public surface, see each domain's `README.md`.
```
Controller → Service → Repository → DB
```
## Layering Rules
- **Controllers never call repositories directly.**
- **Services never reach into another domain's repository.** Call the other domain's service instead.
-`DocumentService``PersonService.getById()``PersonRepository`
-`DocumentService``PersonRepository` directly
→ See [docs/ARCHITECTURE.md §Layering rule](../docs/ARCHITECTURE.md#layering-rule)
**LLM reminder:** controllers never call repositories directly; services never reach into another domain's repository — always call the other domain's service.
## Key Entities
| Entity | Table | Key Relationships |
|---|---|---|
| `Document` | `documents` | ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver; name aliases table |
| `Tag` | `tag` | ManyToMany with documents via `document_tags`; self-referencing parent for tree |
| `AppUser` | `app_users` | ManyToMany groups (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `TranscriptionBlock` | `transcription_blocks` | Per-document, per-page text blocks with polygons |
| `DocumentAnnotation` | `document_annotations` | Free-form annotations on document pages |
| `Comment` | `document_comments` | Threaded comments with mentions |
| `Notification` | `notifications` | User notification feed |
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
| Entity | Table | Key Relationships |
| --------------------------- | ------------------------------- | ------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver; name aliases table |
| `Tag` | `tag` | ManyToMany with documents via `document_tags`; self-referencing parent for tree |
| `AppUser` | `app_users` | ManyToMany groups (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `TranscriptionBlock` | `transcription_blocks` | Per-document, per-page text blocks with polygons |
| `DocumentAnnotation` | `document_annotations` | Free-form annotations on document pages |
| `Comment` | `document_comments` | Threaded comments with mentions |
| `Notification` | `notifications` | User notification feed |
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -104,32 +101,15 @@ public class MyEntity {
## Error Handling
Use `DomainException` for all domain errors:
→ See [CONTRIBUTING.md §Error handling](../CONTRIBUTING.md#error-handling)
```java
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "...")
DomainException.forbidden("...")
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "...")
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "...")
```
When adding a new `ErrorCode`:
1. Add to `ErrorCode.java`
2. Mirror in frontend `src/lib/errors.ts`
3. Add Paraglide translation key in `messages/{de,en,es}.json`
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` — never throw raw exceptions from service methods. For simple controller validation (not domain logic), `ResponseStatusException` is acceptable: `throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "…")`. When adding a new `ErrorCode`: add to `ErrorCode.java`, mirror in `frontend/src/lib/shared/errors.ts`, add i18n keys in `messages/{de,en,es}.json`.
## Security / Permissions
Use `@RequirePermission` on controller methods or classes:
→ See [docs/ARCHITECTURE.md §Permission system](../docs/ARCHITECTURE.md#permission-system)
```java
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(...) { ... }
```
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
`PermissionAspect` checks the current user's `UserGroup.permissions` at runtime.
**LLM reminder:** `@RequirePermission(Permission.WRITE_ALL)` is **required** on every `POST`, `PUT`, `PATCH`, `DELETE` endpoint — not optional. Do not mix with Spring Security's `@PreAuthorize`. Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`.
## OCR Integration
@@ -141,49 +121,35 @@ The backend orchestrates OCR by calling the Python `ocr-service` microservice vi
- `OcrBatchService` — handles batch/job workflows
- `OcrAsyncRunner` — async execution of OCR jobs
For ocr-service internals, see [`ocr-service/README.md`](../ocr-service/README.md).
## API Testing
HTTP test files in `backend/api_tests/` for the VS Code REST Client extension.
## How to Run
### Local Development
```bash
cd backend
# Run with dev profile (requires PostgreSQL + MinIO running via docker-compose)
./mvnw spring-boot:run
# Build JAR (with tests)
./mvnw clean package
# Build JAR skipping tests
./mvnw spring-boot:run # Run with dev profile (requires PostgreSQL + MinIO)
./mvnw clean package # Build JAR (with tests)
./mvnw clean package -DskipTests
# Run all tests
./mvnw test
# Run a single test class
./mvnw test -Dtest=ClassName
# Run with coverage (JaCoCo)
./mvnw clean verify
./mvnw test # Run all tests
./mvnw test -Dtest=ClassName # Run a single test class
./mvnw clean verify # Run with JaCoCo coverage report
```
### OpenAPI TypeScript Generation
**OpenAPI / TypeScript type generation:**
1. Build and start backend with `--spring.profiles.active=dev`
2. In `frontend/`, run: `npm run generate:api`
1. Start backend with `--spring.profiles.active=dev`
2. In `frontend/`: `npm run generate:api`
### Profiles
- **dev** (default): Enables OpenAPI, dev configs, e2e seeds
- **prod**: Production profile — no dev endpoints
**LLM reminder:** always regenerate types after any model or endpoint change — the most common cause of "where did my TypeScript type go?"
## Testing
- Unit tests: Mockito + JUnit, pure in-memory
- Slice tests: `@WebMvcTest`, `@DataJpaTest` with Testcontainers PostgreSQL
- Integration tests: Full Spring context with Testcontainers
- Coverage gate: 88% branch coverage overall (JaCoCo)
- Coverage gate: 88% branch coverage (JaCoCo)

View File

@@ -190,6 +190,13 @@
<artifactId>owasp-java-html-sanitizer</artifactId>
<version>20240325.1</version>
</dependency>
<!-- HTML → plain-text extraction for comment previews -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
</dependency>
</dependencies>

View File

@@ -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.

View File

@@ -29,5 +29,11 @@ public record ActivityFeedItemDTO(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
)
UUID annotationId
UUID annotationId,
@Nullable
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
description = "Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments."
)
String commentPreview
) {}

View File

@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
import org.raddatz.familienarchiv.document.comment.CommentService;
import org.raddatz.familienarchiv.document.comment.CommentData;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
import org.raddatz.familienarchiv.user.UserService;
@@ -133,9 +134,9 @@ public class DashboardService {
.filter(Objects::nonNull)
.distinct()
.toList();
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
Map<UUID, CommentData> commentDataByComment = commentIds.isEmpty()
? Map.of()
: commentService.findAnnotationIdsByIds(commentIds);
: commentService.findDataByIds(commentIds);
return rows.stream().map(row -> {
ActivityActorDTO actor = row.getActorId() != null
@@ -146,7 +147,10 @@ public class DashboardService {
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
: null;
UUID commentId = row.getCommentId();
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
CommentData commentData = commentId != null ? commentDataByComment.get(commentId) : null;
UUID annotationId = commentData != null ? commentData.annotationId() : null;
String commentPreview = commentData != null && !commentData.preview().isBlank()
? commentData.preview() : null;
return new ActivityFeedItemDTO(
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
actor,
@@ -158,7 +162,8 @@ public class DashboardService {
row.getCount(),
happenedAtUntil,
commentId,
annotationId
annotationId,
commentPreview
);
}).toList();
}

View File

@@ -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.

View File

@@ -1,7 +1,12 @@
package org.raddatz.familienarchiv.dashboard;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Aggregate counts for the dashboard/persons stats bar.
*/
public record StatsDTO(long totalPersons, long totalDocuments) {
public record StatsDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalPersons,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalDocuments,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) long totalStories) {
}

View File

@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.dashboard;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.dashboard.StatsDTO;
import org.springframework.stereotype.Service;
@@ -12,8 +13,9 @@ public class StatsService {
private final PersonService personService;
private final DocumentService documentService;
private final GeschichteService geschichteService;
public StatsDTO getStats() {
return new StatsDTO(personService.count(), documentService.count());
return new StatsDTO(personService.count(), documentService.count(), geschichteService.countPublished());
}
}

View File

@@ -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) {}

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.document;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -48,6 +49,7 @@ import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.security.core.Authentication;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -388,6 +390,23 @@ public class DocumentController {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
}
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<DocumentDensityResult> density(
@RequestParam(required = false) String q,
@RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags,
@RequestParam(required = false) String tagQ,
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
DocumentDensityResult result = documentService.getDensity(
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
.body(result);
}
// --- TRAINING LABELS ---
public record TrainingLabelRequest(String label, boolean enrolled) {}

View File

@@ -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);
}
}

View File

@@ -100,7 +100,45 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
d.meta_date DESC NULLS LAST
""")
List<UUID> findRankedIdsByFts(@Param("query") String query);
// Unpaged path — for bulk-edit "select all" and density chart
List<UUID> findAllMatchingIdsByFts(@Param("query") String query);
/**
* Returns one page of FTS-ranked document IDs with the total match count.
*
* <p>Each row contains (in column order):
* <ol>
* <li>UUID — document id</li>
* <li>double — ts_rank score</li>
* <li>long — COUNT(*) OVER () — full match count, not page count</li>
* </ol>
*
* <p>Returns an empty list when the query matches no documents (including
* stopword-only queries where websearch_to_tsquery returns an empty tsquery).
* Use findAllMatchingIdsByFts for the unpaged bulk-edit path.
*/
@Query(nativeQuery = true, value = """
WITH q AS (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
'g'))
END AS pq
), matches AS (
SELECT d.id, ts_rank(d.search_vector, q.pq) AS rank
FROM documents d, q
WHERE d.search_vector @@ q.pq
)
SELECT id, rank, COUNT(*) OVER () AS total
FROM matches
ORDER BY rank DESC, id
OFFSET :offset LIMIT :limit
""")
List<Object[]> findFtsPageRaw(@Param("query") String query,
@Param("offset") int offset,
@Param("limit") int limit);
/**
* Returns match-enrichment data for a set of documents identified by their IDs.

View File

@@ -48,6 +48,7 @@ import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -125,6 +126,74 @@ public class DocumentService {
return titles;
}
/**
* Per-month document counts for the timeline density widget (issue #385).
*
* <p>Filter-reactive: the chart recomputes when other filters (sender,
* receiver, tag, q, status) change so it always matches the list it sits
* above. Date bounds (`from`/`to`) are deliberately omitted — the chart is
* the surface for picking those, so it must always span the broader space
* the user is selecting within.
*
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
* because the existing {@link Specification} predicates compose easily
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
* controller layer absorbs repeated browse loads.
*
* <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
* — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
* 'YYYY-MM')) and accept that the criteria/specification surface needs a
* parallel native-query path.
*/
public DocumentDensityResult getDensity(DensityFilters filters) {
List<UUID> ftsIds = resolveFtsIds(filters.text());
if (ftsIds != null && ftsIds.isEmpty()) {
return DocumentDensityResult.empty();
}
List<LocalDate> dates = loadFilteredDates(filters, ftsIds);
return aggregateByMonth(dates);
}
/**
* Returns the FTS-ranked document IDs when {@code text} is non-blank, or {@code null}
* when no full-text query is active. An empty list means the FTS query ran but
* matched zero documents — the caller short-circuits on that signal.
*/
private List<UUID> resolveFtsIds(String text) {
if (!StringUtils.hasText(text)) return null;
return documentRepository.findAllMatchingIdsByFts(text);
}
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
boolean hasFts = ftsIds != null;
Specification<Document> spec = buildSearchSpec(
hasFts, ftsIds, null, null,
filters.sender(), filters.receiver(),
filters.tags(), filters.tagQ(),
filters.status(), filters.tagOperator());
return documentRepository.findAll(spec).stream()
.map(Document::getDocumentDate)
.filter(Objects::nonNull)
.toList();
}
/** Buckets {@code dates} into one {@link MonthBucket} per YYYY-MM and computes min/max. */
private DocumentDensityResult aggregateByMonth(List<LocalDate> dates) {
if (dates.isEmpty()) return DocumentDensityResult.empty();
Map<String, Integer> counts = new java.util.TreeMap<>();
for (LocalDate d : dates) {
counts.merge(YearMonth.from(d).toString(), 1, Integer::sum);
}
List<MonthBucket> buckets = counts.entrySet().stream()
.map(e -> new MonthBucket(e.getKey(), e.getValue()))
.toList();
LocalDate minDate = dates.stream().min(LocalDate::compareTo).orElse(null);
LocalDate maxDate = dates.stream().max(LocalDate::compareTo).orElse(null);
return new DocumentDensityResult(buckets, minDate, maxDate);
}
/**
* Lädt eine Datei hoch.
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
@@ -416,7 +485,7 @@ public class DocumentService {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
}
@@ -576,39 +645,43 @@ public class DocumentService {
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008).
if (isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
return relevanceSortedPageFromSql(text, pageable);
}
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
// rank list. Cost scales linearly with match count — acceptable while documents
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
// documents with null sender/receivers. Cost scales with match count —
// acceptable while documents stays under ~10k rows. (ADR-008)
if (sort == DocumentSort.RECEIVER) {
// In-memory sort on page slice (≤ page size rows) — acceptable
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
if (sort == DocumentSort.SENDER) {
// In-memory sort on page slice (≤ page size rows) — acceptable
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
// RELEVANCE: default when text present and no explicit sort given
// RELEVANCE with active filters: load filtered subset and sort in-memory by rank.
boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE);
if (useRankOrder) {
List<Document> results = documentRepository.findAll(spec);
Map<UUID, Integer> rankMap = new HashMap<>();
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
List<Document> sorted = results.stream()
.sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
List<Document> sorted = documentRepository.findAll(spec).stream()
.sorted(Comparator.comparingInt(doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList();
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
}
@@ -619,6 +692,39 @@ public class DocumentService {
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
}
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status) {
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
&& from == null && to == null && sender == null && receiver == null
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
}
/**
* Pure-text RELEVANCE path — pagination and ts_rank ordering pushed into SQL.
* Called when no non-text filters are active (ADR-008).
*/
private DocumentSearchResult relevanceSortedPageFromSql(String text, Pageable pageable) {
long rawOffset = pageable.getOffset();
if (rawOffset > Integer.MAX_VALUE) return DocumentSearchResult.of(List.of());
int offset = (int) rawOffset;
int limit = pageable.getPageSize();
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
// Preserve ts_rank order from SQL across the JPA findAllById call.
Map<UUID, Integer> rankMap = new HashMap<>();
List<UUID> pageIds = new ArrayList<>();
for (int i = 0; i < ftsPage.hits().size(); i++) {
rankMap.put(ftsPage.hits().get(i).id(), i);
pageIds.add(ftsPage.hits().get(i).id());
}
List<Document> docs = documentRepository.findAllById(pageIds).stream()
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
.toList();
return buildResultPaged(docs, text, pageable, ftsPage.total());
}
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
int from = Math.min((int) pageable.getOffset(), sorted.size());
int to = Math.min(from + pageable.getPageSize(), sorted.size());
@@ -658,6 +764,7 @@ public class DocumentService {
return switch (sort) {
case TITLE -> Sort.by(direction, "title");
case UPLOAD_DATE -> Sort.by(direction, "createdAt");
case UPDATED_AT -> Sort.by(direction, "updatedAt");
default -> Sort.by(direction, "documentDate");
};
}
@@ -943,6 +1050,28 @@ public class DocumentService {
return result;
}
private static final int COL_ID = 0;
private static final int COL_RANK = 1;
private static final int COL_TOTAL = 2;
/**
* Maps raw Object[] rows from {@link DocumentRepository#findFtsPageRaw} to an
* {@link FtsPage}. Uses pattern-matching UUID cast to guard against driver-level
* type variance (some JDBC drivers return UUID as String).
*/
private static FtsPage toFtsPage(List<Object[]> rows) {
if (rows.isEmpty()) return new FtsPage(List.of(), 0);
long total = ((Number) rows.get(0)[COL_TOTAL]).longValue();
List<FtsHit> hits = rows.stream()
.map(r -> {
UUID id = r[COL_ID] instanceof UUID u ? u : UUID.fromString(r[COL_ID].toString());
double rank = ((Number) r[COL_RANK]).doubleValue();
return new FtsHit(id, rank);
})
.toList();
return new FtsPage(hits, total);
}
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}

View File

@@ -1,5 +1,5 @@
package org.raddatz.familienarchiv.document;
public enum DocumentSort {
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, RELEVANCE
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE, UPDATED_AT, RELEVANCE
}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.document;
import java.util.UUID;
/** A single document hit from a paginated FTS query — id and its ts_rank score. */
record FtsHit(UUID id, double rank) {}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.document;
import java.util.List;
/** One page of FTS results — the ranked hit list for this page and the total match count. */
record FtsPage(List<FtsHit> hits, long total) {}

View File

@@ -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
) {}

View File

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

View File

@@ -27,7 +27,9 @@ public class CommentController {
// ─── Block (transcription) comments ────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
public List<DocumentComment> getBlockComments(@PathVariable UUID blockId) {
public List<DocumentComment> getBlockComments(
@PathVariable UUID documentId,
@PathVariable UUID blockId) {
return commentService.getCommentsForBlock(blockId);
}
@@ -48,6 +50,7 @@ public class CommentController {
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToBlockComment(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {

View File

@@ -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) {}

View File

@@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.document.comment.DocumentComment;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
import org.raddatz.familienarchiv.document.comment.CommentRepository;
import org.raddatz.familienarchiv.notification.NotificationService;
import org.jsoup.Jsoup;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -28,21 +29,29 @@ import java.util.UUID;
@RequiredArgsConstructor
public class CommentService {
private static final int PREVIEW_MAX_CHARS = 120;
private final CommentRepository commentRepository;
private final UserService userService;
private final NotificationService notificationService;
private final AuditService auditService;
private final TranscriptionService transcriptionService;
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
public Map<UUID, CommentData> findDataByIds(Collection<UUID> commentIds) {
if (commentIds == null || commentIds.isEmpty()) return Map.of();
Map<UUID, UUID> result = new HashMap<>();
Map<UUID, CommentData> result = new HashMap<>();
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
result.put(c.getId(), new CommentData(c.getAnnotationId(), stripAndTruncate(c.getContent())));
}
return result;
}
private String stripAndTruncate(String html) {
if (html == null || html.isBlank()) return "";
String text = Jsoup.parse(html).text().trim();
return text.length() > PREVIEW_MAX_CHARS ? text.substring(0, PREVIEW_MAX_CHARS) : text;
}
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
return withRepliesAndMentions(roots);

View File

@@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException;
import lombok.extern.slf4j.Slf4j;
// "Handler" is Spring's @RestControllerAdvice naming convention — not a generic suffix.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

View File

@@ -56,6 +56,10 @@ public class GeschichteService {
// ─── Read API ────────────────────────────────────────────────────────────
public long countPublished() {
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
}
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
@@ -77,8 +81,10 @@ public class GeschichteService {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
Specification<Geschichte> spec = Specification.allOf(
GeschichteSpecifications.hasStatus(effective),
GeschichteSpecifications.hasAuthor(authorId),
GeschichteSpecifications.hasAllPersons(personIds),
GeschichteSpecifications.hasDocument(documentId),
GeschichteSpecifications.orderByDisplayDateDesc()

View File

@@ -42,6 +42,12 @@ public final class GeschichteSpecifications {
};
}
// null authorId → no restriction (PUBLISHED path passes null; Spring Data skips null predicates)
public static Specification<Geschichte> hasAuthor(UUID authorId) {
return (root, query, cb) ->
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
}
public static Specification<Geschichte> hasDocument(UUID documentId) {
return (root, query, cb) -> {
if (documentId == null) return null;

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,14 @@ public class PersonController {
@GetMapping
@RequirePermission(Permission.READ_ALL)
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
public ResponseEntity<List<PersonSummaryDTO>> getPersons(
@RequestParam(required = false) String q,
@RequestParam(required = false, defaultValue = "0") int size,
@RequestParam(required = false) String sort) {
if ("documentCount".equals(sort) && size > 0 && q == null) {
int safeSize = Math.min(size, 50);
return ResponseEntity.ok(personService.findTopByDocumentCount(safeSize));
}
return ResponseEntity.ok(personService.findAll(q));
}

View File

@@ -69,6 +69,22 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
nativeQuery = true)
List<PersonSummaryDTO> searchWithDocumentCount(@Param("query") String query);
// ORDER BY uses the computed alias "documentCount" — valid PostgreSQL (aliases allowed in ORDER BY,
// unlike WHERE/HAVING). This is intentional; it would silently fail on MySQL or H2.
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
ORDER BY documentCount DESC
LIMIT :limit
""",
nativeQuery = true)
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
// --- Correspondent queries ---
@Query(value = """

View File

@@ -41,6 +41,10 @@ public class PersonService {
return personRepository.searchWithDocumentCount(q.trim());
}
public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
return personRepository.findTopByDocumentCount(limit);
}
public Person getById(UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));

View File

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

View File

@@ -7,6 +7,7 @@ import org.springframework.security.core.Authentication;
import java.util.UUID;
// Cross-cutting auth helper; no domain home — "Utils" is the correct suffix here.
public final class SecurityUtils {
private SecurityUtils() {}

View File

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

View File

@@ -88,7 +88,8 @@ public class AppUser {
};
public static String computeColor(UUID id) {
return PALETTE[Math.abs(id.hashCode()) % PALETTE.length];
// Math.floorMod avoids the Integer.MIN_VALUE overflow trap in Math.abs(hashCode())
return PALETTE[Math.floorMod(id.hashCode(), PALETTE.length)];
}
@PrePersist

View File

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

View File

@@ -271,9 +271,10 @@ public class UserService {
@Transactional
public UserGroup createGroup(GroupDTO dto) {
UserGroup group = new UserGroup();
group.setName(dto.getName());
group.setPermissions(dto.getPermissions());
UserGroup group = UserGroup.builder()
.name(dto.getName())
.permissions(dto.getPermissions() != null ? dto.getPermissions() : new HashSet<>())
.build();
return groupRepository.save(group);
}

View File

@@ -38,6 +38,12 @@ spring:
starttls:
enable: true
server:
# Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that
# request.getScheme(), redirect URLs, and Spring Session "Secure" cookies
# reflect the original https client request, not the http hop from Caddy.
forward-headers-strategy: native
management:
health:
mail:

View File

@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC);

View File

@@ -0,0 +1,8 @@
-- Speeds up "documents by sender" queries used on /persons/[id] Korrespondenz-Überblick (#306),
-- /briefwechsel, and bulk-edit flows.
CREATE INDEX IF NOT EXISTS idx_documents_sender_id
ON documents(sender_id);
-- Speeds up "comments by author" queries on admin user detail and (future) contributor profile.
CREATE INDEX IF NOT EXISTS idx_comments_author_id
ON document_comments(author_id);

View File

@@ -0,0 +1,7 @@
-- Remove duplicate (group_id, permission) rows that accumulated without a UNIQUE constraint.
-- Keeps the row with the smallest ctid (earliest physical insertion order).
DELETE FROM group_permissions a
USING group_permissions b
WHERE a.ctid < b.ctid
AND a.group_id = b.group_id
AND a.permission = b.permission;

View File

@@ -0,0 +1,11 @@
-- Add NOT NULL and PRIMARY KEY to group_permissions.
-- Requires V63 to have run first (no duplicates can remain).
--
-- After this migration, future seed migrations can use:
-- INSERT INTO group_permissions ... ON CONFLICT DO NOTHING
-- instead of the INSERT ... WHERE NOT EXISTS pattern used before V64.
ALTER TABLE group_permissions
ALTER COLUMN permission SET NOT NULL;
ALTER TABLE group_permissions
ADD CONSTRAINT pk_group_permissions PRIMARY KEY (group_id, permission);

View File

@@ -0,0 +1,8 @@
-- Promote the de-facto unique constraint on transcription_block_mentioned_persons to a named PK.
-- uq_tbmp_block_person (added in V57) is backed by a B-tree index identical to a PK;
-- this rename makes the naming convention explicit (pk_* vs uq_*).
ALTER TABLE transcription_block_mentioned_persons
DROP CONSTRAINT uq_tbmp_block_person;
ALTER TABLE transcription_block_mentioned_persons
ADD CONSTRAINT pk_tbmp PRIMARY KEY (block_id, person_id);

View File

@@ -399,6 +399,86 @@ class MigrationIntegrationTest {
AND dc.annotation_id IS NOT NULL
""";
// ─── V62: indexes on FK columns ──────────────────────────────────────────
@Test
void v62_idx_documents_sender_id_exists() {
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM pg_catalog.pg_indexes WHERE tablename = 'documents' AND indexname = 'idx_documents_sender_id'",
Integer.class);
assertThat(count).isEqualTo(1);
}
@Test
void v62_idx_comments_author_id_exists() {
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM pg_catalog.pg_indexes WHERE tablename = 'document_comments' AND indexname = 'idx_comments_author_id'",
Integer.class);
assertThat(count).isEqualTo(1);
}
// ─── V63+V64: group_permissions dedup + primary key ──────────────────────
@Test
void v64_pk_group_permissions_exists() {
Integer count = jdbc.queryForObject(
"""
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'group_permissions'
AND c.conname = 'pk_group_permissions'
AND c.contype = 'p'
""",
Integer.class);
assertThat(count).isEqualTo(1);
}
@Test
void v64_permission_column_isNotNullable() {
Integer count = jdbc.queryForObject(
"""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'group_permissions'
AND column_name = 'permission'
AND is_nullable = 'NO'
""",
Integer.class);
assertThat(count).isEqualTo(1);
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void v64_rejectsDuplicateGroupPermission() {
UUID groupId = createUserGroup("DuplicateTestGroup-" + UUID.randomUUID());
try {
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId);
assertThatThrownBy(() ->
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId)
).isInstanceOf(DataIntegrityViolationException.class);
} finally {
jdbc.update("DELETE FROM group_permissions WHERE group_id = ?", groupId);
jdbc.update("DELETE FROM user_groups WHERE id = ?", groupId);
}
}
// ─── V65: tbmp UNIQUE promoted to PRIMARY KEY ─────────────────────────────
@Test
void v65_pk_tbmp_exists() {
Integer count = jdbc.queryForObject(
"""
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'transcription_block_mentioned_persons'
AND c.conname = 'pk_tbmp'
AND c.contype = 'p'
""",
Integer.class);
assertThat(count).isEqualTo(1);
}
// ─── helpers ─────────────────────────────────────────────────────────────
private UUID createPerson(String firstName, String lastName) {
@@ -482,4 +562,10 @@ class MigrationIntegrationTest {
""", id, recipientId, docId, commentId);
return id;
}
private UUID createUserGroup(String name) {
UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO user_groups (id, name) VALUES (?, ?)", id, name);
return id;
}
}

View File

@@ -0,0 +1,48 @@
package org.raddatz.familienarchiv.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.boot.web.server.autoconfigure.ServerProperties.ForwardHeadersStrategy;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.io.ClassPathResource;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Binds {@code server.forward-headers-strategy} from {@code application.yaml} into
* Spring Boot's typed {@link ForwardHeadersStrategy} enum. The binder rejects any
* value that is not a valid enum constant ({@code BindException}), so a typo
* ({@code "nativ"}, {@code "Native"}, {@code "framework "}) or a future Spring
* rename of the property fails the test, not silently degrades to {@code NONE}.
*
* <p>No Spring context, no embedded server, no Testcontainers — this is the
* cheapest test that pins the contract "Caddy's X-Forwarded-Proto is trusted".
*/
class ForwardHeadersConfigurationTest {
@Test
void forward_headers_strategy_binds_to_NATIVE() {
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
yaml.setResources(new ClassPathResource("application.yaml"));
Properties props = yaml.getObject();
assertThat(props).as("application.yaml must be on the classpath").isNotNull();
Binder binder = new Binder(ConfigurationPropertySources.from(
new PropertiesPropertySource("application", props)));
ForwardHeadersStrategy strategy = binder
.bind("server.forward-headers-strategy", ForwardHeadersStrategy.class)
.orElseThrow(() -> new AssertionError(
"server.forward-headers-strategy is missing from application.yaml"));
assertThat(strategy)
.as("Spring must trust X-Forwarded-Proto from Caddy so that "
+ "request.getScheme(), redirect URLs, and the Spring Session "
+ "'Secure' cookie reflect the original https client request.")
.isEqualTo(ForwardHeadersStrategy.NATIVE);
}
}

View File

@@ -13,6 +13,7 @@ import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
import org.raddatz.familienarchiv.document.comment.CommentService;
import org.raddatz.familienarchiv.document.comment.CommentData;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.transcription.TranscriptionService;
import org.raddatz.familienarchiv.user.UserService;
@@ -142,7 +143,8 @@ class DashboardServiceTest {
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
when(commentService.findDataByIds(List.of(commentId)))
.thenReturn(Map.of(commentId, new CommentData(null, "preview text")));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
@@ -162,8 +164,8 @@ class DashboardServiceTest {
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
.thenReturn(Map.of(commentId, annotationId));
when(commentService.findDataByIds(List.of(commentId)))
.thenReturn(Map.of(commentId, new CommentData(annotationId, "preview text")));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
@@ -187,7 +189,62 @@ class DashboardServiceTest {
assertThat(items).hasSize(1);
assertThat(items.get(0).commentId()).isNull();
assertThat(items.get(0).annotationId()).isNull();
verify(commentService, never()).findAnnotationIdsByIds(anyList());
verify(commentService, never()).findDataByIds(anyList());
}
// ─── getActivity commentPreview ───────────────────────────────────────────
@Test
void getActivity_populates_commentPreview_for_COMMENT_ADDED_rows() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
when(commentService.findDataByIds(List.of(commentId)))
.thenReturn(Map.of(commentId, new CommentData(null, "Hello family!")));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items.get(0).commentPreview()).isEqualTo("Hello family!");
}
@Test
void getActivity_leaves_commentPreview_null_for_TEXT_SAVED_rows() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items.get(0).commentPreview()).isNull();
}
@Test
void getActivity_leaves_commentPreview_null_when_comment_is_deleted() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID deletedCommentId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", deletedCommentId);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
when(commentService.findDataByIds(List.of(deletedCommentId))).thenReturn(Map.of());
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items.get(0).commentPreview()).isNull();
}
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────

View File

@@ -44,7 +44,7 @@ class StatsControllerTest {
@Test
@WithMockUser(authorities = "READ_ALL")
void getStats_returns200_withCorrectCounts() throws Exception {
when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L));
when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L, 2L));
mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk())
@@ -55,7 +55,7 @@ class StatsControllerTest {
@Test
@WithMockUser(authorities = "READ_ALL")
void getStats_returns200_withZeroCounts() throws Exception {
when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L));
when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L, 0L));
mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk())

View File

@@ -7,6 +7,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.dashboard.StatsDTO;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import org.raddatz.familienarchiv.person.PersonService;
import static org.assertj.core.api.Assertions.assertThat;
@@ -17,6 +18,7 @@ class StatsServiceTest {
@Mock PersonService personService;
@Mock DocumentService documentService;
@Mock GeschichteService geschichteService;
@InjectMocks StatsService statsService;
@Test
@@ -30,6 +32,17 @@ class StatsServiceTest {
assertThat(stats.totalDocuments()).isEqualTo(12L);
}
@Test
void getStats_includes_totalStories() {
when(personService.count()).thenReturn(3L);
when(documentService.count()).thenReturn(7L);
when(geschichteService.countPublished()).thenReturn(5L);
StatsDTO stats = statsService.getStats();
assertThat(stats.totalStories()).isEqualTo(5L);
}
@Test
void getStats_returnsZero_whenNoEntities() {
when(personService.count()).thenReturn(0L);

View File

@@ -44,6 +44,7 @@ import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -1240,4 +1241,100 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.errors[0].message").value(
org.hamcrest.Matchers.containsString("not found")));
}
// ─── GET /api/documents/density ───────────────────────────────────────────
@Test
void density_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/density"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void density_returns200_withResultBody_whenAuthenticated() throws Exception {
when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(
List.of(new MonthBucket("1915-08", 2), new MonthBucket("1915-09", 1)),
java.time.LocalDate.of(1915, 8, 3),
java.time.LocalDate.of(1915, 9, 1)));
mockMvc.perform(get("/api/documents/density"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.buckets").isArray())
.andExpect(jsonPath("$.buckets[0].month").value("1915-08"))
.andExpect(jsonPath("$.buckets[0].count").value(2))
.andExpect(jsonPath("$.minDate").value("1915-08-03"))
.andExpect(jsonPath("$.maxDate").value("1915-09-01"));
}
// Pins produces=APPLICATION_JSON_VALUE on the density mapping so the OpenAPI/TypeScript
// codegen records application/json instead of the wildcard. Without produces= the
// request-mapping accepts any Accept header and the OpenAPI emit falls back to the
// wildcard. Sending an Accept header that JSON cannot satisfy must NOT return 200 —
// Spring rejects with 406 (HttpMediaTypeNotAcceptableException), which our
// GlobalExceptionHandler may surface as 400. Either way it proves the route is
// locked to JSON.
@Test
@WithMockUser
void density_declaresApplicationJsonContentType() throws Exception {
when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null));
mockMvc.perform(get("/api/documents/density")
.accept(MediaType.APPLICATION_XML))
.andExpect(status().is4xxClientError());
}
@Test
@WithMockUser
void density_emitsPrivateCacheControlHeader() throws Exception {
when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null));
mockMvc.perform(get("/api/documents/density"))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.containsString("max-age=300")))
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.containsString("private")));
}
@Test
@WithMockUser
void density_forwardsSenderAndTagFilters() throws Exception {
when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null));
UUID senderId = UUID.randomUUID();
mockMvc.perform(get("/api/documents/density")
.param("senderId", senderId.toString())
.param("tag", "Familie")
.param("tag", "Urlaub")
.param("tagOp", "OR"))
.andExpect(status().isOk());
verify(documentService).getDensity(eq(new DensityFilters(
null, senderId, null,
List.of("Familie", "Urlaub"),
null, null,
org.raddatz.familienarchiv.tag.TagOperator.OR)));
}
@Test
@WithMockUser
void density_forwardsStatusAndQueryText() throws Exception {
when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null));
mockMvc.perform(get("/api/documents/density")
.param("q", "Brief")
.param("status", "REVIEWED"))
.andExpect(status().isOk());
verify(documentService).getDensity(eq(new DensityFilters(
"Brief", null, null, null, null,
DocumentStatus.REVIEWED,
org.raddatz.familienarchiv.tag.TagOperator.AND)));
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1,109 @@
package org.raddatz.familienarchiv.document;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Repository-level integration tests for {@code findFtsPageRaw}: verifies that the
* paginated FTS query returns exactly page-size rows and that the window-function
* total reflects the full match count, not just the page count.
*
* <p>Uses real Postgres via Testcontainers so the GIN index, tsvector trigger, and
* {@code websearch_to_tsquery} semantics are identical to production.
*
* <p>{@code AFTER_CLASS} dirty-context keeps the Spring context alive for all tests
* in this class and rebuilds it once at the end, rather than after every test.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class DocumentFtsPagedIntegrationTest {
@Autowired DocumentRepository documentRepository;
@Autowired EntityManager em;
// 60 docs match "Walter"; 10 docs with "Hans" do not.
private static final int WALTER_COUNT = 60;
private static final int PAGE_SIZE = 50;
@BeforeEach
void seed() {
documentRepository.deleteAll();
em.flush();
for (int i = 0; i < WALTER_COUNT; i++) {
documentRepository.saveAndFlush(doc("Brief von Walter Nr. " + i));
}
for (int i = 0; i < 10; i++) {
documentRepository.saveAndFlush(doc("Brief von Hans Nr. " + i));
}
em.clear();
}
@Test
void findFtsPageRaw_firstPage_returnsPageSizeRows() {
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
assertThat(rows).hasSize(PAGE_SIZE);
}
@Test
void findFtsPageRaw_windowTotal_equalsFullMatchCount_notPageSize() {
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
long total = ((Number) rows.get(0)[2]).longValue();
assertThat(total).isEqualTo(WALTER_COUNT);
}
@Test
void findFtsPageRaw_lastPage_returnsRemainder() {
int remainder = WALTER_COUNT % PAGE_SIZE; // 60 % 50 = 10
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", PAGE_SIZE, PAGE_SIZE);
assertThat(rows).hasSize(remainder);
long total = ((Number) rows.get(0)[2]).longValue();
assertThat(total).isEqualTo(WALTER_COUNT);
}
@Test
void findFtsPageRaw_noMatches_returnsEmptyList() {
List<Object[]> rows = documentRepository.findFtsPageRaw("XYZ_KEIN_TREFFER", 0, PAGE_SIZE);
assertThat(rows).isEmpty();
}
@Test
void findFtsPageRaw_stopwordOnlyQuery_returnsEmptyList_noException() {
assertThatNoException().isThrownBy(() -> {
List<Object[]> rows = documentRepository.findFtsPageRaw("der die das und", 0, PAGE_SIZE);
assertThat(rows).isEmpty();
});
}
// ─── Helper ───────────────────────────────────────────────────────────────
private Document doc(String title) {
return Document.builder()
.title(title)
.originalFilename(title.replace(" ", "_") + ".pdf")
.status(DocumentStatus.UPLOADED)
.build();
}
}

View File

@@ -69,7 +69,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Alter Brief"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
assertThat(ids).hasSize(1);
}
@@ -79,7 +79,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Alter Brief"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Briefe");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Briefe");
assertThat(ids).hasSize(1);
}
@@ -89,7 +89,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("furchtb");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("furchtb");
assertThat(ids).hasSize(1);
}
@@ -99,7 +99,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Familienfoto"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
assertThat(ids).isEmpty();
}
@@ -115,7 +115,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("schreiben");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("schreiben");
assertThat(ids).contains(doc.getId());
}
@@ -125,14 +125,14 @@ class DocumentFtsTest {
Document doc = documentRepository.saveAndFlush(document("Leeres Dokument"));
em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).isEmpty();
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).isEmpty();
UUID annotationId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Grundbuch Eintrag 1923", 0));
em.flush();
em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId());
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
}
@Test
@@ -144,13 +144,13 @@ class DocumentFtsTest {
em.flush();
em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId());
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
blockRepository.deleteById(block.getId());
em.flush();
em.clear();
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).doesNotContain(doc.getId());
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).doesNotContain(doc.getId());
}
// ─── Ranking ───────────────────────────────────────────────────────────────
@@ -166,7 +166,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Grundbuch");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Grundbuch");
assertThat(ids).hasSize(2);
assertThat(ids.get(0)).isEqualTo(docA.getId());
@@ -179,7 +179,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Ein Brief von der Oma"));
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("der die das und");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("der die das und");
assertThat(ids).isEmpty();
}
@@ -195,7 +195,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Wille");
assertThat(ids).contains(doc.getId());
}
@@ -205,7 +205,7 @@ class DocumentFtsTest {
documentRepository.saveAndFlush(document("Brief"));
em.clear();
assertThatNoException().isThrownBy(() -> documentRepository.findRankedIdsByFts("((("));
assertThatNoException().isThrownBy(() -> documentRepository.findAllMatchingIdsByFts("((("));
}
// ─── Weight C: sender/receiver names ───────────────────────────────────────
@@ -223,7 +223,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Schmidt");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Schmidt");
assertThat(ids).contains(doc.getId());
}
@@ -241,7 +241,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Raddatz");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Raddatz");
assertThat(ids).contains(doc.getId());
}
@@ -260,7 +260,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Familiengeschichte");
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Familiengeschichte");
assertThat(ids).hasSize(1);
}
@@ -278,7 +278,7 @@ class DocumentFtsTest {
em.flush();
em.clear();
List<UUID> rankedIds = documentRepository.findRankedIdsByFts("Grundbuch");
List<UUID> rankedIds = documentRepository.findAllMatchingIdsByFts("Grundbuch");
Specification<Document> spec = Specification.where(hasIds(rankedIds))
.and(hasStatus(DocumentStatus.UPLOADED));

View File

@@ -21,17 +21,22 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DocumentServiceSortTest {
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
private static final Pageable PAGE = org.springframework.data.domain.PageRequest.of(0, 10_000);
@Mock DocumentRepository documentRepository;
@Mock PersonService personService;
@@ -43,12 +48,12 @@ class DocumentServiceSortTest {
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@InjectMocks DocumentService documentService;
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
// ─── DATE sort ────────────────────────────────────────────────────────────
@Test
void searchDocuments_with_DATE_sort_and_text_sorts_chronologically_not_by_relevance() {
UUID id1 = UUID.randomUUID(); // rank position 0 (higher relevance, older doc)
UUID id2 = UUID.randomUUID(); // rank position 1 (lower relevance, newer doc)
UUID id1 = UUID.randomUUID(); // higher relevance, older doc
UUID id2 = UUID.randomUUID(); // lower relevance, newer doc
Document older = Document.builder().id(id1)
.title("Brief").status(DocumentStatus.UPLOADED)
@@ -57,38 +62,48 @@ class DocumentServiceSortTest {
.title("Brief").status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1960, 1, 1)).build();
// FTS returns id1 first (higher rank), id2 second
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
// findAll(spec, pageable) — the correct date path — returns date-DESC order
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(newer, older)));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
assertThat(result.items()).hasSize(2);
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
}
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
@Test
void searchDocuments_relevance_pureText_calls_findFtsPageRaw_not_findAllMatchingIds() {
UUID id1 = UUID.randomUUID();
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any()))
.thenReturn(List.of(doc(id1)));
documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
}
@Test
void searchDocuments_with_RELEVANCE_sort_and_text_preserves_fts_rank_order() {
UUID id1 = UUID.randomUUID(); // rank position 0
UUID id2 = UUID.randomUUID(); // rank position 1
UUID id1 = UUID.randomUUID(); // higher rank — must appear first
UUID id2 = UUID.randomUUID(); // lower rank
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1)); // unordered from DB
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
// Expect: rank order restored (id1 first)
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
@@ -97,16 +112,82 @@ class DocumentServiceSortTest {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc2, doc1));
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
@Test
void searchDocuments_relevance_returns_empty_when_offset_exceeds_maxInt() {
// offset = pageNumber * pageSize; choose values so offset > Integer.MAX_VALUE
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, hugePage);
assertThat(result.items()).isEmpty();
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
}
// ─── toFtsPage — UUID-as-String JDBC driver variance ────────────────────
@Test
void searchDocuments_relevance_handles_string_uuid_from_jdbc_driver() {
String stringId = "11111111-1111-1111-1111-111111111111";
UUID uuidId = UUID.fromString(stringId);
// Simulate a JDBC driver that returns the id column as String instead of UUID
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, PAGE);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
}
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
@Test
void searchDocuments_relevance_with_active_filter_uses_inMemory_path() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
when(documentRepository.findAll(any(Specification.class)))
.thenReturn(List.of(doc(id2), doc(id1)));
// sender filter is active → triggers in-memory path, not findFtsPageRaw
LocalDate from = LocalDate.of(1900, 1, 1);
documentService.searchDocuments(
"Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository).findAllMatchingIdsByFts("Brief");
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private static Document doc(UUID id) {
return Document.builder().id(id).title("Brief").status(DocumentStatus.UPLOADED).build();
}
private static List<Object[]> ftsRows(UUID id, double rank, long total) {
List<Object[]> rows = new ArrayList<>();
rows.add(new Object[]{id, rank, total});
return rows;
}
}

View File

@@ -33,6 +33,7 @@ import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDate;
@@ -1402,6 +1403,21 @@ class DocumentServiceTest {
assertThat(result.items()).hasSize(1); // only the slice is enriched
}
@Test
void searchDocuments_UPDATED_AT_sort_resolves_to_updatedAt_field() {
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
DocumentSort.UPDATED_AT, "DESC", null,
org.springframework.data.domain.PageRequest.of(0, 5));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getSort())
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
}
@Test
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
@@ -1604,9 +1620,10 @@ class DocumentServiceTest {
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc));
List<Object[]> ftsRows = new java.util.ArrayList<>();
ftsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
@@ -1638,9 +1655,10 @@ class DocumentServiceTest {
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(doc));
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
@@ -2186,7 +2204,7 @@ class DocumentServiceTest {
@Test
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter(
"xyz", null, null, null, null, null, null, null, null);
@@ -2321,4 +2339,61 @@ class DocumentServiceTest {
assertThat(documentService.save(doc)).isEqualTo(doc);
verify(documentRepository).save(doc);
}
// ─── getDensity ────────────────────────────────────────────────────────────
private static DensityFilters anyFilters() {
return new DensityFilters(null, null, null, null, null, null, null);
}
@Test
void getDensity_returnsEmptyResult_whenNoDocumentsMatch() {
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(anyFilters());
assertThat(result.buckets()).isEmpty();
assertThat(result.minDate()).isNull();
assertThat(result.maxDate()).isNull();
}
@Test
void getDensity_groupsMatchingDocumentsByMonth() {
Document a = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
Document b = Document.builder().documentDate(LocalDate.of(1915, 8, 17)).build();
Document c = Document.builder().documentDate(LocalDate.of(1915, 9, 1)).build();
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(a, b, c));
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(anyFilters());
assertThat(result.buckets()).extracting(MonthBucket::month)
.containsExactly("1915-08", "1915-09");
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(2, 1);
assertThat(result.minDate()).isEqualTo(LocalDate.of(1915, 8, 3));
assertThat(result.maxDate()).isEqualTo(LocalDate.of(1915, 9, 1));
}
@Test
void getDensity_excludesDocumentsWithNullDate() {
Document dated = Document.builder().documentDate(LocalDate.of(1915, 8, 3)).build();
Document undated = Document.builder().documentDate(null).build();
when(documentRepository.findAll(any(Specification.class))).thenReturn(List.of(dated, undated));
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
DocumentDensityResult result = documentService.getDensity(anyFilters());
assertThat(result.buckets()).extracting(MonthBucket::count).containsExactly(1);
}
@Test
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
when(documentRepository.findAllMatchingIdsByFts("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));
}
}

View File

@@ -44,6 +44,14 @@ class CommentControllerTest {
// ─── Block comment endpoints ─────────────────────────────────────────────
@Test
@WithMockUser
void getBlockComments_returns400_when_documentId_is_not_a_UUID() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(get("/api/documents/NOT-A-UUID/transcription-blocks/" + blockId + "/comments"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void getBlockComments_returns200() throws Exception {
@@ -115,6 +123,15 @@ class CommentControllerTest {
// ─── Block reply endpoints ───────────────────────────────────────────────
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
+ "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isBadRequest());
}
@Test
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID();

View File

@@ -19,6 +19,7 @@ import org.raddatz.familienarchiv.notification.NotificationService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -644,62 +645,99 @@ class CommentServiceTest {
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
}
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
// ─── findDataByIds ────────────────────────────────────────────────────────
@Test
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
UUID commentA = UUID.randomUUID();
UUID annotationA = UUID.randomUUID();
UUID commentB = UUID.randomUUID();
UUID annotationB = UUID.randomUUID();
when(commentRepository.findAllById(List.of(commentA, commentB)))
.thenReturn(List.of(
DocumentComment.builder().id(commentA).annotationId(annotationA).build(),
DocumentComment.builder().id(commentB).annotationId(annotationB).build()
));
assertThat(commentService.findAnnotationIdsByIds(List.of(commentA, commentB)))
.containsOnly(
java.util.Map.entry(commentA, annotationA),
java.util.Map.entry(commentB, annotationB)
);
}
@Test
void findAnnotationIdsByIds_returnsEmptyMap_forEmptyInput() {
assertThat(commentService.findAnnotationIdsByIds(List.of())).isEmpty();
void findDataByIds_returns_empty_map_when_input_is_empty() {
assertThat(commentService.findDataByIds(List.of())).isEmpty();
verify(commentRepository, never()).findAllById(anyList());
}
@Test
void findAnnotationIdsByIds_omitsUnknownIds() {
UUID known = UUID.randomUUID();
UUID knownAnnotation = UUID.randomUUID();
UUID missing = UUID.randomUUID();
when(commentRepository.findAllById(List.of(known, missing)))
.thenReturn(List.of(
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
));
void findDataByIds_strips_html_and_extracts_plain_text() {
UUID id = UUID.randomUUID();
when(commentRepository.findAllById(List.of(id)))
.thenReturn(List.of(DocumentComment.builder().id(id)
.content("<p><strong>Hello</strong> world</p>").build()));
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
.containsOnly(java.util.Map.entry(known, knownAnnotation))
.doesNotContainKey(missing);
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(id));
assertThat(result.get(id).preview()).isEqualTo("Hello world");
}
@Test
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
UUID legacy = UUID.randomUUID();
UUID block = UUID.randomUUID();
UUID annotation = UUID.randomUUID();
when(commentRepository.findAllById(List.of(legacy, block)))
.thenReturn(List.of(
DocumentComment.builder().id(legacy).annotationId(null).build(),
DocumentComment.builder().id(block).annotationId(annotation).build()
));
void findDataByIds_truncates_at_exactly_120_chars() {
UUID id = UUID.randomUUID();
String text121 = "a".repeat(121);
when(commentRepository.findAllById(List.of(id)))
.thenReturn(List.of(DocumentComment.builder().id(id).content(text121).build()));
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
.containsOnly(java.util.Map.entry(block, annotation))
.doesNotContainKey(legacy);
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
}
@Test
void findDataByIds_preserves_content_at_exactly_120_chars() {
UUID id = UUID.randomUUID();
String text120 = "a".repeat(120);
when(commentRepository.findAllById(List.of(id)))
.thenReturn(List.of(DocumentComment.builder().id(id).content(text120).build()));
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).hasSize(120);
}
@Test
void findDataByIds_returns_empty_string_for_blank_content() {
UUID id = UUID.randomUUID();
when(commentRepository.findAllById(List.of(id)))
.thenReturn(List.of(DocumentComment.builder().id(id).content(" ").build()));
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty();
}
@Test
void findDataByIds_returns_empty_string_for_null_content() {
UUID id = UUID.randomUUID();
when(commentRepository.findAllById(List.of(id)))
.thenReturn(List.of(DocumentComment.builder().id(id).content(null).build()));
assertThat(commentService.findDataByIds(List.of(id)).get(id).preview()).isEmpty();
}
@Test
void findDataByIds_omits_deleted_comments_from_result_map() {
UUID present = UUID.randomUUID();
UUID deleted = UUID.randomUUID();
when(commentRepository.findAllById(List.of(present, deleted)))
.thenReturn(List.of(DocumentComment.builder().id(present).content("Hi").build()));
Map<UUID, CommentData> result = commentService.findDataByIds(List.of(present, deleted));
assertThat(result).containsKey(present);
assertThat(result).doesNotContainKey(deleted);
}
@Test
void findDataByIds_preserves_annotationId_alongside_preview() {
UUID id = UUID.randomUUID();
UUID annotationId = UUID.randomUUID();
when(commentRepository.findAllById(List.of(id)))
.thenReturn(List.of(DocumentComment.builder().id(id)
.annotationId(annotationId).content("Text").build()));
CommentData data = commentService.findDataByIds(List.of(id)).get(id);
assertThat(data.annotationId()).isEqualTo(annotationId);
assertThat(data.preview()).isEqualTo("Text");
}
@Test
void findDataByIds_sets_null_annotationId_when_comment_has_no_annotation() {
UUID id = UUID.randomUUID();
when(commentRepository.findAllById(List.of(id)))
.thenReturn(List.of(DocumentComment.builder().id(id)
.annotationId(null).content("Text").build()));
assertThat(commentService.findDataByIds(List.of(id)).get(id).annotationId()).isNull();
}
private void stubBlock(UUID docId, UUID blockId) {

View File

@@ -159,6 +159,26 @@ class GeschichteServiceIntegrationTest {
.isEmpty();
}
@Test
void list_DRAFT_does_not_return_other_users_drafts() {
// writer creates a draft; writer2 (also BLOG_WRITE) should not see it
AppUser writer2 = appUserRepository.save(AppUser.builder()
.email("writer2-int@test")
.password("hash")
.build());
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Writer 1 draft");
dto.setBody("<p>private</p>");
geschichteService.create(dto);
authenticateAs(writer2, Permission.BLOG_WRITE);
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
assertThat(result).isEmpty();
}
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle(title);

View File

@@ -81,6 +81,29 @@ class PersonControllerTest {
.andExpect(jsonPath("$[0].firstName").value("Hans"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_delegatesTopByDocumentCount_whenSortAndSizeGiven() throws Exception {
PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz");
when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top));
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Käthe"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_capsTopByDocumentCount_atFifty() throws Exception {
ArgumentCaptor<Integer> sizeCaptor = ArgumentCaptor.forClass(Integer.class);
when(personService.findTopByDocumentCount(sizeCaptor.capture())).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "999"))
.andExpect(status().isOk());
assertThat(sizeCaptor.getValue()).isEqualTo(50);
}
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
return new PersonSummaryDTO() {
public java.util.UUID getId() { return UUID.randomUUID(); }

View File

@@ -35,4 +35,15 @@ class AppUserTest {
.count();
assertThat(distinct).isGreaterThan(1);
}
@Test
void computeColor_returnsValidPaletteColorForIntegerMinValueHash() {
// UUID "80000000-0000-0000-0000-000000000000" has hashCode() == Integer.MIN_VALUE.
// Math.abs(Integer.MIN_VALUE) overflows back to Integer.MIN_VALUE (negative), making
// Math.abs(hashCode()) % n unsafe for palette sizes that don't evenly divide MIN_VALUE.
// Math.floorMod eliminates this edge case entirely.
UUID minHashId = UUID.fromString("80000000-0000-0000-0000-000000000000");
assertThat(minHashId.hashCode()).isEqualTo(Integer.MIN_VALUE);
assertThat(EXPECTED_PALETTE).contains(AppUser.computeColor(minHashId));
}
}

View File

@@ -902,4 +902,18 @@ class UserServiceTest {
assertThat(result.getName()).isEqualTo("Familie");
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
}
@Test
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
dto.setName("Leser");
dto.setPermissions(null);
UserGroup saved = UserGroup.builder().id(UUID.randomUUID()).name("Leser").build();
when(groupRepository.save(any())).thenReturn(saved);
userService.createGroup(dto);
verify(groupRepository).save(argThat(g -> g.getPermissions() != null && g.getPermissions().isEmpty()));
}
}

231
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,231 @@
# Production / staging Docker Compose for Familienarchiv.
#
# This is a self-contained file (not an overlay over docker-compose.yml).
# All services for the prod stack live here. Environment isolation is
# achieved via the docker compose project name:
#
# production: docker compose -f docker-compose.prod.yml -p archiv-production ...
# staging: docker compose -f docker-compose.prod.yml -p archiv-staging --profile staging ...
#
# Volumes, networks and containers are namespaced by the project name,
# so the two environments cohabit cleanly on the same host.
#
# Required env vars (provided by .env.production / .env.staging in CI):
# TAG image tag (release tag or "nightly")
# PORT_BACKEND, PORT_FRONTEND host-side ports (bound to 127.0.0.1 only)
# APP_DOMAIN e.g. archiv.raddatz.cloud / staging.raddatz.cloud
# POSTGRES_PASSWORD Postgres password
# MINIO_PASSWORD MinIO root password (admin operations only)
# MINIO_APP_PASSWORD MinIO application service-account password
# (least-privilege scope: archive bucket only)
# OCR_TRAINING_TOKEN token guarding ocr-service /train endpoint
# APP_ADMIN_USERNAME seeded admin email (e.g. admin@archiv.raddatz.cloud)
# APP_ADMIN_PASSWORD seeded admin password — CRITICAL: locked in on
# first deploy because UserDataInitializer only
# creates the account if the email does not exist
# MAIL_HOST, MAIL_PORT, SMTP relay (production only; staging uses mailpit)
# MAIL_USERNAME, MAIL_PASSWORD
# APP_MAIL_FROM sender address (e.g. noreply@raddatz.cloud)
networks:
archiv-net:
driver: bridge
volumes:
postgres-data:
minio-data:
ocr-models:
ocr-cache:
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: archiv
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: archiv
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- archiv-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U archiv -d archiv"]
interval: 10s
timeout: 5s
retries: 5
minio:
# Pinned MinIO release for reproducible deploys. Bumped manually until
# Renovate is bootstrapped for these production images (see follow-up issue).
image: minio/minio:RELEASE.2025-02-28T09-55-16Z
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: archiv
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
volumes:
- minio-data:/data
networks:
- archiv-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# Idempotent bucket bootstrap + service-account creation.
# Runs once per `docker compose up` and exits 0. The entrypoint is
# extracted to infra/minio/bootstrap.sh so the (non-trivial) idempotent
# logic is readable, reviewable, and unit-testable as a script rather
# than YAML-escaped shell.
create-buckets:
# Custom image bakes bootstrap.sh in at build time. A bind-mount fails on
# the Docker-out-of-Docker production runner because the host daemon
# resolves the relative path against the host filesystem, not the
# runner container's CWD. See #506 + infra/minio/Dockerfile.
build:
context: ./infra/minio
# Declare one-shot intent so `docker compose up -d --wait` treats
# exited(0) as success rather than "not running, fail". Pair with
# backend's `service_completed_successfully` dependency below. See #510.
restart: "no"
depends_on:
minio:
condition: service_healthy
networks:
- archiv-net
environment:
MINIO_PASSWORD: ${MINIO_PASSWORD}
MINIO_APP_PASSWORD: ${MINIO_APP_PASSWORD}
# Dev-only mail catcher; gated behind the staging profile so production
# never starts it. Staging workflow runs with `--profile staging`.
mailpit:
# Pinned for reproducibility; bumped manually until Renovate is bootstrapped.
image: axllent/mailpit:v1.29.7
restart: unless-stopped
profiles: ["staging"]
networks:
- archiv-net
healthcheck:
# TCP-port open check via BusyBox `nc`. The previous wget-based probe
# introduced a non-obvious binary dependency on the mailpit image; a
# future tag that ships without wget would silently disable the
# healthcheck. `nc` is part of BusyBox in the upstream image.
test: ["CMD-SHELL", "nc -z localhost 8025 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
ocr-service:
build:
context: ./ocr-service
restart: unless-stopped
expose:
- "8000"
# Surya OCR loads ~5GB of transformer models at startup; first request
# triggers a further ~1GB Kraken model download into ocr-cache.
# CX42+ (16 GB RAM) honours the default. On a CX32 (8 GB) override with
# OCR_MEM_LIMIT=6g (slower first-request, fits the host).
mem_limit: ${OCR_MEM_LIMIT:-12g}
memswap_limit: ${OCR_MEM_LIMIT:-12g}
volumes:
- ocr-models:/app/models
- ocr-cache:/root/.cache
environment:
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
TRAINING_TOKEN: ${OCR_TRAINING_TOKEN}
OCR_CONFIDENCE_THRESHOLD: "0.3"
OCR_CONFIDENCE_THRESHOLD_KURRENT: "0.5"
# SSRF allowlist pinned explicitly to the internal MinIO hostname.
# In prod the OCR service only fetches PDFs from MinIO over the
# docker network; localhost/127.0.0.1 are dev-only sources and
# must NOT be reachable here. Do not widen to `*`.
ALLOWED_PDF_HOSTS: "minio"
networks:
- archiv-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 120s
backend:
image: familienarchiv/backend:${TAG:-nightly}
build:
context: ./backend
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
ocr-service:
condition: service_healthy
# Gate startup on the bucket bootstrap. Without this, backend
# starts in parallel with create-buckets and may race the policy
# bind. Also tells compose's `up -d --wait` that create-buckets
# is a one-shot that must complete successfully. See #510.
create-buckets:
condition: service_completed_successfully
# Bound to localhost only — Caddy fronts external traffic.
ports:
- "127.0.0.1:${PORT_BACKEND}:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
SPRING_DATASOURCE_USERNAME: archiv
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Application uses the bucket-scoped service account, not MinIO root.
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: archiv-app
S3_SECRET_KEY: ${MINIO_APP_PASSWORD}
S3_BUCKET_NAME: familienarchiv
S3_REGION: us-east-1
# No SPRING_PROFILES_ACTIVE — base application.yaml is production-ready
# (Swagger disabled, show-sql off, open-in-view false).
APP_BASE_URL: https://${APP_DOMAIN}
APP_ADMIN_USERNAME: ${APP_ADMIN_USERNAME}
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD}
APP_OCR_BASE_URL: http://ocr-service:8000
APP_OCR_TRAINING_TOKEN: ${OCR_TRAINING_TOKEN}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT:-587}
MAIL_USERNAME: ${MAIL_USERNAME:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@raddatz.cloud}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-true}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-true}
networks:
- archiv-net
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
frontend:
image: familienarchiv/frontend:${TAG:-nightly}
build:
context: ./frontend
target: production
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ports:
- "127.0.0.1:${PORT_FRONTEND}:3000"
environment:
# SSR fetches go inside the docker network; clients hit https://${APP_DOMAIN}
API_INTERNAL_URL: http://backend:8080
ORIGIN: https://${APP_DOMAIN}
networks:
- archiv-net
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/login >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 20s

View File

@@ -13,7 +13,7 @@ services:
ports:
- "${PORT_DB}:5432"
networks:
- archive-net
- archiv-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
@@ -35,7 +35,7 @@ services:
- "${PORT_MINIO_API}:9000" # API Port
- "${PORT_MINIO_CONSOLE}:9001" # Web-Oberfläche
networks:
- archive-net
- archiv-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
@@ -56,7 +56,7 @@ services:
exit 0;
"
networks:
- archive-net
- archiv-net
# --- Mail catcher: Mailpit (dev only) ---
# Catches all outgoing emails and displays them in a web UI.
@@ -69,7 +69,7 @@ services:
- "${PORT_MAILPIT_UI:-8025}:8025" # Web UI
- "${PORT_MAILPIT_SMTP:-1025}:1025" # SMTP
networks:
- archive-net
- archiv-net
# --- OCR: Python microservice (Surya + Kraken) ---
# Single-node only: OCR training reloads the model in-process after each run.
@@ -99,7 +99,7 @@ services:
OCR_CLAHE_TILE_SIZE: "8" # CLAHE tile grid size (NxN tiles per page)
OCR_MAX_CACHED_MODELS: "2" # LRU cache; each model ~500 MB, so 2 = ~1 GB resident
networks:
- archive-net
- archiv-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
@@ -150,7 +150,7 @@ services:
ports:
- "${PORT_BACKEND}:8080"
networks:
- archive-net
- archiv-net
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
interval: 15s
@@ -163,6 +163,7 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
target: development # Dockerfile is multi-stage; default would be the production stage
container_name: archive-frontend
restart: unless-stopped
depends_on:
@@ -184,10 +185,10 @@ services:
ports:
- "${PORT_FRONTEND}:5173"
networks:
- archive-net
- archiv-net
networks:
archive-net:
archiv-net:
driver: bridge
volumes:

146
docs/ARCHITECTURE.md Normal file
View 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.

View File

@@ -1,97 +1,5 @@
# Docs — Familienarchiv
# docs/
## Overview
→ See [docs/README.md](./README.md) for the folder structure and documentation guide.
Project documentation organized into four categories: architecture decision records (ADRs), system architecture diagrams, infrastructure runbooks, and detailed UI/UX specifications.
## Folder Structure
```
docs/
├── adr/ # Architecture Decision Records
├── architecture/ # C4 model diagrams and system architecture docs
├── infrastructure/ # Deployment, CI/CD, and ops guides
├── specs/ # UI/UX feature specifications (HTML)
├── app-analysis-*.md # Application analysis reports
├── mail.md # Mail system documentation
├── security-guide.md # Security policies and hardening guide
├── STYLEGUIDE.md # Coding and design style guide
├── TODO-backend.md # Backend backlog
└── TODO-frontend.md # Frontend backlog
```
## ADR (`adr/`)
Architecture Decision Records capture major technical decisions and their rationale.
| ADR | Title | Status |
|---|---|---|
| `001-ocr-python-microservice.md` | OCR as a separate Python container | Accepted |
| `002-polygon-jsonb-storage.md` | Polygon coordinates in JSONB columns | Accepted |
| `003-chronik-unified-activity-feed.md` | Unified activity feed (Chronik) | Accepted |
When making a significant architectural change (new service, data model change, technology swap), write a new ADR following the format:
- Status (Proposed / Accepted / Deprecated / Superseded)
- Context (forces at play)
- Decision (what we decided)
- Consequences (trade-offs)
- Alternatives Considered (table format)
## Architecture (`architecture/`)
Contains C4 model diagrams describing the system at different zoom levels:
- **Context diagram** — How Familienarchiv fits into the user and system ecosystem
- **Container diagram** — The high-level technology choices (Spring Boot, SvelteKit, PostgreSQL, MinIO, OCR service)
- **Component diagram** — Major structural components within the backend
Written in Markdown with embedded Mermaid or PlantUML diagrams (`c4-diagrams.md`).
## Infrastructure (`infrastructure/`)
Operational documentation for running Familienarchiv in production and CI.
| Document | Purpose |
|---|---|
| `ci-gitea.md` | Gitea CI/CD pipeline configuration |
| `production-compose.md` | Production Docker Compose setup |
| `s3-migration.md` | Migrating documents between S3 buckets |
| `self-hosted-catalogue.md` | Self-hosted software catalogue |
## Specs (`specs/`)
High-fidelity UI/UX specifications written as standalone HTML files. These are design documents that describe exact layout, interactions, and responsive behavior before implementation.
Each spec typically includes:
- Visual mockups with CSS-in-HTML styling
- Interaction flows and state transitions
- Responsive breakpoint behavior
- Accessibility requirements
Examples of active spec areas:
- Document detail page (`document-topbar-*.html`, `documents-page-spec.html`)
- Admin interfaces (`admin-redesign-*.html`, `admin-tag-overhaul.html`)
- Transcription workflows (`inline-transcription-*.html`, `annotation-transcription-*.html`)
- Dashboard and activity feeds (`dashboard-*.html`, `chronik-spec.html`)
- OCR admin (`ocr-admin-spec.html`)
## How to Use
1. **Before implementing a feature**, check `specs/` for an existing specification.
2. **When proposing a new architecture**, draft an ADR in `adr/` and discuss before coding.
3. **When deploying**, follow `infrastructure/production-compose.md`.
4. **Keep TODO files updated** — they serve as lightweight backlogs.
## Style Guide
`STYLEGUIDE.md` covers:
- Code formatting and linting rules
- Component naming conventions
- Color palette and typography
- Accessibility standards (WCAG 2.1 AA)
## Contributing
- ADRs should be sequential (`NNN-descriptive-name.md`).
- Specs should be self-contained HTML files viewable in a browser.
- Infrastructure docs should include copy-pasteable commands.
**LLM reminder:** ADRs are sequential — use the next number after the highest existing one in `docs/adr/`. When making a significant architectural change (new service, data model change, technology swap), write a new ADR before implementing.

346
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,346 @@
<!-- 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 :3000| Frontend["Web Frontend\nSvelteKit Node adapter"]
Caddy -->|HTTP :8080| Backend["API Backend\nSpring Boot / Jetty :8080"]
Backend -->|JDBC :5432| DB[(PostgreSQL 16)]
Backend -->|S3 API :9000| MinIO[(MinIO)]
Backend -->|HTTP :8000 internal| OCR["OCR Service\nPython FastAPI"]
OCR -->|presigned URL| MinIO
Caddy -->|SSE proxy_pass| Backend
```
**Key facts:**
- Caddy terminates TLS and reverse-proxies to frontend (`:3000`) and backend (`:8080`). The Caddyfile is committed at [`infra/caddy/Caddyfile`](../infra/caddy/Caddyfile) and is installed on the host as `/etc/caddy/Caddyfile` (symlink).
- The host binds all docker-published ports to `127.0.0.1` only; Caddy is the sole external entry point.
- The OCR service has **no published port** — reachable only on the internal Docker network from the backend.
- SSE notifications transit Caddy (browser → Caddy → backend); the backend is never reachable directly from the public internet. The SvelteKit SSR layer is bypassed for SSE, but Caddy is not.
- The Caddyfile responds `404` on `/actuator/*` (defense in depth). Internal monitoring scrapes the backend on the docker network, not through Caddy.
- Production and staging cohabit on the same host via docker compose project names: `archiv-production` (ports 8080/3000) and `archiv-staging` (ports 8081/3001).
### 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 the default `mem_limit: 12g` — set the `OCR_MEM_LIMIT=6g` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow) before deploying on a CX32. The prod compose interpolates this var with a 12g default.
### Dev vs production differences
| Concern | Dev (`docker-compose.yml`) | Prod (`docker-compose.prod.yml`) |
|---|---|---|
| MinIO image tag | `minio/minio:latest` | Pinned `minio/minio:RELEASE.…` |
| Data persistence | Bind mounts `./data/postgres`, `./data/minio` | Named Docker volumes (`postgres-data`, `minio-data`) |
| MinIO credentials for backend | Root user/password | Service account `archiv-app` with bucket-scoped rights |
| Bucket creation | `create-buckets` helper | Same helper, plus service-account bootstrap on every up |
| Spring profile | `dev,e2e` (Swagger + e2e overrides) | unset — base `application.yaml` is production-ready |
| Mail | Mailpit (local catcher) | Real SMTP (production) / Mailpit via `profiles: [staging]` (staging) |
| Frontend image | Dev server, `target: development`, port 5173 | Node adapter, `target: production`, port 3000 |
| Host port binding | All published | Bound to `127.0.0.1` only; Caddy is the front door |
| Deploy method | `docker compose up -d` (manual) | Gitea Actions: `nightly.yml` (staging, cron) and `release.yml` (production, on `v*` tag) — both use `up -d --wait` |
Full prod compose: [`docker-compose.prod.yml`](../docker-compose.prod.yml). Workflow files: [`.gitea/workflows/nightly.yml`](../.gitea/workflows/nightly.yml), [`.gitea/workflows/release.yml`](../.gitea/workflows/release.yml).
---
## 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 (dev compose only — prod compose hardcodes `archiv`) | `minio_admin` | YES (dev) | — |
| `MINIO_ROOT_PASSWORD` / `MINIO_PASSWORD` | MinIO root password. **Used only by the `mc admin` bootstrap in prod, never by the backend.** | `change-me` | YES | YES |
| `MINIO_APP_PASSWORD` | Password for the `archiv-app` service account that the backend uses. Bucket-scoped via `readwrite` policy on `familienarchiv`. Bootstrapped by `create-buckets`. | — | YES (prod) | YES |
| `MINIO_DEFAULT_BUCKETS` | Bucket name (dev compose only — prod compose hardcodes `familienarchiv`) | `archive-documents` | YES (dev) | — |
### 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` | — | — |
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
---
## 3. Bootstrap from scratch
Production and staging deploy via Gitea Actions (`release.yml` on `v*` tag, `nightly.yml` on cron). The server itself only needs to host Caddy, Docker, and the runner — the workflows handle the rest.
### 3.1 Server one-time setup
```bash
# Base hardening
ufw default deny incoming && ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw enable
# /etc/ssh/sshd_config: PasswordAuthentication no, PermitRootLogin no
# Install Caddy 2 (https://caddyserver.com/docs/install#debian-ubuntu-raspbian)
apt install caddy
# Use the Caddyfile from the repo (replace path with the runner's clone target)
ln -sf /opt/familienarchiv/infra/caddy/Caddyfile /etc/caddy/Caddyfile
systemctl reload caddy
# fail2ban — protect /api/auth/login from credential stuffing.
# Jail watches the Caddy JSON access log for 401 responses on
# /api/auth/login. The jail (maxretry=10 / findtime=10m / bantime=30m)
# and filter are committed under infra/fail2ban/ — symlink them in:
apt install fail2ban
ln -sf /opt/familienarchiv/infra/fail2ban/jail.d/familienarchiv.conf \
/etc/fail2ban/jail.d/familienarchiv.conf
ln -sf /opt/familienarchiv/infra/fail2ban/filter.d/familienarchiv-auth.conf \
/etc/fail2ban/filter.d/familienarchiv-auth.conf
systemctl reload fail2ban
# Verify after first deploy with:
# fail2ban-client status familienarchiv-auth
# fail2ban-regex /var/log/caddy/access.log familienarchiv-auth
# Tailscale — used by the backup pipeline to reach heim-nas (follow-up issue)
curl -fsSL https://tailscale.com/install.sh | sh && tailscale up
# Self-hosted Gitea runner — register against the repo with a runner token.
# This runner is assumed single-tenant: the deploy workflows write .env.*
# files to disk during execution (cleaned up unconditionally on completion).
# A multi-tenant runner would need to switch to stdin-piped env files.
# (See https://docs.gitea.com/usage/actions/quickstart for the register step.)
```
### 3.2 DNS records
```
archiv.raddatz.cloud A <server IP>
staging.raddatz.cloud A <server IP>
git.raddatz.cloud A <server IP>
```
### 3.3 Gitea secrets (Repo → Settings → Actions → Secrets)
| Secret | Used by | Notes |
|---|---|---|
| `PROD_POSTGRES_PASSWORD` | release.yml | strong unique password |
| `PROD_MINIO_PASSWORD` | release.yml | MinIO root password; used only at bootstrap |
| `PROD_MINIO_APP_PASSWORD` | release.yml | application service-account password |
| `PROD_OCR_TRAINING_TOKEN` | release.yml | `python3 -c "import secrets; print(secrets.token_hex(32))"` |
| `PROD_APP_ADMIN_USERNAME` | release.yml | e.g. `admin@archiv.raddatz.cloud` |
| `PROD_APP_ADMIN_PASSWORD` | release.yml | **⚠ locked permanently on first deploy** — see §3.5 |
| `STAGING_POSTGRES_PASSWORD` | nightly.yml | different from prod |
| `STAGING_MINIO_PASSWORD` | nightly.yml | different from prod |
| `STAGING_MINIO_APP_PASSWORD` | nightly.yml | different from prod |
| `STAGING_OCR_TRAINING_TOKEN` | nightly.yml | different from prod |
| `STAGING_APP_ADMIN_USERNAME` | nightly.yml | e.g. `admin@staging.raddatz.cloud` |
| `STAGING_APP_ADMIN_PASSWORD` | nightly.yml | locked on first staging deploy |
| `MAIL_HOST` | release.yml | SMTP relay hostname (prod only) |
| `MAIL_PORT` | release.yml | typically `587` |
| `MAIL_USERNAME` | release.yml | SMTP user |
| `MAIL_PASSWORD` | release.yml | SMTP password |
### 3.4 First deploy
```bash
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
# Expected: docker compose up -d --wait succeeds for archiv-staging, then
# the workflow's "Smoke test deployed environment" step asserts:
# - https://staging.raddatz.cloud/login returns 200
# - HSTS header is present
# - /actuator/health returns 404 (defense-in-depth check)
# 2. (Optional) Re-verify manually
curl -I https://staging.raddatz.cloud/
# Expected: 200 (login page) with HSTS + X-Content-Type-Options headers
# 3. When staging looks healthy, push a v* tag to trigger release.yml
git tag v1.0.0 && git push origin v1.0.0
```
### 3.5 ⚠ Admin password is locked on first deploy
`UserDataInitializer` creates the admin user **only if the email does not exist**. The first successful deploy persists the admin password to the database. Changing `PROD_APP_ADMIN_PASSWORD` in Gitea secrets after that point has **no effect** — the secret is only consulted when the row is missing.
Before the first deploy: rotate `PROD_APP_ADMIN_PASSWORD` to a strong value. After the first deploy: change the admin password via the in-app account settings, not via the Gitea secret.
---
## 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 (nightly `pg_dump` + MinIO `mc mirror` over Tailscale to `heim-nas`) is a follow-up issue. Until that ships: **manual backups are the only recovery option.**
### Rollback
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
```bash
TAG=v1.0.0 docker compose \
-f docker-compose.prod.yml \
-p archiv-production \
--env-file /opt/familienarchiv/.env.production \
up -d --wait --remove-orphans
```
If the rollback target image is no longer present on the host (host disk pruned, etc.), re-trigger `release.yml` for that tag from Gitea Actions UI — it rebuilds and redeploys.
**Flyway migrations are not auto-rolled-back.** If a release contained a destructive migration (drop column, rename table), a tag rollback brings the schema back to a previous app version but the data shape has already changed. For breaking schema changes, prefer a forward-only fix.
---
## 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 |

123
docs/GLOSSARY.md Normal file
View File

@@ -0,0 +1,123 @@
# 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 18991950 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/`.
---
## Infrastructure Terms
**archiv-app** — the bucket-scoped MinIO service account the backend uses to read and write the `familienarchiv` bucket. Distinct from the MinIO root account (`archiv`, used only by the bootstrap container for admin operations). Defined and provisioned in [`infra/minio/bootstrap.sh`](../infra/minio/bootstrap.sh) and consumed by the backend as `S3_ACCESS_KEY` in [`docker-compose.prod.yml`](../docker-compose.prod.yml). The attached `archiv-app-policy` grants `s3:GetObject/PutObject/DeleteObject` on `familienarchiv/*` and `s3:ListBucket/GetBucketLocation` on the bucket only — not the built-in `readwrite` policy which would grant `s3:*` on all buckets.
_See also [ADR-010 — MinIO stays self-hosted, not Hetzner OBS](./adr/010-minio-self-hosted-not-hetzner-obs.md)._
---
## 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
View 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)

View 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`.

View File

@@ -0,0 +1,68 @@
# ADR-008: SQL-level pagination for full-text search via window-function CTE
## Status
Accepted
## Context
`DocumentRepository.findAllMatchingIdsByFts` (formerly `findRankedIdsByFts`) returns all matching document IDs for a FTS query. `DocumentService.searchDocuments` then paginates in memory on the RELEVANCE sort path.
A pre-production audit against 1,520 documents measured:
```
rows_per_call: 911 / call (query: "walter")
```
At current scale this is acceptable — 911 UUIDs ≈ 14 KB, ms-level DB time. At 100 K+ documents two failure modes emerge:
1. **Memory**: a broad query returns ~60 K UUIDs ≈ 1 MB per request, multiplied by concurrent users.
2. **Latency**: the `LATERAL` join does work proportional to match-set size; at 60 K matches the FTS step alone exceeds 100 ms per query.
Tracked as finding **F-31 (High)** in the pre-production architectural review.
## Decision
Push pagination and rank ordering into SQL for the RELEVANCE sort path when no non-text filters are active (pure full-text search):
```sql
WITH q AS (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''', '''\\1'':*', 'g'))
END AS pq
), matches AS (
SELECT d.id, ts_rank(d.search_vector, q.pq) AS rank
FROM documents d, q
WHERE d.search_vector @@ q.pq
)
SELECT id, rank, COUNT(*) OVER () AS total
FROM matches
ORDER BY rank DESC, id
OFFSET :offset LIMIT :limit
```
`COUNT(*) OVER ()` returns the full match count alongside each page row in a single round-trip — no separate count query needed.
`rows_per_call` for the FTS query drops from match-set size (911) to page size (≤ 50).
When non-text filters (date range, sender, receiver, tags, status) are also active, the existing path is preserved: `findAllMatchingIdsByFts` returns all ranked IDs, which are passed as an `IN` clause to the JPA Specification, and `totalElements` comes from the JPA `Page.getTotalElements()`. This keeps the count accurate across the combined filter set.
## Alternatives Considered
**1. Two-query approach (separate COUNT + paged SELECT)**
Correct, but doubles round-trips. The window function achieves the same result in one query.
**2. Capped result set with a user-visible warning**
Return at most N results (e.g. 500) and show "showing top 500 of many results". Simpler, but degrades UX for broad queries and doesn't reduce latency proportionally (still scans N rows).
**3. Full SQL rewrite combining FTS + JPA Specification filters**
Possible via a native query that embeds all filter predicates. Eliminates the in-memory SENDER/RECEIVER sort paths and the two-phase approach. High complexity, tight coupling to schema details, loses type-safe JPA Specification composition. Deferred to a future refactor if scale demands it.
## Consequences
- **`rows_per_call` for pure-text FTS searches drops to ≤ page size** — the primary metric.
- **SENDER and RECEIVER sort paths stay in-memory** for combined text+filter queries. For pure-text queries with SENDER/RECEIVER sort, the current approach (fetch all matched IDs, build spec, load all matched entities, sort in-memory) still runs. This is acceptable while the archive stays under ~10 K documents.
- **RELEVANCE sort with text+filters still loads the full filtered entity set in-memory.** The filtered set is typically much smaller than the raw FTS match set, so the cost is bounded by filter selectivity, not total match count.
- **`findAllMatchingIdsByFts` is retained** for: (a) the bulk-edit "select all" fast path (`findIdsForFilter`), (b) the document density chart (`getDensity`), and (c) the SENDER/RECEIVER in-memory sort paths.

View File

@@ -0,0 +1,50 @@
# ADR-009: Standalone `docker-compose.prod.yml`, not an overlay
## Status
Accepted
## Context
The repository's `docker-compose.yml` is a development stack: every service is built locally, ports are exposed on `0.0.0.0` for dev tooling, the frontend runs `npm run dev` with hot-reload, the backend is `spring-boot:run` with the dev profile, and there is no Caddy, no `archiv-app` service account, no admin-credential lock-in, no healthcheck-gated startup sequence. The dev stack reflects "single developer on a laptop", not "production on a single VPS".
The pre-merge design (issue #497, comment #8331) sketched two ways to add a production stack:
1. **Overlay** — keep `docker-compose.yml` as the base, add `docker-compose.prod.yml` as a `-f` overlay (`docker compose -f docker-compose.yml -f docker-compose.prod.yml up`). Compose merges the two files at runtime.
2. **Standalone** — make `docker-compose.prod.yml` a fully self-contained file that does not reference or merge with `docker-compose.yml` at all. Project-name namespacing (`-p archiv-production`, `-p archiv-staging`) keeps multi-environment deploys clean on a single host.
The earlier `docs/infrastructure/production-compose.md` notes assumed overlay because the original plan was to **remove** MinIO in production (replace with Hetzner Object Storage), so the prod file would only need to remove one service and add a few. With MinIO retained (see ADR-010), the prod stack diverges from dev in essentially every service: build vs pre-built image, target stage, port binding, env vars, healthcheck, restart policy, mem_limit, profile gating, service account, depends_on chain. Overlay would mostly be `override:` blocks that nullify the dev defaults — a fragile inversion.
## Decision
`docker-compose.prod.yml` is standalone. Production and staging both run it directly:
```
production: docker compose -f docker-compose.prod.yml -p archiv-production --env-file .env.production ...
staging: docker compose -f docker-compose.prod.yml -p archiv-staging --env-file .env.staging --profile staging ...
```
Environment isolation is achieved via the Docker Compose project name (`-p`). Volumes, networks, and containers are namespaced by the project name, so production and staging cohabit cleanly on the same host without interfering.
The dev `docker-compose.yml` is unchanged — `docker compose up` still works for developers, and its `frontend` service now specifies `target: development` explicitly so the new multi-stage Dockerfile builds the right stage.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| Overlay (`-f base.yml -f prod.yml`) | With MinIO retained and most services differing across nearly every field, the overlay would consist mostly of `override:` blocks that null out dev defaults. Compose's merge semantics for nested keys (env, ports, healthcheck) are sharp — silent merges of port mappings, env-var entries, and depends_on edges cost reviewer hours. Standalone is one file the reader can hold in their head. |
| Two fully separate files (dev + prod) but with shared YAML anchors via `extends:` | `extends:` works across files but is a niche feature and is increasingly discouraged in compose v2. Reviewer load is higher than reading two flat files. |
| Generate prod compose from a template at deploy time (e.g. ytt, kustomize) | Adds a build-time step and a new tool to the operator toolchain. Justified for a fleet of 10+ environments; overkill for production + staging on one host. |
| Single compose file with environment-specific profiles | Compose profiles select which *services* run, not which *configuration* a service runs with. Using profiles to swap "build locally" vs "pull image" would smear dev and prod across one file. |
## Consequences
- The prod file can be read top-to-bottom without cross-referencing `docker-compose.yml`. Onboarding and review cost drops.
- Volume namespacing is automatic (`archiv-production_postgres-data`, `archiv-staging_postgres-data`) — no manual `volumes:` aliasing.
- Dev compose churn (e.g. swapping a dev port) cannot accidentally affect production. The two files are independent.
- The cost is duplication: identical environment variables (e.g. `POSTGRES_DB: archiv`) appear in both files. This duplication is bounded — there is no incentive to add more services that exist in both — and the alternative (overlay) carries its own duplication via `override:` boilerplate.
- The retired `docs/infrastructure/production-compose.md` narrative is trimmed to a pointer at the live files. The cost/sizing rationale is preserved there.
## Future Direction
If the deployment fleet ever grows beyond two environments on one host (e.g. add a `demo` environment, or shard staging across two VPS for load testing), revisit the templating decision. At three+ environments the duplication starts to bite and a template engine (kustomize or ytt) becomes attractive.

View File

@@ -0,0 +1,53 @@
# ADR-010: MinIO stays self-hosted on the production VPS
## Status
Accepted
## Context
`docs/infrastructure/production-compose.md` (pre-this-PR) sketched a production topology in which the application bucket migrates from in-cluster MinIO to Hetzner Object Storage (OBS, S3-compatible). The motivation was operational: one less service to back up, no MinIO RAM/disk pressure on the VPS, hand off durability to the hyperscaler.
Two facts revisited at pre-merge review (issue #497, comment #8331) changed the answer:
1. **Current data size is small.** The archive is ~13 GB of file uploads (Kurrent letters, scanned ODS files, attachment PDFs). Hetzner OBS billing on this size is dominated by the per-month base fee (~5 EUR/mo for the smallest unit), not capacity or egress. The break-even point against the VPS's existing disk is far above the current footprint.
2. **MinIO is already production-grade.** The dev stack uses MinIO; the backend already drives it via the AWS SDK v2 with a generic `S3_ENDPOINT`. Switching providers is a runtime env-var change (`S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`) plus an `mc mirror` to copy objects. There is no application-level rewrite cost waiting.
If Hetzner OBS were a one-way-door (provider-specific SDK, complex IAM integration, multi-month migration), the decision would deserve a serious weighing. As reversible as the migration is, deferring it costs nothing.
## Decision
MinIO stays on the production VPS for the first launch. The application bucket is created and managed inside the docker-compose stack (`infra/minio/bootstrap.sh`). The backend uses a least-privilege service account (`archiv-app`) with a bucket-scoped IAM policy, not the MinIO root credentials.
Hetzner Object Storage is **explicitly deferred**, not rejected. The migration path is documented as a runbook in `docs/DEPLOYMENT.md` (when the trigger fires): provision an OBS bucket, run `mc mirror local-minio:/familienarchiv obs:/familienarchiv`, rotate the three env vars, restart the backend, decommission the MinIO service from `docker-compose.prod.yml`.
## Triggers to re-evaluate
Revisit the decision when **any** of the following holds:
- The `minio-data` volume exceeds 50 GB and is growing > 5 GB/month.
- MinIO healthcheck latency exceeds 200 ms p95 (signal of disk pressure on the host).
- The VPS upgrade required to keep MinIO healthy costs more per month than the equivalent OBS bucket + traffic.
- Backup of the MinIO volume to `heim-nas` over Tailscale (deferred follow-up) is implemented and consistently runs > 30 min nightly. At that point durability-as-a-service starts paying for itself.
The migration runbook in `docs/DEPLOYMENT.md` is the script for executing the swap when one of the triggers fires.
## Alternatives Considered
| Alternative | Why rejected (for now) |
|---|---|
| Migrate to Hetzner Object Storage in this PR | Premature. Adds an external dependency, locks the operator into the Hetzner ecosystem before the data has demonstrated it needs hyperscaler durability, blocks the PR on a migration that buys ~5 GB of headroom. |
| Migrate to S3 (AWS) for HA across regions | Way over-spec for a family archive. Egress cost would dwarf any benefit; durability concerns at this size are addressed by nightly off-site backup, not by multi-region replication. |
| Drop S3 abstraction entirely; store files directly on the VPS disk | Possible, but loses the bucket-policy IAM surface (least-privilege service account), loses presigned-URL flow (OCR service downloads files via short-lived URLs, not via shared filesystem), loses the migration path to OBS. The S3 indirection is cheap insurance. |
| Self-hosted on-VPS plus periodic `mc mirror` to Hetzner OBS for off-site backup | This is the **target** for the backup pipeline follow-up. Treated as backup, not primary — primary stays MinIO. |
## Consequences
- The production VPS sizing (Hetzner CX42, 16 GB RAM, 80 GB disk) must accommodate MinIO's working set. Current footprint leaves ample headroom.
- Backup of MinIO data is the operator's responsibility until the off-site `mc mirror` pipeline is implemented (deferred follow-up). The DEPLOYMENT.md rollback procedure explicitly flags this — manual backup is the only recovery option until the pipeline ships.
- The backend never sees the MinIO root password; it uses the `archiv-app` service account with a bucket-scoped IAM policy (see `infra/minio/bootstrap.sh`). A backend RCE/SSRF cannot escalate beyond the `familienarchiv` bucket.
- The migration to Hetzner OBS remains a small, well-understood runbook step rather than a major refactor. No application code, no SDK swap.
## Future Direction
When one of the triggers above fires, the migration is: provision OBS bucket → `mc mirror` → rotate three env vars → restart backend → remove MinIO service from compose. The bucket-scoped policy translates 1:1 to an OBS user policy (S3-compatible).

View File

@@ -0,0 +1,58 @@
# ADR-011: Single-tenant Gitea runner with secrets-on-disk env-files
## Status
Accepted
## Context
The deploy workflows (`.gitea/workflows/nightly.yml`, `release.yml`) execute on a self-hosted Gitea Actions runner. The runner has Docker-out-of-Docker access (the host's Docker socket is mounted into the runner), so `docker compose build` produces images on the host daemon and `docker compose up` consumes them directly — no registry hop.
Two workflow steps shape the security model:
1. **"Write env file"** — the workflow writes every required secret to `.env.staging` or `.env.production` on the runner's filesystem so that `docker compose --env-file` can consume them. The file lives on disk for the duration of the workflow.
2. **"Cleanup env file"** — the matching `if: always()` step deletes the env file after the workflow ends, regardless of success.
This shape only works under one operational assumption: **the runner is single-tenant**. The runner is owned by the same operator who owns the secrets, no other repositories run jobs on the same runner, and no untrusted code is executed (no public fork PRs trigger workflows). If any of those held, the env-file-on-disk approach would be a credential exposure path — a sibling job could read `.env.production`, or a malicious PR could exfiltrate the secrets via a step.
The alternative — `docker compose --env-file <(printf "..." )` (bash process substitution) — is technically supported and would keep secrets out of the on-disk filesystem. It is more secure under a multi-tenant runner but requires bash 4+ and is brittle inside YAML (the `printf` step would need to escape every secret value containing newlines, equals signs, or quotes).
## Decision
The runner is treated as single-tenant for the lifetime of the v1 deployment. The workflows write env-files to disk under that assumption and rely on the `if: always()` cleanup step to remove them. The operational assumption is documented in-comment at the top of both workflow files (`nightly.yml`, `release.yml`) so the next operator who considers adding a second repo or accepting public PRs has the trigger surfaced in front of them.
Concretely:
- The Gitea runner only runs jobs for `marcel/familienarchiv`.
- No public fork PRs trigger the workflows (Gitea defaults to requiring an explicit approval on first-time contributor PRs for the actions to run).
- Secrets are stored in Gitea repository secrets and injected via `${{ secrets.* }}`. They land in the env-file at workflow start and are removed at workflow end.
## Migration trigger
Switch to the multi-tenant-safe pattern when **any** of the following becomes true:
- A second repository starts using the same runner.
- A workflow accepts contributions that can run untrusted code (public PRs without manual approval).
- The runner is moved off the operator's controlled host onto shared infrastructure.
The migration path is one-step per workflow: replace the "Write env file" step with `--env-file <(printf '%s' "${{ secrets.STAGING_ENV_BLOB }}")` and store the full env-file as a single Gitea secret. The cleanup step is then unnecessary because the env-file never touches disk.
## Alternatives Considered
| Alternative | Why rejected (for now) |
|---|---|
| `--env-file <(printf "...")` via bash process substitution | More secure under multi-tenant. Brittle for multi-line / quoted secret values; harder to debug ("env file not found" with no diff to inspect). Justified once the trigger above fires. |
| Docker secrets (`docker secret create` + `compose secrets:`) | Designed for Swarm; outside of Swarm, compose secrets read from files anyway, so the on-disk surface is the same. Adds complexity without changing the threat model. |
| External secret manager (Vault, AWS Secrets Manager) | Adds a third-party dependency to the deploy path. For a family-archive deployment with one operator and one VPS, the cost outweighs the benefit at this scale. |
| GitHub-hosted ephemeral runners | Would require uploading the prod-deploy artifacts to a registry first, then a deploy step on the VPS connecting back. Inverts the current Docker-out-of-Docker simplicity for marginal security gain. The single-tenant self-hosted runner *is* ephemeral in practice — the secrets are written to a directory the runner controls, then deleted. |
## Consequences
- The runner host's filesystem is in the secret-trust boundary. The host is hardened per `docs/DEPLOYMENT.md` (ufw, fail2ban, Tailscale-only SSH).
- An operator who later adds a second repo to the runner without revisiting the workflows would silently break the trust assumption. The in-file comments at the top of `nightly.yml` and `release.yml` are the breadcrumb that surfaces the assumption at change time.
- The `if: always()` cleanup step is load-bearing: removing it (e.g. during a future workflow refactor) leaves credentials on disk between runs. Treat it as a permanent invariant.
- Workflow debuggability stays high: an operator who needs to know what env-file the deploy ran with can SSH onto the host while a workflow is in flight and `cat .env.staging` — useful for first-deploy diagnostics.
## Future Direction
When the trigger fires, migrate both workflows in a single PR: replace the "Write env file" step with a single `--env-file <(printf '%s' …)` invocation, drop the cleanup step, and consolidate the per-secret Gitea entries into a single multi-line `STAGING_ENV_BLOB` / `PROD_ENV_BLOB` secret. Single commit, both workflows, no application change.

View File

@@ -1,5 +1,9 @@
# Familienarchiv — C4 Architecture Diagrams
> For domain terminology used in these diagrams, see [docs/GLOSSARY.md](../GLOSSARY.md).
>
> **Cross-diagram stubs:** Components placed outside a `System_Boundary` block with a "See diagram X" annotation are reference stubs — they represent a component fully defined in another sub-diagram and appear here only to show the cross-domain dependency without duplicating the full definition.
## Level 1 — System Context
Who uses the system and what external systems does it interact with.
@@ -8,13 +12,15 @@ Who uses the system and what external systems does it interact with.
C4Context
title System Context: Familienarchiv
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews documents")
Person(member, "Family Member", "Searches, browses, and reads archived documents")
Person(admin, "Administrator", "Manages users, triggers bulk imports, reviews and transcribes documents")
Person(member, "Family Member", "Access by administrator invite. Searches, browses, reads, and transcribes archived documents.")
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
Rel(member, familienarchiv, "Searches and views via browser", "HTTPS")
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
```
---
@@ -28,13 +34,16 @@ C4Container
title Container Diagram: Familienarchiv
Person(user, "User", "Admin or family member")
System_Ext(mail, "Email Service", "SMTP server. Delivers notification and password-reset emails.")
System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles session cookies, search UI, document viewer, and admin panel.")
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, and Excel import.")
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications.")
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, and Spring Session data.")
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Objects keyed as documents/{UUID}_{filename}.")
@@ -43,8 +52,12 @@ C4Container
Rel(user, frontend, "Uses", "HTTPS / Browser")
Rel(frontend, backend, "API requests with Basic Auth token", "HTTP / REST / JSON")
Rel(backend, user, "SSE notifications (server-sent events)", "HTTP / SSE — direct backend-to-browser")
Rel(backend, db, "Reads and writes metadata and sessions", "JDBC / SQL")
Rel(backend, storage, "Uploads and streams document files", "HTTP / S3 API (AWS SDK v2)")
Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JSON")
Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP")
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
Rel(mc, storage, "Creates bucket on startup", "MinIO Client CLI")
```
@@ -52,149 +65,444 @@ C4Container
## Level 3 — Components: API Backend
The internal structure of the Spring Boot backend.
The internal structure of the Spring Boot backend, split into seven focused sub-diagrams.
### 3a — Security & Authentication
How requests are authenticated and write operations are authorised.
```mermaid
C4Component
title Component Diagram: API Backend
title Component Diagram: API Backend — Security & Authentication
Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and constructs an Authentication token; delegates credential validation to DaoAuthenticationProvider via BCrypt. Permits password-reset, invite, and register endpoints without authentication.")
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects. Logs unknown permissions.")
}
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods", "")
Rel(secConf, userDetails, "Wires as UserDetailsService", "")
Rel(userDetails, db, "Loads user by email", "JDBC")
```
### 3b — Document Management & Import
Document management, file storage, and bulk Excel/ODS import.
```mermaid
C4Component
title Component Diagram: API Backend — Document Management & Import
Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL")
ContainerDb(minio, "MinIO")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.")
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel/ODS mass import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).")
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and validates credentials via BCrypt.")
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.")
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.")
Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel/ODS files from /import mount. Tracks import state (IDLE/RUNNING/DONE/FAILED) and delegates to ExcelService. Returns immediately; processing runs asynchronously.")
Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel/ODS workbooks (Apache POI). Column indices configurable via application.properties. Creates/updates document records per row.")
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.")
Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents. Endpoints: search, get by ID, update metadata, upload file, download file, get conversation thread.")
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Also returns all documents sent by a person.")
Component(userCtrl, "UserController", "Spring MVC — /api/users", "Returns current user (/me). Creates and deletes users (requires ADMIN_USER permission).")
Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel mass import (requires ADMIN permission).")
Component(groupCtrl, "GroupController", "Spring MVC — /api/groups", "Lists and manages permission groups.")
Component(tagCtrl, "TagController", "Spring MVC — /api/tags", "Lists tags for typeahead.")
Component(docSvc, "DocumentService", "Spring Service", "Core business logic: store, update, search documents. Resolves persons and tags. Delegates file I/O to FileService. Builds JPA Specifications for dynamic search queries.")
Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths. Downloads with content-type detection (PDF, JPEG, PNG, octet-stream).")
Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel workbooks (Apache POI). Column indices are configurable via application.properties. Creates/updates document records per row.")
Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel files from /import mount. Delegates to ExcelService. Runs asynchronously so the HTTP response returns immediately.")
Component(userSvc, "UserService", "Spring Service", "User CRUD. Encodes passwords with BCrypt. Assigns users to permission groups.")
Component(dataInit, "DataInitializer", "CommandLineRunner", "On startup: creates default admin user and groups if none exist. Seeds test data (persons, documents) if DB is empty.")
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents. Supports Specification-based dynamic search, conversation thread queries (bidirectional sender/receiver), and filename lookups.")
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable query predicates: hasText (full-text across title/filename/transcription/location), hasSender, hasReceiver (join), isBetween (date range), hasTags (subquery AND logic).")
Component(personRepo, "PersonRepository", "Spring Data JPA", "Lists all persons sorted by last name. Supports name search for typeahead.")
Component(userRepo, "AppUserRepository", "Spring Data JPA", "Finds users by username. Used by Spring Security and UserService.")
Component(tagRepo, "TagRepository", "Spring Data JPA", "Finds or creates tags by name (case-insensitive).")
Component(groupRepo, "UserGroupRepository", "Spring Data JPA", "Manages permission groups.")
Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client bean with path-style access for MinIO. Validates MinIO connectivity on startup.")
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by username from DB. Converts group permissions to Spring GrantedAuthority objects.")
Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.")
Component(docSpec, "DocumentSpecifications", "JPA Criteria API", "Factory for composable predicates: hasText (full-text), hasSender, hasReceiver, isBetween (date range), hasTags (subquery AND/OR logic).")
}
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
Rel(secFilter, permAspect, "Authenticated requests proceed", "")
Rel(secFilter, docCtrl, "Routes to", "")
Rel(secFilter, personCtrl, "Routes to", "")
Rel(secFilter, userCtrl, "Routes to", "")
Rel(secFilter, adminCtrl, "Routes to", "")
Rel(permAspect, docCtrl, "Guards", "AOP @Around")
Rel(permAspect, userCtrl, "Guards", "AOP @Around")
Rel(permAspect, adminCtrl, "Guards", "AOP @Around")
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. Called by DocumentService to resolve sender / receiver persons by ID.")
Component(tagSvc, "TagService", "Spring Service", "See diagram 3d. Called by DocumentService to find or create tags by name.")
Rel(frontend, docCtrl, "Document requests", "HTTP / JSON")
Rel(frontend, adminCtrl, "Trigger import", "HTTP / JSON")
Rel(docCtrl, docSvc, "Delegates to", "")
Rel(adminCtrl, massImport, "Triggers", "")
Rel(userCtrl, userSvc, "Delegates to", "")
Rel(docSvc, fileSvc, "Upload / download files", "")
Rel(docSvc, docRepo, "Reads / writes documents", "")
Rel(docSvc, docSpec, "Builds search predicates", "")
Rel(docSvc, personRepo, "Resolves sender / receivers", "")
Rel(docSvc, tagRepo, "Finds or creates tags", "")
Rel(massImport, excelSvc, "Parses Excel file", "")
Rel(docSvc, personSvc, "Resolves sender / receivers", "")
Rel(docSvc, tagSvc, "Finds or creates tags", "")
Rel(massImport, excelSvc, "Parses Excel/ODS file", "")
Rel(excelSvc, docSvc, "Creates / updates documents", "")
Rel(minioConf, fileSvc, "Provides S3Client and S3Presigner beans", "")
Rel(fileSvc, minio, "PUT / GET / presigned URL objects", "S3 API / HTTP")
Rel(docRepo, db, "SQL queries", "JDBC")
```
### 3c — Document Transcription Pipeline
Annotation-driven transcription: page markup, text blocks, versioning, and comment threads.
```mermaid
C4Component
title Component Diagram: API Backend — Document Transcription Pipeline
Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(transcriptionCtrl, "TranscriptionBlockController", "Spring MVC — /api/transcription", "CRUD for transcription text blocks per document page. Manages sort order, review status, and block version history.")
Component(annotationCtrl, "AnnotationController", "Spring MVC — /api/documents/{id}/annotations", "CRUD for free-form page annotations with polygon coordinates, colour coding, and file-hash tracking.")
Component(commentCtrl, "CommentController", "Spring MVC — /api/documents/{id}/comments", "Threaded comment CRUD on transcription blocks with @mention support and notification triggers.")
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "Creates and updates transcription blocks from annotation regions. Tracks block versions, sanitizes text with an HTML allow-list, and triggers mentions.")
Component(transcriptionQueueSvc, "TranscriptionQueueService", "Spring Service", "Assembles segmentation, transcription, and review queue projections by delegating to DocumentService and AuditLogQueryService.")
Component(annotationSvc, "AnnotationService", "Spring Service", "Manages document page annotations with polygon coordinates. Called by OcrAsyncRunner to persist OCR-generated block boundaries.")
Component(commentSvc, "CommentService", "Spring Service", "Creates and manages threaded comments with @mention parsing. Triggers NotificationService for REPLY and MENTION events.")
Component(blockRepo, "TranscriptionBlockRepository", "Spring Data JPA", "Reads and writes TranscriptionBlock and TranscriptionBlockVersion records.")
Component(annotationRepo, "AnnotationRepository", "Spring Data JPA", "Reads and writes DocumentAnnotation records.")
Component(commentRepo, "CommentRepository", "Spring Data JPA", "Reads and writes DocumentComment records.")
}
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by TranscriptionQueueService to assemble pipeline queue projections.")
Component(auditQuerySvc, "AuditLogQueryService", "Spring Service", "See diagram 3g. Called by TranscriptionQueueService for pipeline activity data.")
Rel(frontend, transcriptionCtrl, "Transcription block requests", "HTTP / JSON")
Rel(frontend, annotationCtrl, "Annotation requests", "HTTP / JSON")
Rel(frontend, commentCtrl, "Comment requests", "HTTP / JSON")
Rel(transcriptionCtrl, transcriptionSvc, "Delegates to", "")
Rel(transcriptionCtrl, transcriptionQueueSvc, "Queries pipeline queues", "")
Rel(annotationCtrl, annotationSvc, "Delegates to", "")
Rel(commentCtrl, commentSvc, "Delegates to", "")
Rel(transcriptionSvc, blockRepo, "Reads / writes blocks and versions", "")
Rel(annotationSvc, annotationRepo, "Reads / writes annotations", "")
Rel(commentSvc, commentRepo, "Reads / writes comments", "")
Rel(transcriptionQueueSvc, documentSvc, "Queries pipeline document state", "")
Rel(transcriptionQueueSvc, auditQuerySvc, "Queries pipeline activity data", "")
Rel(blockRepo, db, "SQL queries", "JDBC")
Rel(annotationRepo, db, "SQL queries", "JDBC")
Rel(commentRepo, db, "SQL queries", "JDBC")
```
### 3d — Users, Groups & Administration
User lifecycle, permission groups, tag management, and authentication endpoints.
```mermaid
C4Component
title Component Diagram: API Backend — Users, Groups & Administration
Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(userCtrl, "UserController", "Spring MVC — /api/users", "Returns current user (/me), creates and deletes users (requires ADMIN_USER), supports user search and profile updates.")
Component(groupCtrl, "GroupController", "Spring MVC — /api/groups", "Lists and manages permission groups.")
Component(tagCtrl, "TagController", "Spring MVC — /api/tags", "Lists tags for typeahead, supports tag merge, tree structure, and subtree deletion.")
Component(inviteCtrl, "InviteController", "Spring MVC — /api/auth/invite", "Creates invite codes and validates them at registration time. Rate-limited via WebConfig interceptor.")
Component(authCtrl, "AuthController", "Spring MVC — /api/auth", "Handles user registration (POST /register) and password reset token endpoints (/forgot-password, /reset-password).")
Component(userSvc, "UserService", "Spring Service", "User CRUD with BCrypt password encoding, group assignment, and audit logging. Orchestrates invite-based registration and password reset tokens.")
Component(tagSvc, "TagService", "Spring Service", "Tag CRUD with name search, hierarchical tree structure, merge/reparent operations, and recursive subtree deletion.")
Component(dataInit, "DataInitializer", "CommandLineRunner", "On startup: creates default admin user and groups if none exist. Seeds test data if DB is empty.")
Component(userRepo, "AppUserRepository", "Spring Data JPA", "Finds users by email. Supports search by email or display name.")
Component(groupRepo, "UserGroupRepository", "Spring Data JPA", "Manages permission groups.")
Component(tagRepo, "TagRepository", "Spring Data JPA", "Finds or creates tags by name (case-insensitive). Supports recursive ancestor/descendant CTE queries and merge/reparent helpers.")
}
Rel(frontend, userCtrl, "User requests", "HTTP / JSON")
Rel(frontend, groupCtrl, "Group requests", "HTTP / JSON")
Rel(frontend, tagCtrl, "Tag requests", "HTTP / JSON")
Rel(frontend, inviteCtrl, "Invite validation", "HTTP / JSON")
Rel(frontend, authCtrl, "Registration and password reset", "HTTP / JSON")
Rel(userCtrl, userSvc, "Delegates to", "")
Rel(groupCtrl, userSvc, "Delegates to", "")
Rel(tagCtrl, tagSvc, "Delegates to", "")
Rel(tagSvc, tagRepo, "Reads / writes tags", "")
Rel(inviteCtrl, userSvc, "Creates and validates invites", "")
Rel(authCtrl, userSvc, "Registers users, resets passwords", "")
Rel(userSvc, userRepo, "Reads / writes users", "")
Rel(userSvc, groupRepo, "Assigns groups", "")
Rel(userDetails, userRepo, "Loads user by username", "")
Rel(fileSvc, minio, "PUT / GET objects", "S3 API / HTTP")
Rel(docRepo, db, "SQL queries", "JDBC")
Rel(personRepo, db, "SQL queries", "JDBC")
Rel(userRepo, db, "SQL queries", "JDBC")
Rel(tagRepo, db, "SQL queries", "JDBC")
Rel(groupRepo, db, "SQL queries", "JDBC")
Rel(dataInit, db, "Seeds initial data", "JDBC")
Rel(secConf, userDetails, "Wires", "")
Rel(minioConf, fileSvc, "Provides S3Client bean", "")
Rel(userRepo, db, "SQL queries", "JDBC")
Rel(groupRepo, db, "SQL queries", "JDBC")
Rel(tagRepo, db, "SQL queries", "JDBC")
```
### 3e — Persons & Family Graph
Person management including family relationship modelling and transitive inference.
```mermaid
C4Component
title Component Diagram: API Backend — Persons & Family Graph
Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Returns documents sent by or received by a person, correspondent suggestions, and person summary with document counts.")
Component(relCtrl, "RelationshipController", "Spring MVC — /api/network, /api/persons/{id}/relationships", "CRUD for explicit person relationships and the full family network graph (nodes + edges) used by the Stammbaum view.")
Component(personSvc, "PersonService", "Spring Service", "Person CRUD, alias management, and merge operations (reassigns all document sender/receiver references before deleting duplicate persons).")
Component(relSvc, "RelationshipService", "Spring Service", "Manages explicit directional family relationships (PARENT_OF, SPOUSE_OF, SIBLING_OF, etc.) with optional date ranges and notes.")
Component(relInference, "RelationshipInferenceService", "Spring Service", "Computes transitive family relationships from explicit edges to infer grandparent/grandchild, aunt/uncle, and other extended-family links for the network graph.")
Component(personRepo, "PersonRepository", "Spring Data JPA", "Queries persons with name search (including aliases), correspondent discovery, person summaries with document counts, and merge/reassignment helpers.")
Component(relRepo, "PersonRelationshipRepository", "Spring Data JPA", "Reads and writes PersonRelationship records. Supports lookup by person ID, by relation type, and existence checks for deduplication.")
}
Rel(frontend, personCtrl, "Person requests", "HTTP / JSON")
Rel(frontend, relCtrl, "Relationship and graph requests", "HTTP / JSON")
Rel(personCtrl, personSvc, "Delegates to", "")
Rel(relCtrl, relSvc, "Delegates to", "")
Rel(relCtrl, relInference, "Queries inferred graph", "")
Rel(personSvc, personRepo, "Reads / writes persons", "")
Rel(relSvc, relRepo, "Reads / writes relationships", "")
Rel(relInference, relRepo, "Reads relationships for inference", "")
Rel(personRepo, db, "SQL queries", "JDBC")
Rel(relRepo, db, "SQL queries", "JDBC")
```
### 3f — OCR Orchestration
How the Spring Boot backend manages OCR jobs, streams results, and trains recognition models.
```mermaid
C4Component
title Component Diagram: API Backend — OCR Orchestration
Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL")
ContainerDb(minio, "MinIO")
Container(ocrPy, "OCR Service", "Python FastAPI")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(ocrCtrl, "OcrController", "Spring MVC — /api/ocr", "REST entry point: trigger single or batch OCR jobs, stream progress via SSE, query job status, and manage training runs and per-sender models.")
Component(ocrSvc, "OcrService", "Spring Service", "Creates OcrJob and OcrJobDocument records, checks Python service health, and delegates async execution to OcrAsyncRunner.")
Component(ocrBatch, "OcrBatchService", "Spring Service", "Orchestrates multi-document OCR jobs, iterating documents and delegating each to OcrAsyncRunner.")
Component(ocrAsync, "OcrAsyncRunner", "Spring Component — @Async", "Async worker that streams OCR results from Python page by page, persists transcription blocks and annotations via domain services, and emits progress via SSE.")
Component(ocrClient, "RestClientOcrClient", "Spring Component", "HTTP client wrapping the Python service: POST /ocr/stream (NDJSON), /train, /segtrain, and /train-sender. Falls back from streaming to batch on 404.")
Component(ocrTraining, "OcrTrainingService", "Spring Service", "Orchestrates model training: exports training data as ZIP, calls Python /train or /segtrain, persists training metrics in OcrTrainingRunRepository.")
Component(ocrJobRepo, "OcrJobRepository, OcrJobDocumentRepository", "Spring Data JPA", "Reads and writes OcrJob and OcrJobDocument records. Tracks job status (RUNNING/DONE/FAILED), per-document progress, page counts, and error messages.")
}
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist transcription blocks per page.")
Component(annotationSvc, "AnnotationService", "Spring Service", "See diagram 3c. Called by OcrAsyncRunner to persist OCR-generated annotation regions per page.")
Rel(frontend, ocrCtrl, "OCR trigger, status, and progress requests", "HTTP / JSON / SSE")
Rel(ocrCtrl, ocrSvc, "Single-document jobs", "")
Rel(ocrCtrl, ocrBatch, "Batch jobs", "")
Rel(ocrCtrl, ocrTraining, "Training runs", "")
Rel(ocrSvc, ocrAsync, "Delegates async execution", "")
Rel(ocrBatch, ocrAsync, "Delegates async execution", "")
Rel(ocrAsync, ocrClient, "Streams OCR results page by page", "HTTP / NDJSON")
Rel(ocrTraining, ocrClient, "Sends training data ZIP", "HTTP / multipart")
Rel(ocrClient, ocrPy, "POST /ocr/stream, /train, /segtrain, /train-sender", "HTTP / REST")
Rel(ocrAsync, transcriptionSvc, "Saves transcription blocks per page", "")
Rel(ocrAsync, annotationSvc, "Saves annotation regions per page", "")
Rel(ocrAsync, ocrJobRepo, "Reads / writes OCR job state", "")
Rel(ocrJobRepo, db, "SQL queries", "JDBC")
Rel(ocrAsync, minio, "Generates presigned URLs for PDF fetch", "S3 API")
Rel(ocrPy, minio, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
Rel(ocrTraining, db, "Persists training run metrics", "JDBC")
```
### 3g — Supporting Domains
Audit logging, dashboard stats, SSE notifications, stories (Geschichten), and cross-cutting exception handling.
```mermaid
C4Component
title Component Diagram: API Backend — Supporting Domains
Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(auditSvc, "AuditService", "Spring Service — @Async", "Writes audit log entries asynchronously via a dedicated TaskExecutor, with transaction-aware logging to prevent deadlocks on concurrent saves.")
Component(auditQuery, "AuditLogQueryService", "Spring Service", "Queries audit logs for activity feeds, pulse stats, recent contributors, and per-document history. Facade over AuditLogRepository.")
Component(dashCtrl, "DashboardController", "Spring MVC — /api/dashboard", "REST endpoints for the user dashboard: recent document resume (/resume), weekly transcription pulse stats (/pulse), and activity feed (/activity) with kind filtering and pagination.")
Component(statsCtrl, "StatsController", "Spring MVC — /api/stats", "Returns aggregate counts (total persons, total documents) for the UI stats bar.")
Component(statsSvc, "StatsService", "Spring Service", "Queries aggregate counts: total persons and total documents.")
Component(dashSvc, "DashboardService", "Spring Service", "Assembles the user dashboard: recent document resume (calls DocumentService + TranscriptionService), weekly transcription pulse stats, and activity feed with contributor avatars.")
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
}
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. Called by DashboardService to fetch document titles and resume data.")
Component(transcriptionSvc, "TranscriptionService", "Spring Service", "See diagram 3c. Called by DashboardService to fetch transcription block progress for resume.")
Rel(frontend, dashCtrl, "Dashboard requests", "HTTP / JSON")
Rel(frontend, statsCtrl, "GET /api/stats", "HTTP / JSON")
Rel(frontend, notifCtrl, "Notification stream and history", "HTTP / JSON / SSE")
Rel(frontend, geschCtrl, "Story requests", "HTTP / JSON")
Rel(dashCtrl, dashSvc, "Delegates to", "")
Rel(statsCtrl, statsSvc, "Delegates to", "")
Rel(statsSvc, db, "Reads aggregate counts", "JDBC")
Rel(dashSvc, auditQuery, "Fetches activity feed and pulse stats", "")
Rel(dashSvc, documentSvc, "Fetches document titles and resume data", "")
Rel(dashSvc, transcriptionSvc, "Fetches transcription block progress for resume", "")
Rel(notifCtrl, notifSvc, "Delegates to", "")
Rel(notifCtrl, sseRegistry, "Registers client SSE connection", "")
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients", "")
Rel(geschCtrl, geschSvc, "Delegates to", "")
Rel(auditSvc, db, "Writes audit_log", "JDBC")
Rel(auditQuery, db, "Reads audit_log", "JDBC")
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
Rel(geschSvc, db, "Reads / writes geschichten", "JDBC")
```
---
## Level 3 — Components: Web Frontend
The internal structure of the SvelteKit frontend.
The internal structure of the SvelteKit frontend, split into four focused views.
### 3a — Middleware, Auth & Layout
Per-request middleware: session validation, i18n, auth cookie handling, and auth pages.
```mermaid
C4Component
title Component Diagram: Web Frontend
title Component Diagram: Web Frontend — Middleware, Auth & Layout
Person(user, "User")
Container(backend, "API Backend", "Spring Boot")
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(hooks, "hooks.server.ts", "SvelteKit Server Hook", "Two responsibilities: (1) userGroup handle — reads auth_token cookie, fetches /api/users/me, stores user in event.locals. (2) handleFetch — intercepts all outgoing fetch() calls, injects Authorization header from cookie. Redirects to /login if token absent.")
Component(hooks, "hooks.server.ts", "SvelteKit Server Hook", "Four handle layers: (1) handleAuth — redirects unauthenticated users to /login; (2) userGroup — reads auth_token cookie, fetches /api/users/me, stores user in event.locals; (3) handleFetch — injects Authorization header on all outgoing /api/ calls; (4) handleLocaleDetection — sets language cookie from Accept-Language header.")
Component(i18n, "hooks.ts (Paraglide)", "SvelteKit Client Hook", "Client-side i18n middleware. Detects language from URL and sets the active locale for Paraglide.js translation functions.")
Component(layout, "+layout.server.ts", "SvelteKit Layout Loader", "Passes event.locals.user down to all child pages so every route has access to the authenticated user.")
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL search params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons, returns results. Page: renders search form with full-text, date range, sender/receiver typeahead, tag filters. Displays paginated document list.")
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: fetches /api/documents/{id}. Handles 401 redirect to login, 404 error. Page: shows document metadata, file viewer (PDF/image inline), transcription, tags.")
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Form with PersonTypeahead for sender/receiver, TagInput for tags, date/location fields. Submits PUT to /api/documents/{id}.")
Component(persons, "/persons and /persons/[id]", "SvelteKit Routes", "Lists all persons. Detail page shows person metadata and all documents they sent.")
Component(conversations, "/conversations", "SvelteKit Route", "Selects two persons via PersonTypeahead, fetches /api/documents/conversation, displays chronological exchange.")
Component(loginPage, "/login", "SvelteKit Route", "Form action: encodes username:password as Base64 Basic Auth token, POSTs to /api/users/me to validate, sets auth_token httpOnly cookie (SameSite=strict, maxAge=86400), redirects to /.")
Component(loginPage, "/login", "SvelteKit Route", "Form action: encodes email:password as Base64 Basic Auth token, POSTs to /api/users/me to validate, sets auth_token httpOnly cookie (SameSite=strict, maxAge=86400), redirects to /.")
Component(logoutPage, "/logout", "SvelteKit Route (server-only)", "Clears the auth_token cookie and redirects to /login.")
Component(adminPage, "/admin", "SvelteKit Route", "User management UI (create/delete users). Excel import trigger button (calls /api/admin/trigger-import).")
Component(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend. Used by PersonTypeahead for typeahead suggestions.")
Component(apiTags, "/api/tags (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/tags to backend. Used by TagInput for autocomplete.")
Component(typeahead, "PersonTypeahead.svelte", "Svelte Component", "Async autocomplete for selecting a person. Debounces input, calls /api/persons?q=.")
Component(tagInput, "TagInput.svelte", "Svelte Component", "Multi-tag input. Supports free-text entry and selecting existing tags from /api/tags.")
Component(registerPage, "/register", "SvelteKit Route", "Loader validates invite code via GET /api/auth/invite/{code}. Form action: POST /api/auth/register to create the user account.")
Component(forgotPw, "/forgot-password", "SvelteKit Route", "Form action: POST /api/auth/forgot-password. Always responds with success to prevent email enumeration.")
Component(resetPw, "/reset-password", "SvelteKit Route", "Form action: POST /api/auth/reset-password with the token from the query string.")
}
Rel(user, hooks, "Every browser request", "HTTPS")
Rel(hooks, backend, "GET /api/users/me (session check)", "HTTP / Basic Auth")
Rel(hooks, loginPage, "Redirect if no token", "")
Rel(layout, homePage, "Provides user context", "")
Rel(layout, docDetail, "Provides user context", "")
Rel(layout, adminPage, "Provides user context", "")
Rel(homePage, backend, "GET /api/documents/search", "HTTP / JSON")
Rel(homePage, backend, "GET /api/persons", "HTTP / JSON")
Rel(docDetail, backend, "GET /api/documents/{id}", "HTTP / JSON")
Rel(docDetail, backend, "GET /api/documents/{id}/file", "HTTP / Binary stream")
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
Rel(conversations, backend, "GET /api/documents/conversation", "HTTP / JSON")
Rel(hooks, layout, "Stores authenticated user in event.locals", "")
Rel(loginPage, backend, "POST /api/users/me (auth check)", "HTTP / Basic Auth")
Rel(adminPage, backend, "GET/POST/DELETE /api/users", "HTTP / JSON")
Rel(adminPage, backend, "POST /api/admin/trigger-import", "HTTP / JSON")
Rel(registerPage, backend, "GET /api/auth/invite/{code}, POST /api/auth/register", "HTTP / JSON")
Rel(forgotPw, backend, "POST /api/auth/forgot-password", "HTTP / JSON")
Rel(resetPw, backend, "POST /api/auth/reset-password", "HTTP / JSON")
```
Rel(apiPersons, backend, "GET /api/persons", "HTTP / JSON")
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
### 3b — Document Workflows
Document search, viewing, editing, enrichment, and the shared components that support them.
```mermaid
C4Component
title Component Diagram: Web Frontend — Document Workflows
Person(user, "User")
Container(backend, "API Backend", "Spring Boot")
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
Component(docBulkEdit, "/documents/bulk-edit", "SvelteKit Route", "Multi-document metadata editor. Loader: GET /api/documents/incomplete. Requires WRITE_ALL (redirects otherwise). Action: PATCH /api/documents/bulk.")
Component(enrichPage, "/enrich/[id]", "SvelteKit Route", "Guided enrichment workflow. Loader: GET /api/documents/{id}. Progressively saves annotations and transcription blocks.")
Component(apiPersons, "/api/persons (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/persons?q=... to backend for PersonTypeahead suggestions.")
Component(apiTags, "/api/tags (SvelteKit API)", "SvelteKit Server Route", "Proxies GET /api/tags to backend for TagInput autocomplete.")
Component(typeahead, "PersonTypeahead.svelte", "Svelte Component", "Async autocomplete for selecting a person. Debounces input, calls /api/persons?q=.")
Component(tagInput, "TagInput.svelte", "Svelte Component", "Multi-tag input. Supports free-text entry and selecting existing tags from /api/tags.")
}
Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
Rel(docNew, backend, "GET /api/persons, POST /api/documents", "HTTP / JSON + Multipart")
Rel(docBulkEdit, backend, "GET /api/documents/incomplete, PATCH /api/documents/bulk", "HTTP / JSON")
Rel(enrichPage, backend, "GET/POST /api/transcription, POST /api/documents/{id}/annotations", "HTTP / JSON")
Rel(homePage, typeahead, "Uses for sender/receiver filter", "")
Rel(docEdit, typeahead, "Uses for sender/receiver selection", "")
Rel(docNew, typeahead, "Uses for sender selection", "")
Rel(docEdit, tagInput, "Uses for tag management", "")
Rel(typeahead, apiPersons, "Fetches suggestions", "HTTP")
Rel(tagInput, apiTags, "Fetches existing tags", "HTTP")
Rel(apiPersons, backend, "GET /api/persons", "HTTP / JSON")
Rel(apiTags, backend, "GET /api/tags", "HTTP / JSON")
```
### 3c — People, Stories & Discovery
Person directory, bilateral conversations, activity feed, stories, family tree, and user profiles.
```mermaid
C4Component
title Component Diagram: Web Frontend — People, Stories & Discovery
Person(user, "User")
Container(backend, "API Backend", "Spring Boot")
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.")
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
}
Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON")
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
```
### 3d — Administration & Help
Admin panel sub-routes and the transcription help guide.
```mermaid
C4Component
title Component Diagram: Web Frontend — Administration & Help
Person(admin, "Administrator")
Person(user, "User")
Container(backend, "API Backend", "Spring Boot")
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(adminUsers, "/admin/users, /admin/users/[id], /admin/users/new, /admin/invites", "SvelteKit Routes", "User directory, create/update/delete users, and manage invite codes. Requires ADMIN_USER permission.")
Component(adminGroups, "/admin/groups, /admin/groups/[id], /admin/groups/new", "SvelteKit Routes", "Permission group management: create/edit groups and their permission sets.")
Component(adminTags, "/admin/tags and /admin/tags/[id]", "SvelteKit Routes", "Tag administration: edit tag hierarchy, merge tags, delete subtrees.")
Component(adminOcr, "/admin/ocr and /admin/ocr/[personId]", "SvelteKit Routes", "Global and per-person OCR configuration. Manages script types and triggers sender model training.")
Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers Excel/ODS mass import (POST /api/admin/trigger-import). Displays import state.")
Component(hilfe, "/hilfe/transkription", "SvelteKit Route", "Static transcription style guide for Kurrent and Sütterlin character recognition. No backend calls.")
}
Rel(admin, adminUsers, "Manages users and invites", "HTTPS / Browser")
Rel(user, hilfe, "Views transcription style guide", "HTTPS / Browser")
Rel(adminUsers, backend, "GET/POST/DELETE /api/users, POST /api/auth/invite", "HTTP / JSON")
Rel(adminGroups, backend, "GET/POST/PUT/DELETE /api/groups", "HTTP / JSON")
Rel(adminTags, backend, "GET/PUT/DELETE /api/tags", "HTTP / JSON")
Rel(adminOcr, backend, "GET/POST /api/ocr (global config and sender training)", "HTTP / JSON")
Rel(adminSystem, backend, "POST /api/admin/trigger-import, GET /api/admin/import-status", "HTTP / JSON")
```
---
@@ -211,12 +519,12 @@ sequenceDiagram
participant Backend as Backend (Spring Boot)
participant DB as PostgreSQL
User->>Browser: Enter username + password
User->>Browser: Enter email + password
Browser->>Frontend: POST /login (form action)
Frontend->>Frontend: Base64 encode "user:password"
Frontend->>Frontend: Base64 encode "email:password"
Frontend->>Backend: GET /api/users/me<br/>Authorization: Basic <token>
Backend->>Backend: Spring Security parses Basic Auth
Backend->>DB: SELECT user WHERE username=?
Backend->>DB: SELECT user WHERE email=?
DB-->>Backend: AppUser + groups + permissions
Backend->>Backend: BCrypt.matches(password, hash)
Backend-->>Frontend: 200 OK — UserDTO
@@ -264,3 +572,14 @@ sequenceDiagram
Backend-->>Frontend: 200 OK — Document JSON
Frontend-->>User: Refreshed document view
```
---
## Database
Entity-relationship and full column reference for the PostgreSQL schema (30 tables, 7 domain groups). Source files in `docs/architecture/db/`.
- **[db-relationships.puml](db/db-relationships.puml)** — Entity relationships: all tables and foreign-key connections, grouped by domain. Start here for an overview.
- **[db-orm.puml](db/db-orm.puml)** — Full schema reference: all columns and types for all 30 tables. Use this when mapping Java entities to database columns.
> Schema as of Flyway V60 (2026-05-06). Open in VS Code with the PlantUML extension (server: `http://heim-nas:8500`).

View 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 |

View 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

View File

@@ -0,0 +1,32 @@
@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.")
Container(caddy, "Reverse Proxy", "Caddy 2 (host-installed)", "TLS termination (auto Let's Encrypt). Routes /api/* to backend:8080, everything else to frontend:3000. Responds 404 on /actuator/* and adds HSTS, X-Content-Type-Options, Referrer-Policy headers.")
System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
Container(frontend, "Web Frontend", "SvelteKit / Node adapter / port 3000", "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 / port 8080", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications. Trusts X-Forwarded-* headers from Caddy.")
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). Backend uses a bucket-scoped service account (archiv-app), not MinIO root.")
Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.")
}
Rel(user, caddy, "HTTPS", "TLS 1.2/1.3")
Rel(caddy, frontend, "Reverse proxies non-/api requests", "HTTP / loopback:3000")
Rel(caddy, backend, "Reverse proxies /api/*", "HTTP / loopback:8080")
Rel(frontend, backend, "API requests with Basic Auth token", "HTTP / REST / JSON")
Rel(backend, user, "SSE notifications (server-sent events)", "HTTP / SSE — fronted by Caddy")
Rel(backend, db, "Reads and writes metadata and sessions", "JDBC / SQL")
Rel(backend, storage, "Uploads and streams document files using archiv-app service account", "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, "Bootstraps bucket + service account on startup", "MinIO Client CLI")
@enduml

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Some files were not shown because too many files have changed in this diff Show More