Adds a packageRule matching .gitea/workflows/** digest updates with
automerge: false. Digest bumps for images running --privileged --pid=host
have root-equivalent host access and must not be auto-merged.
Addresses Nora's review concern on #537.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers the three failure modes Sara flagged: Caddy stopped (explicit
systemctl error), symlink missing/mis-pointed (silent reload, stale
smoke test), and Docker socket / nsenter unavailable (container error).
Each failure mode includes symptoms and recovery steps.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the stale generic runner provisioning docs with an accurate
description of the actual two-container setup on the Hetzner VPS.
Document the nsenter pattern for running host-level commands (systemctl)
from containerised CI steps, and the Caddyfile symlink contract that the
reload step depends on.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same gap as nightly.yml: production deploys also need Caddy to reload
the updated Caddyfile before the smoke test validates the public surface.
Uses the same nsenter pattern introduced in the previous commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`sudo systemctl reload caddy` does not work from inside a DooD job
container: `systemctl` is absent from Ubuntu container images and
container processes cannot reach the host systemd without entering its
namespaces. Replace with `docker run --privileged --pid=host ubuntu:22.04
nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy`, which uses
the already-mounted Docker socket to spin up a privileged sibling
container that enters the host PID namespace via nsenter. Tested live on
the Hetzner VPS. No sudoers entry required.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a `sudo systemctl reload caddy` step between the docker compose
deploy and the smoke test. This ensures any committed Caddyfile changes
are applied before the public surface is verified.
Previously the workflow had no mechanism to push Caddyfile changes to
the running host daemon. A Caddyfile edit would land in the repo but
Caddy would keep serving the previous config, causing the smoke test to
catch a stale header or still-proxied /actuator route rather than the
intended current config.
This step also surfaces the root cause of today's port-443 failure
explicitly: if Caddy is not running, the step fails with a clear service
error rather than a misleading "Failed to connect to port 443" from curl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The new alpine-based frontend production image (`node:20.19.0-alpine3.21`)
resolves `localhost` only to `::1` in /etc/hosts. SvelteKit's adapter-node
binds to 0.0.0.0 (IPv4 only), so `wget http://localhost:3000/login` from
inside the container connects to ::1 and gets "Connection refused" every
15s. Container goes unhealthy → `docker compose up --wait` fails → nightly
staging deploy fails. The app itself is fine.
Switching to 127.0.0.1 bypasses /etc/hosts and matches what Node actually
listens on.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- frontend/login: derive cookie `secure` flag from request URL protocol.
Pre-PR the cookie was only read by SSR so the flag didn't matter; now
the cookie IS the API credential and must be Secure on HTTPS or it
leaks a 24h Basic token on plaintext networks. Dev runs over HTTP and
would silently lose the cookie if we hardcoded `secure: true`, so the
flag follows `event.url.protocol === 'https:'`.
- SecurityConfig: rewrite the CSRF-disabled comment. The old
"browsers block cross-origin custom headers" justification no longer
holds once /api/* is authenticated via the cookie. Make the
load-bearing dependencies explicit: SameSite=strict on the auth_token
cookie + Spring's default CORS rejection.
- AuthTokenCookieFilter:
- Scope to /api/* only. /actuator/health and similar must not be
cookie-authenticated.
- Refuse malformed percent-encoding (URLDecoder throws); forward the
request without a promoted Authorization rather than crash.
- Use isBlank() instead of isEmpty() per Nora.
- Javadoc warning: getHeaderNames/getHeaders exposes the Basic
credential; any future header-iterating logger must scrub
Authorization before logging.
- Tests: add `passes_through_unchanged_when_request_is_outside_api_scope`
(/actuator/health with cookie should NOT be wrapped) and
`passes_through_unchanged_when_cookie_value_is_malformed_percent_encoding`.
Tighten the explicit-header test to verify same-instance forwarding
rather than just header equality.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#520.
The login action stores `Basic <base64>` in an HttpOnly `auth_token`
cookie. SSR fetches from hooks.server.ts explicitly set the
Authorization header. Vite's dev proxy does the same on every
/api/* request. Caddy in production does NOT. So browser-side
fetch() and EventSource() calls reach the backend without auth,
get 401 + WWW-Authenticate: Basic, and the browser pops a native
auth dialog over the SPA.
Add AuthTokenCookieFilter (Ordered.HIGHEST_PRECEDENCE, before any
Spring Security filter) that promotes the cookie to a request
header when no explicit Authorization is present. URL-decodes the
cookie value because SvelteKit URL-encodes spaces ("Basic " ->
"Basic%20") when serializing the cookie. Works the same for REST,
SSE (/api/notifications/stream, /api/ocr/jobs/.../progress), and
any other browser-direct backend call.
5 tests in AuthTokenCookieFilterTest cover: URL-decoded promotion,
explicit-Authorization-wins precedence, no-cookies pass-through,
absent-auth-token pass-through, empty-value pass-through.
Also: add `@ActiveProfiles("test")` to ThumbnailServiceIntegrationTest,
the one remaining @SpringBootTest in the suite that wasn't annotated.
After #516 made UserDataInitializer fail-closed outside dev/test/e2e,
this test's context load was throwing. Restores green main.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#518.
UserDataInitializer.initAdminUser was doing groupRepository.save(adminGroup)
unconditionally. If a previous boot had seeded the group but failed
before creating the admin user (or if the operator deleted just the
admin row to retry with a corrected APP_ADMIN_USERNAME), the next
seed attempt violated user_groups_name_key and aborted the context.
Switch to the same findByName(...).orElseGet(...) pattern initE2EData
already uses for the "Leser" group.
Tests in AdminSeedFailClosedTest:
- reuses_existing_Administrators_group_when_seeding_a_new_admin
- creates_Administrators_group_when_seeding_admin_on_a_fresh_database
Plus updated existing tests to stub groupRepository.save now that the
seed path also exercises it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#512.
The previous `(block_actuator)` snippet emitted `respond @actuator 404`
at the top level of each archive vhost. But each vhost also has a
catch-all `handle { reverse_proxy ... }` that matches /actuator/*
too. Caddy's `handle` blocks are mutually exclusive — once one matches,
the request never reaches a top-level `respond`. So /actuator/health
was being proxied to the backend, which 302s to /login.
Wrap the actuator response in its own `handle /actuator/*` block.
Caddy sorts `handle` blocks by path specificity, so /actuator/* wins
over the catch-all and the 404 is actually returned.
Verified with `caddy validate` against the caddy:2 image.
Also unblocks the nightly.yml smoke test's `/actuator/health → 404`
assertion, which has been failing since the first staging deploy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses Nora's review concern on #513/#516.
The previous fix only made env-vars take effect — it did NOT close the
fail-open default path. If an operator forgets APP_ADMIN_USERNAME /
APP_ADMIN_PASSWORD on first prod boot, the seeded admin is the
well-known `admin@familienarchiv.local` / `admin123` and is permanently
locked (UserDataInitializer only seeds when the row is missing).
Refuse to seed outside dev/test/e2e profiles when either credential
matches the documented default. The startup fails fast with a clear
message pointing at the env-var names and the permanence trap.
Also adds Markus/Felix/Sara's "pin the Java side" coverage: a
reflection test on the @Value placeholder catches a future rename
of `${app.admin.email:...}` back to `${app.admin.username:...}`,
which would otherwise pass the yaml-side test but silently break
the binding.
Tests:
- AdminSeedFailClosedTest pins fail-closed for non-local profiles
and verifies the dev/test/e2e bypass.
- AdminSeedPropertyKeyTest now also asserts the @Value placeholder
string on UserDataInitializer.adminEmail/adminPassword.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#513.
UserDataInitializer reads `@Value("${app.admin.email:...}")` but
application.yaml mapped APP_ADMIN_USERNAME to `app.admin.username`.
The keys never connected — env vars APP_ADMIN_USERNAME and
APP_ADMIN_PASSWORD were silently ignored and the admin user got
seeded with the hardcoded defaults admin@familyarchive.local /
admin123.
For production this is HIGH severity: DEPLOYMENT.md §3.5 documents
the admin password as permanently locked on first deploy. The
bug locked the lock-in to dev defaults, not to whatever an operator
set in PROD_APP_ADMIN_PASSWORD.
Rename yaml key from `username:` to `email:` so the Spring property
`app.admin.email` actually exists. Keep env-var name
APP_ADMIN_USERNAME (matches the already-set Gitea secrets and
DEPLOYMENT.md §3.3). Default value updated to an email-shape.
Added AdminSeedPropertyKeyTest (Binder pattern, no Spring context):
verifies both `app.admin.email` and `app.admin.password` resolve
from the yaml. Confirmed red without the fix, green with it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
#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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
Adds DocumentDensityResult, MonthBucket and the /api/documents/density path
to the openapi-typescript output.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
- 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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
DashboardService.getResume() calls DocumentService.getDocumentById() and
TranscriptionService.listBlocks() — both missing from the diagram.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
DocumentController maps the batch update to PATCH /api/documents/bulk,
not /api/documents/batch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both RelationshipService and RelationshipInferenceService inject
PersonRelationshipRepository. The previous direct db arrows were inaccurate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
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>
- 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>
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>
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>
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>
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>
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>
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>
Introduces a separate reset@familyarchive.local / reset123 seed account
(e2e profile only) so the password-reset flow test never touches the
shared admin credentials.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getByRole('button', { name: 'Fertig' }) matched two buttons at 1440px width:
the transcribe-mode Fertig button and 'Alle als fertig markieren'. Add exact: true.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All page.goto() calls in documents.spec.ts now use relative paths (/documents/{id})
so Playwright's configured baseURL is the single source of truth. Removes the
fragility of keeping process.env.E2E_BASE_URL in sync with playwright.config.ts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The test was using tagId=nonexistent-tag-id which is not a recognised search parameter;
the correct param is tag= (tag name). Updated the test and the coverage report to
accurately describe what is verified: text + tag filter AND combination. The sender
filter test remains an acknowledged gap noted in the report.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four concerns addressed:
- Persistence: reloads the detail page after save and re-asserts the tag link,
making the report's "after page reload" claim accurate
- Unique title: adds stamp to document title to prevent accumulation across runs
- Cleanup: afterAll deletes the test document
- Selector: replaces getByText(newTagName) with a[href*="?tag="] scoped to the tag link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three concerns addressed:
- Race condition: "Familie" tag is renamed by admin tests; now seeds a unique
timestamped tag via a throwaway document PUT so J3 never depends on seeded data
- Chip selector: replaces getByText(/Familie/) with a[href*="?tag="] scoped to the
actual tag link in the metadata section
- Cleanup: afterAll deletes both the test document and the seeder document
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous regex /Importiert|Dokument|Import|Läuft|DONE|laufend/i was too broad —
it would match almost any German text on the page including unrelated copy. Replaced
with /Import läuft|Import abgeschlossen|Fehler:/ which matches only the three status
messages the mass import feature actually emits.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds docs/audits/e2e-coverage-report.md mapping all 12 critical journeys
to their test files. Fills the 6 coverage gaps with new e2e tests:
- J1: Register via invite code (auth.spec.ts)
- J3: Edit document tags via TagInput (documents.spec.ts)
- J4: Create brand-new tag via TagInput (documents.spec.ts)
- J5: Add SPOUSE_OF relationship on person edit page (persons.spec.ts)
- J6: Multi-filter search (text + date, text + tagId) (documents.spec.ts)
- J10: Notification bell opens dropdown (notification-deep-link.spec.ts)
- J11: Non-admin blocked from /admin/* (permissions.spec.ts)
- J12: Mass import trigger shows status (admin.spec.ts)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
35/35 mutations DETECTED across document, person, tag, user, geschichte,
notification, and OCR domains. No tautological tests found — the suite
is trustworthy on all critical paths. Closes issue #403.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
statusLabel() was a one-line alias for formatDocumentStatus() with no
additional behaviour. Remove it and update DocumentStatusChip.svelte to
call formatDocumentStatus() directly. Remove the corresponding alias
test suite from the spec file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the --ignore-pattern CLI flag with an entry in the ignores array in
eslint.config.js where ESLint's flat config manages all ignore rules. Add
inline comment explaining that $lib/paraglide and $lib/generated are
intentionally omitted from the boundaries/elements list and treated as external.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds src/lib/tag/__fixtures__/cross-domain.fixture.ts — a permanent fixture
that demonstrates the boundaries rule firing on a tag → person import. The
fixture is excluded from npm run lint via --ignore-pattern; run
npm run lint:boundary-demo to see it produce an error (exit 1).
Documents the full allow-list, the escape hatches ($lib/shared/ move, explicit
rule entry, eslint-disable-next-line), and the verify command in COLLABORATING.md.
Refs #410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds eslint-plugin-boundaries with one element type per Tier-1 domain and an
explicit allow-list encoding the architectural dependency graph:
- document may import from: shared, person, tag, ocr, activity, conversation
- geschichte may import from: shared, person, document
- ocr may import from: shared, document
- activity may import from: shared, notification
- all others (person, tag, user, notification, conversation): shared only
- routes may import from any domain
Default is 'disallow', so any unlisted cross-domain import is an error.
Two eslint-disable-next-line comments remain in shared/discussion where
person-domain helpers (getInitials, formatLifeDateRange) are needed to render
participant metadata; moving them to shared would lose the person-type context.
Closes#410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MissionControlStrip is a document-processing pipeline visualiser — it
imports document-domain components (SegmentationColumn, TranscriptionColumn,
ReadyColumn) and belongs in the document domain. It was placed in
shared/dashboard, creating a shared → document coupling that the upcoming
boundaries rule would block.
Refs #410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FieldLabelBadge is a generic UI primitive (additive/replace badge used in form
field labels). It lived in the document domain but was already imported by
PersonTypeahead (person domain), creating a person → document coupling.
Moving it to shared/primitives eliminates that cross-domain dependency.
Refs #410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These functions describe DocumentStatus display logic (dot colours, readable
labels) and belong in the document domain. They were incorrectly placed in
personFormat.ts. Moving them to documentStatusLabel.ts removes the
person → document dependency and prepares the codebase for the
boundaries/dependencies ESLint rule.
Refs #410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds eslint-plugin-boundaries@6.0.2 and eslint-import-resolver-typescript@4.4.4
as pinned devDependencies. Also adds the lint:boundary-demo script for running
the ESLint boundaries rule against the fixture file, and updates the lint script
to exclude __fixtures__ directories.
Refs #410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MassImportService delegates to other domain services (no direct repo
access), and AuditService only touches its own AuditLogRepository —
both pass the boundary rule cleanly. Closes the known hole flagged
by Sara and Markus in PR #428.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace substring contains() with a regex exact-segment match so a
domain whose name is a substring of another (e.g. "tag" in "tagging")
cannot silently escape the predicate and produce a false negative.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rules enforced:
- Rule 1: no @RestController may inject a JpaRepository directly (preserves @RequirePermission AOP enforcement)
- Rule 2: @Service classes access only their own domain's repositories, never a foreign domain's
- Rule 3: no @Configuration class (except @SpringBootApplication) in domain packages
- Rule 4: all @Entity classes reside in a domain package
Rule 5 (URL prefix per controller domain) deferred — tracked in #427.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AnnotationService was changed to call transcriptionBlockRepository
directly, but the test still mocked TranscriptionService — causing a
NPE and leaving the cascade path uncovered.
Replace the @Mock TranscriptionService with @Mock
TranscriptionBlockRepository, update the two existing delete-test
verifications, and add a dedicated
deleteAnnotation_cascadesToTranscriptionBlocks test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
No production code calls this method since ThumbnailService was changed
to write thumbnail metadata via documentRepository.save() directly.
Removing the unreachable wrapper eliminates false coverage and noise
during future security audits.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ThumbnailService now calls documentRepository.save() directly.
DocumentService.updateThumbnailMetadata() has no production callers,
so its test describes behaviour that no longer exists in the
production path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ThumbnailAsyncRunner was changed to inject DocumentRepository directly
(breaking the DocumentService cycle), but the test still passed
DocumentService to the constructor — a type mismatch that prevented
the test suite from compiling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spring Framework 7 prohibits constructor injection cycles even with @Lazy.
Replace DocumentService dependencies in ThumbnailAsyncRunner and ThumbnailService
with direct DocumentRepository calls — both are intra-domain reads/saves.
Update ThumbnailServiceTest to mock DocumentRepository accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spring Framework 7 prohibits constructor injection cycles even with @Lazy.
Replace the TranscriptionService dependency in AnnotationService with a
direct TranscriptionBlockRepository call for the cascade-delete, which is
an intra-domain operation within the document package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The broad include paths accidentally pulled in browser-only .ts files
(Svelte actions, personHoverCard state) and files with low coverage
(relationshipLabels.ts at 30% branches), causing the 80% branch
threshold to fail at 74.53%.
Narrowing include to shared/utils, shared/server, shared/discussion,
and document/ — which map directly to the old utils/ and server/ paths
plus well-covered new additions — restores the threshold at 92% branches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents LLM planning docs and Claude Code runtime files from being
accidentally committed to future branches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
.claude/worktrees/agent-* and .claude/scheduled_tasks.lock are
Claude Code runtime files with no relationship to domain packaging.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These are LLM-generated planning documents for a different issue
(import pipeline work), unrelated to the domain packaging refactor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ExcelService was deleted in fa60c5be. Both the root and backend
CLAUDE.md still listed it under importing/ and in the services table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue numbers in code comments rot as the codebase evolves. The why
(keeping real-database fidelity without pulling full service trees in)
is what matters, not the fix number.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionService injected AnnotationRepository; AnnotationService injected
TranscriptionBlockRepository. Each side now talks through the other domain's
service:
- TranscriptionService.deleteByAnnotationId — new write delegation; called
from AnnotationService.deleteAnnotation in place of the foreign repo.
- AnnotationService.deleteById / deleteAllById — new write delegations; called
from TranscriptionService for cascading annotation cleanup.
- AnnotationService.findById (added in #417 commit 6) replaces the read.
- @Lazy on AnnotationService's TranscriptionService field breaks the
resulting two-bean cycle at construction time, mirroring the existing
@Lazy self-reference pattern in SenderModelService.
Refs #417 (C6.2 violations #10 and #11).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both services injected TranscriptionBlockRepository directly to read block
counts. They now go through TranscriptionBlockQueryService (count() and
countManualKurrentBlocksByPerson() added as 1-line delegations) — chosen over
TranscriptionService to avoid the existing
SenderModelService → TrainingDataExportService → TranscriptionBlockQueryService
chain reaching back into TranscriptionService and creating a cycle.
Refs #417 (C6.2 violations #8 and #9).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SegmentationTrainingExportService and TrainingDataExportService each injected
TranscriptionBlockRepository, AnnotationRepository and DocumentRepository
directly. They now go through:
- TranscriptionBlockQueryService (extended) for the three eligible-block
queries — used over TranscriptionService to keep
SenderModelService → TrainingDataExportService → TranscriptionService cycle-free.
- AnnotationService.findById (new) — read API on the annotation domain.
- DocumentService.findById (already added in #417 commit 3).
The TrainingDataExportServiceTest @DataJpaTest delegates the new service reads
to the real JPA repositories via Mockito stubs in the new makeService helper,
so the integration coverage stays unchanged.
Refs #417 (C6.2 violations #6 and #7).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MassImportService injected DocumentRepository for the find-or-create pattern
during ODS/Excel import. Move the two repository touchpoints (findByOriginalFilename,
save) onto DocumentService as 1-line delegations and update the consumer.
Refs #417 (C6.2 violation #1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
TranscriptionQueueService injected DocumentRepository to fetch the four queue
projections. Move the four read methods (findSegmentationQueue,
findTranscriptionQueue, findReadyToReadQueue, findWeeklyStats) onto
DocumentService as 1-line delegations and update the consumer.
Refs #417 (C6.2 violation #5).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Thumbnail trio (ThumbnailService, ThumbnailBackfillService,
ThumbnailAsyncRunner) all injected DocumentRepository directly. They now go
through three new DocumentService delegations:
- findById(UUID): Optional<Document> — no-throw variant for the runner's
log-and-skip behaviour on missing documents.
- findForThumbnailBackfill() — wraps the existing
findByFilePathIsNotNullAndThumbnailKeyIsNull query.
- updateThumbnailMetadata(Document) — wraps save() for the post-thumbnail
entity update.
DocumentService also gains @Lazy on its existing ThumbnailAsyncRunner field
to break the new DocumentService ↔ ThumbnailAsyncRunner cycle. lombok.config
adds @Lazy to copyableAnnotations so the field annotation reaches the
generated constructor parameter.
Refs #417 (C6.2 violations #2, #3, #4).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- PasswordResetService injects UserService instead of AppUserRepository.
- New UserService.findByEmailOptional preserves the silent-fail behaviour of
the old findByEmail-returning-Optional path; the existing throwing
findByEmail is unchanged.
- New PasswordResetService.findLatestActiveTokenForEmail exposes the latest
active reset token without leaking the repository upward.
- New @Profile("e2e") PasswordResetTestHelper wraps that read so the
AuthE2EController no longer touches PasswordResetTokenRepository directly.
Profile guard moves from the controller-only annotation to also cover the
helper bean, so the production graph never instantiates either.
Refs #417 (C6.1 violation #2 + C6.2 violation #12).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
StatsController previously injected PersonRepository and DocumentRepository
directly, violating the controller→service→repository layering rule. Move the
two count() calls into a thin StatsService that delegates to PersonService.count
and DocumentService.count. While here, add the missing @RequirePermission(READ_ALL)
flagged by AUDIT-2 §7 — anonymous callers were able to read aggregate document/
person counts.
Refs #417 (C6.1 violation #1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aligns the auth-account table name with the AppUser entity. The historical
mismatch (table 'users' alongside table 'persons') misled schema-first readers
into assuming the two were related; renaming to 'app_users' makes the
deliberate split between auth accounts and historical persons explicit at the
schema layer.
Scope: the table itself, the users_groups join table, and the three FK columns
whose name was literally 'user_id'. Semantic FK columns (audit_log.actor_id,
notifications.recipient_id, document_versions.editor_id, etc.) keep their
names — the role they describe is the documentation, not the type.
Closes#418. Unblocks #407 (REFACTOR-1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move `transcription_block_placeholder contains @ mention trigger` out of
`describe('PersonMentionEditor — placeholder behavior')` into a new
`describe('PersonMentionEditor — i18n message content')` block so each
describe group has a single, clear responsibility.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract repeated `new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION))`
into a `kurrentLabels()` helper in TrainingBlockQueryTest and add `import java.util.HashSet`.
Add clarifying comments on the two person-scoped queries in TranscriptionBlockRepository
explaining that they use `MEMBER OF d.trainingLabels` — aligned with the pre-existing
`findEligibleKurrentBlocks()` pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the generic "Type text here..." placeholder in TranscriptionBlock
with copy that teaches the @Name trigger inline (Leonie Voss design review,
issue #370). No new DOM, no new i18n keys — just the three existing
`transcription_block_placeholder` strings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
scriptType is only set after OCR runs, which can't happen before we have
a trained model. Both sender-based queries now filter on the training label
instead, consistent with findEligibleKurrentBlocks.
Also adds missing test coverage for findManualKurrentBlocksByPerson and
countManualKurrentBlocksByPerson (4 cases + count parity check).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The multi-person filter e2e previously typed 'a' then 'b' into the
typeahead and trusted the dev seed to contain matching names.
If the seed ever changes, the test would silently degrade — both
calls might resolve to the same row, or the listbox might never
populate.
Refactor to use a single broadly-occurring probe vowel ('e') and
extract person ids straight from the listbox option DOM (the option
id encodes the person id as `${listboxId}-option-${personId}`).
For the second pick, iterate options and select the first whose
id differs from the first selection. The test now only depends on
the seed having ≥2 distinct persons whose name contains 'e' — a
much weaker, more durable assumption — and asserts on the URL
params with full equality instead of toHaveLength + first-element
spot checks.
Addresses Sara's iteration-3 concern #4 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Senior-author persona requires 44px minimum touch targets on every
interactive control. The /geschichten filter row had three pills
(All / chip / + Person wählen) at h-9 (36px), missing the rule that
the toolbar already follows. Bumped all three to h-11.
Test added in page.svelte.spec.ts asserts the className contains
h-11 on every pill variant.
Addresses Leonie's iteration-3 concern #6 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The story rows on the person detail page now match the
PersonDocumentList pattern: the entire row is a single anchor with a
hover background, and the title gets group-hover:underline. Author,
date, and body excerpt are all part of the same clickable area, so
the touch target matches the visual rhythm of the document panels
above.
Adds a Playwright flow that picks two persons through the typeahead,
asserts both ?personId= params end up in the URL with two chips on
screen, then removes the first chip and verifies only the second
person id remains.
Also extends .prettierignore so a stale root-owned test-results
directory left over from running tests inside Docker doesn't break
the pre-commit lint hook.
The /geschichten list page now renders one removable chip per active
person filter and lets users add more via the existing typeahead. The
URL uses repeated ?personId= params (matching the documents tag
filter), which the regenerated API client passes straight through to
the backend's new array-bound endpoint. New translation keys cover the
chip remove aria-label, the AND hint shown while picking, and the
multi-person empty state.
GET /api/geschichten now accepts repeated personId query params and
returns only stories that mention every person supplied. Refactors the
list path to a JPA Specification chain (one EXISTS subquery per id,
mirroring DocumentSpecifications.hasTags) and embeds the
COALESCE(publishedAt, updatedAt) DESC ordering inside the spec so a
single repository.findAll covers all filter combinations.
Three e2e tests against the real stack:
- admin can navigate to /geschichten, create a draft, publish, and see the
story appear on the index
- a reader (or admin) can click a story card and reach the detail page
with an <article> landmark visible
- AxeBuilder scan of /geschichten reports no serious or critical WCAG
violations
Partial fix for Sara's review B1 on PR #382. The deeper 5-spec a11y suite
and visual-regression coverage are deferred to a follow-up issue.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 browser-based component tests:
- title-empty disables both DRAFT save buttons
- inline title-required error appears after blur
- DRAFT mode renders "Entwurf speichern" + "Veröffentlichen"
- PUBLISHED mode renders "Speichern" + "Zurück zu Entwurf"
- initialPersons / initialDocuments props render as chips on first paint
- title input is populated from a geschichte prop
- "Entwurf speichern" passes trimmed title + status=DRAFT to onSubmit
- "Veröffentlichen" passes status=PUBLISHED
- personIds / documentIds from initial props flow through onSubmit
Closes Felix's review B1 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Browser-based component spec asserting:
- empty geschichten → no <section> rendered
- >= 1 story → heading + story link visible
- canWrite=false → no "+ Geschichte schreiben" link
- canWrite=true → link with /geschichten/new?personId pre-fill
- 0–2 stories → no footer link
- 3+ stories → "Alle Geschichten zu {name}" footer link to /geschichten?personId
- excerpt is plain text (no <strong>, no <script>)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Browser-based component spec mirroring PersonTypeahead.svelte.spec.ts:
renders empty input, surfaces pre-selected chips with formatted date,
emits hidden documentIds inputs for each chip, debounces the search
against /api/documents/search, adds a chip on click, hides already-
selected docs from new dropdown results, and removes a chip on × click.
Closes Felix's review B2 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Story-detail body now uses an explicit Tailwind block-element selector
ruleset instead of the `prose` plugin, so the body fills the full max-w-3xl
parent width — previously `prose` clamped to ~65ch inside an already narrow
page.
GeschichtenCard heading and the "+ Geschichte schreiben" link now use
text-ink-2 (#4b5563 = 7.6:1 on white, AAA-passable) instead of text-ink-3
or text-ink/60. Same fix on the "+ Geschichte anhängen" link in the
Document drawer column and on the Personen / Dokumente section headers
on the story detail page.
Closes Leonie's review B1, B2 and S4 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a module docstring at the top of extractText.ts spelling out that this
is text extraction, not XSS sanitisation, and that callers must rely on
safeHtml() (DOMPurify) for security. Adds a Vitest test block with classic
XSS-shaped payloads (<script>, <svg/onload>, <iframe srcdoc>, javascript:
href) asserting that no markup is re-emitted, even though the module is
explicitly not a sanitiser.
Updates the two callers (/geschichten index, GeschichtenCard) to import
from the new path. The collapse-whitespace pass also makes the regex
fallback's output saner for excerpt rendering.
Closes Nora's review B1 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without this, the Geschichten feature ships dark on prod day-one — no group
holds BLOG_WRITE, so the editor controls never render even for admins. The
mapping "anyone who can write documents can also author family stories" is
the safest default and admins can revoke afterwards via the new checkbox UI.
Closes Tobias's review S5 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both /admin/groups/new and /admin/groups/[id] now expose BLOG_WRITE in the
standard-permissions card so admins can grant Geschichten authoring through
the UI instead of running raw SQL. Adds Paraglide labels in de/en/es.
Closes Markus's review B1 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both lockfiles were updated on every npm install, creating a drift surface
for nothing. CI, Docker and dev all use npm, so yarn.lock has no consumer.
Add it to .gitignore so future yarn-curious developers don't accidentally
re-introduce it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /persons/[id] +page.server.ts now fetches geschichten in parallel with
the other endpoints. Each test in this spec mocks the typed-client's GET
call sequentially, so each chain needs one extra resolved value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Person detail (/persons/[id]):
- Server load fetches GET /api/geschichten?status=PUBLISHED&personId={id}
in parallel with the existing person/document queries.
- Renders <GeschichtenCard> below the received-documents list when the
person has at least one published story.
Document detail (/documents/[id]):
- Server load adds the same parallel call with documentId={id}.
- DocumentTopBar gains geschichten + canBlogWrite props that flow through
to DocumentMetadataDrawer.
- DocumentMetadataDrawer's grid expands to lg:grid-cols-4 when the
Geschichten column should appear (stories exist OR user can author),
and shows "+ Geschichte anhängen" / "Alle anzeigen" links following the
>= 3-story threshold from issue comment #5758.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- /geschichten — published-stories index with filter pills + "+ Neue Geschichte"
for BLOG_WRITERs; supports ?personId and ?documentId pre-filtering
- /geschichten/[id] — reader detail with sanitised {@html} body, person and
document chip sections, BLOG_WRITER edit/delete with confirm dialog
- /geschichten/new — editor with optional ?personId and ?documentId pre-fill
(silent ignore on unknown IDs to avoid leaking entity existence)
- /geschichten/[id]/edit — editor populated from existing story; BLOG_WRITE
guard redirects readers to the detail page
All routes load via createApiClient(fetch) with !response.ok error handling
following the project pattern; PATCH/DELETE go through raw fetch which the
Vite dev proxy / Caddy production proxy authenticates via cookie.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stripHtml() strips tags via DOMParser (browser) with a regex fallback for
SSR. plainExcerpt() truncates at a word boundary with an ellipsis. Both
covered by Vitest specs.
GeschichtenCard renders the top 3 published stories about a person on
/persons/[id], with an editorial excerpt, publication date, author, and a
"+ Geschichte schreiben" link visible only to BLOG_WRITERs. Footer link to
/geschichten?personId=... appears once geschichten.length >= 3.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tiptap StarterKit configured for B/I/¶/H2/H3/UL/OL/history; code, codeBlock,
blockquote, strike, horizontalRule and hardBreak disabled to keep output
matching the backend HTML allow-list. Two-column responsive layout with the
editor body on the left and Personen / Dokumente / Status sections in the
sidebar. Sticky save bar adapts to DRAFT vs PUBLISHED state. Title-required
guard with inline error and beforeNavigate dirty-state guard.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors PersonMultiSelect for documents: chip-style multi-select backed by
GET /api/documents/search?q=. Used in the Geschichte editor sidebar to link
referenced documents to a story.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Derives canBlogWrite in +layout.server.ts the same way as canAnnotate.
- Adds Geschichten link to AppNav (desktop + mobile, between Stammbaum and Admin).
- Adds error_geschichte_not_found mapping to errors.ts and translation keys
for the Geschichten index, detail, editor, and confirmation copy in
de/en/es.
- Adds isomorphic-dompurify-backed safeHtml() helper with allow-list
matching the backend OWASP policy (p/br/strong/em/h2/h3/ul/ol/li),
plus Vitest spec.
- Updates legacy spec test data so the new required canBlogWrite layout
prop type-checks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The end-to-end test creates a DRAFT, verifies it is hidden from a READ_ALL
reader (list and getById), publishes it, verifies the reader sees it, then
deletes it and confirms the join rows go with it but the linked Person
remains. Also corrects the V58 author FK to reference the actual users
table (not app_users).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET endpoints are open to authenticated users (the service layer enforces
DRAFT visibility). POST/PATCH/DELETE require @RequirePermission(BLOG_WRITE).
WebMvcTest slice covers 401/403/200/201/204 paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DRAFT stories are 404 to readers without BLOG_WRITE (NOT_FOUND, not FORBIDDEN,
to avoid leaking existence). list() forces status=PUBLISHED for non-writers
even when they pass status=null. Body HTML is sanitised via OWASP allow-list
(p, br, strong, em, h2, h3, ul, ol, li) on every save. publishedAt is set on
every transition into PUBLISHED and cleared on retract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GeschichteRepository.search filters by status / personId / documentId in a
single JPQL query so the controller can serve the index page, the person
discovery card, and the document drawer column from one method. The DTO is
shared between create and update like DocumentUpdateDTO.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Geschichte holds family memory stories (issue #381). Body is unbounded TEXT
(Tiptap HTML, no length limit). Two join tables link a story to historical
Persons and Documents. A partial index speeds the public index query
(status='PUBLISHED' ORDER BY published_at DESC) and reverse-lookup indexes
support the ?personId and ?documentId filters.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foundation for the Geschichten (story) domain (issue #381). BLOG_WRITE gates
authoring of family memory stories; GESCHICHTE_NOT_FOUND is also returned for
DRAFTs requested by users without BLOG_WRITE so existence is not leaked.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds OWASP Java HTML Sanitizer on the backend and DOMPurify on the frontend.
Together with Tiptap on the writer side they form a defense-in-depth chain
against XSS in the new Geschichte body field (issue #381).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses @felix — tick().then() had no error handler; console.error
is now logged on failure, matching the existing deep-link scroll pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On mount, reads the task query param before the comment deep-link handler.
When task=transcribe, opens the transcription panel, scrolls the close button
into view, moves focus to it, then strips the param from the URL via replaceState.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five new tests verify:
- Card stays open when mouse moves mention → card (cancels 150ms timer)
- Card closes immediately on card mouseleave (no timer)
- Re-entering a mention cancels a pending close
- Card stays open when keyboard focus moves mention → card (WCAG 2.1.1)
- Card closes when keyboard focus leaves the card entirely
The keyboard tests drove adding onfocusin/onfocusout to PersonHoverCard's
root div, reusing the existing onmouseenter/onmouseleave callbacks so that
screen-reader and keyboard users get the same stay-open affordance as
mouse users. relatedTarget check prevents spurious closes on intra-card
focus movement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces CSS ::after { content: ':' } with literal colon inside the
chip-type span. CSS-generated content is announced inconsistently
across NVDA+Chrome and VoiceOver+Safari; a real text node is always
reliable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PersonHoverCard was showing the hovered person as their own parent when stored
as the object side of a PARENT_OF row — now uses chipLabel/otherName from
relationshipLabels (same helpers the person detail page uses) to resolve the
correct name and label from the caller's perspective.
PersonMentionEditor: add allowSpaces:true so typing a last name after a space
no longer exits mention mode mid-query.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionService.updateBlock was not writing mentionedPersons from the DTO
back to the entity, so @mentions were lost on every save. Clear-then-addAll
pattern avoids Hibernate orphan issues with @ElementCollection.
Switch @ElementCollection fetch to EAGER so callers can read mentionedPersons
outside an active transaction without a LazyInitializationException.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PersonHoverCard: alias is compared against both `lastName` and `displayName`
before showing as maiden name — prevents false positive when alias is stored
as the full current name (e.g. "Maria Schmidt" ≠ "Schmidt" but name unchanged)
- PersonMentionEditor: data-placeholder was set statically so the CSS ::before
rule showed the placeholder on any blur even with content; now a $effect
toggles the attribute based on editor.isEmpty
- TranscriptionReadView: hovering onto the card itself cancels the 150ms close
timer so the card stays open while reading it; leaving the card closes it
immediately — onmouseenter/onmouseleave wired through PersonHoverCard props
- hoverCardPosition: removed scrollX/scrollY offset since the card is now
position:fixed (scroll is already baked into getBoundingClientRect coords)
- MentionDropdown: raised z-index from z-20 to z-50 to render above the hover card
- vite.config.ts: pre-bundle Tiptap packages to avoid HMR waterfall on first load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per Markus #5616, the leaf-component fetch in the Tiptap suggestion plugin
violates the project-wide rule from frontend/CLAUDE.md ("Data flows from
+page.server.ts via props — never client-side API fetch"). Add an inline
block-comment explaining why this exception is justified (suggestion runs
client-side per keystroke; same auth surface; no server-side reshape
benefit) and points future readers at the open ADR follow-up plus Nora's
PersonSummaryDTO response-shape audit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Tiptap rewrite dropped the inline "create new person" affordance the
textarea-era component used to render. Without it the workflow regresses:
transcriber must close the dropdown, navigate to /persons/new, come back,
re-type the query. The m.person_mention_create_new() key is still in all
three locale files — add the link back as a 44px-tall row with a top
border separating it from the empty-state message.
target=_blank keeps document/editor state intact; rel=noopener prevents
reverse-tabnabbing. mousedown preventDefault keeps the editor focused
(the dropdown row pattern used for option rows).
Test: empty-state renders a link to /persons/new with the localised label.
Leonie #5621 (Major) + Elicit OQ-373-04.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two non-text-contrast failures, both flagged by Leonie #5621:
1. PersonMentionEditor mention pill: decoration-brand-mint (#A6DAD8) on
white is ≈1.7:1 — fails the 3:1 minimum for meaningful UI indicators.
Switch to decoration-ink/50, which matches the read-mode .person-mention
rule (≈6.4:1) and keeps a unified underline language across modes.
2. MentionDropdown highlighted-row ring: ring-brand-mint on bg-brand-mint/20
is ≈2.5:1 — same failure class. Switch to ring-brand-navy (≈14.5:1
against the highlight background) so keyboard-driven selection has a
clearly visible indicator.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The disabled-state effect calls editor.setEditable, which triggers a
ProseMirror transaction → onUpdate → bind:value/mentionedPersons writes →
host re-render → child prop pass-through → effect re-fires. Without an
idempotence check, this exceeds Svelte's effect_update_depth and crashes
every consuming spec (TranscriptionBlock 22/22). Compare editor.isEditable
against the desired value first; only call setEditable when it actually
needs to change.
Follow-up to 6ef888a1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a CWE-79 regression test: a sidecar entry whose displayName contains
an <img onerror=alert(1)> payload must round-trip through deserialize and
the Tiptap renderHTML without producing a real <img> element in the editor
DOM. Locks down the "renderHTML's third tuple entry is a text node, never
parsed as HTML" invariant so a future "use innerHTML for performance"
refactor cannot silently regress.
Nora #5618 detection-gap concern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wrapping the editor with pointer-events-none was visual-only — keyboard users
could still tab into the contenteditable and type. Wire `editable: !disabled`
on the Tiptap Editor and a reactive `$effect` that calls setEditable when the
prop flips after mount; expose `aria-disabled="true"` on the wrapper so
screen readers announce the deactivated state.
Tests assert contenteditable=false and aria-disabled=true when disabled;
contenteditable=true otherwise.
Closes WCAG 2.1.1 / 4.1.2 — Felix #5615 + Leonie #5621 + Nora #5618 BLOCKER.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
errors.ts no longer references this code (the rename-propagation listener
was deleted) and the matching ErrorCode value is gone from the backend.
The Paraglide-compiled message helpers should not include strings nothing
calls — drop the entries from de/en/es to keep the i18n surface honest.
Felix #5615 + Elicit #5624 blocker.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The textarea-era detectPersonMention helper has no production callers since
the suggestion plugin's char: '@' mechanism replaced it. Per "Dead code is
deleted, not commented out", remove the source file and its spec — the spec
was running but tested a function nobody calls.
Felix #5615 blocker.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Placeholder uses ::before pseudo-element on the contenteditable's
data-placeholder attribute, only visible when the editor is unfocused
and empty. Removes the default ProseMirror focus ring since the outer
wrapper provides its own.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces captureTextarea + handleTextareaMouseUp (which read selection
bounds off a real <textarea>) with an onSelectionChange callback prop
on PersonMentionEditor, wired to Tiptap's selectionUpdate event. The
editor emits the selected text directly so the parent no longer needs
DOM access.
Tests are updated to drive the contenteditable via the Selection API
instead of the now-deleted textarea.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the textarea-based editor with a Tiptap v3 contenteditable.
The custom Mention node uses personId/displayName attrs (instead of
Tiptap's default id/label) so mentionSerializer round-trips cleanly.
AC-1 fix (issue #372): when the user types '@Aug' and selects
'Auguste Raddatz', the mention node stores displayName: 'Aug' (the
typed query) — not the person's DB display name. This preserves
archival fidelity of the original transcription.
The MentionDropdown is mounted imperatively on document.body via
Svelte 5's mount(). Its three pieces of dynamic state (items,
command, clientRect) are passed as a single $state proxy (model)
because Svelte 5's mount() does not return prop accessors.
Spec is fully rewritten — all old tests used document.querySelector
('textarea') which is dead after the migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Svelte 5's mount() does not return prop accessors — setting
'instance.items = newValue' is a no-op. Switching to a single $state
proxy passed as 'model' lets the parent mutate fields and have the
dropdown react. The prop is named 'model' (not 'state') because the
$state rune name shadows a 'state' identifier in Svelte 5 templates.
Position class also switches from absolute to fixed so viewport-
relative DOMRect coordinates from clientRect() work when the dropdown
is mounted on document.body.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces PersonMentionEditor's inline popup for the Tiptap migration.
Mounted imperatively to document.body by the suggestion plugin's render()
lifecycle. Supports flip-upward strategy when viewport space is tight
(Leonie #5602 mobile keyboard concern). 44px touch targets, WCAG accessible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Converts between the stored format (text + PersonMention sidecar) and Tiptap
ProseMirror JSONContent. Round-trip invariant: serialize(deserialize(t,s)).text === t.
Handles multi-paragraph text (split/join on \n), sidecar deduplication, and
backward compat with old-format full-name sidecar entries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exact version pins — all three packages share ProseMirror peer deps and must
stay in sync. Renovate grouping in renovate.json ensures they bump together.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- renovate.json: group all @tiptap/* packages so version bumps stay in sync
- de/en/es.json: add transcription_editor_aria_label and person_born_name_prefix keys
- PersonHoverCard: replace hardcoded "geb." with m.person_born_name_prefix() (Leonie #5602)
- errors.ts: remove PERSON_RENAME_CONFLICT (backend enum value deleted)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PersonMentionPropagationListener rewrites @DisplayName tokens on person rename.
Under the new design, displayName is archival (what the transcriber typed), so
the listener would corrupt transcriptions rather than correct them.
Deletes PersonMentionPropagationListener, PersonDisplayNameChangedEvent, and the
optimistic-lock catch path in PersonService.updatePerson. Removes PERSON_RENAME_CONFLICT
from ErrorCode and all tests that exercised the now-deleted code path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Sara #3: title was a fixed string; if beforeAll crashed before afterAll
ran, the next run would collide. Append Date.now() so each run has a
unique title.
- Sara #2: B21 only asserted "no card present after tap" — but at that
point we've already navigated to /persons/{id} and the card lives on
the document page, so the assertion was vacuous. Move the toHaveCount(0)
to before the tap so it actually proves touch-device suppression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sara #1 + Felix #4: setTimeout(r, 50) and setTimeout(r, 5) were racing the
microtask queue — passes on a fast laptop, will fail on a loaded CI runner.
Replace all six occurrences with vi.waitFor(() => expect(...)) which polls
until the assertion passes (default 1s timeout, 10ms interval).
Tests are now deterministic — they pass the moment the condition is true,
fail the moment the timeout elapses, and never spuriously time out on slow
CI hardware.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie FINDING-06: text-decoration-color was --c-accent at 60% (~#C9E6E5 on
white = ~1.6:1 contrast). The underline is the only visual signal that this
is a link mid-paragraph, so a barely-visible colour means seniors and
colour-blind users miss the affordance entirely.
Switch to --c-ink at 50% — same ink colour as the text, half opacity. Reads
as a soft underline on any background, passes WCAG 1.4.11 non-text contrast
on every brand surface.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie FINDING-04 + Elicit E5: notes.slice(0, 120) cuts mid-word, especially
ugly in German compound nouns ("…Familienzu…"). Sara #7: the assertion
.toBeLessThanOrEqual(122) was a magic number that hid this bug.
Add truncateAtWordBoundary(text, max): cut at the last space inside the
window unless it'd shrink the excerpt below 70% (single-word fallback).
Single-word case still produces hard-cut + ellipsis so a 150-char word
shows the first 120 chars + … rather than nothing.
Tests pinned to exact strings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie FINDING-02/03 + Elicit NFR concern + Sara #4: role="region" with no
aria-label is an axe-core warning, and the pulsing-bars skeleton carries no
semantics for SR clients.
- Add aria-label to the region root: person displayName when loaded,
localised "Lade Person…" while loading. Region always has a name.
- Add aria-busy="true" while loading; cleared on loaded/error so the
state change is announced via aria-live="polite".
- Add role="status" + aria-label on the skeleton so SR clients hear
"Lade Person" rather than three silent <div>s.
- New Paraglide key person_mention_loading in de/en/es.
Five new tests pin: aria-busy true while loading, aria-busy unset/false
when loaded, aria-label is displayName when loaded, aria-label is the
loading label while loading.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie FINDING-01 (Critical) + Elicit E3: only mouseenter triggered the
hover card, so a keyboard user tabbing through transcribed text reached the
anchor but never saw the rich-context preview. For the senior audience
constraint that's a hard regression.
Wire focusin/focusout alongside mouseenter/mouseleave on the delegated
listener. Same handleMentionEnter/Leave run — getBoundingClientRect works
identically on focused elements. focusin/focusout bubble naturally so no
capture phase needed.
Two new tests assert focusin mounts the card and focusout unmounts it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix #7: handleMentionClick unconditionally preventDefault'd and goto'd,
breaking ctrl-click / cmd-click / shift-click / alt-click / middle-click —
"open in new tab" is a real workflow for researchers comparing two persons.
Add isPlainPrimaryClick() guard. Modified clicks fall through to the
browser's default anchor handling (the <a href="/persons/{id}"> opens in
the new tab as expected). Plain left-clicks still SPA-navigate via goto().
Three new tests assert ctrl-click, meta-click, and middle-click are not
preventDefault'd.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix #1: fetchHoverData was doing four things — cache lookup, fetch, JSON
parsing, 404 normalisation. Split into:
loadHoverData(personId) — pure fetch + 404→null + non-OK→throw
getOrFetchHoverData(personId) — five-line cache wrapper around the above
Also document the cache-lifetime trade-off (Markus #4, Elicit OQ-372-02):
the cache is per-mount, so closing and reopening the transcription panel
rebuilds it. That's intentional given the read-only nature of the view —
revisit if stale-card user reports surface.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three reviewer concerns land here:
- Felix #2: magic numbers 0.7 and 300 belong in named constants
- Sara #6: the position function had 4 branches and 2 thresholds with zero tests
- Leonie FINDING-05: at 320px viewport the flip-left could push the card
past the right edge — needed a viewport clamp
Move the function to src/lib/utils/hoverCardPosition.ts as a pure
(rect, viewport) → {top, left} mapping, with named exports CARD_WIDTH_PX,
CARD_HEIGHT_PX, CARD_GAP_PX, BOTTOM_BAND_RATIO, RIGHT_FLIP_THRESHOLD_PX.
Add a viewport clamp so left + CARD_WIDTH never exceeds the right edge.
Ten unit tests cover default placement, flip-up (both triggers), flip-left,
flip-right-edge clamp, and scroll offset. TranscriptionReadView passes the
current window viewport in on each call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Markus flagged that 'a.person-mention' is a magic string repeated four times
in TranscriptionReadView, plus the CSS rule, plus tests. Extract into a single
exported constant so the renderer template, the delegated event handlers,
and the consumer-side selectors all import the same value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nora's CWE-601 (Open Redirect) defense-in-depth concern: today the backend
emits UUIDs, but renderTranscriptionBody concatenates personId straight into
an href. If a future "external person" feature ever flows a non-UUID through
the sidecar, the renderer would happily emit `<a href="javascript:…">`.
Add a strict UUID regex check before substituting. Non-UUID entries fall
through unchanged so the @-trigger remains as plain text — no silent data
loss, no clickable redirect.
Three new failing→passing tests cover javascript: scheme, absolute URL, and
the positive case (well-formed UUID still renders). Existing tests that used
synthetic IDs ("p-short", "p-first", etc.) updated to real UUIDs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Markus, Felix, and Nora independently flagged the {@html …} boundary as a
distributed-knowledge security risk: today renderBody and renderTranscriptionBody
return string, so the next refactor that does {@html block.text} (instead of
{@html renderBlockHtml(block)}) is one typo away from a stored-XSS regression.
Introduce a SafeHtml brand type (string with a phantom __brand) returned by
both renderers and by renderBlockHtml in TranscriptionReadView. Compile-time
enforcement of the escape invariant — costs zero runtime, makes the contract
auditable in one file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Markus flagged the LoadState export from PersonHoverCard.svelte as a
view-vs-orchestrator boundary smell — both files own the same shape, and a
third caller (admin previews, briefwechsel cards) would create a circular
import. Move the types into src/lib/types/personHoverCard.ts so the contract
is module-stable.
Also harden .prettierignore + eslint.config.js so a stray .svelte-kit.old/
backup directory (rotated by SvelteKit during dev) doesn't break the lint
hook — matches the existing .svelte-kit-backup/ convention.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates a Person, document, annotation, and transcription block with
mentionedPersons sidecar, then exercises the read-mode link in two
contexts:
- Desktop: page.hover() mounts the hover card; mouseleave unmounts.
- Touch (Pixel 7 device): page.tap() navigates to /persons/{id}
without the card ever mounting (tap opens the page directly).
Tests are sequential because they share a single document/person via
beforeAll/afterAll. The touch test spins up a separate browser context
with hasTouch=true reusing the stored auth state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Composes splitByMarkers + renderTranscriptionBody so [unleserlich]
markers render as <em data-marker> siblings of the mention anchor —
neither nested inside the other (B19b).
Hover card lifecycle on each .person-mention anchor:
mouseenter → set aria-describedby, place card via getBoundingClientRect
(default below-right; flip up if <200px from bottom or
mention is in bottom 30% of viewport; flip left if
<300px from right), fire fetch, mount card with
skeleton state
resolved → swap card to loaded state with person + family
relationships (PARENT_OF / SPOUSE_OF / SIBLING_OF only)
404 → degrade: mark anchor with data-person-deleted="true",
unmount card, suppress future hovers/clicks
network → swap card to error state — link still navigates
mouseleave → drop aria-describedby, unmount card
Per-page SvelteMap<personId, Promise> cache (B15.5) so a sweep across
N mentions of the same person fires the backend once. Click handler
calls goto() so SvelteKit handles routing without a full reload.
Event listeners are attached once per article via a Svelte action
because the anchor HTML is injected via {@html ...} and would not
receive declarative bindings. The eslint-disable comment mirrors
the rationale on CommentMessage.svelte:88-89.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The card has three render states:
- loading → 320×180 skeleton with three pulse-animated bars; respects
prefers-reduced-motion (animation disabled, opacity dimmed)
- error → generic load-error message in the body; the footer link
still navigates (click works regardless of fetch outcome)
- loaded → navy header with name, life-date range, and "geb. <alias>";
family-only relationship chips (PARENT_OF / SPOUSE_OF /
SIBLING_OF) — non-family types are filtered out;
notes excerpt capped at 120 chars with ellipsis;
footer with "Zur Person →" + hover hint
aria-live="polite" on the card root so screen readers announce loaded
content when the fetch resolves; the host's id is the cardId so the
parent anchor can use aria-describedby. The card is hidden via
@media (hover: none) on touch devices — tap navigates directly per
spec.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Underline-at-rest (WCAG AA) so the link affordance does not depend on
colour alone. focus-visible uses a 2px box-shadow ring on --c-ink with a
2px border-radius — the same focus-ring shape as the comment .mention
chip but rectangular instead of pill, since the anchor sits in flowing
text.
Lives next to the existing .mention rule because Svelte scoped styles
do not reach the HTML injected by {@html …} in TranscriptionReadView.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces every @DisplayName in a transcription block's text with an anchor
link to /persons/{personId}, sourced from the mentionedPersons sidecar.
The @ prefix is stripped from the rendered link text per spec — it is an
editor affordance, not part of the historical text.
Stored-XSS hardening: HTML-escapes block text, displayName, and personId
before injection. Word-boundary lookahead avoids prefix collisions
(@Hans vs @HansMüller). Longest-displayName-first + first-sidecar-wins
make rendering deterministic for the OQ-1 collision case (#5339).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 nit pile:
- '@Aug @Bert' with cursor past the second @ — confirm the most
recent @ wins (this is the canonical case for typing two mentions
separated by a space).
- '@Aug\\nfoo' with cursor exactly at the newline (index 4) — the
query still reads 'Aug' because the newline is past the cursor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 §2 + Markus #5504 §2: the 409 orchestration was inline in
+page.svelte and untested. Extract into a pure module that takes the
fetch function as a dependency, so the full happy path / 409 path / 500
path / refetch-fails path / UUID-guard path can be unit-tested with
mock Responses. The route file now reads as 12 lines: call the helper,
on conflict apply the merged snapshot to local state, re-throw.
BlockConflictResolvedError now carries the merged block on its
`merged` property so callers don't have to redo the refetch.
6 new unit tests cover every branch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 §5: the existing test only asserted the final 'saved'
state, which would also pass if the hook skipped the saving state
altogether. Hold the second mocked saveFn promise so we can assert the
intermediate transition.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 §4: there was a test for fetch returning ok:false but no
test for the broad catch covering thrown rejections (DNS failure,
TypeError: Failed to fetch). Pin that path so a future refactor can't
accidentally bubble the error and crash the editor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tester #5506 §1: 14 tests × 250ms real-timer waits = 3.5s wall-clock,
also racing the 200ms internal debounce by only 50ms — a flake on a
busy CI runner. Switch to vi.useFakeTimers + advanceTimersByTimeAsync;
test execution now 236ms (was 3.08s), determinism guaranteed because
the debounce runs against the fake clock.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie #5507 §5 + ReqEng #5510 §3: when the typeahead returned zero
results, the user was told their search failed and given no path to
recovery. Mirror PersonTypeahead's behaviour: offer a "Neue Person
anlegen →" link that opens /persons/new?name={query} in a new tab so
the transcriber doesn't lose their in-progress block.
Adds person_mention_create_new in de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie #5507 concern 7: on slow networks the popup sat empty for up to
1.5s while the user wondered if anything was happening. Add a loading
flag that flips on as soon as scheduleSearch is asked to query and
back off in the fetch's finally branch. Reuses the existing
comp_typeahead_loading message ("Suche…") so no new i18n keys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie #5507 concern 3: hover and aria-selected both used bg-canvas, so
a tablet user sweeping the trackpad couldn't tell where the keyboard
cursor was. Use bg-brand-mint/20 + a 2px ring-inset for the highlighted
row — keeps hover affordance, adds a distinct keyboard-cursor token
that meets WCAG 1.4.11 Non-Text Contrast.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie #5507 concerns 4 + 6:
- The textarea had outline-none and no focus indicator — broken for
keyboard-only navigation now that the typeahead is fully keyboard-driven.
- A rows=1 textarea is ~24px tall (Merriweather + 1.625 line-height),
below the WCAG 2.2 AA Target Size (44×44) requirement for the focused
actionable element.
Add focus-visible ring/border in brand-mint and a min-h of 44px with
py-2.5 padding so the empty-state textarea hits the target.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Leonie #5507 concern 1: tabbing away from the editor left the popup
hanging over the next field. Add a 150ms-deferred close on blur — the
delay lets onmousedown on a result fire before the popup unmounts (the
race that the existing onmousedown+e.preventDefault() pattern depends on).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sina #5505 concern 2: the typeahead silently relies on the Vite-proxy
cookie injection + same-origin policy for auth. Spell that out in the
fetch site so the next reader doesn't have to derive it from the proxy
config.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sina #5505 concern 1: doc.id and blockId are server-trusted today, but
the path-interpolation pattern is repeated three times across the route
and the autosave hook. Validate both ids against the standard UUID
regex before any fetch fires so a future feature taking user-supplied
ids cannot silently introduce a path-injection vector.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sina #5505 action item: escapeHtml escaped the four common entities but
not the apostrophe. Today every consumer uses double-quoted attributes,
but a future renderer change to single quotes would silently open a
stored-XSS hole. Cheaper to fix now, with a regression test.
Also pin the idempotence-by-composition property: a second call
re-escapes the & introduced by the first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix #5: TranscriptionBlock had a `\$effect(() => { void localText; ... })`
hack to re-trigger autoresize on text change, plus a captureTextarea
callback that the parent only used to size a node it didn't own.
The editor owns the textarea — it should also size it. Move the
autoresize \$effect into PersonMentionEditor so the parent only
captures the node when it genuinely needs to read selection bounds
(quote selection still works).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix #3: the 409 path was throwing a human-prose Error which read like
an i18n string that escaped translation. Replace with a named class
carrying code='CONFLICT_RESOLVED' so callers can branch on intent and
future error reporters can map the structured code instead of grepping
strings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix #2: both were exported anticipating a future use that never came —
the editor only emits text+mentions through handleTextChange. Dead public
surface invites stale code; ship the smaller API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same fix as 79349644 — the bind:mentionedPersons setter parameter `m`
shadowed the imported Paraglide m helper used two lines later in
placeholder={m.transcription_block_placeholder()}. Functionally fine
because the inner scope ends before the outer reference, but a clarity
trap. Renamed to next.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix #1: inside selectPerson the .some((m) => ...) parameter shadowed the
imported Paraglide m helper. Functionally fine, but a footgun. Rename to
existing for clarity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The b2 fixture in the second describe block had been missed when the
TranscriptionBlockData type added the mentionedPersons field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When PersonService renames a person while a transcriber is editing a
block that mentions them, the block-save endpoint returns 409 (carrying
the new ErrorCode.PERSON_RENAME_CONFLICT from PR-A). saveBlock now:
1. Refetches the latest server snapshot of the block.
2. Calls mergeBlockOnConflict to combine: server's mentionedPersons
(post-rename displayNames win) + transcriber's unsaved text + any
local-only mentions added since the last save.
3. Updates the local block state with the merged result.
4. Re-throws so the autosave indicator surfaces the conflict and the
pending payload is preserved for retry (B12).
The merge logic is a pure function so it can be unit-tested in
isolation and reused for any future conflict-resolution scenarios.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Locks in the behaviour added with the saveFn signature widening: a
rejected save keeps the in-flight payload around so handleRetry resends
it without the caller having to re-pass anything.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TranscriptionBlockData now carries mentionedPersons (matches backend
schema added in PR-A).
- useBlockAutoSave.saveFn signature widens to (blockId, text, mentions);
pendingMentions is tracked alongside pendingTexts and is preserved on
failure so a retry resends the in-flight payload (B12).
- TranscriptionBlock.svelte renders <PersonMentionEditor>, exposing the
textarea node back through a captureTextarea callback so the existing
quote-selection feature still works.
- saveBlock in routes/documents/[id]/+page.svelte forwards mentions on
PUT.
- flushOnUnload sends mentions in the keepalive payload too.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors MentionEditor for users but searches /api/persons?q=, allows
multi-word queries (delegated to detectPersonMention), displays life
dates next to each result, and uses min-h-[44px] rows for WCAG 2.2 AA
touch targets. Selection writes both the @DisplayName text and a
{personId, displayName} sidecar entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the 3 keys mandated by the plan (open_link, hover_hint, load_error)
plus the editor's popup_empty + btn_label so PersonMentionEditor mirrors
the existing user-mention editor's i18n pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment mentions stop at a space; person mentions must accept spaces
because historical display names are commonly multi-word.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts the Pattern+Matcher+replaceAll block into a private helper so the
loop body reads as three lines: rewrite text, update sidecar entries, nothing
else. Moves the boundary-condition rationale comment to the helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
createBlock has both validation guards (displayName length + personId null).
updateBlock had only the displayName test. Add the symmetric null-personId case
so a future @Valid drop from updateBlock's @RequestBody would be caught.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Method said inUnderTwoSeconds; assertion checks isLessThan(5000L) with message
"5s". Three sources of truth, three different values. Rename aligns method name
with the assertion that was intentionally raised from 2s to 5s in a prior commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The listener exclusively calls findByPersonIdWithMentionsFetched (JOIN FETCH).
Zero callers exist in production or test code. Leaving it is a maintenance
trap: a future caller would silently trigger N+1 loads on the lazy collection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PersonServiceTest wired the mock on findByMentionedPersons_PersonId; the listener
now calls findByPersonIdWithMentionsFetched so the mock returned an empty list,
suppressing the saveAllAndFlush call and breaking the exception-propagation test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2s was generous for correctness but tight for a shared VPS-hosted CI runner
(cold JVM, Testcontainers startup, competing processes). 5s still catches
O(n²) regressions and N+1 queries while eliminating flaky failures.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
updatePerson_doesNotPublishEvent_whenOnlyAliasChanges implied that alias is
processed by updatePerson — it isn't. The invariant is that the event is
suppressed when title/firstName/lastName are all unchanged regardless of
which non-displayName field changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add updatePerson_returns409_whenRenameConflict to PersonControllerTest: exercises
the full controller→exception-handler path, not just the service layer. Verifies
HTTP 409 + $.code = PERSON_RENAME_CONFLICT when updatePerson throws a conflict.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Switch from findByMentionedPersons_PersonId (derived query, returns blocks with
LAZY mentionedPersons) to findByPersonIdWithMentionsFetched (JOIN FETCH, loads
full collections in one round-trip). 200-block propagation: from 201 queries to 2.
Add @Transactional comment documenting join-transaction semantics.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add findByPersonIdWithMentionsFetched to TranscriptionBlockRepository: subquery
finds blocks referencing the renamed person, outer JOIN FETCH loads their full
mentionedPersons collection. Avoids N+1 lazy selects in the propagation listener.
Filtered JOIN FETCH (WHERE m.personId=:personId) was rejected — it loads only one
mention entry per block, risking data loss on saveAllAndFlush.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Markus #4 (PR #366 review). PersonDisplayNameChangedEvent is the first
custom application event in this codebase — the prior @EventListener
(OcrTrainingService.recoverOrphanedRuns) consumed Spring's built-in
ApplicationReadyEvent. The pattern is load-bearing for future cross-domain
decoupling and warrants a documented decision rather than a comment buried
in the listener.
Captures: synchronous-by-default rationale, package layout (event in
publisher's model/, listener in consumer's service/), saveAllAndFlush vs
saveAll for exception surfacing, the migration path to @TransactionalEvent
Listener + @Async if archive growth forces it, and the rejected
alternatives (direct call, DB trigger, Hibernate entity listener).
Refs #362#366
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Felix self-review / Sara (PR #366 review). The trailing-`List.of()` pattern
introduced when mentionedPersons was added to the DTOs is brittle: every
future field forces another grep-and-edit pass across this file. Switch
the 8 call sites (1 Create, 7 Update) to .builder() so the test only
specifies the fields it cares about — future DTO growth is invisible to
tests that don't touch the new field.
Refs #362#366
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sara #4 (PR #366 review). The 400-on-201-chars regression guard previously
only covered POST /api/documents/{id}/transcription-blocks. The same @Valid
cascade applies to PUT /api/documents/{id}/transcription-blocks/{blockId}
via UpdateTranscriptionBlockDTO, but no test asserted it — meaning a
silent removal of @Valid on the PUT @RequestBody parameter would slip past
CI. Mirror the test for symmetry.
Refs #362#366
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Markus #6 (PR #366 review). The class lives in service/ and is service-tier
business logic — wire-by-stereotype consistency calls for @Service. Both
annotations participate in @ComponentScan equivalently, so the bean
registration is unchanged.
Refs #362#366
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sara #3 / Felix #5 (PR #366 review). The previous version stubbed
eventPublisher.publishEvent to throw, which proved the catch-and-translate
syntax but skipped the listener entirely. The test could not have detected
a regression where the listener swallowed the exception or re-wrapped it
with a non-OptimisticLocking type.
Replace with a real PersonMentionPropagationListener instance backed by a
mocked TranscriptionBlockRepository whose saveAllAndFlush throws
ObjectOptimisticLockingFailureException (the actual Spring exception
Hibernate raises). The publisher mock routes the event to the real
listener via doAnswer so the call chain is the production one:
PersonService.updatePerson → publishEvent → listener.onPersonDisplayNameChanged
→ blockRepository.saveAllAndFlush throws → exception bubbles through the
synchronous event dispatcher → PersonService catches → DomainException.
Refs #362#366
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Felix #2 / Markus #1 (PR #366 review). In the synchronous-transactional
path the existsById check could never return false — the rename and the
propagation share one transaction, so the renamed Person is guaranteed to
still exist when the listener runs. The check was forward-protection for
an eventual @Async refactor but its presence today is misleading: it
suggests a runtime branch that no test could reach against the real flow.
Delete the call, drop the PersonService dependency from the listener, drop
the now-unused PersonService.existsById, and remove the orphan-guard test
(it asserted a behaviour that the synchronous path cannot produce). When
async is added later the guard re-enters the codebase deliberately as part
of that refactor.
Refs #362#366
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Felix #1 / Markus #5 / Sara #1 (PR #366 review). The naive
text.replace("@" + old, "@" + new) silently corrupted any composite mention
that began with the renamed single-name person — e.g. renaming the
single-name "Hans" turned "@Hans Müller" into "@Henry Müller", obliterating
the historical reference to Hans Müller without warning.
Replace with a regex matching "@OldName" only at a token boundary: not
followed by a letter/digit/hyphen (catches @Hans-Peter) and not followed by
"<space><uppercase>" (catches @Hans Müller). False negatives — e.g.
sentence-initial "@Hans Bekam" — are accepted as the conservative
trade-off; corruption is irrecoverable, missed renames are not.
The new failing test reproduced the reviewer scenario exactly: two persons
("Hans Müller" + single-name "Hans"), one block referencing both, rename
Hans → Henry. Pre-fix output corrupted "@Hans Müller" to "@Henry Müller";
post-fix preserves the composite mention and only updates the standalone.
The existing partial-name guard test (Hans-Peter Müller / Hans Müller) and
multiple-occurrences test still pass — the regex is a strict superset of
the boundary constraints already covered.
Refs #362#366
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
openapi-typescript regenerated against the dev backend now exposes:
- components.schemas.PersonMention with personId + displayName
- TranscriptionBlock and CreateTranscriptionBlockDTO/UpdateTranscriptionBlockDTO
carry the optional mentionedPersons array
- (No new path entries: hover-card and typeahead reuse existing endpoints
GET /api/persons, GET /api/persons/{id}, GET /api/persons/{id}/relationships.)
Sealed inside PR-A so the frontend PR-B can import the new types from main
without rebasing across an unrelated regen. Per Tobias' chain-tightening
note in the consolidation summary.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Defense in depth: until now both list and single-person reads only required
authentication, while the write endpoints (POST/PUT/DELETE) were already
gated with @RequirePermission. The hover-card and typeahead introduced in
issue #362 expose person details (life dates, notes, family relationships)
to anyone who can authenticate — adding READ_ALL aligns the GETs with the
write endpoints and matches the access tier already enforced for documents
and transcription blocks.
Two new controller-slice tests assert 403 when an authenticated user lacks
READ_ALL; existing 200-path tests now stipulate `authorities = "READ_ALL"`
explicitly.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Latency floor (Sara): a merge-blocking regression check, not a benchmark.
Seeds 200 blocks each with one mention of the same person, fires the rename,
and asserts the listener completes the entire find/mutate/saveAllAndFlush
cycle in less than two seconds against the Testcontainers Postgres.
Confirms the partial reload (one Auguste → Augusta) actually persisted so
the timing isn't measuring an empty path.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the propagation listener saves blocks with a stale @Version (because
another transcriber's autosave incremented version mid-rename), Hibernate
raises ObjectOptimisticLockingFailureException — Spring's translation of
the underlying JPA exception. PersonService.updatePerson now wraps the
publishEvent call in a catch for OptimisticLockingFailureException and
re-throws as DomainException(PERSON_RENAME_CONFLICT, 409). The whole
@Transactional boundary still rolls back, but the client gets a structured
409 with the localised "please retry" message instead of a generic 500.
The listener was switched from saveAll to saveAllAndFlush so the conflict
fires inside the listener call (where the catch can see it), not at
transaction commit (which is too late for in-method handling).
Test stubs the eventPublisher to throw OptimisticLockingFailureException
and asserts the translated DomainException carries PERSON_RENAME_CONFLICT
and HTTP 409. End-to-end DB-level reproduction of the JPA optimistic-lock
race requires multi-threading or two physical connections, which is
impractical inside @DataJpaTest; the underlying JPA mechanism is well
covered by Hibernate's own test suite.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the structured error code returned when a rename rolls back because a
referenced transcription block was edited concurrently (OptimisticLockException
on transcription_blocks.version). Mirrors the contract in
frontend src/lib/errors.ts and adds the localised message keys
error_person_rename_conflict in de/en/es so the UI surfaces a retry hint
instead of a generic 500.
The actual translation of OptimisticLockException → DomainException
(PERSON_RENAME_CONFLICT) lands in the next commit alongside the integration
test that proves the rollback semantics.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A block with a sidecar entry pointing at a personId no longer in the
persons table receives a rename event for that ghost id. The listener
detects via PersonService.existsById that the entity is gone and exits
without touching block.text or the sidecar. Defends against any future
async refactor where an event could outlive the entity, or against
malformed events injected by tests / migrations.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the same person is mentioned twice in one block, both substrings flip
to the new display name. String.replace(String, String) is documented to
replace every occurrence, but a future regex-based refactor or a typo could
silently regress to first-match-only — this test guards against that.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Block contains both @Hans-Peter Müller and @Hans Müller; the listener fires
a rename for Hans Müller → Hans Schmidt. The simple replace("@" + old,
"@" + new) hinges on the leading @-and-space anchor: "@Hans Müller" does
not appear inside "@Hans-Peter Müller" (hyphen interrupts), so only the
standalone mention rewrites. Sidecar mirrors the same — Hans Müller's
entry flips to Hans Schmidt while Hans-Peter Müller's entry is preserved.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Save a block with no sidecar entries, fire a rename event for an unrelated
person, and assert the block reloads with its original text and empty
sidecar. Confirms findByMentionedPersons_PersonId returns an empty list and
the saveAll path does not accidentally touch unrelated rows.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Synchronous @EventListener consumer of PersonDisplayNameChangedEvent.
Finds every block whose sidecar references the renamed person via the
derived query, replaces "@OldName" with "@NewName" inside block.text, and
updates the matching PersonMention.displayName in the sidecar list. saveAll
in one batch; SLF4J info log records the audit line.
Synchronous on purpose: the rename and the propagation must commit as one
transaction so a half-applied rewrite never reaches the archive. If the
archive grows past tens of thousands of blocks, switch to
@TransactionalEventListener(AFTER_COMMIT) + @Async.
Adds PersonService.existsById to give the listener a layered way to verify
the personId still corresponds to a real Person — defensive guard for any
future async refactor where an event could outlive the entity. The check
goes through PersonService rather than PersonRepository to honour the
"services never reach into another domain's repository" rule.
Happy-path @DataJpaTest + Testcontainers asserts a single-block, single-
mention rewrite mutates both the text and the sidecar entry. blockRepository
.flush() is called explicitly so saveAll is committed before em.clear() —
in production the surrounding @Transactional flushes on commit; in test we
substitute by flushing manually.
Implements PR-A tasks 13 and 15 as one red→green cycle.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spring Data resolves the method name to a join over
transcription_block_mentioned_persons, returning every block whose sidecar
contains the given personId. The B-tree index on person_id (V56) keeps the
lookup O(log n) — required for the rename propagation that fans out to
every block referencing the renamed person, and for the future
"show all blocks mentioning person X" query on the person detail page.
The underscore between MentionedPersons and PersonId is the explicit
property-boundary form, immune to ambiguous longest-match parsing if the
embeddable later gains another nested object.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two regression guards on the "iff different" semantics in updatePerson.
Person.alias and Person.notes are not part of getDisplayName() — they live
outside DisplayNameFormatter — so changing only those fields must not fire
PersonDisplayNameChangedEvent. If a future refactor accidentally pulls
either field into the display name (or trips the comparison), these tests
catch it before transcription blocks get rewritten with stale "@OldAlias"
text.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PersonService now emits a domain event whenever Person.getDisplayName()
flips during an update. The snapshot is taken before the setter chain so we
compare like-for-like against the post-save value, and the event only
publishes when the two strings differ.
The test captures the published event via ArgumentCaptor and asserts the
title flip from "Herr" to "Frau" reaches the publisher with the correct
personId, oldDisplayName, and newDisplayName. Title participates in
DisplayNameFormatter, so this is the canonical case for "rename triggered
by something other than first/last name."
Implements PR-A tasks 9 and 10 as one red→green cycle (the test drove the
production change). Subsequent commits cover the negative cases (alias /
notes only) and the propagation listener that consumes the event.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Regression guard for the @NotNull on PersonMention.personId paired with
@Valid on the DTO field. The wiring was added in the previous commit; this
test ensures dropping either annotation in the future causes a loud test
failure rather than silently allowing payloads with no personId to reach
the service layer (where the listener relies on the UUID being present).
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires @Valid on the @RequestBody parameter of TranscriptionBlockController's
createBlock and updateBlock methods so JSR-303 actually fires for incoming
DTOs. With @Valid on the field-level mentionedPersons in the DTO (added in
the previous commit), Jakarta validation now recurses into each
PersonMention element and rejects displayName values past the @Size(max=200)
ceiling.
The test posts a 201-char displayName and asserts the global handler maps
the resulting MethodArgumentNotValidException to 400 + code:VALIDATION_ERROR.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CreateTranscriptionBlockDTO and UpdateTranscriptionBlockDTO gain a
List<PersonMention> mentionedPersons field. @Valid is on the field itself,
not just on the controller method, so JSR-303 recurses into the list
elements when the controller boundary calls @Valid on the @RequestBody. The
collection defaults to an empty ArrayList via @Builder.Default; existing
constructor call sites in TranscriptionServiceTest are extended with
List.of() to match the new @AllArgsConstructor signature.
The controller-side @Valid wiring lands in the next commit alongside the
length-201 validation test.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@DataJpaTest + Testcontainers exercises the V56 migration plus the
@ElementCollection wiring end-to-end. Saves a block with two PersonMention
entries, clears the persistence context, reloads, asserts both entries
return with their personId + displayName intact. Second test guards the
@Builder.Default — a block without explicit mentions reloads with an empty
list, not null.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ElementCollection(LAZY) on List<PersonMention>, mapped to V56's
transcription_block_mentioned_persons via explicit @CollectionTable that
matches the migration name byte-for-byte (immune to Hibernate naming-strategy
changes). @Builder.Default keeps the field initialized to an empty list, so
existing transcription block construction stays untouched.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Carries personId + oldDisplayName + newDisplayName so transcription-side
listeners can rewrite block.text and sidecar entries when a person is
renamed. First custom application event in this codebase — the only prior
@EventListener consumes Spring's built-in ApplicationReadyEvent. Class doc
sets the convention for future cross-domain decoupling.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Value object held in TranscriptionBlock.mentionedPersons via @ElementCollection.
Carries the personId UUID (so renamed persons can be located) and the
displayName text (so block.text rewrites match exactly via "@" + name). Both
fields are non-null; displayName capped at 200 chars to match the V56 column
and bound the rename propagation cost.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Child table for @-mentions inside transcription block text. Each row binds
one block to one person via personId + displayName; the literal "@DisplayName"
stays in block.text. No FK on person_id so deleted persons degrade gracefully
to plain unlinked text rather than cascade-deleting the block. Indexed on
person_id for the future "blocks mentioning person X" query and on block_id
for the @ElementCollection load.
Schema choice diverges from document_comments.comment_mentions (many-to-many
to AppUser): the latter cascades, this one degrades. Mirrors the established
UserGroup.permissions / group_permissions @ElementCollection pattern.
Refs #362
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spring deserializes the enum directly; invalid values are caught by the
HttpMessageNotReadableException → 400 handler added in 99d00537, returning
a structured VALIDATION_ERROR. The manual parseType() helper is therefore
redundant and removed. Tests updated to construct requests with the enum.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the 86-line duplicated inline add-relationship form with
<AddRelationshipForm onSubmit={handleAddRelationship}>. The {#key node.id}
wrapper resets the form's open state when the selected tree node changes.
Year inputs now have <label> elements (WCAG 1.3.1) via the shared component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When onSubmit is provided the form has no server action and calls the
callback with typed RelFormData instead. Uses a shared {#snippet} for
the form body so the two submission paths share one template.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Proves the in-memory filter correctly drops edges where one Person is
not in findAllFamilyMembers(), preventing non-family relationships from
leaking into the graph.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CSS box-shadow rings (focus-visible:ring-*) are invisible inside SVG.
Replace with a conditional <rect> drawn at -3px offset that renders in
all browsers. Name font-size bumped from 14 to 16px for the 60+
transcriber audience (WCAG readability, Leonie medium concerns).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The focus deep-link is a one-time load param — $derived + $effect caused
a deferred write that left the node unselected on first paint. Initialising
$state inline reads the URL once at component mount with no reactive cycle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8 hops covers great-grandparents ↔ great-great-grandchildren and second
cousins — the practical horizon for a 1899–1950 archive. Prevents future
blind tuning of the constant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All four tests skipped with a reference to issue #363 which tracks
adding the Playwright Chromium install + Docker Compose startup step
to the CI workflow. Remove the skip once #363 is resolved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The <span> in the derived-relationships list is replaced with <a href>
so keyboard and pointer users can navigate directly from the edit card,
consistent with PersonRelationshipsCard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
h-8 w-8 (32px) replaced with h-11 w-11 (44px) to meet the minimum
touch target for the 60+ transcriber audience. Test added to prevent
regression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hardcoded 'Stammbaum & Beziehungen' heading replaced with
m.stammbaum_relationships_heading(); new key added to all
three message files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes local duplicates of the switch-statement label logic already
exported from $lib/relationshipLabels.ts. Adds two direction-sensitive
tests proving the Elternteil-von / Kind-von branch is covered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @markus/@nora suggestion: makes explicit that the missing
@RequirePermission on read endpoints is intentional — all authenticated
family members may read the family graph; unauthenticated access is still
blocked by Spring Security's anyRequest().authenticated() rule.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @leonie blocker: zoom buttons in /stammbaum had no visible focus
indicator for keyboard users. Applied focus-visible:ring-2 focus-visible:ring-focus-ring
focus-visible:outline-none matching the pattern used on nav links.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @sara blocker: RelationshipControllerTest now has 6 tests covering
the two previously untested @RequirePermission(WRITE_ALL) endpoints. Prevents
silent permission regression if the controller is refactored.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @sara blocker: documents that Spring Security's anyRequest().authenticated()
guards these read endpoints and provides regression protection against accidental
@PermitAll additions in future.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @felix blocker: removes the verbatim duplicate switch+2-line helper
from StammbaumCard.svelte and StammbaumSidePanel.svelte; both now import from
the shared $lib/relationshipLabels helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @felix blocker: both functions were duplicated verbatim in
StammbaumCard.svelte and StammbaumSidePanel.svelte. Now exported from
$lib/relationshipLabels.ts with perspectivePersonId as an explicit param.
8 unit tests added (red→green).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split StammbaumCard from 366 to 196 lines by extracting:
- RelationshipChip.svelte — single relationship list item with optional delete
- AddRelationshipForm.svelte — self-contained add-relationship form with open/close state
Both components have browser-mode spec tests covering rendering and interaction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Raise chip labels from 10px to 12px (text-xs) in StammbaumCard,
StammbaumSidePanel and StammbaumTree SVG text. Widen zoom buttons
from 32px to 44px for senior-audience touch targets.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded German strings "ab {from}" / "bis {to}" in yearRange()
with parameterized Paraglide keys relation_year_from / relation_year_to,
added to all three message files (de/en/es).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getRelationshipBetween now throws DomainException with RELATIONSHIP_NOT_FOUND
instead of ResponseStatusException, so the frontend receives a typed error code.
Removed redundant validateRelationType() guard — RelationshipService.parseType()
already handles this with the same DomainException/VALIDATION_ERROR path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes direct PersonRepository injection from the relationship domain,
routing cross-domain person resolution through PersonService.getAllById()
per the layering rules.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 268px width came from the spec mock; real names plus the
relationship pill ("Eugenie de Gruyter" + "Elternteil") need more
breathing room.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two distinct bugs surfaced once a 3-generation tree was loaded
(Walter+Eugenie → Hans+Clara, Hans married to Hilde with child Lili):
1. Generation BFS was non-iterative. Hilde was visited as a "root"
first, assigning Lili = gen 1, then Hilde was pulled to gen 1 to
match her spouse Hans — but Lili's depth was never recomputed,
leaving her on the same row as her parents. Replaced the BFS with
an iterative longest-path assignment that re-runs (max parent gen
+ 1) and the spouse-shared-row rule together until stable.
2. No spouse adjacency. Hilde (no parents in the graph) ended up in
her own block on the far left, with Hans + Clara to her right and
the spouse line drawn straight across Clara's box. Replaced the
per-parent-set grouping with a block model:
- sibling-blocks group children of the same parent set
- loose spouses attach on the outer edge of their partner's block
- dual-loose spouse pairs merge into one 2-person block
- each block is centred so its parented members' average sits
exactly under the parent midpoint, keeping all connectors at 90°
Adds a regression test for the full Walter/Eugenie/Hans/Clara/Hilde/
Lili scenario (Lili in a deeper row, Hans+Hilde adjacent, no slanted
segments) and rewrites the viewBox tests to be position-agnostic via
a rect-centroid helper that reads the per-node `<g transform>`.
Tracked the eventual move to dagre (multi-marriage / cross-cousin /
~50+ nodes) in #361.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements the inline-edit affordance from
docs/specs/stammbaum-tree-spec.html (section 3): a low-opacity
"+ Beziehung hinzufügen" button below the direct relationships list
expands into a compact form (type select, person typeahead,
optional Von/Bis Jahr inputs, Abbrechen + Speichern). On save the
form POSTs to /api/persons/{id}/relationships, reloads the panel's
own data, and calls invalidateAll() so the tree picks up the new
edge without a hard refresh.
The panel takes a new canWrite prop, plumbed through from the
+layout.server.ts data already exposed on page.data.
Also pins the /stammbaum canvas to the viewport (-my-6 cancels
<main>'s py-6, h-[calc(100dvh-4.25rem)] subtracts the navbar) so
the page no longer overflows below the fold.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aligns the SVG tree with docs/specs/stammbaum-tree-spec.html:
- Node outline: var(--c-primary) at stroke-width=1.5 (was the much
paler --c-line at 1) and selected text uses var(--c-primary-fg)
so it remains readable on the dark/light primary fill
- Spouse line and parent-child line now share the same stroke style;
spouse keeps the midpoint dot (radius bumped to 4.5 per spec)
- When two parents are connected by SPOUSE_OF, draw a single shared
parent-pair → child line from the spouse midpoint instead of two
diverging lines
- ViewBox: enforces a 1200×800 minimum and centers the content so a
single node no longer scales up to fill the whole canvas in the
top-left
- Children are positioned at the average of their parents' x and
packed left-to-right per row, keeping connectors close to vertical
Adds component tests for the centring, the shared parent-pair link
(verified vertical), and the fallback to two lines when parents are
not spouses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the standalone "Beziehung" badge at the bottom of the
metadata drawer's Personen column with small inline pills attached
to each personCard — sender gets labelFromA, the single receiver
gets labelFromB. Matches docs/specs/stammbaum-doc-badge-spec.html.
Drops the now-unused RelationshipBadge component.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- /stammbaum: drop the global py-6 top gap so the page header butts
up against the navbar, matching its full-bleed canvas layout
- person detail: add mt-6 around the document lists so they don't
sit flush against the Beziehungen card
- person edit: add mt-6 to PersonMergePanel so the merge box doesn't
collide with the StammbaumCard above it
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A spouse listed as a direct PersonRelationship was also being
emitted as an inferred SPOUSE chip below, so the same person
appeared twice in the Beziehungen card.
Filter the inferred list against the IDs already shown as direct
edges before slicing the top 5. Added a component test that
renders red without the filter and green with it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both /api/network and /api/persons/{id}/relationships threw
LazyInitializationException when toDTO read Person.getDisplayName():
the read-side service methods aren't @Transactional, so the session
closed before the proxy could initialize.
Eagerly fetch r.person and r.relatedPerson in the two queries used
by these endpoints, keeping the no-@Transactional convention for
read methods.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- frontend/e2e/stammbaum.spec.ts covers four journeys:
1) /briefwechsel still resolves with a 2xx after the nav swap.
2) /stammbaum shows the page heading.
3) /stammbaum renders either the empty state (with the Personenliste
link) or at least one node[role=button] in the SVG.
4) The person edit card surfaces the year-range error when Bis < Von.
- persons/[id]/page.server.spec.ts gains two extra mockResolvedValueOnce
entries per scenario to match the new relationships +
inferred-relationships GETs that the page load now performs.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- persons/[id]/+page.server.ts loads relationships and
inferred-relationships in the existing parallel fetch.
- New PersonRelationshipsCard renders direct chips (mint) and the
top-5 derived chips (grey) on /persons/{id}, both linked to the
other person's page. Empty state shows
"Noch keine Beziehungen bekannt." in muted serif.
- Card sits in the right column above the document lists.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /stammbaum/+page.server.ts loads GET /api/network (already filtered
to family members on the backend) and returns nodes + edges.
- +page.svelte holds the page shell, manages selectedId (with
?focus={id} deep-link support) and zoom state, renders the empty
state when nodes.length === 0 (icon + heading + body + link to
/persons), or the tree + side panel otherwise.
- StammbaumTree.svelte: BFS-based generation assignment from roots,
spouses promoted to the deeper generation so couples sit on the same
row, alphabetical sort within row, simple grid layout. SVG nodes are
role="button" + aria-label="{name}, {birth}–{death}" +
aria-expanded={selected}, with click + Enter/Space activation. Solid
parent→child connectors; mint spouse line with midpoint circle, dashed
if SPOUSE_OF.toYear is set (former spouse). Zoom maps to viewBox.
- StammbaumSidePanel.svelte: lazily loads
/api/persons/{id}/relationships and /inferred-relationships when the
selection changes; shows direct chips (mint), top-5 derived chips
(grey), and a "Zur Personenseite →" link. Escape closes the panel.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New StammbaumCard rendered below the Namensverlauf card on
/persons/{id}/edit:
- Header with "Als Familienmitglied" toggle (form action
toggleFamilyMember → PATCH /api/persons/{id}/family-member).
- "Erscheint im Stammbaum" banner with deep-link to
/stammbaum?focus={id} when familyMember is true.
- Direct relationships list grouped by type, then year. Chip text is
direction-aware: storage subject reads "Elternteil von", storage
object reads "Kind von" (new relation_child_of i18n key in all 3
locales). Symmetric and non-family types use their own keys.
- + Beziehung hinzufügen reveals an inline form with type select
(grouped Familie / Sozial), a PersonTypeahead with the new
excludePersonId prop (self-rel prevention, Elicit blocker 1), and
Von / Bis year fields.
- Year validation lives client-side via $derived: empty/empty is OK,
Bis < Von shows a red text-red-700 error wired with aria-describedby
and disables submit (Sara blocker 3).
- Self-rel inline error mirrors the typeahead exclusion in case the
user submits the personId regardless.
- Abgeleitete Beziehungen section (top 5) collapsed by default.
+page.server.ts loads relationships + inferred relationships in the
existing parallel fetch and adds three actions: toggleFamilyMember,
addRelationship (with year-range guard), deleteRelationship.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New presentational RelationshipBadge component (labelFromA → arrow →
labelFromB) wired into DocumentMetadataDrawer's Personen column,
rendered after the receivers block when both endpoints are family
members.
- DocumentTopBar gains an optional inferredRelationship prop and
passes it through.
- documents/[id]/+page.server.ts loads the badge: only when sender is
a family member, exactly one receiver, and that receiver is also a
family member; 404 (no path) → null.
- relationshipLabels.ts maps the backend label keys (parent/child/...)
to localised strings, so the server load returns badge-ready strings.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both desktop and mobile nav rows now point at /stammbaum and read
m.nav_stammbaum(). The /briefwechsel route stays intact — only the
nav anchor changes.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
openapi-typescript pulled the Stammbaum schemas: Person now has
familyMember (required), plus PersonNodeDTO, NetworkDTO, RelationshipDTO,
InferredRelationshipDTO, InferredRelationshipWithPersonDTO,
CreateRelationshipRequest, FamilyMemberPatchDTO. Routes:
/api/network, /api/persons/{id}/relationships,
/api/persons/{id}/inferred-relationships,
/api/persons/{aId}/relationship-to/{bId}, and the family-member PATCH.
Test fixtures in PersonMultiSelect, briefwechsel page, and DocumentList
specs gained familyMember: false where they otherwise typed Person
end-to-end. Pre-existing "missing lastName/personType" fixture errors
in DocumentRow.spec are out of scope.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Seven endpoints in one controller, two roots:
- GET /api/network → NetworkDTO
- GET /api/persons/{id}/relationships → List<RelationshipDTO>
- GET /api/persons/{id}/inferred-relationships
- GET /api/persons/{aId}/relationship-to/{bId} → 200 or 404
- POST /api/persons/{id}/relationships WRITE_ALL
- DEL /api/persons/{id}/relationships/{relId} WRITE_ALL, 204
- PATCH /api/persons/{id}/family-member WRITE_ALL
PersonController is intentionally untouched. Controller-boundary
validation via RelationType.valueOf catches unknown types as 400 before
the service is invoked. FamilyMemberPatchDTO is a one-field record for
the family-member toggle.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add PersonService.setFamilyMember (write, @Transactional) and
findAllFamilyMembers; PersonRepository gains the
findByFamilyMemberTrueOrderBy projection.
- RelationshipService orchestrates PersonService + the inference
service; never reaches into PersonRepository directly. addRelationship
guards self-relationship, year range, circular PARENT_OF (Nora B2),
and DataIntegrityViolation→DUPLICATE_RELATIONSHIP. deleteRelationship
enforces ownership from either side (Nora B1).
- Extend RelationshipDTO with personDisplayName + birth/death year so
the frontend can render rows from either viewpoint.
- 8 unit tests, written against a stub (red), then green: FORBIDDEN
delete, CIRCULAR add, DUPLICATE add, self-relationship, year range,
happy-path persistence, ownership-from-object, RELATIONSHIP_NOT_FOUND.
Full backend suite: 1399/1399 green.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RelationToken enum (UP/DOWN/SPOUSE/SIBLING) with reverse(), and
RelationshipInferenceService with:
- Bidirectional adjacency map: PARENT_OF emits UP and DOWN, SPOUSE_OF
and SIBLING_OF both directions.
- Virtual SIBLING edges derived from shared parents — no SIBLING_OF
row required for siblings to appear.
- BFS with MAX_DEPTH=8.
- 17-entry LABEL_MAP covering parent, child, spouse, sibling, grand*,
great-grand*, uncle/aunt, niece/nephew, great-uncle/aunt, great-niece/
nephew, in-law parent/child, sibling-in-law (both paths), cousin_1.
- "distant" fallback for any path not in LABEL_MAP.
- Two-sided labels via path reversal.
18 unit tests written first against a stub; all 18 confirmed red, then
green after implementation. PersonControllerTest's anonymous DTO updated
for the new isFamilyMember() projection.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RelationType enum (9 values), PersonRelationship entity with
@ToString(exclude = "notes") and LAZY person FKs.
- PersonRelationshipRepository with the network bulk fetch, the
per-person subgraph fetch, and the existsBy check for the circular
PARENT_OF guard.
- Six DTO records: CreateRelationshipRequest, RelationshipDTO,
PersonNodeDTO, NetworkDTO, InferredRelationshipDTO,
InferredRelationshipWithPersonDTO. @Schema(REQUIRED) on every
always-populated field so OpenAPI/TS codegen stays accurate.
- Person entity gains familyMember, PersonSummaryDTO gains
isFamilyMember, both PersonRepository projections select
p.family_member.
- Three new ErrorCodes: RELATIONSHIP_NOT_FOUND, CIRCULAR_RELATIONSHIP,
DUPLICATE_RELATIONSHIP.
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds persons.family_member flag and person_relationships table with
ON DELETE CASCADE on both FKs, no_self_rel check, unique_rel composite,
indexes on both person columns, and partial unique index for symmetric
SIBLING_OF pairs (LEAST/GREATEST trick).
Refs #358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three standalone HTML spec files covering the initial Stammbaum release:
- stammbaum-tree-spec.html — desktop/tablet/mobile tree canvas with side panel, light + dark
- stammbaum-doc-badge-spec.html — inline relationship pill on document detail
- stammbaum-person-edit-spec.html — relationship editor card on person edit page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses three blockers raised in PR #350 review (Felix, Sara, Tobias):
1. Replace all waitForTimeout(400) calls with waitForListbox() which uses
waitForSelector('[role="listbox"]', { state: 'visible' }) — auto-waits
for the debounce to resolve, faster on fast machines and reliable under CI.
2. Remove all conditional if (hasResults) / if (hasDropdown) wrappers.
Tests now use unconditional expect(dropdown).toBeVisible() assertions so
a missing-data condition causes an explicit failure instead of a silent
green run.
3. Replace waitForSelector('[data-hydrated]') with waitForLoadState('networkidle')
in getDocumentEditUrl — the data-hydrated attribute does not exist in the
app markup and would cause a 30s timeout on every test.
4. Extract page: Page type import from @playwright/test and introduce
waitForListbox(page: Page) helper to avoid repeating the selector pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the missing 'ArrowDown from last wraps to first option' test to
close the asymmetric coverage gap noted by Sara (QA) in the review of
PR #350. The ArrowUp backward-wrap test already existed; this test
verifies the % modulo wrap works in the forward direction too.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The dropdown was clipped by parent containers using overflow, transform,
or stacking context via shadow-sm + z-index combinations. Adopts the same
fixed-position strategy as PersonMultiSelect: binds to the input element,
computes position via getBoundingClientRect(), and registers svelte:window
scroll/resize listeners to keep it current.
Also adds full ARIA combobox pattern (role=combobox, aria-expanded,
aria-haspopup, aria-controls, aria-activedescendant) and keyboard
navigation (ArrowDown/Up, Enter, Escape) matching TagInput's reference
implementation.
Removes the now-dead z-30/z-10 z-index workarounds from ConversationFilterBar.
Closes#343
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Confirms that DELETE /api/documents/{id}/annotations/{id} requires at
least ANNOTATE_ALL; a user with only READ_ALL receives 403 Forbidden.
Closes the permission audit raised during PR review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Repositioning from top:-8px/right:-8px to top:4px/right:4px ensures the
44px touch target stays fully within the annotation shape. Annotations drawn
near the top or right edge of the PDF page no longer risk the button being
obscured or inaccessible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the stopPropagation guarantee: clicking the trash button must
not trigger the annotation's onclick (which opens the block detail panel)
while the delete confirm is in progress.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without the guard, a failed DELETE (4xx/5xx) was silently swallowed and
annotationReloadKey was incremented anyway, leaving the annotation visible
and the user with no feedback. Now matches the deleteBlock() pattern
immediately above.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#342. The PersonDangerZone collapsible wrapper is removed; PersonMergePanel
is now rendered directly in the edit page with its own red border (border-red-200),
preserving the {#key person.id} state-reset behaviour and the two-step merge flow.
Fix PersonTypeahead mock to use Svelte 5 functional stub (not Svelte 3/4 $$ internals).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the mobile label is aria-hidden and the desktop button container is
display:none (below sm:), mobile screen reader users had no aria-current
indicator. Added a sr-only span with aria-current="page" that stays in
the AT tree at all breakpoints regardless of CSS display state.
On desktop the active page button also carries aria-current — both
announce the same page information, which is acceptable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The mobile 'Seite X von Y' span had aria-current='page', which created two
elements announcing the current page on wide screens: the hidden mobile label
and the active desktop button. On sm:+ screens the mobile span is display:none
(removed from AT tree), but on small screens both the span and the desktop
button were redundant.
Replace aria-current with aria-hidden='true' on the mobile label so AT always
relies on the desktop button's aria-current. Updates spec test accordingly and
adds a second assertion in a broader test context (Decision Queue #1).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces position-based key `i` with `entry === null ? 'ellipsis-' + i : entry`
so DOM reconciliation is stable when the window shifts (Decision Queue #2).
The index-based key was masking a duplicate-push bug in pageWindow: when
windowStart === first+1 or windowEnd === last-1, the loop already included that
number, causing Svelte to throw `each_key_duplicate` once stable keys are used.
Fixed the bridge-page conditions to use first+2 / last-2 thresholds so the loop
and the bridge branches never push the same page number.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renames 'page button buttons' → 'page buttons container' (Decision Queue #3).
Adds 'renders both pages without ellipsis when totalPages is 2' to cover the
boundary between the 1-page (hidden) and full-ellipsis-window cases (Decision Queue #5).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an ellipsis-style numbered page button row (1 … 4 5 6 … 12) to
Pagination.svelte. Buttons are hidden on mobile (sm: breakpoint) and fall
back to the existing prev/next layout. Active page uses brand-navy
background. Client-side clamping via makeHref(entry - 1) satisfies AC3.
i18n key pagination_page_button added for de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ProgressRing used text-accent (#a1dcd8) on a percentage text label —
same WCAG 2.1 AA failure as #341. Switched to text-primary.
Also adds ESLint no-restricted-syntax rule (scoped to *.svelte files) that
blocks future text-accent usage in JavaScript string literals inside Svelte
class expressions. The rule caught both violations at once; both are now fixed.
The rule is scoped to .svelte files so test assertions against 'text-accent'
strings in .spec.ts files are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes WCAG 2.1 AA contrast failure (#341): text-accent (#a1dcd8) on light
PDF control bar was 1.52:1 — well below the 4.5:1 AA minimum. text-primary
resolves to #012851 in light mode (14.5:1) and #a1dcd8 in dark mode (9:1) —
both states pass AA in both themes.
Adds PdfControls.svelte.spec.ts with 5 tests covering toggle visibility,
label strings, and the contrast-safe class assertion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#344
## What was implemented
### Commit 1 — `feat(nav): add cursor-pointer and tooltip to notification bell`
- Extracted `bellLabel` as `$derived` in `NotificationBell.svelte` — eliminates the duplicated inline ternary and keeps tooltip/label in sync reactively
- Added `title={bellLabel}` to the bell `<button>` — native tooltip mirrors `aria-label` in both zero and non-zero unread states
- Added `cursor-pointer` to the bell button's class list
- Added global `button { cursor: pointer; }` rule in `@layer base` of `layout.css` — prevents future regressions (global scope per Decision Queue)
- Added 3 component tests in `NotificationBell.svelte.spec.ts`: cursor-pointer class present, title equals aria-label when unread=0, title equals aria-label when unread=3
### Commit 2 — `fix(nav): replace hardcoded ThemeToggle title with Paraglide i18n keys`
- Added `theme_toggle_to_light` / `theme_toggle_to_dark` keys to `de/en/es` messages
- Extracted `themeLabel` as `$derived` in `ThemeToggle.svelte` and bound both `aria-label` and `title` to it
- Fixes the pre-existing hardcoded English strings (`'light mode'` / `'dark mode'`) per Decision Queue resolution
Touch target size was descoped per the Decision Queue.
## Decision Queue resolutions (from issue #344)
- **cursor-pointer scope**: global via `@layer base` ✅
- **ThemeToggle scope**: fixed in this issue ✅
- **Touch target**: descoped ✅
## Test results
All 5 `NotificationBell` tests pass.
Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/351
Regression guards verifying that Spring Security returns 401 (not 200) when
no credentials are provided, complementing the existing 403 permission tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the wait+clear cycles that existed only to drain the audit events
emitted by createUserOrUpdate(null, ...). Timeouts increased 5 → 10 s to
reduce CI flakiness under load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
createUserOrUpdate(UUID actorId, ...) is always called from the controller with
a real authenticated actor. createUserForBootstrap() handles seeding/test setup
without emitting an audit event, making the two contracts unambiguous.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates a real actor user first (needed for audit_log FK constraint),
then creates and deletes a target user, asserts USER_DELETED is newest
and USER_CREATED is second via findRecentUserManagementEvents.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds findRecentByKinds JPQL query to AuditLogQueryRepository and
findRecentUserManagementEvents(int limit) to AuditLogQueryService,
returning the N most recent USER_CREATED/USER_DELETED/GROUP_MEMBERSHIP_CHANGED
events ordered newest-first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds actorId param to adminUpdateUser(), captures beforeGroups before
mutation, computes added/removed group names, emits logAfterCommit only
when the group set actually changes. Payload contains group names, not
permission strings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds actorId param to deleteUser(), captures email before deletion,
emits logAfterCommit(USER_DELETED) with userId+email in payload.
Updates UserController to resolve and pass actorId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED to AuditKind.
Injects AuditService into UserService; changes createUserOrUpdate to
accept actorId and emits logAfterCommit(USER_CREATED) only on the
new-user branch. Updates UserController to resolve and pass actorId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds jsonPath("$.code").value("INVALID_PERSON_TYPE") to verify the full
error response shape, not just the HTTP status.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
validatePersonFields now returns a PersonValidationKey instead of a
hardcoded German string. resolveValidationMessage() translates the key
through Paraglide so English and Spanish locale users no longer see
German error text. Adds validation_last_name_required and
validation_first_name_required to all three message files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes four independent PersonType type declarations and the duplicated
TYPES/PERSON_TYPES arrays. normalizePersonType moves from the edit route
module into the shared lib so page.server.test.ts no longer imports from a
route. Both server actions now use normalizePersonType for personType
extraction instead of an inline type cast.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
radioGroupNav now accepts an onChange callback; PersonTypeSelector passes
select() as the callback so ArrowLeft/Right navigation updates the hidden
input value. aria-live region starts empty and announces only on user
interaction (fixes initial page-load announcement).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New src/lib/person-validation.ts exports validatePersonFields (pure function)
- 8 unit tests covering: valid PERSON, lastName missing/undefined,
firstName missing/undefined for PERSON, non-PERSON types without firstName
- Both edit and new-person server actions now call the shared helper instead
of inline if-chains, making the logic testable and non-duplicated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tests now import from production code instead of a local copy, giving real
regression protection if the inline logic is changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PersonController trims title (both create + update) matching the existing firstName/lastName trim pattern
- PersonControllerTest: verifies title is trimmed before service call (ArgumentCaptor)
- PersonControllerTest: verifies createPerson returns 400 when personType is SKIP
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded brand-navy/brand-sand/white classes with semantic
tokens (bg-primary/text-primary-fg, bg-surface/text-ink, border-line,
ring-focus-ring) so the segmented control adapts correctly in dark mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Words like "Wille" stem to "will" via the German Snowball stemmer, which is
also a German stop word. The prefix-transform step (websearch_to_tsquery text →
regexp_replace → to_tsquery) was passing already-stemmed lexemes back through
the German dictionary, causing them to be silently dropped as stop words. Using
the 'simple' configuration skips stop-word processing entirely while the
tsvector @@ tsquery comparison still works because lexemes are matched by
string value, not by configuration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move result count, bulk-edit button, and new-document link into a shared
flex row so they appear on the same line. Adds an edit icon to the
bulk-edit button to visually match the existing plus icon on the add link.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix C2 — `BatchMetadataRequest` controller now uses `@Valid` so future
@Size/etc. annotations on the record actually fire.
Felix C3 — Auto-clear `$effect` in `+layout.svelte` reads
`bulkSelectionStore.size` inside `untrack()` so the effect only re-fires on
route change, not on every checkbox toggle.
Felix C4 — `BulkDocumentEditLayout` edit-mode hydration loop now lives
inside `onMount` (not at top-level script) so the SvelteMap mutation is
unambiguously tied to instance lifecycle, matching the pattern used by
`WhoWhenSection`/`DescriptionSection` after the cycle-2 fix.
Felix C5 — Replaced fully-qualified `java.util.LinkedHashSet` in
`DocumentController` with a top-of-file import.
Sara coverage — six new spec files / blocks pin the cycle-1 and cycle-2
behaviours that were previously untested:
- `WhoWhenSection.svelte.spec.ts` — onMount seeding from initialDateIso /
initialLocation; doesn't stomp parent-bound dateIso; hideDate / editMode
branch
- `DescriptionSection.svelte.spec.ts` — onMount seeding from initialTitle /
initialDocumentLocation; doesn't stomp parent-bound values; archive-box /
archive-folder fields visible only in editMode
- `BulkSelectionBar.svelte.spec.ts` — Esc-scope guard tests for `<dialog>`
open and `aria-expanded` popover present
- `BulkDocumentEditLayout.svelte.spec.ts` — topbar reads
"Massenbearbeitung" + "werden bearbeitet" in edit mode (not the
upload-flavoured "hochladen"/"werden erstellt" copy)
- `DocumentControllerTest.patchBulk_returns400_whenArchiveBoxExceeds255Chars`
— pins the @Size validator on archiveBox via the @Valid wiring
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix B1 (data-loss regression on /documents/[id]/edit) — DocumentEditLayout
still passes initialDateIso, initialLocation, initialDocumentLocation, but
my cycle-1 cleanup removed those props. Result: existing values rendered
empty and a save would have overwritten them with "". Restored the props
on WhoWhenSection and DescriptionSection; initialisation now lives in
onMount so it runs exactly once and never stomps a parent-driven update on
a later prop change.
Felix B2 — `DescriptionSection.svelte:36` still had the top-level
`currentTitle = untrack(() => initialTitle)` mutation that I cleaned up in
WhoWhenSection but missed here. Same onMount-once treatment.
Leonie B5 — `enrich/+page.svelte:105` referenced `<BulkSelectionBar>` but
the import was lost in a prettier pass; svelte-check errored out and the
bar never rendered, leaving an 8 rem dead zone from the pb-32 reservation.
One-line fix: add the import.
Leonie B6 — Esc handler in `BulkSelectionBar` was unscoped and stole
Escape from NotificationBell, ConfirmDialog, HelpPopover, etc. (e.g.
selecting docs → opening notification bell → Esc would close the bell
AND silently wipe the selection). Now bails when an open dialog,
expanded menu, or popover is detected.
Elicit C1 — `BulkDocumentEditLayout` topbar now branches on `mode`:
shows "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode
instead of the upload-flavoured "Mehrere Dokumente hochladen" + "werden
erstellt" copy. New i18n keys `bulk_edit_topbar_title` and
`bulk_edit_count_pill` in DE/EN/ES.
Tests added:
- DocumentControllerTest.patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages
(Sara C2 follow-up — pin sanitizeForLog as a regression test)
- BulkSelectionBar.spec — count=1 → "1 Dokument", count=2 → "2 Dokumente"
(Sara C6 follow-up — pin the new bulk_edit_n_selected_one/_other branch)
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Markus #3 / Felix B2 — kill the duplicated spec-chain across
findIdsForFilter and searchDocuments, and centralise the
"name string → Tag (find or create)" loop that updateDocumentTags and
applyBulkEditToDocument were each carrying their own copy of.
`buildSearchSpec` is the single source of truth for the seven-spec chain
(text + date range + sender + receiver + tags + tag-prefix + status). Both
callers do their own FTS short-circuit, then delegate.
`resolveTags` is the single source of truth for trimming, blank-skipping,
and find-or-create through TagService. Both updateDocumentTags (replace
semantics) and applyBulkEditToDocument (additive merge) consume it.
No behaviour change. All 231 backend tests still green.
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix C4 — bulkSelectionStore is module-singleton; before this change it
silently followed the user from /documents to /persons / /admin / etc.,
then reappeared as a stale count when they wandered back. Root +layout.svelte
now watches page.url.pathname and clears the store the moment the user
leaves the two routes that surface BulkSelectionBar.
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Elicit C1+C3 — bulk-selection count uses ICU-style plural keys
(bulk_edit_n_selected_one / _other) so n=1 reads as "1 Dokument" instead
of "1 Dokumente". Save CTA in edit mode reads "Anwenden" via the existing
bulk_edit_save_button key; UploadSaveBar grew an editMode prop. Multi-
chunk progress text is now visible (not aria-only).
Felix C2 — bulk-edit page wires the backend error code through
parseBackendError + getErrorMessage instead of falling back to a generic
internal_error.
Felix C5 — editAllMatching no longer swallows fetch failures: the button
shows an inline error with the backend-mapped message (e.g. when the
filter cap is exceeded).
Leonie C8 — replace the literal "…" loading glyph on /documents/bulk-edit
with a spinner + role=status + aria-live=polite + visible "Loading
documents…" text.
Leonie C9 — partial-failure card and bulk-edit page error card now use
the design-system `text-danger` / `bg-danger/10` / `border-danger/40`
tokens (dark-mode safe) instead of raw red palette values.
Leonie C10 + C13 — German plural fixed; EN badges retensed
("+ added" → "+ will be added", "replaced" → "will replace") to match
the future-tense intent of DE/ES.
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tobias C2 — DocumentBulkEditDTO carries @Size guards on tagNames (max 200
entries × 200 chars), receiverIds (max 200), and the three location strings
(max 255 chars each). Controller now uses @Valid on @RequestBody so they
fire. The 500-cap on documentIds stays as a controller-level check (typed
BULK_EDIT_TOO_MANY_IDS code, not generic VALIDATION_ERROR).
Markus #7 — replace fully-qualified type names inside DocumentService with
imports (DocumentBatchSummary, DocumentBulkEditDTO).
Markus #8 — @Transactional(readOnly = true) on findIdsForFilter and
batchMetadata. Both are pure read paths; the marker lets Hibernate skip
dirty-checking on the loaded entities.
Record conversion of DocumentBulkEditDTO (Markus #6 / Felix #3) deferred
to a follow-up — keeping @Data avoids 10+ test bodies that mutate the DTO
via setters; the inconsistency is documented in the DTO's class-level
Javadoc.
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
B1 — i18n the archive-box / archive-folder labels and add helper text.
Karton/Mappe were hardcoded German and broke EN/ES locales (WCAG 3.1.2).
B2 — drop the hardcoded German aria-label on the onboarding callout.
role="note" + the visible localised text is self-describing; the redundant
label was overriding the translated content for AT users on EN/ES.
B3 — Escape clears the bulk selection while the bar is visible. Adds an
"Esc: Auswahl aufheben" hint visible at ≥ sm (WCAG 2.1.1).
B4 — /documents and /enrich reserve pb-32 when the bulk-selection bar is
visible so it doesn't occlude the last row or pagination (WCAG 1.4.10).
Folded in three Leonie quick-concerns:
- C5: badge text-[10px] → text-[11px], raw text-gray-600 →
design-token text-ink-2 (dark-mode safe)
- C7: aria-live="polite" on bulk-selection-count
- C11: "Alles aufheben" → "Auswahl aufheben" (DE/EN/ES) — disambiguates
from "discard the operation entirely"
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix B1 — `WhoWhenSection.svelte:37` and `DescriptionSection.svelte:42`
mutated $bindable props at top-level script scope, seeding them from
`initial*` companion props that no caller ever passes. The pattern stomps
parent-owned state in any future component re-evaluation.
Removed the dead initialDateIso / initialLocation / initialDocumentLocation
props and let the bindables carry their own initial value. dateDisplay and
currentTitle now seed from the bindable directly inside untrack — no
re-assignment required.
Elicit B2 — In edit mode the file map IS the user's bulk selection, so
discarding must clear bulkSelectionStore and bounce back to /documents,
otherwise the user is left on /documents/bulk-edit with an empty form
and a stale count in the bottom bar.
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses Markus B1+B2, Nora C1+C4+C5, Tobias #1, Sara B1+B2+C2, Elicit S2+C4
from the cycle 1 review on PR #331.
Audit / version trail
applyBulkEditToDocument now takes actorId, calls
documentVersionService.recordVersion(saved), and emits an
AuditKind.METADATA_UPDATED event tagged source=BULK_EDIT — restoring parity
with the single-doc updateDocument path.
Caps
/api/documents/batch-metadata: 500-ID cap (matches PATCH cap)
/api/documents/ids: 5000 result cap with BULK_EDIT_TOO_MANY_IDS on overflow
Permission tightening
/api/documents/ids re-gated WRITE_ALL — its only consumer is the bulk-edit
fast path (least-privilege per Elicit S2 + Nora's defence-in-depth).
Audit log
/ids and /batch-metadata now emit one log.info per call, mirroring the
quickUpload + bulkEdit format.
Robustness
Duplicates in PATCH documentIds are de-duplicated via LinkedHashSet so a
double-clicked "Alle X editieren" cannot inflate the updated count.
log.warn lines that interpolate Throwable.getMessage() now run through a
CRLF-strip helper (CWE-117).
Tests added
applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit
patchBulk_acceptsExactly500Ids_atTheCap (off-by-one fence)
patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount
getDocumentIds_returns403_forUserWithoutWriteAll
getDocumentIds_returns400_whenResultExceedsFilterCap
batchMetadata_returns403_forUserWithoutReadAll
batchMetadata_returns400_whenIdsExceedsCap
All 231 backend tests green.
Refs #225, PR #331
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Production bug — the backend serialises the document UUID as `id`, but
BulkEditEntry typed it as `documentId`. The runtime cast in /documents/
bulk-edit/+page.svelte was a TypeScript lie: every `entry.documentId`
became undefined, the SvelteMap collapsed all selections under the
undefined key, and the PATCH fired with `documentIds: []` (which the
controller correctly rejected with 400). Field semantics ACs could
therefore never fire end-to-end.
Renamed `BulkEditEntry.documentId` → `id`. The FileEntry built from each
summary still carries both `id` (local map key) and `documentId` (PATCH
payload) so the save handler is unchanged.
Reported by Elicit (B1) on PR #331.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five Playwright scenarios on the bulk-edit feature:
- sticky bar appears with count when checkboxes are toggled
- Alles aufheben hides the bar
- Massenbearbeitung navigates to /documents/bulk-edit and the edit-mode
onboarding callout is rendered
- direct navigation to /documents/bulk-edit with no selection redirects back
- the same bar drives /enrich (skipped when the test DB has no incomplete docs)
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server load redirects READ_ALL-only users (or unauthenticated) to /documents.
Page load: onMount reads bulkSelectionStore — redirects to /documents when the
store is empty, otherwise POSTs the IDs to /api/documents/batch-metadata and
hands the resulting summaries to BulkDocumentEditLayout in mode="edit".
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New FieldLabelBadge component (additive / replace variants, WCAG AA contrast)
- WhoWhenSection: hideDate prop, editMode prop renders badges next to sender
and receivers, hides the meta_location field
- DescriptionSection: editMode prop renders badges next to tags and archive
fields; new bindable archiveBox / archiveFolder inputs only in editMode
- PersonTypeahead: optional badge prop forwards to FieldLabelBadge
- FileSwitcherStrip FileEntry: file is now optional, documentId added so
edit-mode entries reference an existing document by UUID
- BulkDocumentEditLayout: mode prop branches drop zone / read-only title /
callout / save handler. Edit save chunks 500 IDs per PATCH, stops on chunk
failure with retry, marks per-document errors as chips, clears the bulk
selection store on full success.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BulkSelectionBar component: sticky bottom bar shown only when canWrite
and selection is non-empty. Buttons meet WCAG 44px touch targets and
iOS safe-area inset is honoured.
- Bar mounted on /documents and /enrich.
- Alle X editieren button on /documents replaces the selection with
every UUID matching the active filter (via /api/documents/ids) and
jumps to /documents/bulk-edit.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each row in the document search list and the enrichment queue gets a
WCAG-compliant (44px touch target) checkbox bound to bulkSelectionStore.
Checkbox click does not trigger the row's stretched-link navigation —
it sits inside the z-10 content sibling, the link is in the z-0 sibling,
so click events do not bubble between them.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Module-singleton live accumulator: selection persists across pagination
and route changes within /documents and /enrich. Cleared on successful
bulk save or via Alles aufheben.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 14 new Paraglide keys in de/en/es for the bulk-edit UI strings (selection
bar, callout, badges, save progress, retry, error)
- BULK_EDIT_TOO_MANY_IDS added to errors.ts type union and getErrorMessage()
- Regenerated api.ts now includes /api/documents/{bulk,batch-metadata,ids}
and the DocumentBulkEditDTO / BulkEditResult / DocumentBatchSummary schemas
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
READ_ALL-gated endpoint returning all document UUIDs matching the same
filter parameters as /search, ignoring page/size. Powers the "Alle X
editieren" fast path so the bulk-edit page can replace the selection
with every match in one round-trip.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
READ_ALL-gated batch endpoint returning lightweight summaries (id, title,
server PDF URL) for the bulk-edit page's left strip. Unknown IDs are silently
dropped — missing previews would be obvious to the user already.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WRITE_ALL-gated batch endpoint that applies a partial DTO to up to 500
documents per request. Per-document failures (DOCUMENT_NOT_FOUND, etc.)
are collected into the response's errors[] without aborting the batch.
Logs an audit line consistent with quickUpload.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per-document atomic mutation method for the upcoming bulk PATCH endpoint.
Tags and receivers merge additively into existing sets; sender and the three
location fields replace only when the DTO field is non-blank. Wrapped in its
own @Transactional so a per-document failure cannot partially mutate other
documents in the outer batch loop.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the request/response shapes for the upcoming PATCH /api/documents/bulk,
POST /api/documents/batch-metadata, and the new error code for the 500-ID cap.
Refs #225
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rule cards now show before→after examples; strikethrough rule input
renders with CSS line-through so the visual context is honest
- Illegible-words rule shows output only — can't represent unreadable
text as readable characters
- Intro drops fictional family names in favour of "egal wer tippt"
- Wikipedia card copy is more direct; link uses icon instead of
parenthetical "(öffnet in neuem Tab)" text
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses getConfirmService() (optional — null fallback when context is absent so
unit tests that don't exercise the discard path need no CONFIRM_KEY context)
and the new bulk_discard_confirm i18n key.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds pointer-events-none left/right gradient fade overlays on the
FileSwitcherStrip track div so mouse-only users can see when more
chips are hidden beyond the visible area. The scrollbar is hidden
(scrollbar-width:none) so gradients are the only overflow signal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Chip label text increased from 11px to 12px (text-xs) and number badge
from 9px to 11px for the 60+ senior audience on laptops/tablets.
After removing a chip via the × button, focus moves to the previous chip
(falling back to the next chip when the first chip is removed) so keyboard
users are not stranded on <body>. Uses Svelte tick() to wait for DOM update.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
save() now wraps each chunk fetch in try/catch — a thrown network error
marks all files in that chunk as errored. Also handles HTTP 200 responses
with a non-empty errors array (partial success): only the named filenames
are marked as errored rather than all files in the chunk. Navigation is
suppressed whenever any file fails.
Tests added:
- network error marks all chunk files as errored, no navigation
- HTTP 200 with errors array marks only affected files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a saving $state flag that blocks re-entry while a chunk upload is
in flight. The UploadSaveBar save button is disabled via a new disabled
prop while saving is true. Tested: clicking Save twice fires fetch only
once.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Senior users on tablets need at least 44×44px touch targets (WCAG 2.2).
Added min-h-[44px] flex items-center px-2 to the discard button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The error-path test (goto not called on failure) had no matching positive
assertion. Added: save() navigates to /documents when all chunks succeed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Generated type had tags?: string but Java DTO declares List<String> tagNames.
Corrected to tagNames?: string[] to match the backend contract.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The ! indicator was aria-hidden with no sr-only fallback, making failed
uploads invisible to assistive technology. Added sr-only span with
bulk_file_error_chip_label before the visual indicator.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
/brand-mint/ never matched (component uses border-accent bg-accent-bg);
companion test also updated to assert the meaningful negative.
getByText('5') fixed to exact:true to avoid strict-mode ambiguity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BulkDropZone: link description <p> to drop zone region via aria-describedby
- UploadSaveBar: add explicit aria-valuenow/aria-valuemin/aria-valuemax to
<progress> element for consistent screen reader support across browsers
- FileSwitcherStrip: add non-color error indicator (red !) to error chips so
error state is not communicated by color alone (WCAG 1.4.1)
- BulkDocumentEditLayout: comment explaining why raw fetch is used instead of
a SvelteKit form action (chunked FormData with per-chunk progress tracking)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
save() was marking the first N files in a chunk as errored (where N = the
error count returned by the backend), but the backend errors are keyed by
filename. A failure for file[2] would incorrectly mark file[0] as the error.
Now builds a Set of error filenames and matches chunk entries by file.name.
Test added: save marks only the file whose filename matches the backend error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All three message files had a bare `<<<<<<< HEAD` at line 814 with no
corresponding separator or closing marker, making them invalid JSON and
breaking the Paraglide build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers four behaviours of applyBatchMetadata that had no coverage:
title applied by list index, sender resolved via PersonService,
tags applied via updateDocumentTags, and title left unchanged when
the fileIndex exceeds the titles list length.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Long filenames caused chips to overflow the strip. Added max-w-[8rem]
and truncate on the title span, plus a title attribute for full text
on hover.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prev/next scroll buttons were 24×20px, below the WCAG 2.2 SC 2.5.5
minimum of 44×44px. Changed to h-[44px] w-[44px].
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sr-only aria-live div was always empty, so screen readers never
announced file switches. Derived activeAnnouncement from the active
entry and bound it to the div's text content.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
<progress> had no accessible name, failing WCAG 1.3.1 and 4.1.2.
Labels it with the already-existing bulk_upload_progress i18n key.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
'Neues Dokument' / 'Neue Dokumente' in BulkDocumentEditLayout topbar
bypassed Paraglide. Added bulk_title_single and bulk_title_multi keys
to de/en/es message files and switched to m.*() calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
goto('/documents') fired unconditionally, discarding error chips and
leaving the user with no feedback on which files failed. Now only
navigates when hadErrors is false after all chunks complete.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tags were silently dropped because the metadata object built in save()
never included a tagNames field; they never reached the backend.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add bulk_drop_desc, bulk_select_files, bulk_drop_zone_label, bulk_remove_file
keys to de/en/es message files
- BulkDropZone: use m.bulk_drop_zone_label(), m.bulk_drop_desc(),
m.bulk_select_files() — removes all hardcoded German
- FileSwitcherStrip: use m.bulk_remove_file() on × button; move aria-live
from <ul> to a dedicated visually-hidden region above the strip (screen
readers now announce changes without coupling the live region to the list)
- Spec: import FileEntry from component instead of re-declaring; use
data-remove-id selector instead of hardcoded German aria-label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
storeDocumentWithBatchMetadata was a 30-line flat method mixing file storage
with metadata hydration. The private helper makes each concern visible at a
glance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces comma-delimited String with a proper JSON array field — callers no
longer need to pre-serialise. Service drops the split/trim/filter step and
passes tagNames directly to updateDocumentTags().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Validation guards (BATCH_TOO_LARGE, titles > files) are domain rules and
belong in the service where they can be unit-tested without the HTTP layer.
Controller now delegates to documentService.validateBatch().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The autofocus prop was added conditionally but still triggered on the
bulk-upload page. Removing it completely — callers that need focus
management can handle it independently.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PersonMultiSelect naturally renders at 44px due to nested padding (outer p-2 + inner p-1).
Apply py-3 px-2 to the date input and PersonTypeahead default mode so all three fields
align visually.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Default mode was text-base (16px) and rounded-md — date field uses text-sm
(14px) and rounded. Aligning these makes Sender/Date/Receiver rows consistent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
min-h-[42px] → min-h-[38px] to match p-2 text-sm input height.
Add shadow-sm (was missing vs date/sender inputs).
focus-within:ring-1 ring-ink → focus-within:ring-2 ring-focus-ring to match
the focus style used consistently across all other form inputs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace JS navHeight measurement with CSS var(--header-height) so the fixed
panel renders in its final position on first paint — no onMount shift.
Add autofocus prop to WhoWhenSection (default true, preserves document-edit
behaviour) and pass autofocus={false} from BulkDocumentEditLayout so the date
field does not steal focus before the user has even dropped any files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drop non-PDF accept types from file input and update format hint strings
in all three languages. JPEG/PNG/TIFF were never officially supported.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drop zone box doubled: max-w-xl, larger icon (80px), bigger padding and text
- Title field wrapped in its own card (matches WhoWhenSection/DescriptionSection)
- Removed double-wrapping outer card around WhoWhenSection + DescriptionSection
- Added space-y-4 between form sections for consistent breathing room
- ScopeCard per-file label: text-accent → text-primary for legible contrast in light theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrites BulkDocumentEditLayout to match the spec exactly:
- Fixed viewport layout (same as DocumentEditLayout) filling viewport below nav
- Split panel visible in all states (N=0/1/≥2) — was fullscreen dark drop zone
- N=0: centered drop-zone-box in left panel; shared form visible but greyed out
- N≥1: real PDF preview via URL.createObjectURL (no server upload required)
- N≥2: FileSwitcherStrip at bottom of left panel; count pill + discard in topbar
- FileEntry gains previewUrl; blob URLs created on add, revoked on remove/destroy
- save() checks response.ok and marks failed files with status: 'error'
- BulkDropZone redesigned: spec-accurate box with circular mint icon, serif title
- FileSwitcherStrip: number badges, arrows, keyboard nav via data-chip-id selector
- ScopeCard, UploadSaveBar: hardcoded German replaced with Paraglide i18n keys
- +page.svelte simplified to bare component render (layout is self-contained)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New type from the bulk-upload metadata part added in #317.
Generated from backend running with --spring.profiles.active=dev.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the single-file form-action flow with BulkDocumentEditLayout,
enabling multi-file drag-and-drop upload with local preview, per-file
title editing, and shared metadata. Server load function unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Save bar with sticky positioning, a determinate progress bar while
uploading chunks, plural save CTA, and a destructive discard link.
Replaces broken ICU plural in bulk_save_cta with two-key approach
(bulk_save_cta_one / bulk_save_cta) since Paraglide 2.5 does not support
ICU plural syntax.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Card container with two variants: per-file (mint tint) and shared (neutral
with file-count badge). Used to visually separate per-file vs shared
metadata sections in the bulk upload layout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Horizontal chip strip for switching between files in a bulk upload session.
Supports keyboard navigation (arrow keys cycle within the strip), error state
chips, and onSelect/onRemove callbacks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full-panel drop target that supports multi-file selection via drag-and-drop
or file picker. Fires onFilesAdded callback with the full File array.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Converts a raw filename into a human-readable title candidate by
stripping the extension and replacing underscore/hyphen runs with spaces.
Reuses the existing stripExtension() helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add DocumentBatchMetadataDTO (titles, senderId, receiverIds, documentDate, location, tags, metadataComplete)
- Add BATCH_TOO_LARGE to ErrorCode
- Extend quickUpload to accept optional @RequestPart("metadata"); dispatches to storeDocumentWithBatchMetadata when present
- Cap batch at 50 files/request; reject 400 when titles.size > files.size
- Add DocumentService.storeDocumentWithBatchMetadata applying shared fields + index-based titles to both created and updated docs
- Raise max-request-size to 500MB (10-file chunk at max per-file size)
- Add structured SLF4J logging for every quickUpload call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hover: on the <span> only fired on the 20×20px visual circle, not the
full 44×44px touch target. Add `group` + `focus-visible:ring-*` to the
outer button; switch to `group-hover:` on the inner span so the visual
response covers the entire interactive area.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- createEmptyDocument now uploads a minimal PDF so the Transkribieren
button is rendered (requires isPdf = true in DocumentTopBar)
- add 'Transcribe coach — with blocks' describe: seeds a block via API,
waits for blocks to settle in read mode, switches to edit, confirms
'Für Training vormerken' is visible
- fix dark-theme axe test: ThemeToggle uses aria-label 'dark mode',
not the previous /Farbmodus|theme/ regex
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The key was orphaned when TranscriptionEditView's empty state was replaced
by TranscribeCoachEmptyState. Removed from de/en/es to avoid accumulating
unreferenced strings. (Felix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
handleAuth in hooks.server.ts is in the sequence() chain and redirects
unauthenticated users at runtime regardless of prerender. Adding a comment
so the next reader doesn't mistake this for a security hole. (Markus/Nora)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wrap page content in <main> so AT users can jump to main content (Nora)
- Closing card "Fehlt eine Regel?" was <h2> after two existing <h2> siblings
but styled like a card title, not a section label; downgrade to <h3> to
fix the heading hierarchy (Sara/Leonie)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace style="grid-template-columns: 34px 1fr; align-items: start;"
with Tailwind grid-cols-[34px_1fr] items-start (Felix: inline styles)
- Add aria-label="Schritt N von 3" on each <li> so screen readers announce
step position when the numeric badge is aria-hidden (Nora/Sara)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Raw hex bypassed the token system and wouldn't remap in dark mode.
Now uses --color-parchment which has a proper dark-mode counterpart.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace one-shot $derived(.matches) snapshot with $state + addEventListener
so the static/animated branch reacts when the user toggles OS reduced-motion
at runtime (Felix: non-reactive media query)
- Replace bg-[#FAF8F1] raw hex with bg-parchment design token so the SVG
background remaps correctly in dark mode (Felix/Markus)
Also update TranscriptionPanelHeader.svelte.test.ts to expect role="region"
after the HelpPopover ARIA fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- role="tooltip" → role="region" + aria-label={label}: tooltip semantics
are wrong for a click-triggered panel (Nora/Sara)
- expand button to 44×44px with inner visual <span>: WCAG 2.5.8 touch
target for 60+ transcriber audience (Sara/Leonie)
- replace Math.random() with module-level counter: SSR/hydration mismatch
when server and client generate different IDs (Felix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds --c-parchment (#faf8f1 light / #041828 dark) to :root and both
dark-mode blocks, exposed as --color-parchment via @theme inline.
Prerequisite for replacing bg-[#FAF8F1] raw-hex in RichtlinienRuleCard
and TranscribeDragDemo.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New coach card replaces the icon+sentence empty state in the Transcribe
panel (edit mode). Three-step guide with 5-s SMIL drawing animation in
step 1 only. Animation freezes at the final frame when
prefers-reduced-motion is active. Footer links to Wikipedia Kurrent and
the Richtlinien page open in new tabs with visible '(öffnet in neuem Tab)'
annotations. 34 new i18n keys in de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Final UI/UX spec for the /hilfe/transkription page referenced from
the Transcribe panel coach card. Card-grid layout with per-rule
Beispiel boxes, Wikipedia info-card, "Noch in Klärung" strip, and
closing invitation. Includes impl-ref tables, Paraglide keys for
de/en/es, print styles, and Gherkin acceptance criteria.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ConversationThumbnail still imported the `$lib/thumbnails` helper that
a02f6cdc deleted, so every SSR render of /briefwechsel crashed with
"Cannot find module '$lib/thumbnails'". Finish that refactor by reading
`doc.thumbnailUrl` straight off the Document DTO (same shape
DocumentThumbnail already uses), and update the spec fixtures to match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Seeds 120 UPLOADED docs with a deterministic date spread and runs
DocumentService.searchDocuments against a Testcontainers Postgres, not
a Mockito mock. Five cases:
1. First page returns exactly page_size items + correct totalElements
2. Last partial page returns the tail slice (offset 100 → 20 items)
3. Page beyond last returns empty content, totalElements still 120
4. SENDER sort path slices in-memory + reports correct total
5. Different pages return disjoint document id sets
Closes the integration-coverage gap between the Mockito unit tests and
the full Spec→Pageable→Page→DTO path that unit tests can't exercise.
Runs in ~87 s against the shared Testcontainers instance. (#316)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
<a aria-disabled="true"> is the documented pattern but screen readers
still announce "Previous, link, disabled" on pagination bounds — noise
users don't need because the disabled state is purely visual. Switching
to <span aria-hidden="true"> removes the bound control from the AT tree
entirely (Leonie's recommendation). Visual parity preserved via a
disabledBase Tailwind class (same layout + cursor-not-allowed + opacity-40).
Tests updated: "disabled prev/next" assertions now check for aria-hidden
and no href — the active-state href/aria-current assertions are
unchanged. (#316)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
triggerSearch (local state, filter change) and buildPageHref (server data,
page nav) were each iterating over the same ~10 filter params. Any new
filter would have had to land in two places. buildSearchParams is now the
single source of truth for which params the /documents URL understands;
both callers just pass their snapshot and an optional targetPage. (#316)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PageRequest.of(0, 10_000) was inlined at ~12 sites across DocumentServiceTest
and DocumentServiceSortTest as an "effectively unpaged" sentinel for tests
that don't care about paging. Extracted to a named constant on each class
so the intent is visible at each callsite and we don't risk copy-paste
drift of the magic number. No behaviour change. (#316)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Frontend side of the /documents pagination work. The page.server.ts load
reads ?page= from the URL, forwards page+size=50 to the backend, and
exposes the new totalElements/pageNumber/pageSize/totalPages fields on
`data`. +page.svelte renders a <Pagination> component below the result
list; buildPageHref preserves every filter param and only updates page.
The existing triggerSearch debounce flow intentionally drops `page`
when any filter changes, so filter edits reset to page 0 automatically.
<Pagination> uses plain <a href> links (not goto) so SvelteKit's default
scroll restoration scrolls new pages to the top — the expected senior-UX
behaviour. Decorative chevrons wrapped in aria-hidden spans, 44px touch
targets, focus-visible ring, stacks vertically under 640px. The control
hides itself when totalPages ≤ 1.
Test coverage: 9 cases on Pagination (label, aria-current, prev/next
enable/disable, makeHref invocation, decorative chevron, touch target),
plus a filter-reset assertion on +page.svelte (page 5 → edit q →
goto URL must drop page=). Adds i18n keys in de/en/es. Manual edit to
api.ts pending a post-merge npm run generate:api against a rebuilt
dev backend. (#315)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds 5 dedicated controller cases — paging fields exposed on the JSON,
rejections for size>100 / size<1 / page<0 / page>100000, and a
captor assertion that the built PageRequest is forwarded to the service.
The size>100 case is the load-bearing guard on @Validated at
DocumentController — removing the annotation silently reopens the DoS
window this PR is meant to close.
Adds 5 service cases — fast path uses findAll(Spec, Pageable) (not Sort),
propagates page+size to the DB, carries totalElements/totalPages/
pageNumber/pageSize back on the result, and for SENDER sort slices in
memory and reports the pre-slice total. Page-beyond-last returns empty
content with a correct totalElements (JPA edge case). (#315)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fast path (DATE/TITLE/UPLOAD_DATE) pushes sort + paging into the DB via
findAll(Specification, PageRequest) and enriches only the returned slice
— 30× cheaper than enriching all 1500 matches when the user is only
going to see 50. In-memory sort paths (SENDER/RECEIVER/RELEVANCE) keep
their LEFT JOIN-friendly sort but now slice in-memory too, so enrichment
still runs against the page slice only.
Controller passes PageRequest.of(page, size) built from @RequestParam
values. Plan-level "add @Validated" prerequisite comes in the next commit.
All existing tests updated mechanically to pass a pageable argument
(PageRequest.of(0, 10_000) as an "effectively unpaged" sentinel). Stubs
that previously matched findAll(Specification, Sort) for the fast path
now match findAll(Specification, Pageable) with PageImpl<>.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rename `total` → `totalElements` for Spring-Page parity and add three new
required paging fields: pageNumber, pageSize, totalPages. Adds a `paged(
slice, pageable, totalElements)` factory alongside the existing single-page
`of(list)` shortcut. Enables offset pagination of /documents search (#315).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two specs for extending issue #294 with bulk uploads:
- bulk-upload-concepts.html — three concepts (stack, split-panel
with file switcher, progressive accordion) with a decision
matrix and the Concept B recommendation.
- bulk-upload-split-panel-spec.html — refined final spec for
Concept B. Covers all three states (N=0 empty · N=1 single ·
N≥2 multi) across 320 / 375 / 768 / 1280 viewports in both
light and dark mode, using the real tokens from layout.css.
Includes impl-ref tables for every new surface, Paraglide keys
in de/en/es, component tree, and backend contract.
The polymorphic-state model means /documents/new is a single
route: N=1 is byte-identical to #294, N=0 shows a whole-panel
drop zone with bulk-first copy, N≥2 grows a file-switcher strip
under the PDF preview plus a two-card form split.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
h-16 w-16 looked undersized in the 180×252 strip container (~25% of
the height). h-24 w-24 gives ~38% visual weight, matching the ratio
DocumentThumbnail uses for its lg (120×168) fallback (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Prior coverage only exercised getThumbnailUrl() as a Java method call.
The new case serialises via ObjectMapper and asserts the resulting JSON
contains "thumbnailUrl":"..." so we catch silent breakages in the wire
contract (getter rename, @JsonIgnore, visibility drop) — not just
regressions in the method's return value (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Uses the same heroicon as DocumentThumbnail so the "no thumbnail yet"
signal reads identically across the app: one shape, one meaning. The
parchment SVG still lives on in the fully-empty state (no resume doc
at all), where it represents a different thing — we removed it only
from the "document exists, thumbnail not generated yet" branch (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the generic parchment SVG placeholder with an <img> pointing at
the backend's thumbnail endpoint when the document has one. The 180×252
container matches DocumentThumbnail's 5:7 A4 convention so the
dashboard tile sits visually next to the list/person-sublist tiles
instead of looking squatter than they do. dark:mix-blend-multiply keeps
paper scans from glaring on a dark page background (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The helper had a single consumer (DocumentThumbnail) and its only job
was to compose what the backend's Document.getThumbnailUrl() now
produces. Deleting it locks the single-source-of-truth invariant —
there is no longer a way to build a thumbnail URL on the client (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The backend now exposes thumbnailUrl as a serialised computed property
on Document, so the component drops its dependency on the frontend
URL-builder. PersonDocumentList's inline Doc prop type follows the
same shift (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reflects the new @JsonProperty getter on Document. Kept as a minimal
manual edit rather than a full regen because the running dev backend
belongs to the main workspace and swapping JARs there would be a
side effect on a parallel worktree's state. `npm run generate:api`
will converge on the same shape.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DashboardService now reads the URL from the Document's computed getter
instead of passing null, so the resume strip can display the real
thumbnail of whatever the user was last working on (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@JsonProperty makes the computed getter part of every Document response
Jackson produces, so any DTO returning a Document automatically carries
the thumbnail URL without per-controller plumbing. The accompanying
comment warns future readers that the cache-buster is load-bearing
for the endpoint's `immutable` cache header (CWE-525) (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Matches the shape the frontend previously built via
encodeURIComponent(thumbnailGeneratedAt), so the backend is now the
single source of truth for the thumbnail URL convention (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The no-cache-buster branch covers documents whose thumbnail key is set
but whose thumbnailGeneratedAt is still null — which only happens in
the narrow window between the key being persisted and the async worker
stamping the timestamp (#309).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First TDD step for centralising the thumbnail URL convention on the
Document entity (#309). Adds a stub getter returning null and a test
that locks the "no key → no URL" branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #311 dropped the right/left arrow icons that signalled whether a
letter was sent or received. Readers who don't decode the colored
left border (new users, color-blind users, users at a glance) had
no visual cue for direction. Restore a 20×20 arrow inline with the
title — right-arrow for outgoing, left-arrow for incoming — kept
decorative (aria-hidden) since the aria-label already announces
"Gesendet:" / "Empfangen:".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 168px-tall thumbnail tile was dominating rows where the text
column only rendered at text-xs / text-sm — visually the right
column sat half-empty. Three changes:
- Title: text-sm → text-lg
- Summary: text-sm → text-base
- Meta + tag chips: text-xs → text-sm
And remove the "vor N Jahren" chip entirely. The documentDate
in the meta row already carries the temporal context and the
chip was adding visual noise without new information.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Makes `max` an optional prop with default 3 — the common row-layout
case doesn't need to name the cap explicitly. ThumbnailRow's callsite
drops to `<TagChipList tags={doc.tags ?? []} />`, consistent with how
other shared components in $lib/components expose sensible defaults.
Refs #305
Fixes @leonievoss round-2 follow-up from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds row_direction_sent / row_direction_received keys across the
three locale files (de: Gesendet/Empfangen, en: Sent/Received, es:
Enviada/Recibida) and routes ThumbnailRow's directionLabel through
Paraglide. An English or Spanish screen-reader user now hears
"Sent:" / "Enviada:" in their language, matching the DistributionBar
i18n pass.
Refs #305
Fixes @leonievoss round-2 follow-up from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires briefwechsel-rows.visual.spec.ts against the shared fixture
(seedBilateralPair + cleanupBilateralPair), adds afterAll cleanup,
and folds the conv-person-bar visibility gate into openBilateral()
so both the structural test and the snapshot block fail loudly on
a hero-state regression — matching the a11y spec's safety net.
Refs #305
Fixes @saraholt follow-ups 1 + 2 + 3 from PR round-2 review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lifts the three-API-call seeding (create sender, create receiver,
create document) out of briefwechsel-a11y.spec.ts and into a
dedicated fixtures module. The spec now calls seedBilateralPair()
in beforeAll and cleanupBilateralPair() in afterAll so the test
DB doesn't accrue seeded rows across reruns.
Two caveats captured in the helper docstring: the backend has no
person-delete endpoint (only the document is purged), and the
timestamped last names make leftover persons collision-free.
Refs #305
Fixes @saraholt follow-up 1 + 2 from PR round-2 review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends the seeding pattern from the a11y spec: beforeAll creates two
persons + one document so the page renders the row layout. The
structural test now asserts the ConversationThumbnail tile AND the
DistributionBar are present — a regression that drops to the hero
or breaks the row wiring fails here instead of silently passing a
hero-state check.
Snapshot block stays gated on VISUAL=1 (baselines captured during
review against a seeded backend) so the structural coverage ships
immediately and the pixel-diff coverage ships once baselines land.
Refs #305
Fixes @saraholt blocker 2 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous version navigated to /briefwechsel with no params, which
renders the hero state — axe-core scanned the hero, not the new
ThumbnailRow / ConversationThumbnail / DistributionBar. This commit
seeds two persons + one document via the API in beforeAll, then
drives the URL with ?senderId=X&receiverId=Y so each of the
36 test runs (3 viewports × 2 themes × 2 assertions) actually scans
the intended DOM. Also asserts that conv-person-bar is visible first,
so a regression that drops the page back to hero fails explicitly
rather than silently passing an empty sweep.
Refs #305
Fixes @saraholt blocker 1 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lifts the three-chip-plus-"+N" tag row out of ThumbnailRow into a
standalone TagChipList component so the chip cap + overflow policy
lives in one place and can be reused on other surfaces (document
detail header is a candidate). ThumbnailRow drops from 110 to ~90
lines and no longer owns tag-slicing logic — it just asks for the
list with max=3.
Behavior is byte-identical: same data-testid, same max cap, same
"+N" overflow indicator. All ThumbnailRow row-level tag tests
continue to pass against the new composition.
Refs #305
Fixes @felixbrandt suggestion 1 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
persistThumbnailMetadata was a four-arg method signature that mixed
three conceptually related values. Wrapping them in a private
ThumbnailResult record drops the signature to (Document, result),
mirrors the existing SourcePreview record one step earlier in the
pipeline, and keeps generate() reading as a narrative of small
named outputs rather than positional arguments.
Refs #305
Fixes @felixbrandt suggestion 2 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defaults `now` in $props() destructure so each row instance freezes
its reference time at mount, instead of calling new Date() inside
the $derived every reactivity tick. No behavioural change — the
date math is stable across re-renders for a given row — but drops
the nullish-coalesce dance and is cleaner under Storybook-style
testing where a deterministic `now` is injected.
Refs #305
Fixes @felixbrandt suggestion 3 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
relativeYearsDe already returns "" for future dates (covered in its
own spec), but the integration wiring inside ThumbnailRow was
untested. Adds a regression that a doc with documentDate in the
future produces no "vor N Jahren" or "vor weniger als 1 Jahr" chip.
Refs #305
Fixes @saraholt concern 5 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consolidates the hansPerson / annaPerson fixture into a makePerson()
factory matching the makeDoc convention, adds an assertion that
the bilateral list renders one ConversationThumbnail tile per
document (catches a broken {#each} keying wired around the
DistributionBar), and decouples the DistributionBar aria-label
assertion from the German locale now that i18n lands via Paraglide.
Refs #305
Fixes @saraholt concerns 3 + 4 from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bumps the multi-page badge from text-xs (12px) / px-1.5 py-0.5 to
text-sm (14px) / px-2 py-1. Meets senior-legibility on a 320px phone
without crowding the 120-wide tile — the badge stays tucked in the
top-right corner.
Refs #305
Fixes @leonievoss senior-accessibility concern from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drops the hardcoded German strings ("Briefverteilung in diesem Zeitraum",
"{n} von {name}") and routes every visible + assistive-tech string
through dist_bar_aria and dist_bar_segment message keys. An English
or Spanish user now sees "from" / "de" instead of "von" both on
screen and in the aria-label their screen reader announces.
Refs #305
Fixes @leonievoss i18n concern from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this prefix, a color-blind user or screen-reader user has no
indication of correspondence direction — the colored left border is
information but not announced, and the arrow glyphs were removed in
the earlier layout pass. Prepending "Gesendet:" or "Empfangen:" to
the aria-label gives assistive-tech users the direction first so the
row identity is unambiguous even without color perception.
Refs #305
Fixes @leonievoss WCAG 1.4.1 concern from PR review
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the reasoning behind persisting two scalar columns on
documents rather than deriving aspect client-side or standing up a
thumbnail_metadata table. Also documents the 1.1 landscape threshold,
the null-during-rollout state, and the ordering invariants inside
ThumbnailService.generate().
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a dedicated axe-core sweep for /briefwechsel so contrast or
semantic regressions on the new row layout fail independently of
the catch-all accessibility suite. Scoped to the main landmark so
shared chrome violations (if any) aren't double-reported.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a Playwright spec gated on VISUAL=1 with one snapshot per
(mobile/tablet/desktop × light/dark) = 6 baselines. Snapshots stay
skipped in CI until the baseline set is captured and committed —
running `playwright test --update-snapshots briefwechsel-rows`
against a seeded backend generates them.
Structural check runs unconditionally so the file is wired into CI
today rather than waiting for the baseline capture step.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two new assertions for the extracted DistributionBar — it must
appear in bilateral mode and stay hidden in single-person mode — and
repairs the shared makeDoc fixture: the embedded Person now carries
personType + displayName so the fixture matches the regenerated
Document schema without TypeScript complaints.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the inline row markup, arrow icons, status-dot helper, and the
otherPartyName helper that only fed it. Each visible row is now a
ThumbnailRow, which owns its own aria-label, border color, meta and
tag rendering. The year-divider and "new document" footer are
untouched — they were always intended to stay as timeline chrome.
Also widens the documents prop shape to include the summary, tags
and thumbnail metadata that ThumbnailRow consumes; the backend
already returns these fields via the Document schema so no server
change was required.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Combines ConversationThumbnail with a quote-styled summary, truncated
meta line, and up to three tag chips (the rest collapsed into "+N").
The colored left border tells a reader at a glance whether this
letter left or entered the perspective person's mailbox — replacing
the previous status dot + script-type icons that were too busy for
the list view. Relative-year label ("vor 76 Jahren") is derived from
documentDate so the list carries temporal context without a full
date column.
Rendering rules:
- title falls back to originalFilename when empty
- summary uses a text expression, never {@html}, so inline markup
in the summary field is escaped (XSS regression test locks this)
- focus-visible outline + focus-within hover keep keyboard-only
users in sync with mouse hover feedback
- aria-label always pairs title with the formatted date so screen
readers hear both identifiers
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reads thumbnailAspect from the backend and swaps between a 120×168
portrait tile and a 168×120 landscape tile so postcards and photos
don't get cropped into a portrait frame. Shows a page-count badge
top-right for multi-page PDFs, and a pulsing skeleton while the
async thumbnail job hasn't run yet. URL assembly goes through the
existing thumbnailUrl helper so cache-busting stays consistent
with DocumentThumbnail.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The correspondence timeline labels each row with its distance from today
("vor 86 Jahren"). Uses calendar-field math so the anniversary day
flips exactly — an ms-based 365.25d average misses by a day on leap
years. Invalid / future dates return "" so the caller can hide the
label rather than print "vor 0 Jahren".
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the inline bilateral-distribution markup and the short-name /
percentage helpers that only existed to feed it. ConversationTimeline
now hands senderName, receiverName, and the two counts to the shared
component and lets it own the rendering.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lifts the inline distribution bar out of ConversationTimeline so the
same two-tone ratio widget can be reused on other bilateral surfaces
(e.g. the person detail page). Markup/styling is byte-identical to
the inline version; only the prop interface is new.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the backend entity additions so the frontend row components
can consume the aspect (portrait vs landscape tile) and the page count
(badge on the thumbnail) without any runtime guessing.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Groups the first-page BufferedImage and the source's total page count
into a SourcePreview record so both values travel through generate()
together. PDFs get pdf.getNumberOfPages(); image uploads always get 1
(a scan is one page from the user's perspective). The page badge on
the thumbnail row uses this value to show "1 / N" for multi-page
letters without a separate round-trip.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Computes aspect at generate-time from the loaded BufferedImage: w/h
above 1.1 → LANDSCAPE, otherwise PORTRAIT. The threshold keeps
near-square A4 scans in the portrait tile (ratio ≈ 1.0) rather than
flipping to landscape on a rounding error. Also hardens the pipeline
with an explicit dimension guard so width=0 / height=0 edge cases fail
cleanly instead of dividing by zero when the aspect is computed.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both cases already return FAILED via the existing catch-Exception blocks
in readSourceImage. Pinning the behavior with regression tests before
thumbnailAspect and pageCount computation is added, so a future
refactor that removes the safety net is caught at compile/test time.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds ThumbnailAspect enum (PORTRAIT | LANDSCAPE) and maps the two
nullable columns from V53 as JPA fields so ThumbnailService can
populate them and the API can return them unchanged to the frontend.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two nullable metadata columns to documents, populated by
ThumbnailService when it generates the JPEG preview. Both remain null
until the existing admin backfill endpoint reruns the service.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The archive has ~4 persons over 100 letters and ~90% with five or
fewer — the original spec's 851-letter default fit no one.
Redesign introduces three tiers gated on letterCount (Compact ≤ 5,
Standard 6–49, Rich ≥ 50) sharing one dashboard block: navy header +
4-cell stats strip at every non-Empty tier, with Standard appending
direction bar + top correspondents and Rich further appending
histogram + top locations + tag cloud. Backend skips expensive
aggregations for non-Rich persons; histogram and tag cloud ship
lazy-loaded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
extract_page_blocks() walked `record.boundary` and `record.baseline`
unconditionally, so a record that arrived without either (malformed
kraken output, or a MagicMock in tests that iterates to nothing)
crashed with "min() arg is an empty sequence".
Coerce both attributes through list(), require at least 3 points for
the polygon path, fall back to the baseline path when the polygon is
missing, and skip the record entirely when neither is usable —
emitting no block is safer than emitting one with garbage coordinates.
The test helper now sets `boundary` and `baseline` explicitly to
mirror real Kraken 7.0 records (and so the happy-path test exercises
the polygon branch). A new regression test covers the skip path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main.py unifies the call to both engines and always passes
`sender_model_path` (None for non-Kurrent scripts). Surya's
extract_region_text / extract_page_blocks accepted one fewer positional
arg than Kraken's, so every guided-OCR run on a TYPEWRITER or
HANDWRITING_LATIN document raised "takes 5 positional arguments but 6
were given" and the stream returned 0 blocks / 1 skipped page.
Add an ignored `sender_model_path` kwarg to both Surya functions so the
signatures match Kraken's, and guard the regression with two signature
tests in test_engines.py that compare both engines' parameter lists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 12px text felt cramped next to the larger 120×168 thumbnail. Lift
the date / VON / AN / progress label to 14px so the row reads
comfortably without changing the width or the row height.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Nesting the tag <button> inside the row's <a href="…"> made the browser
treat any click on the button as a click on the anchor, sending the
user to the document detail page even though the tag handler called
goto() with the tag-filter URL. e.stopPropagation() doesn't cancel
the anchor's default navigation.
Refactor to the stretched-link pattern: the row-wide anchor sits as an
overlay (`absolute inset-0 z-0`) and the content wrapper sits above it
(`relative z-10` + `pointer-events-none`). Tag buttons re-enable
pointer events with `pointer-events-auto`, so they're true siblings of
the anchor and receive their own clicks. Empty content areas pass
through to the anchor for whole-row navigation.
The vitest-browser client project doesn't load Tailwind CSS, so the
z-index has no effect there and Playwright's coordinate-based click
hits the anchor instead of the button. Trigger the click directly on
the button DOM element in the unit test (with a comment explaining the
test-env constraint); the actual user-facing behavior is verified via
playwright against the running dev server.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refill the columns that went visually empty after the previous dedup
commit (`fc0fc57`):
- Middle column gains the document `summary` (line-clamp-2, italic,
with `summaryOffsets` highlighting — the backend already populates
the offsets, the frontend just wasn't rendering them) and a row of
thin neutral chips for `archiveBox`, `archiveFolder`, and `location`
(~99% of docs in the corpus carry these). Chips are desktop-only
and skip empty values.
- Right column restores `VON sender` and `AN receivers`, now with
`<mark>` highlighting that the previous right-column copy lacked,
so search matches stay visible there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The desktop document-list row showed sender/receiver twice — once
side-by-side in the middle column and again stacked in the right
column. Stack the middle-column block vertically (the side-by-side
grid wasted horizontal space and competed with the larger thumbnail)
and remove the now-redundant copy from the right column.
The middle-column block keeps the search-match highlighting, which the
right-column copy never had.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a `size` prop to DocumentThumbnail (default `sm` keeps the existing
60×84 tile used in person sublists; new `lg` is 120×168) and use `lg`
for the main document-list row, where the previous tile occupied less
than half of the row's vertical space.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chunked requests omit Content-Length entirely. The previous guard
only checked the header and was bypassed. Now the body is buffered
first and its byteLength is checked, catching both cases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the two untested code paths flagged in review:
- PATCH method routes to backend with correct URL
- Requests without Content-Length header pass through (NaN > n = false)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sendBeacon name was misleading after switching to keepalive fetch.
Also adds a test to confirm flush is a no-op when pendingTexts is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blocks requests with Content-Length > 1 048 576 bytes with 413.
Tests cover security guards, body limit, and response forwarding.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sendBeacon always sends POST, but the backend expects PUT for block updates, so
saves were silently dropped on page unload. Replace with fetch({ keepalive: true,
method: 'PUT' }) which survives navigation and uses the correct HTTP method.
Add a catch-all SvelteKit server route at /api/[...path] so all client-side API
calls work in production (without the Vite dev proxy). More-specific routes
(/api/persons, /api/tags, /api/documents/[id]/file) keep precedence.
Closes#204
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @leonievoss and @felixbrandt — fix(ui): "the PDF icon
misleads for image documents" and "swap for a neutral file icon".
The fallback now shows a generic document-text glyph (page outline +
three text lines) instead of the PDF-specific icon with the folded
corner. Applies equally well to PDFs, JPEG/PNG scans, and TIFF
documents — all of which can land in the fallback path.
Also bumped the icon from h-6/w-6 to h-8/w-8 — the previous 24px
glyph looked sparse inside the 60×84 tile (Leonie, post-merge
iteration point #2).
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses @felixbrandt — fix(backend): "the two try blocks in generate()
overlap — a save failure logs 'generation failed' even though the
thumbnail is already in S3 as an orphan".
generate() now orchestrates four stages, each in its own try+log:
readSourceImage / encodeThumbnail / uploadToStorage / persistThumbnailMetadata
persistThumbnailMetadata emits the distinct "orphaned in storage as <key>"
log line so an operator can see database-side failures after the upload
completed. The deterministic key ensures the next run overwrites cleanly,
so the orphan is self-healing.
Also extracts THUMBNAIL_KEY_PREFIX/SUFFIX constants with a comment
explaining the deterministic-overwrite contract.
Adds test: generate_returnsFailed_whenPersistThrows_butUploadSucceeded.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses @mkeller (Markus) — fixes(adr): "the ADR doesn't mention
in-memory BackfillStatus" and "treat this as a layering exception,
acknowledge it explicitly". Two new paragraphs under Operational caveats.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures why thumbnails render in-process rather than being delegated
to ocr-service. Prevents a future reviewer from rehashing the decision
or moving it to the Python side without knowing the trade-offs.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- admin.spec: click 'Thumbnails erzeugen', wait for status DONE
within 30s, screenshot the success message
- accessibility.spec: /admin/system joins the page list so the
thumbnail card is checked in light, system-dark, and manual-dark
axe-core runs
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fourth card on /admin/system mirrors the mass-import pattern:
- POST /api/admin/generate-thumbnails to trigger
- 2000 ms polling on /api/admin/thumbnail-status while RUNNING
- processed / skipped / failed counters in the DONE message
- standalone pollInterval so import and thumbnail polling don't
interfere with each other
Paraglide keys added in de/en/es, mirroring admin_system_import_*.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Home search rows and person detail sidebars now show the real
first-page preview when one exists, falling back to the PDF icon
for documents the backfill hasn't processed yet. The old `variant`
prop on PersonDocumentList is removed — it tinted the icon
differently for sent vs received, which no longer applies with a
uniform thumbnail tile.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Renders the document thumbnail with object-cover + object-top so
letter salutations stay visible, empty alt (title nearby is the
accessible name), loading=lazy, decoding=async, and dark:mix-blend-multiply
for dark mode. Falls back to a PDF icon when thumbnailKey is null —
legacy documents, unsupported content types, or transient failures
all land here.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure function returning /api/documents/{id}/thumbnail?v=<timestamp>
or null when thumbnailKey is missing. The encoded timestamp changes
whenever the backend regenerates a thumbnail (file replace),
invalidating browser caches despite the immutable Cache-Control.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the backend Document entity's new optional fields. Both are
optional (no @Schema requiredMode on the backend side), so legacy
documents without thumbnails stay valid.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spins up a MinIO container (Testcontainers GenericContainer) alongside
the existing PostgresContainerConfig, uploads a sample PDF, runs the
real ThumbnailService, and reads the resulting JPEG back from the
object store. Catches S3 signing / path-style access issues a mocked
S3Client wouldn't — justifies the CI cost (~45s) per walkthrough T9b.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Streams the JPEG thumbnail from S3 with Cache-Control: private,
max-age=31536000, immutable — `private` (not `public`) prevents
shared caches from leaking one user's thumbnail to another (CWE-525).
`immutable` is safe because the URL carries ?v=<thumbnailGeneratedAt>
as a cache-buster that changes whenever the file is replaced.
Authentication falls back to the global .anyRequest().authenticated()
rule, matching the existing /file endpoint's permission model.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- POST /api/admin/generate-thumbnails → triggers async backfill, 202
- GET /api/admin/thumbnail-status → returns current BackfillStatus
Both gated by the class-level @RequirePermission(Permission.ADMIN).
Shape and polling semantics mirror the mass-import endpoints.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sequentially processes all documents with a file but no thumbnail and
tallies processed / skipped / failed counts. Runs on thumbnailExecutor
so it shares back-pressure with live upload thumbnails but can never
saturate them (single-threaded loop).
Concurrent start rejected with THUMBNAIL_BACKFILL_ALREADY_RUNNING.
Emits a structured summary log line on completion for operator
visibility.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ODS/Excel imports that actually upload a file (file.isPresent()) now
trigger thumbnail generation alongside hash/metadata. Metadata-only
import rows produce no thumbnail — nothing to render.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
All four upload code paths (storeDocument, createDocument, updateDocument,
attachFile) now call thumbnailAsyncRunner.dispatchAfterCommit(id) after
the document save. createDocument and updateDocument only dispatch when a
file was actually provided/replaced.
The dispatch is afterCommit-safe: if the surrounding @Transactional
method rolls back, no thumbnail is generated for a document that never
reached the DB.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bridges @Transactional upload paths to the async thumbnail pipeline.
dispatchAfterCommit registers a TransactionSynchronization so the async
task only fires after the surrounding commit (and is silently skipped
on rollback) — mirrors the AuditService.logAfterCommit pattern.
generateAsync wraps the full ThumbnailService.generate call in a 30s
watchdog so a hung PDFBox render cannot occupy a thumbnailExecutor slot
indefinitely.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Renders a 240px-wide JPEG (quality 85) from either a PDF first page
via PDFBox or a JPEG/PNG/TIFF scan via ImageIO, then uploads to
S3 under thumbnails/{docId}.jpg and updates the Document entity.
Scaling uses Graphics2D.drawImage with VALUE_INTERPOLATION_BILINEAR
(not deprecated Image.getScaledInstance). Source is streamed via
FileService.downloadFileStream to avoid buffering 50MB PDFs.
Never throws — returns Outcome.SKIPPED for unsupported content types
and Outcome.FAILED for rendering/upload errors so the backfill can
tally them without aborting the run.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dedicated thread pool (core=1, max=2, queue=200) with CallerRunsPolicy
for back-pressure. Keeps thumbnail rendering off the shared taskExecutor
used by OCR and out of the AbortPolicy queue that drops work on overflow.
Quick-upload batches (15+ files) now apply back-pressure instead of
silently dropping thumbnail jobs.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Thumbnail generation will call this for PDFs up to 50 MB — loading the
full byte[] via downloadFileBytes would cause real memory pressure on
the single-VPS deploy. Stream-based reads let PDFBox parse the first
page without holding the whole file in heap.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
JDK ImageIO handles JPEG, PNG, BMP, GIF out of the box but not TIFF.
Since the document upload allowlist permits image/tiff, the thumbnail
generator must also decode it.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the IMPORT_ALREADY_RUNNING pattern for the concurrent-start
guard in ThumbnailBackfillService.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds findByFilePathIsNotNullAndThumbnailKeyIsNull() used by the
upcoming ThumbnailBackfillService to locate documents that have a
file attached but no thumbnail yet.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two nullable columns to the documents table and their JPA mappings
on the Document entity. Both are left out of the OpenAPI required-mode
schema so the generated TypeScript type exposes them as optional.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two production-ready specs following the chronik-spec format
(scaled wireframes × 3 viewports + impl-ref tables with exact Tailwind
classes and pixel values + WCAG contrast verification):
- briefwechsel-thumbnail-rows-spec.html — /briefwechsel row redesign
with PDF thumbnail, summary-as-quote, bilateral distribution bar;
drops status lifecycle and script-type indicators.
- person-dashboard-spec.html — new Korrespondenz-Überblick block on
/persons/[id] with stats, activity histogram, direction split, top
correspondents/locations, tag cloud. Every tile deep-links to
/briefwechsel with filters.
Both specs share the DistributionBar.svelte component.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brainstorming artifact: 5 HTML mockups comparing approaches to fill the
sparse right-hand space on /briefwechsel rows (reported by users as
"feels empty"):
1. Rich Rows — dense metadata, no images
2. Thumbnail Rows — PDF preview on the left
3. Master-Detail Split — list + persistent preview panel
4. Gallery Cards — grid of letter cards, album style
5. Person Dashboard — insights live on /persons/[id], not here
Picked: #2 (Thumbnail Rows) + #5 (Person Dashboard), followed up by
final specs in separate commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On CLOSED readyState, probes session and redirects to /login only on 401.
On CONNECTING, counts consecutive errors and closes + probes only after 3
failures, preventing infinite retries without killing transient reconnects.
Closes#203
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Upload button text wrapped in hidden xl:inline to hide label below xl
- AppNav logo margin reduced from mr-10 to mr-4 xl:mr-10 at lg breakpoint
Combined these changes bring the header content to ~923px vs ~945px
available space at 1024px, eliminating horizontal overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BackButton gains showLabel prop: showLabel=false renders icon-only with
aria-label, no mr-2 on svg (was causing 0px button width in topbar)
- DocumentTopBar: BackButton restored to h-11 w-11 circular touch target
with showLabel=false matching the original 44×44px <a> it replaced
- Topbar row gets pr-4 (16px right padding per spec); action buttons div
no longer needs its own pr-3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The document detail page back button was missed in the original refactor —
it still pointed to "/" (dashboard) regardless of where the user came from.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BackButton now accepts a `class` prop (default 'mb-4') so callers can
override spacing; resolves hardcoded margin in flex-row topbar snippets
- documents/[id]/edit and enrich/[id] pass class="" to suppress the margin
- Replace weak className unit test with class-prop behaviour tests
- Add [data-hydrated] comment in E2E spec explaining what emits the attribute
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All 7 in-scope back navigation links converted to use history.back().
Admin panel mobile chevron converted inline (icon-only, different
visual pattern). Cancel buttons left as static <a> links.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
/chronik → /aktivitaeten; heading updated in all three locales.
Component folder (lib/components/chronik/) stays unchanged — internal
implementation detail, not user-facing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ChronikTimeline: date buckets now render as bordered cards with muted
header (border-line / bg-surface / shadow-sm) and divide-y row
separators, matching the DocumentList card pattern
- ChronikRow: remove rounded-sm (card handles clipping), hover:bg-canvas
→ hover:bg-muted/50; restore rollup count badge after doc title
- Messages (de/en/es): remove embedded {count} from all four rollup verb
strings so the badge is the single source of truth, consistent with
DashboardActivityFeed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace any(Set.class) with any() to eliminate the raw-type unchecked
cast in DashboardControllerTest
- Derive ALL_ELIGIBLE_KINDS from AuditKind.ROLLUP_ELIGIBLE.stream() so
the integration test constant stays in sync with the production constant
automatically when new kinds are added
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses review concern: the test lived in the dashboard package but
tests the audit domain service. Package-by-feature convention requires
audit tests to live in the audit package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses review concern: the fuer-dich predicate (youMentioned ||
youParticipated) had zero test coverage after feedFilters.test.ts was
deleted. The new clientFilter module is a pure function that is directly
testable, and the test explicitly documents why MENTION_CREATED items
without the youMentioned flag are now excluded (they would have shown
mentions directed at OTHER users under the old feedFilters.ts logic).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each filter pill maps to a specific set of AuditKinds sent as
?kinds= to /api/dashboard/activity. fuer-dich omits kinds so the
server returns all eligible events; client-side predicate on
youMentioned/youParticipated handles the final narrowing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added @Parameter annotation so SpringDoc renders kinds as an
enum-array query param; regenerated TypeScript API types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spring auto-converts ?kinds=FILE_UPLOADED,TEXT_SAVED to Set<AuditKind>.
Absent or empty kinds defaults to ROLLUP_ELIGIBLE. Unknown value → 400.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-arg variant delegates to three-arg with ROLLUP_ELIGIBLE so
existing callers (getPulse) are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Filter is applied at the innermost events CTE to reduce rows
entering the LAG/session CTEs. Existing callers pass ROLLUP_ELIGIBLE
by default so behaviour is unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sidebar was constructing /documents/:id?commentId=… without the
annotationId, so clicking a mention there no-op'ed the deep-link
scroll helper. Route the href through buildCommentHref so the
bell and the chronik sidebar produce identical URLs.
Refs #300.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the inline conditional href construction in favour of the
shared helper. Identical URL shape — behaviour preserved.
Refs #300.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single source of truth for constructing /documents/:id?commentId=…
(&annotationId=…) URLs. Used by the notification bell, the chronik
"Für dich" sidebar, and the chronik main feed so the three surfaces
can no longer diverge.
Refs #300.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the two new optional fields on ActivityFeedItemDTO in the
generated openapi-typescript output. Matches exactly what
'npm run generate:api' would emit against the updated backend DTO;
regenerate on a live backend before merge to confirm drift-free.
Refs #300.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ActivityFeedItemDTO gains nullable commentId and annotationId fields.
DashboardService.getActivity forwards commentId from the projection
and batch-resolves annotationId via the new
CommentService.findAnnotationIdsByIds lookup. Both remain null for
non-comment kinds, so the bulk lookup is skipped entirely when the
feed has no comment rows.
Refs #300.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exposes a CommentService method that maps a collection of
commentIds to their annotationIds via commentRepository.findAllById.
Unknown comments and comments with null annotationId are omitted.
Used by the dashboard activity feed enrichment to supply the
deep-link annotationId without growing the audit SQL query.
Refs #300.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds getCommentId() to ActivityFeedRow and selects
(ag.payload->>'commentId')::uuid from findRolledUpActivityFeed so
chronik consumers can build deep-link URLs for COMMENT_ADDED and
MENTION_CREATED events. Null for other kinds.
Refs #300.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comments only render inside TranscriptionEditView, so a deep-link
into a document with existing reviewed transcriptions landed the
user in read mode with no comment element in the DOM — the scroll
target silently missed.
scrollToCommentFromQuery now takes a setPanelMode callback and calls
it with 'edit' whenever both query params are present. The page's
own transcribe-mode $effect checks a skipInitialPanelMode flag the
deep-link flow sets, so its default-panel-mode logic doesn't race
against the explicit override.
Two new helper tests pin the contract: panel mode is forced to
'edit' both when transcribe mode is off (entering fresh) and when
it is already on (same-page notification click).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small refinements from Felix's review cycle 1:
- replaceState(page.url.pathname, page.state ?? {}) — defend against
first-navigation cases where page.state can be undefined.
- Extract the inline tick + requestAnimationFrame into a named
waitForPanelRender() helper; intent is now readable from onMount.
- Attach .catch() to the fire-and-forget scrollToCommentFromQuery
promise so any helper throw surfaces via console.error instead
of silently disappearing.
No behavior change on the happy path. All existing tests stay green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seeds a document, transcription block, and block comment via API,
then visits /documents/{id}?commentId=X&annotationId=Y and asserts
the page enters transcribe mode, the comment article becomes visible,
and the URL query params are stripped. Runs at 320px and 1440px so
the collapsed PDF strip clipping on mobile is caught. An axe-core
pass guards the new tabindex + focus-visible ring against a11y
regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After navHeight setup, call scrollToCommentFromQuery with the page
URL and callbacks into the component's local state (transcribeMode,
activeAnnotationId, flashAnnotationId) plus SvelteKit's replaceState
to strip the consumed query params.
afterTick awaits both Svelte's tick() and one requestAnimationFrame,
mirroring the existing handleAnnotationClick timing so the annotation
panel has rendered before scrollIntoView fires.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notification deep-link scroll targets #comment-{id}. Add the id to
the article wrapper along with tabindex="-1" so scrollIntoView +
.focus({preventScroll:true}) can land screen-reader and keyboard
focus on the specific comment. A focus-visible ring appears only
for keyboard users so mouse clicks don't trigger a visible outline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure function that reads commentId + annotationId from the page URL,
enters transcribe mode if needed, activates the block's annotation,
scrolls the target comment into view, focuses it for screen readers,
fires the existing annotation flash, and strips the params via the
injected callback.
All side effects go through callbacks so the helper is unit-testable
without mounting the page or a DOM (only scrollIntoView/focus are
called on the injected element). Eight tests cover both absent params,
happy path, transcribe-mode activation, missing DOM target, reduced
motion, flash trigger, and URL strip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only block comments are surfaced by the frontend now. The document-level
and annotation-level comment endpoints and service methods existed but
had no consumer. Remove them along with their repository queries and
test coverage so the surface area matches the actual feature set.
Shared edit, delete, and block reply endpoints stay. postBlockComment
now carries the authorName/mention/audit behaviors previously tested
through the dropped postComment method, so those behaviors remain
covered by the block-scoped test suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously issued block-comment notifications were stored with
annotation_id=NULL because CommentService.postBlockComment did not
populate DocumentComment.annotationId. Now that the code fix is in
place, existing rows need to be filled in so legacy notifications
can also carry the query param that the frontend deep-link flow
expects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
postBlockComment now looks up the block via TranscriptionService and
sets comment.annotationId from block.getAnnotationId(). This closes
the upstream root cause of issue #276, where notifications for block
comments were stored with annotationId=null, breaking the notification
deep-link flow on the document detail page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comments created before audit logging was added in 428c63a2 have no
corresponding audit_log rows, so the Chronik activity feed (which
reads exclusively from audit_log) cannot surface them in "Alle" or
"Für dich", even though the fix from #295 is wired up correctly.
V50 inserts the missing events idempotently from document_comments
and comment_mentions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SvelteKit's default `use:enhance` behaviour calls `form.reset()` after
a successful non-redirecting action, which wipes inputs that use
`value={...}` (property set, not defaultValue). The edit forms now
pass `reset: false` to `update()` so the saved values stay visible
after the success banner appears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SvelteKit 2 forbids mixing a `default` action with named actions; the
page also exports a `delete` action. Posting the edit form therefore
returned a 500 with "When using named actions, the default action
cannot be used." Rename the action to `update` and point the form
at `?/update`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the extracted filterFeed function into the displayFeed derived,
removing 20 lines of inline switch logic from +page.svelte.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract filterFeed(items, filter) from +page.svelte inline switch to a
pure function, widening the fuer-dich branch to include youParticipated.
Regenerate ActivityFeedItemDTO type to include the new field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add youParticipated field to the DTO record and wire row.isYouParticipated()
through DashboardService.getActivity().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add you_participated correlated subquery to findRolledUpActivityFeed.
Carries payload through the aggregated CTE via MIN(payload::text)::jsonb
so the commentId can be matched against notifications.reference_id.
Uses CAST(:currentUserId AS uuid) to avoid Spring Data JPA misparsin ::
cast syntax as a parameter name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add isYouParticipated() to ActivityFeedRow interface and cover four
behaviours in AuditLogQueryRepositoryRolledUpTest: youParticipated
true/false and retroactive youMentioned true/false coverage.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If a row ever receives a malformed uploadedAt (e.g. manual SQL migration,
backend regression), the helper now falls back to "vor 0 Minute(n)"
rather than rendering "vor NaN Tag(en)" to the user.
Addresses Nora's review suggestion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- UploadSuccessBanner dismiss button: 24×24 → 40×40 hit area (icon stays
at 16px). Matches senior-first baseline Leonie flagged.
- DashboardNeedsMetadata chevron: adds motion-reduce:transition-none and
motion-reduce:group-hover:translate-x-0 so users with prefers-reduced-
motion do not see the hover translate.
- Row title prefixed with an sr-only "PDF: " span so assistive tech
announces the document affordance alongside the title.
Addresses Leonie's review concerns #2, #3, and the sr-only nit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "no-callback" and "no-prop" tests no longer rely on an arbitrary
50ms sleep. Test 2 awaits the mocked invalidateAll call (the last async
step of the upload handler) before asserting the callback was not
invoked. Test 3 lets vitest-browser-svelte's own expect.element poll
until the success message appears.
Addresses Sara's and Felix's review concern about flake-prone timing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hoists the $navigating store into a shared __mocks__ module so tests can
drive it through real transitions. Adds two specs covering (a) skeleton
visible while $navigating && topDocs empty and (b) skeleton hidden when
topDocs is non-empty. Also sets aria-busy="true" on the skeleton so
screen readers announce the loading state (Leonie's a11y suggestion).
Addresses Sara's and Felix's review concern that the skeleton branch was
dead code in the test world.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two "happy path" dashboard load tests now mock the two additional
calls added in f5481289 (/api/documents/incomplete + incomplete-count)
so the Promise.allSettled array resolves fully.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Happy-path journey (upload 2 PDFs → banner → CTA → /enrich) plus axe
sweep at 320/768/1440 × light/dark for the dashboard route. Seeded
docs are cleaned up in afterEach via psql so repeated runs stay green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dashboard loader fetches /api/documents/incomplete?size=5 plus the
existing /incomplete-count and surfaces both via data; +page.svelte
renders EnrichmentBlock with the top 5 docs, the total count, and the
bannerCount state bound to DropZone's onUploadComplete callback
(issue #296).
The block returns null when there is nothing to show, so dashboards
without pending uploads stay uncluttered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Optional callback lets the parent route pop a post-upload banner without
lifting state into a store. Dashboard uses it to drive
UploadSuccessBanner (issue #296). Only fires when the server actually
created new documents — duplicates and errors do not trigger it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Composes UploadSuccessBanner + DashboardNeedsMetadata and reserves a
360px skeleton while \$navigating re-runs the loader with a fresh
incomplete list. Prevents the layout-shift jump after a batch upload
(Leonie's resolved decision #3 on issue #296).
Renders nothing when there is nothing to show — keeps the clean empty
dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Transient post-upload banner for issue #296: singular/plural German copy,
aria-live=polite for screen readers, manual X dismiss, 8s auto-dismiss.
"Jetzt ergänzen →" CTA links directly to /enrich so seniors can continue
straight into the enrichment flow after a batch upload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches to two props — topDocs (max 5, capped by caller) and totalCount —
so the footer link can surface "Alle 12 anzeigen →" even when only 5
items are shown. Each row gets a generic document icon, title, relative
upload time and a chevron, wrapped in a single <a> per the issue spec.
Still returns null when topDocs is empty, keeping the empty dashboard
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Singular/plural banner copy, a count-aware "show all" footer link, and
the dismiss aria-label for the new dashboard enrichment-list-block
(issue #296). Covers de / en / es.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure function with injectable now — lets the dashboard enrichment block
render "vor 2 Min." / "vor 3 Std." / "vor 2 Tagen" without clock-based
test flakiness. Reuses the existing comment_time_minutes / _hours /
_days Paraglide keys, no new translations needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the restored list endpoint and the new uploadedAt field on
IncompleteDocumentDTO (issue #296).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the CWE-285 gap Nora flagged on issue #296: both endpoints expose
enrichment-queue information that only writers should see. Brings them in
line with the new /incomplete list endpoint and every other write-path
under DocumentController.
Frontend callers (/enrich/[id]/+page.server.ts) already gate on WRITE_ALL
at the route level, so no client-side change is needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regression test proving the controller clamps client-supplied size
values server-side, closing the unbounded-limit concern Markus flagged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only users who can enrich documents should see the queue.
Mirrors the frontend guard in enrich/+page.server.ts and closes the
CWE-285 gap Nora flagged on issue #296.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores the list endpoint removed in ddd811c6 and caps size at 200.
The dashboard enrichment block (issue #296) and /enrich page both
consume it; /enrich was silently 404ing since the deletion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mapper populates uploadedAt from Document.createdAt so the dashboard
enrichment block can show a relative-time meta line ("vor 2 Min.")
per issue #296.
LocalDateTime matches the convention used by NotificationDTO,
DocumentVersionSummary and InviteListItemDTO.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cheap insurance for the day someone widens the WHERE clause in
findRolledUpActivityFeed. Suggested by Sara in PR #288 review cycle 1.
Inserts two non-eligible events alongside one TEXT_SAVED and asserts the feed
returns the TEXT_SAVED row only.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two PR #288 blockers from Felix and Leonie:
Felix: verbText.indexOf(docTitle) broke when the title was empty (indexOf
returned 0, the before/after slices both emptied) or when the title
substring-matched any word in the compiled Paraglide message (e.g. "Brief"
appearing inside a translated verb). Swap to a sentinel approach: interpolate
{doc} with U+0001, then split the compiled text on that sentinel — robust
regardless of title content or translator sentence order. Two new red tests
lock the invariant: empty title still renders the row link; short titles
that could substring-match render exactly once as a single chronik-doc-title
span.
Leonie: the comment variant rendered „{documentTitle}" as a placeholder,
which made the row show the same title twice — once as the underlined link,
once as the italic "preview quote" — implying the comment was quoting
itself. Replace with an italic ellipsis „…". A new red test asserts the
preview no longer contains the document title text verbatim.
While here, add a SECURITY comment next to the TODO so the next person who
wires item.commentPreview knows the backend must truncate/strip server-side
and the frontend must use {text}, never {@html} (Nora, issue #285#3552).
Part of #285, address PR #288 review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two items flagged as blockers in PR #288 review:
- Markus + Sara: "Mehr laden" calls GET /api/dashboard/activity?offset=N but
the backend's DashboardController only accepts `limit` — `offset` was
silently ignored, and every click re-fetched the same top-40 rows. Rather
than add backend offset/cursor support in this PR (scope creep), remove
the Load-more UI and defer pagination to a follow-up issue. 40 items
covers the default case; the feature can come back with proper backend
support and its own tests.
- Markus + Sara: ?/dismiss and ?/mark-all form actions were dead code —
the UI calls `onMarkRead` / `onMarkAllRead` callbacks (→ singleton →
raw PATCH) and never submits either form. Delete both actions and their
tests. Using the form-action path would require deprecating the
NotificationBell's raw-PATCH as well — that's tracked separately as
#286.
The Dismiss markup split from the previous commit stands on its own.
Part of #285, address PR #288 review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HTML5 forbids interactive content (<button>, <a>, <input>...) as descendants
of <a>. The original <a href=…><button>✓</button></a> markup triggered two
concrete bugs flagged by Felix, Nora, and Leonie in PR #288 review:
- Browsers inconsistently route the nested click: on some engines the
stopPropagation() still bubbles, and the user navigates into the document
instead of dismissing.
- The senior audience (60+) tap-selects with a slight drag, and the OS
treats the interaction as anchor vs. button inconsistently — a
reproducible usability failure Leonie has seen in testing before.
Refactor to the Option-C layout from issue #285 comment #3573: outer <li>
flex container, <a> wrapping avatar + body + time, <button> as a sibling.
Independent focus stops, invalid-HTML gone, no behavioural regression.
A new spec locks the invariant: `dismiss.closest('a')` must be null.
Part of #285, address PR #288 review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three free axe checks light up (light / system-dark / manual-dark) without
further code changes — they run the existing parameterized spec against
/chronik.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Alle anzeigen" link at the bottom of the notification dropdown now
points to /chronik with the new "Zur Chronik →" label key, matching the
unified activity page introduced in #285.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- "Alle anzeigen" link now goes to /chronik (was /documents — the dead-end
bug called out in #285).
- Rollup rows (count > 1) render a primary-colored count badge plus a
compound timestamp line: "14. Apr. · 14:02–14:32" (en-dash U+2013).
- Singleton rows render the existing "14. Apr. 2026" date line.
- BLOCK_REVIEWED now has a verb mapping (re-using the annotation verb until
the spec pins a distinct copy).
- Three new spec cases: rollup count badge + en-dash range, no badge on
singletons, /chronik link assertion.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The app is pre-production — no 301 redirect, the old route and its tests
are removed outright. Profile page's "Benachrichtigungsverlauf ansehen"
link now points to /chronik.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
page.server.ts loads /api/dashboard/activity (limit=40) and unread
/api/notifications in parallel via Promise.allSettled so a dashboard-activity
failure still renders the Für-dich box. Form actions ?/dismiss and ?/mark-all
back the Dismiss and "Alle gelesen" controls with CSRF-safe SvelteKit
endpoints.
+page.svelte composes all six chronik components:
- ChronikFuerDichBox at the top, seeded from the SSR unread set on first
render and switching to the live SSE singleton once notifications arrive;
- ChronikFilterPills below, wired to URL via goto(?filter=…) with
replaceState so the browser history stays clean across filter changes;
- ChronikTimeline for the day-bucketed feed, filtered client-side per pill
(alle / fuer-dich / hochgeladen / transkription / kommentare);
- ChronikEmptyState for first-run vs filter-empty states;
- ChronikErrorCard on activity load failure.
"Mehr laden" pagination keeps focus on the button after load (via tick() +
$state-bound ref), renders 3 static skeleton rows with aria-busy, and
announces "{count} weitere Einträge geladen" through a polite aria-live
region. Inbox-zero in the Für-dich box links to /chronik?filter=fuer-dich.
Co-located page.server.spec.ts covers load(): limit=40, unread=read:false,
filter parsing with "alle" fallback, activity-fulfilled-but-not-ok surfaces
loadError, plus the dismiss and mark-all actions (success + missing-id
branch). 8 tests green.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All under src/lib/components/chronik/:
- ChronikRow.svelte — single orchestrator for four variants (comment / for-you /
rollup / simple), discriminated via $derived. Outer <a> wraps avatar + body +
time; document title is a styled <span> (no nested anchors). Rollup shows
count badge + en-dash time range; for-you gets accent left border + @ marker
hidden below sm:.
- ChronikTimeline.svelte — buckets items by day using bucketByDay() and renders
Heute/Gestern/Diese Woche/Älter section headers with <span> trailing rule.
- ChronikFuerDichBox.svelte — unread mentions card with inbox-zero variant,
per-row Dismiss button (prevents bubbling, calls onMarkRead), aria-live count
badge, and a .fade-in class gated by prefers-reduced-motion.
- ChronikFilterPills.svelte — role=radiogroup with 5 pills, ArrowLeft/Right
keyboard navigation wrapping across the group, single tabstop via dynamic
tabindex.
- ChronikEmptyState.svelte — three variants (first-run / filter-empty /
inbox-zero) sharing a centered-column layout.
- ChronikErrorCard.svelte — warning card with retry button, optional custom
message override.
Verbs map to chronik_singleton_* / chronik_rollup_* per AuditKind so no ICU
pluralization is needed. Comment preview is a TODO placeholder (currently the
document title) pending a backend preview DTO follow-up.
All 40 unit tests green. No type-check or lint errors in these files.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs/adr/003-chronik-unified-activity-feed.md: records the session-rollup
decision (LAG + 120-min gap), the dedupe deletion, the single-endpoint
composition, and the German-URL convention.
- frontend/messages/{de,en,es}.json: adds chronik_* keys for page title,
Für-dich box, filter pills, day headers, singleton/rollup verb variants
per kind, empty states, error card, Mehr-laden pagination, and the Bell
footer link retarget.
No pluralization via ICU match — separate singleton/rollup keys per verb,
per the Felix discussion (comment #3573).
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Left over from the hook→singleton refactor — NotificationDropdown still
imported from the deleted $lib/hooks path.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure function bucketByDay(date, now?, locale?) returns one of
'today'|'yesterday'|'thisWeek'|'older' so ChronikTimeline can
bucket activity rows by relative day without pulling a date
library.
Handles:
- midnight boundary (startOfDay comparison)
- locale-aware week start (Monday for most locales, Sunday for en-US,
en-CA, en-PH, ja-JP, he-IL, pt-BR)
- DST transitions (works off local calendar days)
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-component createNotificationStream() factory with a shared
$lib/stores/notifications.svelte.ts singleton. Ref-counted init()/destroy()
ensures one EventSource per tab no matter how many consumers mount
simultaneously.
Motivation: the /chronik "Für dich" box (#285) needs the same live-arrival
stream that NotificationBell already consumes. Two factories would open two
SSE connections per tab — this refactor avoids the silent regression before
it ships.
- New: src/lib/stores/notifications.svelte.ts (module state, refcount)
- New: src/lib/stores/notifications.svelte.spec.ts (proves single EventSource
across multiple consumers + ref-counted teardown)
- Deleted: src/lib/hooks/useNotificationStream.svelte.ts (factory)
- Deleted: src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts
- NotificationBell now imports the singleton
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds count (required) and happenedAtUntil (optional) to the TypeScript DTO so
Chronik + DashboardActivityFeed can consume rollup rows type-safely.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- V49__add_audit_log_rollup_index.sql: partial covering index on
(actor_id, document_id, kind, happened_at DESC) filtered by the 6 rollup
kinds. Matches the WHERE clause of findRolledUpActivityFeed exactly so the
session-grouping window scan is index-backed.
- DashboardController: clamp limit to 40 (was 20). Chronik requests up to 40
activity items per page; dashboard side-rail still passes 7.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The method no longer deduplicates by hour-trunc — it performs session-style
rollup via LAG()+120-min gap. Rename aligns the public name with the
behavior.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the activity feed query to group consecutive events on the same
(actor, document, kind) into sessions separated by >120 min gaps. A session
becomes one row with count = events-in-session and happenedAtUntil = last
event timestamp. Singletons keep count=1 / happenedAtUntil=null.
Algorithm: LAG() to get the previous event's timestamp in the same partition,
mark a new session when gap > 7200s, then SUM() over an unbounded preceding
window yields a running session_id. Aggregation groups by session_id.
COMMENT_ADDED and MENTION_CREATED always start a new session — these kinds
never roll up so each event stays its own row.
Also adds BLOCK_REVIEWED to the eligible-kinds WHERE clause (Chronik spec §02)
so reviewed blocks appear in the activity feed.
Five new integration tests cover combine-within-2h, split-at-boundary,
no-hard-cap-on-long-session, never-rolls-up-comments/mentions, and the
count/happenedAtUntil contract on both singletons and rollups.
Part of #285.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prepares the activity feed data shape for session-style rollup (#285). Adds two
new fields that carry null-operation defaults for the existing hour-truncated
dedupe query:
- count: int (required) — always 1 for singleton rows
- happenedAtUntil: OffsetDateTime (nullable) — end-of-session timestamp for
future rollup rows; null for singletons
No behavioral change yet — the rollup SQL rewrite lands in a follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Design spec for a dashboard widget that surfaces documents
needing metadata after batch upload. Placed between Resume
strip and MissionControlStrip rather than as a 4th strip
column (strip at visual capacity; batch reality makes count
tiles useless for seniors).
Covers responsive behavior at 320/768/1440, row anatomy with
72/64px touch targets, state matrix (empty/loading/error/
after-upload), full a11y contract, dark-mode verification
notes, and an impl-ref table with exact Tailwind classes.
Refs #296
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ProgressRing renders SVG + percentage label as a flex column (~52px
total). With items-center, the contributor circles aligned to the middle
of the full block, placing them 8px below the ring center. Changed to
items-start on the container and wrapped ContributorStack in h-9 (36px =
SVG height) flex items-center so both circles center at the same 18px.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When sort=SENDER, documents group under the sender's display name card.
When sort=RECEIVER, a document appears under each receiver's card
(with multi-receiver duplication). Falls back to i18n labels for unknown
sender/receiver. Passes sort prop from /documents page to DocumentList.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spec replaces /notifications with a unified /chronik page that merges ambient
archive activity (6 of 8 AuditKinds) and personal mentions/replies. Covers 11
content states across 320/768/1440px viewports, dark mode parity, row anatomy
close-ups, interaction states, WCAG contrast verification, and implementation
notes (routing, API calls, rollup logic, Svelte component structure, i18n keys).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolved 9 conflicts:
- AuditLogQueryRepository/Service: keep HEAD (findRecentContributorsForDocuments)
- ContributorStack: merge main key fix + text-[10px] with HEAD safeColor + aria
- DashboardResumeStrip: merge main text-[10px] with HEAD safeColor
- +page.server/svelte + tests: keep HEAD (pure dashboard, no isDashboard)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The delete button used type=button + requestSubmit() to trigger the form,
which did not reliably fire SvelteKit's enhance submit listener. Replaced
with a type=submit button and an async enhance callback that guards with
the confirm dialog and calls cancel() on rejection.
Also clears the unsaved-changes dirty flag before the redirect so
beforeNavigate doesn't silently block the post-delete navigation.
Closes#277
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionQueueService was importing ActivityActorDTO and AuditLogQueryService
from the dashboard package, creating an inverted dependency (service → dashboard).
Moving these to the audit package where AuditLog lives gives both DashboardService
and TranscriptionQueueService the correct dependency direction (→ audit).
Moved to audit:
- ActivityActorDTO, ActivityFeedRow, ContributorRow, PulseStatsRow (projections)
- AuditLogQueryRepository, AuditLogQueryService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace (actor.name ?? actor.initials + i) with (actor.initials + '-' + actor.color)
to fix operator-precedence bug that made keys order-dependent when name is null
- Add role="img" + aria-label={actor.name ?? actor.initials} so screen readers
and touch users can access contributor names
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
findMostRecentDocumentIdByActor only matched TEXT_SAVED events, so documents
where the user drew annotation bounding boxes (but typed no transcription text)
were invisible to the hero resume card. Extending the IN clause to include
ANNOTATION_CREATED lets annotation-only work surface in the card (0% progress,
no excerpt — the correct state before transcription begins).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AuditService.logAfterCommit() called writeLog() inline inside the afterCommit()
callback. At that point Spring's transaction synchronizations are still active on
the thread, so SimpleJpaRepository.save() throws IllegalStateException which the
catch block silently swallowed — leaving audit_log permanently empty.
Fix: submit writeLog() to auditExecutor so it runs on a fresh thread with no active
synchronization context. Also switch auditExecutor from CallerRunsPolicy to AbortPolicy
to prevent the bug from silently recurring when the queue fills under load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- +layout.svelte: Upload button in header (authenticated users only)
- +page.server.ts: call /api/dashboard/resume, /pulse, /activity;
remove deprecated /api/documents/incomplete and /recent-activity
- +page.svelte: 2-col grid layout (main + 320px sidebar), greeting,
DashboardFamilyPulse + DashboardActivityFeed in sidebar
- DashboardResumeStrip: refactored to use server data (resumeDoc prop),
SVG thumbnail, progress bar with aria-*, empty state, CTA
- DashboardFamilyPulse: new component — weekly stats from audit_log
- DashboardActivityFeed: new component — activity feed with "für dich" badge
- Update specs for new data shapes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greeting, resume card, mission control, family pulse, activity feed,
audit action verbs, and dropzone keys for the Issue #271 dashboard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO,
ActivityActorDTO and the three /api/dashboard/* paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GET /api/documents/incomplete and GET /api/documents/recent-activity are
superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The AppUser entity is mapped to the 'users' table (not 'app_users').
V46 had a broken REFERENCES clause and hardcoded role in REVOKE; V47 and the
native query in AuditLogQueryRepository had the same wrong table name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both DocumentController and TranscriptionBlockController contained
identical private requireUserId helpers. Extracted to a shared static
utility in the security package ahead of DashboardController which
also needs actor resolution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required for dashboard Pulse stat 2 (COUNT DISTINCT blockId).
Without it, two saves on different blocks on the same page
were indistinguishable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds color field assigned from an 8-colour palette keyed on the user's UUID
hash (Math.abs(id.hashCode()) % 8). Fires via @PrePersist/@PreUpdate/@PostLoad
so both new and existing users get the correct colour at runtime.
V47 migration adds the column and fixes the V46 REVOKE bug that hardcoded
role name 'app_user' instead of CURRENT_USER.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DashboardResumeStrip: text-[10px] → text-xs on collaborator initials (WCAG 1.4.4)
- documents/+page.server.ts: use !result.response.ok per CLAUDE.md; keep narrow try/catch for network-level failures only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ContributorStack: text-xs for WCAG 1.4.4 (was text-[10px]), safeColor()
validation to block CSS injection via actor.color, role="img" aria-label
on empty placeholder, {#each} keyed by index
- ContributorStack spec: update empty-state assertion to getByRole('img')
- DocumentRow spec: add stopPropagation regression test for tag click
- documents/page.svelte.spec.ts: new — debounce, URL building, initial state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New documents/+page.svelte wires SearchFilterBar + DocumentList with
URL-driven navigation (goto + SvelteURLSearchParams)
- Reset button in SearchFilterBar now navigates to /documents
- Rename documents/+page.server.spec.ts → page.server.spec.ts to avoid
SvelteKit route-file conflict on the + prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The homepage now always renders the dashboard. Search and browse
moves to the dedicated /documents route (upcoming).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DocumentService.searchDocuments now fetches completion percentages and recent
contributors per document and zips them into DocumentSearchItem records.
Update affected tests to use the new items-based result shape.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a window-function query that returns at most 4 contributors per document
ordered by most-recent activity. Used by DocumentService to populate the
contributors field in DocumentSearchItem (issue #281).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds findCompletionStatsForDocuments() returning reviewed-block percentage
per document in a single native SQL GROUP BY query. Needed for the new
DocumentSearchItem DTO in issue #281.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionQueueService was importing ActivityActorDTO and AuditLogQueryService
from the dashboard package, creating an inverted dependency (service → dashboard).
Moving these to the audit package where AuditLog lives gives both DashboardService
and TranscriptionQueueService the correct dependency direction (→ audit).
Moved to audit:
- ActivityActorDTO, ActivityFeedRow, ContributorRow, PulseStatsRow (projections)
- AuditLogQueryRepository, AuditLogQueryService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace (actor.name ?? actor.initials + i) with (actor.initials + '-' + actor.color)
to fix operator-precedence bug that made keys order-dependent when name is null
- Add role="img" + aria-label={actor.name ?? actor.initials} so screen readers
and touch users can access contributor names
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
findMostRecentDocumentIdByActor only matched TEXT_SAVED events, so documents
where the user drew annotation bounding boxes (but typed no transcription text)
were invisible to the hero resume card. Extending the IN clause to include
ANNOTATION_CREATED lets annotation-only work surface in the card (0% progress,
no excerpt — the correct state before transcription begins).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AuditService.logAfterCommit() called writeLog() inline inside the afterCommit()
callback. At that point Spring's transaction synchronizations are still active on
the thread, so SimpleJpaRepository.save() throws IllegalStateException which the
catch block silently swallowed — leaving audit_log permanently empty.
Fix: submit writeLog() to auditExecutor so it runs on a fresh thread with no active
synchronization context. Also switch auditExecutor from CallerRunsPolicy to AbortPolicy
to prevent the bug from silently recurring when the queue fills under load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- +layout.svelte: Upload button in header (authenticated users only)
- +page.server.ts: call /api/dashboard/resume, /pulse, /activity;
remove deprecated /api/documents/incomplete and /recent-activity
- +page.svelte: 2-col grid layout (main + 320px sidebar), greeting,
DashboardFamilyPulse + DashboardActivityFeed in sidebar
- DashboardResumeStrip: refactored to use server data (resumeDoc prop),
SVG thumbnail, progress bar with aria-*, empty state, CTA
- DashboardFamilyPulse: new component — weekly stats from audit_log
- DashboardActivityFeed: new component — activity feed with "für dich" badge
- Update specs for new data shapes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Greeting, resume card, mission control, family pulse, activity feed,
audit action verbs, and dropzone keys for the Issue #271 dashboard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO,
ActivityActorDTO and the three /api/dashboard/* paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GET /api/documents/incomplete and GET /api/documents/recent-activity are
superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The AppUser entity is mapped to the 'users' table (not 'app_users').
V46 had a broken REFERENCES clause and hardcoded role in REVOKE; V47 and the
native query in AuditLogQueryRepository had the same wrong table name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both DocumentController and TranscriptionBlockController contained
identical private requireUserId helpers. Extracted to a shared static
utility in the security package ahead of DashboardController which
also needs actor resolution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required for dashboard Pulse stat 2 (COUNT DISTINCT blockId).
Without it, two saves on different blocks on the same page
were indistinguishable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds color field assigned from an 8-colour palette keyed on the user's UUID
hash (Math.abs(id.hashCode()) % 8). Fires via @PrePersist/@PreUpdate/@PostLoad
so both new and existing users get the correct colour at runtime.
V47 migration adds the column and fixes the V46 REVOKE bug that hardcoded
role name 'app_user' instead of CURRENT_USER.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instruments CommentService.postComment(), postBlockComment(), and
replyToComment() to fire COMMENT_ADDED after each successful save and
MENTION_CREATED once per mentioned user. The shared logCommentPosted()
helper avoids duplicating the two-call pattern across all three post
methods.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract logAfterCommit() from AnnotationService and TranscriptionService
into AuditService, eliminating duplicate boilerplate (Markus)
- Remove UserService from DocumentService; add actorId param to
storeDocument(), attachFile(), updateDocument() instead — resolves
SecurityContextHolder coupling concern (Markus)
- Update DocumentController to inject UserService and resolve actorId
from Authentication, passing it through to service methods
- Add logAfterCommit() tests to AuditServiceTest with MockedStatic
- Update all test verify() calls to use logAfterCommit() (not log())
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- reviewBlock: add userId param; log BLOCK_REVIEWED only on false→true
- updateBlock: log TEXT_SAVED only when text actually changes; include
pageNumber in payload (resolved from annotation)
- Both events deferred via afterCommit() when inside a transaction
- Update TranscriptionBlockController to pass user to reviewBlock()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Visiting /register without a code now shows a friendly error card
explaining the archive is invite-only, instead of the empty form.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the minimal login-style form with the full spec design:
hero section (eyebrow, headline, subtext), three labelled form sections,
2-column name grid, confirm-password field with client-side match hints,
password strength indicator, notification checkbox card, loading state on
submit, and "already have an account?" footer link.
Backend: adds notifyOnMention to RegisterRequest and wires both
notifyOnMention and notifyOnReply via updateNotificationPreferences on
invite redemption.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Narrow isTrustedProxy to RFC 1918 172.16-31.x.x (was 172.x.x.x)
- Add @Valid/@NotBlank/@Email to RegisterRequest and @Valid to AuthController
- Add FK constraint on invite_token_group_ids.group_id → user_groups(id)
- Add back-to-login link and <main> landmark to register error state
- Add component test suite for register/+page.svelte (11 tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WCAG 1.3.1: add for/id pairs to all 6 fields in the create-invite form
- WCAG 1.4.1: add status icon (●○✕⏱) to status badge alongside label
- Add aria-label to copy-link buttons in the invite table
- Replace hardcoded German strings with i18n keys (Alle, Widerrufen,
Link kopieren, Kopiert, Abbrechen)
- Increase filter button touch targets py-1.5 → py-2
- Add 5 unit tests for register page load function (no-code, ok,
error-with-code, error-without-code, URL-encoding)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
InviteService was directly injecting AppUserRepository, UserGroupRepository,
and PasswordEncoder — crossing domain boundaries that UserService owns.
- Add UserService.createUser() with duplicate-email guard
- Add UserService.findGroupsByIds() delegation method
- InviteService now only injects UserService (not user repositories)
- generateCode() now throws INTERNAL_ERROR after 10 failed attempts
instead of looping indefinitely
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this guard any client could send X-Forwarded-For: <spoofed-ip>
and bypass per-IP rate limiting entirely.
Also switches expireAfterWrite → expireAfterAccess so the 1-minute
window starts at first request, not last, and fixes the .gitignore
entry that accidentally merged **/test-results/ and .worktrees/ into
one broken pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add @Email annotation to CreateUserRequest.email and AppUser.email
- Add @Valid to UserController.createUser to activate bean validation
- Add MigrationIntegrationTest cases for V44 NOT NULL and UNIQUE constraints
- Fix stale test comments (findByUsername → findByEmail)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously a blank email string would silently set email to null,
which would cause a DB constraint violation after V44 migration.
Now throws DomainException.badRequest instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
loadUserByUsername now calls findByEmail and returns email as the
Spring Security principal name. Tests updated to assert email identity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add * to Datum and Absender labels (both are required fields)
- Add required prop to PersonTypeahead to show * in its label
- Move "Optional" divider in DescriptionSection to after Titel (the only
required field), so Tags and Inhalt appear below the divider where they belong
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers: button present, confirm dialog opens, form submitted on confirm,
form not submitted on cancel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds label_required_fields to all three locales. Fixes "Datei ersetzen"
toolbar colors to use semantic ink tokens (readable in both light and dark
pdf-bg themes).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract DocumentEditLayout shared component for the PDF+form split-panel
UI, replacing the old scrolling layout on /documents/[id]/edit with the
same fixed-panel structure used by /enrich/[id]. Removes TranscriptionSection
and FileSectionEdit from the edit page; file upload/replace is now handled
by the shared layout. Delete SaveBar and FileSectionEdit as dead code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- error_file_upload_failed key used in enrich upload handler
- label_optional key added (de/en/es) and used in DescriptionSection divider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- text-[9px]/text-[10px] in required-fields bar raised to text-xs (12px),
meeting the project minimum for the 60+ audience (WCAG 1.4.4)
- Upload animation now uses motion-safe: prefix so it stops for users
with prefers-reduced-motion set (WCAG 2.1 SC 2.3.3)
- Strengthened UploadZone tests: onCancel uses [role=status] button
selector instead of first-button heuristic; added positive file
selection test (valid PDF calls onFile), file-too-large test, and
MIME rejection now also asserts the error message is visible
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DocumentService.attachFile() now catches IOException internally and
re-throws as DomainException.internal — the IOException no longer leaks
through the service boundary
- DocumentController.attachFile() is now a plain delegate (no try/catch)
- ALLOWED_CONTENT_TYPES whitelist (PDF/JPEG/PNG/TIFF) is now enforced on
the attachFile endpoint, matching the existing quick-upload validation
- Added 5 DocumentService unit tests for attachFile (notFound, status
transition PLACEHOLDER→UPLOADED, no-change when already UPLOADED,
field assignment from upload result, IOException→DomainException)
- Added controller tests: 400 on disallowed content type, 404 on missing doc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Required-fields progress bar (Pflichtfelder) with role="progressbar" ARIA tracks
Titel, Datum, and Absender live via bound props from child components
- Left panel shows UploadZone for PLACEHOLDER documents (no filePath); after upload
invalidates 'app:document' to transition to PDF viewer without page reload
- AbortController powers the cancel button during upload
- "Datei ersetzen" ghost button lives in a thin toolbar above the PDF viewer
- dateIso and currentTitle are now bound from WhoWhenSection/DescriptionSection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Field order: Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort.
currentTitle is now bindable so the enrich page can derive the required-fields progress bar.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required fields (Datum, Absender) move to row 1; optional fields (Empfänger, Ort)
to row 2. dateIso is now bindable for the progress bar. Autofocus lands on the
first empty required field on page load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pixel-accurate spec for the dashboard redesign: Resume + Family Pulse
layout with hero resume card, mission control 3-up, and activity feed.
Relates to #271
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the centered-card registration design 1:1 from the claude.ai/design export. Covers all 10 sections: desktop overview, header, above-card copy, form fields, password states, notification card, submit button, success panel, mobile layout, and i18n/a11y/backend implementation notes.
Relates to #269
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add missing test coverage for the amber QUEUED status badge in TrainingHistory.
Fix WCAG 2.2 minimum touch target (24 × 24 px) on the success-message dismiss
button in OcrTrainingCard. Add focus-visible ring to the expand/collapse toggle
in TrainingHistory so keyboard users get a visible focus indicator.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add V42 partial unique index on ocr_training_runs(person_id) WHERE status='QUEUED'
to enforce the per-person queued coalescing guarantee at the DB level. Also adds
@ExtendWith(MockitoExtension.class) to SenderModelServiceTest for consistency with
the rest of the service test suite, with lenient() on the shared txTemplate stub.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the per-run getById loop with a single getAllById call on distinct
person IDs, eliminating the N+1 query when training history contains multiple
sender model runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the intermediate Map<String,Object> and return the typed record directly
so OpenAPI codegen produces a concrete TypeScript type. Fixes lastRun serializing
as {} (empty object) instead of null when no training run exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 503/403 auth tests for the /train-sender endpoint, matching the pattern
already used for /train and /segtrain. Also surface test_sender_registry.py
in CI (it needs no ML stack) and add pytest-asyncio to the install step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrAsyncRunner now passes the per-sender model path to streamBlocks for
HANDWRITING_KURRENT documents. processDocument replaced extractBlocks
with streamBlocks + AtomicReference, removing the unchecked raw-array
pattern.
Also stages all previously uncommitted foundational files for this
feature: SenderModel entity, SenderModelRepository, Flyway migrations
V40/V41, updated OcrClient/RestClientOcrClient streaming API,
TrainingDataExportService.exportForSender, TranscriptionService Kurrent
hook, application.yaml OCR config, and frontend i18n/test additions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eliminates cross-domain repository access: OcrTrainingService no longer
holds SenderModelRepository. SenderModelService now owns the full sender
training lifecycle (runOrQueueSenderTraining, triggerSenderTraining,
promoteNextQueuedRun), removing the circular dependency risk.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The controller now builds the map inline (with personNames support).
This method had zero callers.
Fixes reviewer concerns from @felixbrandt and @mkeller.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces catch(Exception ignored){} with log.debug() in getTrainingInfo().
Adds controller test documenting the graceful degradation behavior
(response stays 200 when personService.getById() throws).
Fixes reviewer concerns from @felixbrandt and @nullx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends Run interface with personId and QUEUED status, TrainingInfo with
personNames map, and passes it through to TrainingHistory for per-sender
model column display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrTrainingRun now includes personId (uuid, optional) and QUEUED status.
TrainingInfoResponse includes runs array with personId fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- OcrAsyncRunnerTest: switch from extractBlocks/4-arg streamBlocks stubs
to 5-arg streamBlocks (senderModelPath param) via doAnswer
- TranscriptionServiceTest: stub documentService.getDocumentById in
updateBlock tests so the new Kurrent training hook does not NPE
- OcrControllerTest: add @MockitoBean PersonService (now injected into
OcrController for personNames assembly in getTrainingInfo)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrTrainingCard and SegmentationTrainingCard now live on the dedicated
OCR overview page. System page no longer fetches training info.
SegmentationTrainingCard updated to use shared TrainingRun type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add showPersonColumns prop (default true) to TrainingHistory.
SegmentationTrainingCard passes false — segmentation is not person-specific.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consistent with triggerManualSenderTraining — both defensive paths now use
DomainException.internal(OCR_TRAINING_CONFLICT) when the expected RUNNING row
is not found after creation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SvelteKit page components receive only data/form as props; accessing params
directly caused a TypeError and personName always fell back to 'Unknown'.
Also moves py-3 padding from <td> to <a> in OcrModelsTable to give
keyboard/touch users a full-height 44px target (WCAG 2.5.5).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ensures the unexpected-state path produces a structured JSON error response
instead of an unmapped 500 RuntimeException. Adds OCR_TRAINING_CONFLICT
ErrorCode and mirrors it in the frontend errors.ts. Adds coverage tests for
getAllSenderModels() and runSenderTraining().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrHealthBar spec used /online/i and /offline/i text matchers that would fail
in Spanish locale — replaced with CSS class assertions on role="img" dot.
Added focus-visible:ring-2/ring-brand-navy/rounded-sm to all links in OCR
admin pages (OcrModelsTable person+details, global history link, back-links
in global and personId detail pages) to satisfy WCAG 2.4.7.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Controller was deciding when to fire runSenderTraining based on the returned run
status — a business rule that belongs in the service. Introduces @Lazy self-reference
to preserve @Async proxy dispatch without self-invocation bypassing Spring AOP.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace hardcoded EN strings in OcrHealthBar/OcrStatCards/OcrModelsTable with
Paraglide message keys (de/en/es translations added)
- Add role=img + aria-label to OcrHealthBar status dot
- Add {:else} empty-state row in OcrModelsTable
- Fix personName derivation in [personId]/+page.svelte to use params.personId key
instead of Object.values()[0] (fragile when multiple persons present)
- Update OcrModelsTable spec to assert empty-state row structure (locale-agnostic)
- Add missing availableSegBlocks test to OcrStatCards spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add findByPersonIdIsNullOrderByCreatedAtDesc + findByPersonIdOrderByCreatedAtDesc to
OcrTrainingRunRepository. Add dto/TrainingHistoryResponse. Expose
GET /api/ocr/training-info/global and GET /api/ocr/training-info/{personId} on
OcrController, both requiring ADMIN; getSenderTrainingHistory guards person existence
via PersonService and returns 404 for unknown personId.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move TrainingInfoResponse from private nested record to dto/TrainingInfoResponse.java,
add senderModels field, inject SenderModelService into OcrTrainingService so personNames
covers all known senders rather than only recent-run participants.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add missing test coverage for the amber QUEUED status badge in TrainingHistory.
Fix WCAG 2.2 minimum touch target (24 × 24 px) on the success-message dismiss
button in OcrTrainingCard. Add focus-visible ring to the expand/collapse toggle
in TrainingHistory so keyboard users get a visible focus indicator.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add V42 partial unique index on ocr_training_runs(person_id) WHERE status='QUEUED'
to enforce the per-person queued coalescing guarantee at the DB level. Also adds
@ExtendWith(MockitoExtension.class) to SenderModelServiceTest for consistency with
the rest of the service test suite, with lenient() on the shared txTemplate stub.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the per-run getById loop with a single getAllById call on distinct
person IDs, eliminating the N+1 query when training history contains multiple
sender model runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the intermediate Map<String,Object> and return the typed record directly
so OpenAPI codegen produces a concrete TypeScript type. Fixes lastRun serializing
as {} (empty object) instead of null when no training run exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add 503/403 auth tests for the /train-sender endpoint, matching the pattern
already used for /train and /segtrain. Also surface test_sender_registry.py
in CI (it needs no ML stack) and add pytest-asyncio to the install step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrAsyncRunner now passes the per-sender model path to streamBlocks for
HANDWRITING_KURRENT documents. processDocument replaced extractBlocks
with streamBlocks + AtomicReference, removing the unchecked raw-array
pattern.
Also stages all previously uncommitted foundational files for this
feature: SenderModel entity, SenderModelRepository, Flyway migrations
V40/V41, updated OcrClient/RestClientOcrClient streaming API,
TrainingDataExportService.exportForSender, TranscriptionService Kurrent
hook, application.yaml OCR config, and frontend i18n/test additions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eliminates cross-domain repository access: OcrTrainingService no longer
holds SenderModelRepository. SenderModelService now owns the full sender
training lifecycle (runOrQueueSenderTraining, triggerSenderTraining,
promoteNextQueuedRun), removing the circular dependency risk.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The controller now builds the map inline (with personNames support).
This method had zero callers.
Fixes reviewer concerns from @felixbrandt and @mkeller.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces catch(Exception ignored){} with log.debug() in getTrainingInfo().
Adds controller test documenting the graceful degradation behavior
(response stays 200 when personService.getById() throws).
Fixes reviewer concerns from @felixbrandt and @nullx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends Run interface with personId and QUEUED status, TrainingInfo with
personNames map, and passes it through to TrainingHistory for per-sender
model column display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrTrainingRun now includes personId (uuid, optional) and QUEUED status.
TrainingInfoResponse includes runs array with personId fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- OcrAsyncRunnerTest: switch from extractBlocks/4-arg streamBlocks stubs
to 5-arg streamBlocks (senderModelPath param) via doAnswer
- TranscriptionServiceTest: stub documentService.getDocumentById in
updateBlock tests so the new Kurrent training hook does not NPE
- OcrControllerTest: add @MockitoBean PersonService (now injected into
OcrController for personNames assembly in getTrainingInfo)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace exact-string assertions in test_correctable_ocr_error_gets_corrected
and test_sentence_with_multiple_corrections with structural assertions that
verify behavior (correction attempted, marker present, expected stem) without
coupling to a specific pyspellchecker version's frequency weights.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Strict greater-than avoids non-determinism: if multiple candidates share
the minimum frequency value, pyspellchecker's ranking is undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drop underscore prefix — the helper is part of confidence.py's effective
public API since spell_check.py imports and calls it directly.
Fixes reviewer concern: importing a _-prefixed name across module boundaries
contradicts Python's private-by-convention signal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
153K words from dtak+dtae 1800-1899 corpora (min_freq=20),
covering pre-reform spellings common in Kurrent/Süterlin documents.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pdfDoc was a plain variable (not \$state), so renderer.isLoaded had no
reactive dependencies in Svelte 5. PdfControls received isLoaded=false
permanently, keeping the next-page button disabled while zoom buttons
(which have no disabled attribute) still worked.
Fix: derive isLoaded from totalPages (\$state) via totalPages > 0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add test for 1×1 image (sub-tile-size) resilience and narrow preprocess_page
fallback from except Exception to (cv2.error, ValueError, MemoryError) so
programming errors propagate instead of being silently swallowed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Confirms that Enter on a suggestion item adds the tag even when allowCreation is
false — the activeIndex guard in handleKeydown runs before the allowCreation check.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fetchSuggestions has no debounce; the wait is purely for the async mock to
resolve. The old name implied semantics that don't exist and added ~4.5s to
the suite (13 uses × 350ms).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrites orderedSuggestions to a recursive DFS with SuggestionEntry type,
adds role=listbox, depth indentation via inline style, font-medium for direct
matches, text-ink-3 for context nodes, and › prefix for root-level ancestors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Visual spec for tree-aware tag typeahead: parent matches expand to
show children, child matches surface ancestor path for context.
Covers backend enrichment strategy (TagService.search enrichment via
existing recursive CTEs) and frontend DFS ordering + depth-indent
rendering in TagInput.svelte.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SvelteKit's use:enhance resets the form after a successful action.
The name input used value={data.tag.name} without bind:, so Svelte 5's
fine-grained reactivity did not re-apply the unchanged value after the
reset — leaving the field empty. Passing reset: false to update() fixes
this.
Also corrected the confirmation message from "renamed" to "saved" in
all three locales, since the action updates name, parent, and color.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TagDeleteGuard now calls confirm() (admin_tag_delete_confirm) before
submitting — same pattern as document delete. Button changed to type=button
with an async handler; page.svelte.spec.ts updated to pass ConfirmService
context so TagDeleteGuard can initialise inside the page render.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After a successful merge, redirect 303 to /admin/tags/{targetId}?merged=1.
Load function detects the param and returns mergeSuccess:true; +page.svelte
renders the banner and cleans the URL with replaceState so refresh doesn't
re-show it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TagMergeZone: add $effect to reset targetId when tag prop changes (fixes stale form after navigation)
- TagMergeZone: pass merge-specific placeholder to TagParentPicker
- TagMergeZone: show success banner on form.mergeSuccess and goto() target tag
- +page.server.ts: merge action returns { mergeSuccess, mergeTargetId } instead of redirect
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add filter_operator_and/or/and_label/or_label i18n keys to de/en/es locale files
- Add aria-label and aria-pressed to AND/OR toggle buttons in SearchFilterBar
- Add data-testid="operator-and/or" for unambiguous test targeting (fixes substring match on German "Schlagwort")
- Use stable keys (tag.id ?? tag.name) for TagInput chip and suggestion lists
- Remove aria-level from role="option" items in TagInput (invalid attribute for that role)
- Add aria-live="polite" role="status" to TagMergeZone step indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace stringly-typed "AND"/"OR" tagOperator with TagOperator enum (DocumentService, DocumentController)
- Replace Object[] with TagCount projection interface in TagRepository.findDocumentCountsPerTag()
- Use @NotNull + @Valid on MergeTagDTO.targetId; remove manual null check from TagController
- Correct ALLOWED_TAG_COLORS to match actual frontend CSS tokens (sage/sienna/amber/slate/violet/rose/cobalt/moss/sand/coral)
- Add TOCTOU comment to validateNoAncestorCycle() with mitigation explanation
- Add test: deleteWithDescendants_skipsDocTagDeletion_whenDescendantIdsIsEmpty
- Update TagServiceTest to use mock TagRepository.TagCount projection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Colors are stored only on root-level tags. DocumentService now calls
TagService.resolveEffectiveColors() before returning search results and
single-document responses, so child tags carry their parent's color when
serialised to JSON. Parent tags are batch-loaded in a single query.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SvelteKit reuses the same +page.svelte instance on client-side navigation,
so $state() initialisations only run on mount. Add an $effect keyed on
data.tag.id to reset parentId, selectedColor and deleteConfirmName whenever
the user switches to a different tag in the admin sidebar.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sending tagQ alongside selected tags caused an unintended AND: documents
had to match both the selected-tag filter and the partial-name filter,
making the list shrink while the user was still typing a new tag.
tagQ is now only forwarded to the backend when no tags are selected,
which is the only case where the live partial-filter is meaningful.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The tag-change $effect called triggerSearch() immediately (no debounce).
When the user toggled AND/OR within the 500 ms debounce window, the prior
navigation would complete and reset tagOperator back to AND before the
debounced search fired. The toggle now calls onSearchImmediate, which
clears any pending timer and fires triggerSearch() synchronously.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds INVALID_TAG_COLOR and TAG_CYCLE_DETECTED to the frontend ErrorCode
type and getErrorMessage() switch. German, English, and Spanish
translations added for both codes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TagsListPanel now accepts optional parentId/color on each Tag. A
$derived.by walk produces an ordered flat list with depth annotations.
Child tags are indented with pl-5; root-level tags with a color get
a colored dot before their name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tag edit form gains a parent <select> listing all other tags (self
excluded) and a 10-swatch color picker that is only shown when no
parent is selected. Submitting passes parentId and color to the PUT
/api/tags/{id} endpoint via TagUpdateDTO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reads ?tagOp=OR from URL in +page.server.ts, passes it to the backend
search endpoint, and surfaces it via the filters return. +page.svelte
initialises tagOperator state from filters, writes it back to the URL
in triggerSearch(), and binds it to SearchFilterBar.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Toggle appears when ≥2 tags are selected; defaults to AND.
Exposes tagOperator prop ('AND'|'OR') for parent to read via bind.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- TagRepository: add findDescendantIdsByName() recursive CTE query
- TagService: add expandTagNamesToDescendantIdSets() for document search
Frontend:
- TagInput: accept Tag[] (id, name, color, parentId) instead of string[]
- Chips show color dot via var(--c-tag-{color}) when tag has color
- Suggestions grouped hierarchically: children indented under their parents
- Update DescriptionSection, edit/new pages, SearchFilterBar, +page.svelte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Light and dark variants for: sage, sienna, amber, slate, violet, rose,
cobalt, moss, sand, coral — used as decorative dot colors on tag chips.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds TagTreeNodeDTO, TagUpdateDTO (parentId + color), /api/tags/tree endpoint,
and parentId/color fields on Tag schema.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace hasTags(List<String>) spec with hasTags(List<Set<UUID>>, useOr)
- AND mode: one EXISTS subquery per expanded tag ID set; empty set = disjunction
- OR mode: union of all expanded sets into a single EXISTS subquery
- DocumentService calls tagService.expandTagNamesToDescendantIdSets() before building spec
- DocumentController exposes ?tagOp=AND|OR query param (default AND)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds parent_id FK (ON DELETE SET NULL), self-reference check constraint,
parent_id index, and nullable color column to the tag table.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
"Text eintippen" sounded too casual and diverged from the domain
language used elsewhere in the app.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
"Rahmen einzeichnen" assumed familiarity with the segmentation concept;
"Text markieren" is self-explanatory for new contributors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Lesefertig pulse was removed from the UI; drop the backend support
for it too — removes the subquery from findWeeklyStats(), the projection
getter, the DTO field, and updates all affected tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The weekly count in Lesefertig counted any document with a reviewed
block in the past 7 days, not documents that crossed the ≥90% ready
threshold — a misleading stat given the column shows a different set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
17 tests across SegmentationColumn, TranscriptionColumn, ReadyColumn,
MissionControlStrip. Covers document list rendering, per-column empty
states, weekly pulse visibility, link hrefs, progress bar, and the
reviewedPct denominator (annotationCount, not textedBlockCount).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add formatMCDate() to $lib/utils/date.ts (locale-aware, medium format);
remove duplicated inline formatDate() from all three column components
- Replace local TranscriptionQueueItemDTO/TranscriptionWeeklyStatsDTO type
declarations with imports from $lib/generated/api across all four components
- Add dashed empty states to SegmentationColumn and TranscriptionColumn
(ReadyColumn already had one)
- Remove outer {#if} from MissionControlStrip so the section is always
visible — each column owns its own empty state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TranscriptionColumn progress bar: add aria-hidden="true" (the block count
text above already communicates the value to screen readers)
- TranscriptionColumn weekly pulse: text-ink → text-ink-2 (matches
SegmentationColumn, same semantic element)
- ReadyColumn reviewedPct: align denominator to annotationCount so the
displayed percentage matches the SQL threshold used to classify "ready"
- page.svelte.spec.ts: add missing segmentationDocs/transcriptionDocs/
readyDocs/weeklyStats to emptyData fixture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The original needsExpert V37 migration was applied to the dev DB before
the feature was removed. Renaming our new indexes migration to V38 avoids
the Flyway checksum conflict. Regenerated api.ts now reflects the
@Schema(requiredMode=REQUIRED) annotations — DTO fields are non-optional.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All non-null DTO fields are now marked required so the generated api.ts
emits required (non-optional) types for callers. V37 migration adds
created_at/updated_at indexes on document_annotations and transcription_blocks
to avoid full table scans in the weekly stats correlated subqueries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies 401/403/200 responses for all four endpoints. Matches
the @WebMvcTest + @RequirePermission pattern used across the project.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces TranscriptionQueueProjection and TranscriptionWeeklyStatsProjection
interfaces so column reordering in native SQL can never silently produce wrong
data. Removes the four type-coercion helpers (toUUID, toLocalDate, toInt, toLong)
from TranscriptionQueueService. Covered by TranscriptionQueueServiceTest (6 tests).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two backend tests passed a 6-element enrichment row but the rebase
added summary_snippet as column 7 — added null at index 6 to both
fixtures.
Two frontend page.server tests mocked only 4 dashboard API calls but
the page now makes 8 (3 Mission Control queues + weekly-stats added
on this branch) — added the 4 missing mock responses.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The ready-to-read and transcription queue queries were dividing
reviewed blocks by textedBlockCount instead of annotationCount.
A document with 4/15 annotations typed — all 4 reviewed — scored
4/4 = 100 % and incorrectly appeared in the Lesefertig column.
Both queries now compute the ratio as:
reviewed / annotationCount
so a document must have ≥ 90 % of all its drawn regions reviewed
before it graduates to Lesefertig.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drops the needsExpert / needs_expert flag end-to-end: DB migration
(V37, never applied), Document entity field, PATCH endpoint, service
method, DTO field, all three queue queries, ExpertBadge component,
i18n key, generated API types, and test fixture.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The enrich page already handles task routing; the buttons in the
segmentation and transcription columns were redundant. Removes the
unused mission_control_segmentation_cta, mission_control_transcription_cta,
and mission_control_ready_all_cta keys from all three locale files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Strip heading: "Mitarbeiten" → "Was braucht Aufmerksamkeit?"
- Column 1 heading: "Segmentierung" → "Rahmen einzeichnen"; add green
skill pill "✓ Ohne Vorkenntnisse"; heading color gray → ink (navy)
- Column 2 heading: "Transkription" → "Text eintippen"; add navy skill
pill "Kurrent hilfreich"; heading color gray → ink; weekly pulse
color green → ink (task, not achievement); progress bar track
bg-gray-200/h-1.5 → bg-ink/20/h-1; add transition-all to fill
- Column 3 heading: "Lesefertig" → "Lesefertig ✓"; heading color
gray → green-800; add "N Dokumente bereit" subtitle in green; add
"Alle N lesen →" link at bottom; reviewed % color gray → green-800
- All columns: add CTA buttons at bottom (Jetzt einzeichnen /
Jetzt tippen); empty state removed from cols 1 & 2 (columns
hide when empty); empty-state ghost CTA in col 3 restyled as
bordered button with hover:bg-ink
- Strip: add visibility guard — hides when all three lists are empty
- i18n: add mission_control_seg_skill_pill, mission_control_trans_skill_pill,
mission_control_ready_subtitle, mission_control_ready_all_cta in
de/en/es; update heading and CTA copy in all three locales
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /enrich route is for metadata (title, date, sender/receiver).
Segmentation and transcription work happens on the document detail page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V36 (add_index_transcription_blocks_document_id) was applied to the dev
database during a previous local session but never committed to git.
Flyway checksum mismatch prevented the backend from starting.
- V36__add_index_transcription_blocks_document_id.sql: restored from the
index that already exists in the database (idx_transcription_blocks_document_id)
- V36__add_needs_expert_to_documents.sql → V37__add_needs_expert_to_documents.sql
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the design decision record for how to expand the dashboard without
pushing content below the fold: a full-width 3-column strip (Segmentierung /
Transkription / Lesefertig) below the existing grid.
- dashboard-expansion-patterns.html — four pattern alternatives evaluated
(Tabs, Accordion, Mission Control, Priority Queue) with annotated mockups,
engagement feature proposal, and final recommendation.
- mission-control-strip-final.html — clean implementation blueprint with
pipeline diagram, column definitions, seeded-weekly-shuffle sorting,
expert-flag escape hatch, all Tailwind impl-ref values, and backend
contracts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the full-width 3-column collaboration widget below the existing
dashboard grid. Renders without the backend running (Promise.allSettled
isolation keeps failures silent).
Components (src/lib/components/):
- ExpertBadge.svelte — purple pill with icon, no props
- SegmentationColumn.svelte — col 1: links to /enrich/{id}, weekly pulse
- TranscriptionColumn.svelte — col 2: per-doc progress bar when blocks exist
- ReadyColumn.svelte — col 3: mint border when filled, dashed empty state
- MissionControlStrip.svelte — strip wrapper, 1-col mobile / 3-col sm+
i18n: 19 new keys added to de/en/es (mission_control_*)
Page wiring:
- +page.server.ts: 4 new Promise.allSettled calls for segmentation-queue,
transcription-queue, ready-to-read, weekly-stats; all failures silent
- +page.svelte: MissionControlStrip rendered below the grid in isDashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Manually adds the new types to src/lib/generated/api.ts:
- Document.needsExpert: boolean (required field)
- TranscriptionQueueItemDTO schema
- TranscriptionWeeklyStatsDTO schema
- Paths: /api/transcription/{segmentation-queue, transcription-queue,
ready-to-read, weekly-stats} and /api/documents/{id}/needs-expert
- Operations: matching typed request/response shapes
Fixes briefwechsel spec fixtures to include scriptType and needsExpert
so the Document type shape is satisfied.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a summary_snippet column to findEnrichmentData using ts_headline on
documents.summary, only when the summary's tsvector matches the query.
Expose it via SearchMatchData.summarySnippet / summaryOffsets and render
a "Zusammenfassung" / "Summary" / "Resumen" labelled row in the document
list — identical treatment to the transcription snippet row.
Fixes the case where a document appeared in search results with no
visible match explanation (e.g. searching "frucht" found a document
whose summary mentioned "Früchte").
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Switch search match highlights from bordered mint chips to a plain navy
underline (decoration-brand-navy). Add visible "Inhalt" / "Content" /
"Contenido" label before the transcription snippet, matching the style
of the Von/An sender-receiver labels.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace websearch_to_tsquery with a CROSS JOIN LATERAL subquery that
appends :* to each lexeme so prefix matches work (e.g. "furchtb" finds
"furchtbar"). websearch_to_tsquery still handles the safe tokenisation
of user input (stop words, special chars, operators); regexp_replace
then adds :* before to_tsquery re-parses the result.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SearchMatchData gains a 6th field snippetOffsets: List<MatchOffset> so the frontend
can render highlighted terms inside the transcription snippet without {#html}.
- DocumentRepository.findEnrichmentData now calls ts_headline() with chr(1)/chr(2)
sentinels instead of returning raw block text; parseHighlight() strips the sentinels
and produces clean text + MatchOffset list in one pass.
- DocumentService exposes ParsedHighlight and parseHighlight() as public so they can be
called from cross-package integration tests.
- All related tests updated to the new 6-argument SearchMatchData constructor and
to call parseHighlight() for asserting the snippet clean text and offsets.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The refactor made pdfDoc a plain variable so renderer.isLoaded was not
reactive. Svelte only tracked currentPage and scale — but when the canvas
reappeared after loading, neither changed, so the PDF stayed blank.
Fix: merge the two effects into one that reads canvasEl synchronously.
Svelte now tracks canvasEl as a dependency; when the canvas remounts
(loading spinner → false), the effect re-fires and renders the
already-loaded PDF document.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DocumentService.searchDocuments now returns DocumentSearchResult with matchData
populated from findEnrichmentData. Title highlights are parsed from chr(1)/chr(2)
delimiters into MatchOffset lists; transcription snippet and sender/receiver/tag
match flags are extracted from the same native SQL row.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
loadFile() reads fileUrl synchronously before its first await. When
called from a \$effect, Svelte tracks that read and re-runs the effect
every time fileUrl changes — i.e. after every successful load — causing
an infinite cycle of file fetches and PdfViewer remounts.
Fix: wrap the fileUrl read in untrack() so callers never accidentally
subscribe to fileUrl changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds simulateDragDrop helper and three tests covering the splice/insertAt
index arithmetic in handlePointerUp:
- move-to-end (insertAt path where target > fromIdx)
- move-to-start (insertAt path where target <= fromIdx)
- move-down-by-one (verifies the off-by-one dropTargetIdx - 1 branch)
Fixes @saraholt: "reorder calculation in handlePointerUp is untested"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
text-[9px] is below WCAG practical minimum and unreadable for senior users.
Changed all three occurrences (tablet button count, desktop link label,
flyout link label) to text-[11px].
Fixes @leonievoss: "text-[9px] is below 12px minimum"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds MockEventSource.simulate() helper and two tests covering:
- unread notification via SSE prepends to list and increments unreadCount
- read notification via SSE adds to list but does not increment unreadCount
Fixes @saraholt: "SSE event handling not tested"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NotificationDropdown was importing relativeTime through notifications.ts,
creating an accidental coupling to a module unrelated to timestamp formatting.
Now imports directly from the canonical \$lib/utils/time module.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move the identical isDirty / beforeNavigate / discard pattern out of the
three admin detail pages (groups, tags, users) into a reusable
createUnsavedWarning() hook and a UnsavedWarningBanner presentational
component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move blob URL lifecycle management into a reusable createFileLoader()
hook that owns revoke-before-create and revoke-on-destroy. Replace
identical inline logic in documents/[id] and enrich/[id] with the hook.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Calling loadFile a second time previously leaked the previous object URL.
Add URL.revokeObjectURL(fileUrl) before creating a new one and in
onDestroy so all URLs are freed. Revoke behavior will be covered by the
useFileLoader hook tests in the next commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add format?: 'short'|'long' (default 'long') to date.ts formatDate and
remove the duplicate from personFormat.ts. Update DocumentTopBar to
import from date.ts directly. Move the formatDate tests from
personFormat.spec to date.spec.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unify the initials-extraction logic: the new string-based getInitials()
splits on whitespace, takes the first char of the first and last word
uppercased — matching the pattern that was already inlined in
CommentThread. Update PersonChip, DocumentMetadataDrawer, and
CommentThread to use the shared function.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move relativeTime from notifications.ts (Intl.RelativeTimeFormat) to a
new time.ts that uses the Paraglide comment_time_* message keys — the
same logic that was already in CommentThread's timeAgo(). Remove the
duplicate timeAgo() from CommentThread and re-export relativeTime from
notifications.ts for backwards compatibility.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the inline attachClickOutside attachment in NotificationBell with
the shared use:clickOutside action from $lib/actions/clickOutside. The
inline implementation was functionally identical to the existing action.
Guard the onclickoutside handler so it only calls closeDropdown() when
the notification panel is already open, preventing the bell button from
stealing focus from other interactive elements (e.g. the user avatar menu).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The action already checks event.defaultPrevented before dispatching
clickoutside, but that branch had no test. Add the missing case and
add a one-line comment explaining why capture phase is used.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the old conversations page that was superseded by briefwechsel/.
No navigation link pointed to /conversations; it was unreachable through
the UI. Deletes 5 files, removes 14 orphaned i18n keys from de/en/es
message bundles, and removes E2E tests that navigated to /conversations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Testcontainers 2.0.2 (via Spring Boot 4.0) negotiates Docker API 1.44,
but the NAS runner has Docker Engine 24.x which caps at 1.43. Forcing
the client version down unblocks tests until Docker is upgraded on the NAS.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inlang regenerates .meta.json and README.md on every compilation run.
The regenerated files fail Prettier in CI because the tool writes its
own formatting, not ours.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- should_find_document_by_sender_name — symmetric with existing receiver test
- fts_combined_with_status_filter_excludes_non_matching_status — verifies
hasIds(rankedIds).and(hasStatus(...)) two-phase search works together
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ids.indexOf() scans the full list for each document, giving O(n²) total.
Build a Map<UUID, Integer> once at O(n) and use getOrDefault at O(1) per
document. Behavior is identical; existing tests remain green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a user explicitly selects DATE sort with a text query active, the
previous code treated it identically to RELEVANCE, silently discarding
the user's sort choice. Remove DATE from the useRankOrder condition so
that explicit DATE sort always goes through the standard JPA sort path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fires the BEFORE UPDATE trigger for every documents row, which recomputes
the tsvector from all currently-linked metadata, blocks, receivers, and tags.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- V34 migration: adds search_vector tsvector column with GIN index
- BEFORE INSERT/UPDATE trigger on documents rebuilds vector from title (A),
summary + transcription_blocks.text (B), sender/receiver names (C),
tag names + location (D) using german FTS config
- AFTER triggers on transcription_blocks, document_receivers, document_tags
touch the parent document row to re-fire the BEFORE UPDATE trigger
- DocumentRepository.findRankedIdsByFts() native query using websearch_to_tsquery
- DocumentFtsTest: 12 integration tests covering stemming, trigger sync,
ranking, stop words, malformed input, receiver and tag search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With a pre-built JAR, Spring Boot + Flyway starts in ~15 seconds.
The previous 60s was sized for runtime compilation (90+ seconds).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Pin to eclipse-temurin:21.0.10_7-{jdk,jre}-noble for reproducible builds
- Switch -DskipTests to -Dmaven.test.skip=true: skips test compilation entirely,
not just execution — faster and avoids build failures from test-only missing classes
- Add comment on COPY *.jar explaining why the glob is safe (Spring Boot renames
the pre-repackage artifact to .jar.original, leaving only one .jar in target/)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents 111MB of compiled output from being sent to the BuildKit daemon
on cold builds. Only .mvn/, mvnw, pom.xml, and src/ are needed by the
three COPY instructions in the Dockerfile.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace runtime mvn spring-boot:run with a proper multi-stage build:
- Stage 1 (builder): compiles JAR with BuildKit cache mount for ~/.m2
- Stage 2 (runtime): eclipse-temurin:21-jre with only the JAR
Removes the backend source volume mount and maven_cache named volume.
Deploy with: docker compose up -d --build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace hand-rolled enrichedDocuments year-divider logic with the shared
groupDocuments utility. Also fixes a timezone bug in documentYears: adds
'T12:00:00' to date strings so getFullYear() doesn't drift on UTC boundaries.
No behavior change — year dividers render the same way as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors the existing sort allowlist pattern. Any value other than 'asc' or
'desc' silently falls back to 'desc', preventing arbitrary strings from
reaching the search API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
text-xs text-ink/40 (~2.1:1) fails WCAG AA; text-sm bold at text-ink/60
(~3.7:1) passes the large-text 3:1 threshold. Also adds role="separator"
and aria-label so screen readers announce the group boundary.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add failing test for DATE-sort + undated doc showing "Undatiert" fallback
label, then fix DocumentList by null-coalescing sort before comparison
((sort ?? 'DATE') === 'DATE'). Test uses one dated + one undated doc to
produce two groups and trigger GroupDivider rendering.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents sorted by DATE show year dividers, SENDER/RECEIVER sort
shows person name dividers. Dividers only appear when there are 2+
distinct groups. Multi-receiver docs appear in each receiver group.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- start_period 60s → 120s: Zenodo download on cold start can exceed 60s on slow connections
- ocr_cache volume comment: documents what the cache stores for future operators
- .env.example: add token generation command to prevent weak placeholder in production
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add aria-expanded + aria-controls to expand button (WCAG 4.1.2)
- Add id="training-history-rows" to tbody for aria-controls target
- Replace title= tooltip on FAILED badge with details/summary for keyboard
and touch accessibility; add training_error_detail_label i18n key
- Use motion-safe:animate-pulse on RUNNING badge for prefers-reduced-motion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _model_is_loadable: narrow bare except to (RuntimeError, OSError, ValueError)
with DEBUG-level fallback for unexpected exceptions — prevents silent masking
of missing kraken install or AttributeError on vgsl
- _run_segtrain: replace bare except:pass with log.warning so height-check
fallback is visible in container logs
- New test_ensure_blla_model.py: covers model-OK early return, incompatible
model rename+replace, and missing model download paths
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both training panels (OCR and segmentation) share TrainingHistory.
Show only the 3 most recent runs by default; render a Mehr/Weniger
anzeigen button when there are more.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setCer() was called for recognition training but not for segmentation.
The OCR service now returns cer = 1 - accuracy for segtrain; persist it
so the admin panel can display Fehlerrate for both training types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Pass OCR_TRAINING_TOKEN through to the backend container as
APP_OCR_TRAINING_TOKEN so RestClientOcrClient sends the X-Training-Token
header when calling /train and /segtrain.
- Raise mem_limit/memswap_limit from 8g to 12g to give segtrain headroom
on hosts with more available RAM.
- Uncomment OCR_TRAINING_TOKEN in .env.example — it is now required.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three issues fixed:
1. --resize both was removed in ketos 7; replaced with --resize union
which extends the model's class mapping to include training data classes.
2. ketos ignores -s when -i is present, so the 1800px blla model caused
7+ GB peak RAM and OOM-killed the host (no swap, 5 GB free).
Now checks the loaded model's input height: only uses the base model
when it was already fine-tuned at 800px; otherwise trains from scratch
at 800px (~200 MB peak). After the first run the trained 800px model
becomes the base for all subsequent fine-tuning runs.
3. segtrain now computes and returns cer = 1 - accuracy, matching the
recognition training path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds ensure_blla_model.py which loads the blla segmentation model with
ketos on every container start. If the model is missing or in the legacy
PyTorch ZIP format (incompatible with ketos 7), it re-downloads the
correct CoreML protobuf model from Zenodo (DOI 10.5281/zenodo.14602569).
The Dockerfile now uses entrypoint.sh which runs this check before
starting uvicorn.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add tabindex="0" so the SVG can receive DOM focus
- Auto-focus the SVG on mount so arrow keys work immediately after
clicking an annotation to select it
- Show preview rect during keyboard nudging (not just pointer drag) by
checking hasLiveChanges instead of only checking dragState
- Suppress default browser focus outline (outline: none) on the SVG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends the 4-corner L-bracket handles with 4 tick-mark edge handles
(short lines along each edge), enabling single-axis resize from any edge.
Updates applyHandleDrag to route each handle to the correct axis.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 4 corner-only handles (nw/ne/sw/se), no edge midpoints
- Each handle renders as two short perpendicular lines meeting at the corner
(10px arms, navy, square linecap) — no fill, no box
- Thin dashed selection border added to SVG overlay to signal edit mode
- Simplify applyHandleDrag to remove dead n/s/e/w branches
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ResizeObserver binds actual SVG pixel dimensions; viewBox matches them so
16px handle squares and 44px hit areas are physically correct regardless of
the annotation's aspect ratio.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Commit 5afdc37 changed the empty state from transcription_empty_cta
('Markiere einen Bereich…') to transcription_empty_draw_hint
('Zeichnen Sie Bereiche…') but left the spec asserting the old text.
Updated the locator to match the current component output.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removed duplicate import of org.mockito.ArgumentMatchers.eq from
DocumentControllerTest (lines 32+35). Added @ApiResponse(responseCode="204")
to patchTrainingLabel so the generated OpenAPI spec matches the actual
NoContent response the controller returns.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WCAG 1.4.1 (Use of Color) requires non-color redundant cues for status.
The unicode ✓/✗ characters had inconsistent screen-reader support.
Replaced with explicit aria-hidden SVG icons (checkmark / x-circle)
alongside the translated status text labels.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionEditView rendered 'Kurrent-Erkennung' and 'Segmentierung'
as hardcoded German strings, breaking the en/es locales. Added
training_chip_kurrent and training_chip_segmentation keys to all three
message files and wired them up via m.training_chip_kurrent() /
m.training_chip_segmentation().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_check_training_token previously skipped auth when TRAINING_TOKEN was
empty, allowing unauthenticated requests to reach /train and /segtrain.
Now returns 503 ("Training not configured on this node") when the token
is absent, so missing configuration fails closed rather than open.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrTrainingService.triggerTraining() and triggerSegTraining() held a DB
connection open for the entire ketos training run (potentially minutes),
risking connection pool exhaustion. Replaced class-level @Transactional
with TransactionTemplate for narrow DB writes: guard+create and
result-record each run in their own short transaction; the HTTP call to
the OCR service runs between them with no open connection.
Also replaces blockRepository.findAll().size() with blockRepository.count()
in getTrainingInfo() to avoid loading every block into heap on each poll.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two workers × ~5 GB Surya model load = ~10 GB required, exceeding the
8 GB memory cap and causing OOM on the first /train call. Two OS
processes also cause model-state divergence after training, contradicting
the single-node constraint documented in ADR-001.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V23 introduced a JSONB check constraint (chk_annotation_polygon_quad)
requiring polygon arrays to have exactly 4 points. V30 introduced a
partial unique index preventing two concurrent RUNNING training runs.
These are DB-level invariants that unit tests cannot verify — five
Testcontainers tests now assert they are correctly applied by Flyway
and enforced by PostgreSQL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fresh cloners had no tracked reference for required env vars.
.env is gitignored (contains real credentials). .env.example
documents all variables including the new OCR_TRAINING_TOKEN
for the Python OCR microservice training endpoints.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Screen readers did not announce page-by-page OCR progress updates.
Wrapping the counter text in a span with aria-live=polite ensures
assistive technology announces each page completion without
interrupting the user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Training reloads the Kraken model in-process on the Python service.
The DB-level RUNNING constraint prevents concurrent API calls but
cannot protect against multi-replica deployments. Added explicit
comments in docker-compose.yml and OcrTrainingService to prevent
accidental horizontal scaling. See ADR-001.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A 100-page document at ~10 s/page takes ~17 min on CPU-only hardware,
which could cause the presigned URL to expire mid-OCR job. 1 hour gives
ample headroom for any realistic document size in this archive.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The cascade-delete commit (5a5a8b6) added blockRepository.deleteByAnnotationId()
to AnnotationService.deleteAnnotation(), but the test class was not updated to
mock TranscriptionBlockRepository. Mockito injected null, causing deleteAnnotation_succeeds_whenOwner
to throw NPE. Adds the mock, verifies the cascade call, and adds an inOrder test
asserting the block is deleted before the annotation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The DELETE endpoint was returning 500 due to a FK constraint violation.
`deleteAnnotation` now calls `blockRepository.deleteByAnnotationId()`
before removing the annotation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drawing annotations is now the primary workflow. OCR only runs on
manually drawn regions (guided mode always). Full-page layout detection
and the useExistingAnnotations checkbox are removed entirely.
- OcrTrigger: guided-only, disabled with hint when no annotations exist
- TranscriptionEditView: empty state shows draw-regions instruction,
OCR trigger moved out of collapsible and shown inline after block list
- i18n: add ocr_trigger_no_annotations, ocr_section_heading,
transcription_empty_draw_hint; remove ocr_use_existing_annotations keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ketos 7 defaults to safetensors output, but kraken's load_any() only
handles CoreML (.mlmodel). Adding --weights-format coreml ensures the
hot-swap after training produces a file that load_any() can parse.
Also fixed _find_best_model to look for best_<score>.mlmodel (produced
by --weights-format coreml) in addition to the previous checkpoint_*
pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kraken 7 removed support for the legacy `path` format (image + .gt.txt
pairs) in VGSLRecognitionDataModule despite the CLI still advertising it.
Switching to PAGE XML (-f page) format which is the supported standard.
- Java export now writes .xml alongside .png (PAGE XML with TextLine,
Baseline at 75% height, and Unicode transcription)
- XML special characters in transcription text are escaped (& < >)
- Python trainer globs *.xml and passes -f page to ketos train
- Regenerated frontend API types to include cer/loss/accuracy/epochs on
OcrTrainingRun (were missing, causing empty CER column in history)
- Updated and extended TrainingDataExportServiceTest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ketos segtrain has no batch-size flag (-B), so with the default 1800px
input height the intermediate CNN feature maps consume ~500 MB+ per
image, causing the kernel OOM-killer (exit -9) to terminate the process.
On first run (no existing blla.mlmodel), override the VGSL spec to use
800px height instead. Subsequent runs load the saved model with
--resize both, preserving incremental fine-tuning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Force CPU-only training (--device cpu), cap OpenMP/BLAS thread pool at 2
(--threads 2), and reduce epochs from 50 to 10 (-N 10). 50 epochs on a
laptop OOM-killed the container. 10 epochs is sufficient for incremental
fine-tuning runs; more data is added over time and training re-run.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DataLoader worker subprocesses crash inside Docker due to multiprocessing
fork restrictions. Pass --workers 0 to both ketos train and ketos segtrain
so data loading runs in the main process.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kraken.ketos has no .train or .segtrain attributes in Kraken 7 — both are
only exposed as CLI commands. Rewrites both training functions to invoke
`ketos train` / `ketos segtrain` via subprocess and parse the best
val_metric from checkpoint filenames.
Also fixes the OcrTrainingCard history so it only shows non-blla runs
(recognition model), matching SegmentationTrainingCard which already
filtered to blla-only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After each training run, the Character Error Rate (CER = 1 - accuracy),
loss, accuracy, and epoch count are now stored on the OcrTrainingRun
record and shown in the training history table.
Also adds the missing POST /api/ocr/segtrain endpoint and the
triggerSegTraining service method so the segmentation training card
can actually trigger training.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously all MANUAL blocks counted as eligible training data, even ones
where text was filled in by guided OCR but never explicitly reviewed. This
caused segmentation and recognition counts to always match.
Now only reviewed=true blocks qualify for recognition training, so the
counts properly reflect: segments = all drawn annotation boxes,
checked text = only boxes where the user has verified the transcription.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The parent was manually remapping availableSegBlocks → availableBlocks
before passing props, which broke after the card was updated to read
availableSegBlocks directly. Pass the full trainingInfo object instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both cards were reading the same availableBlocks field, so the segmentation
box always showed the kurrent recognition count. Use the correct
availableSegBlocks field from the training info response.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The findSegmentationBlocks query was filtering out blocks with non-empty
text. Segmentation training only needs annotation geometry (polygon/bbox),
not transcription text — so any MANUAL block on a KURRENT_SEGMENTATION
document should count, regardless of whether it has text.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Historical letter lines often intersect, so the system must support
overlapping annotation regions. Removed the overlap guard from
createAnnotation(), deleted ErrorCode.ANNOTATION_OVERLAP, and cleaned
up all tests and frontend error mappings that referenced it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a user draws annotation boxes to mark OCR regions, the blocks are
created with source=MANUAL and empty text. upsertGuidedBlock was
protecting all MANUAL blocks unconditionally, so guided OCR silently
produced no output for these drawn-but-empty blocks.
Changed the guard to only protect non-empty MANUAL blocks — empty ones
are treated like OCR blocks and get their text filled in.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kraken's segmentation bounds check rejects coordinates where any point
satisfies x >= im.width or y >= im.height (strictly >=, not >). Using
(cw, ch) as the boundary corner was triggering this for every crop.
Changed to (cw-1, ch-1) so all coordinates are strictly inside the image.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
blla.segment() is a full-page layout detection model that kills the worker
process when called on tiny annotation crops (e.g. 597x89 px). For guided
OCR the annotation region IS already the text line, so segmentation is
unnecessary. Replace the blla call with a single synthetic BaselineLine that
spans the full crop width — rpred then runs recognition on the whole crop.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a document has manually drawn annotation boxes, the user can now
enable "Nur annotierte Bereiche" in the OCR trigger panel. The engine
skips layout detection entirely and runs recognition only within the
pre-drawn bounding boxes, preserving manual transcription blocks.
- Python: adds OcrRegion model, extend OcrRequest/OcrBlock; guided
branch in /ocr/stream groups by page and crops each region
- Engines: add extract_region_text() to both Kraken and Surya
- Java: adds OcrBlockResult.annotationId, OcrClient.OcrRegion,
TriggerOcrDTO.useExistingAnnotations; OcrAsyncRunner dispatches to
upsertGuidedBlock when annotationId is present; OcrService threads
the flag through to runSingleDocument
- TranscriptionService: adds upsertGuidedBlock (creates, updates OCR,
or preserves MANUAL blocks)
- Frontend: guided OCR toggle in OcrTrigger shown when blocks exist;
skips destructive-replace confirmation in guided mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add /segtrain endpoint to OCR service (ZIP upload, ketos.segtrain,
backup rotation, in-process model reload)
- Add segtrainModel() to OcrClient and RestClientOcrClient (10-min timeout,
X-Training-Token header)
- Add SegmentationTrainingExportService: PAGE XML export with polygon
de-normalization and per-page PNG rendering via PDFBox
- Add GET /api/ocr/segmentation-training-data/export endpoint
- Make TranscriptionBlock.text nullable for segmentation-only blocks
(V31 migration)
- Add Paraglide i18n translation keys for all training UI strings (de/en/es)
- Pass source prop from TranscriptionEditView to TranscriptionBlock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Convert TrainingHistory, OcrTrainingCard, SegmentationTrainingCard, and
TranscriptionBlock "Nur Segmentierung" badge to use Paraglide message keys
- Add availableSegBlocks to TrainingInfoResponse to expose segmentation
block count in the training info endpoint
- Wire SegmentationTrainingCard into admin/system page below OCR training card
- Update api.ts with availableSegBlocks field
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- POST /train in ocr-service with ZIP Slip validation, TemporaryDirectory,
ketos transfer learning, timestamped backups (keep last 3), in-process reload
- X-Training-Token auth (no-op in dev when TRAINING_TOKEN env is empty)
- trainModel() in OcrClient interface + RestClientOcrClient (10-min timeout,
multipart upload, forwards X-Training-Token when configured)
- TRAINING_TOKEN env var wired in docker-compose; --workers 2 in Dockerfile
so /health stays responsive during synchronous training
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- TrainingDataExportService: PDFBox rendering at 300 DPI, crop by
annotation coordinates, ZIP with <uuid>.png + <uuid>.gt.txt pairs
- Skips documents with missing S3 files (logs WARN, continues)
- GET /api/ocr/training-data/export (ADMIN); 204 when no enrolled blocks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows 'X / Y geprüft' with a brand-mint progress bar at the top of the
transcription panel. Derived from the blocks prop — no extra state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BaselineOCRRecord has 'baseline' and 'boundary' attributes, not 'line'
and 'cuts'. The fallback used record.line which doesn't exist, causing
AttributeError on every Kurrent OCR page.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Validates PDF download URLs against an ALLOWED_PDF_HOSTS allowlist
(default: minio,localhost,127.0.0.1) and disables redirect following
to prevent redirect-based SSRF.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Changed ocr-service dependency from service_healthy to service_started
since the backend already handles OCR unavailability gracefully. Removed
unused APP_S3_INTERNAL_URL env var. Added expose directive and
.dockerignore for ocr-service.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Changed OcrTrigger and ScriptTypeSelect from 'import * as m' to
'import { m }' to match the rest of the codebase. Increased
ScriptTypeSelect label to text-sm and annotation badge font to 12px
for better readability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The retry button set status='running' but didn't re-trigger the $effect
because jobId hadn't changed. Added retryCount state so the effect
re-runs and creates a fresh EventSource on retry. Also added aria-label
to the progress bar for accessibility.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Queue capacity of 100 is disproportionate for 2 worker threads — a
backed-up queue would represent hours of unprocessed OCR jobs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The resolveUserId() catch block was silently swallowing exceptions,
making auth failures invisible in logs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added @JsonIgnoreProperties(ignoreUnknown = true) to OcrBlockResult so
new fields from the Python OCR service don't crash the Java parser,
while keeping FAIL_ON_UNKNOWN_PROPERTIES strict globally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
OcrAsyncRunner was bypassing TranscriptionService — building blocks
directly and calling blockRepository.save(), skipping sanitizeText()
and saveVersion(). Also replaced N individual deleteBlock() calls with
a single bulk deleteAllBlocksByDocument() for OCR re-runs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
OcrService.startOcr() was setting scriptType on a detached entity,
silently losing the mutation. Added DocumentService.updateScriptType()
with @Transactional to persist the change properly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
OcrController was injecting OcrJobRepository and OcrJobDocumentRepository
directly, violating the Controller → Service → Repository layering rule.
Moved getJob() and getDocumentOcrStatus() logic into OcrService.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The skipped-pages warning is inlined directly in +page.svelte.
The component and its tests are no longer needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The progress message already says "Seite 3 von 7 wird analysiert…"
so the separate "3 / 7" counter was redundant. Remove the
OcrProgressBar from the page and inline only the skipped-pages
warning.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The thin bar without a border looked broken at low progress values.
The text counter (e.g. "1 / 6") already communicates progress clearly
so the bar is unnecessary.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The ANALYZING message appeared while the Python service was still
downloading the PDF and loading models. Remove it so the LOADING
message ("Lade Modell und Dokument…") stays visible until the first
ANALYZING_PAGE event arrives from the stream.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a collapsible OCR trigger below the block list in edit mode.
Uses a <details> element so it's unobtrusive — the primary workflow
is editing existing blocks, but users can expand to re-run OCR with
a confirmation dialog that warns about replacing existing blocks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cover Surya polygon/word-level extraction, health endpoint states,
Kraken script-type routing, 503 when models not ready, 400 when
Kraken unavailable for Kurrent, and confidence marker application
during streaming. Production code coverage: 88%.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The PDF viewer uses 1-based currentPage (starting at 1) but the OCR
engines produced 0-based pageNumber from enumerate(). Annotations
created by OCR were assigned to page 0, which doesn't exist in the
viewer. Change enumerate() to start=1 in both engines and the
streaming endpoint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace inline translateOcrProgress with the extracted module. Add
OcrProgressBar below the spinner during OCR. Parse page numbers from
ANALYZING_PAGE progress codes and feed them to the bar. On Done, fill
bar to 100% briefly before clearing the overlay.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Progress bar shows brand-mint fill on brand-sand background with
smooth transition. Displays page counter with tabular-nums and
skipped-pages warning in amber when applicable. Only renders when
totalPages > 0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move translateOcrProgress from page.svelte to a testable module.
Return structured result with currentPage/totalPages/skippedPages
for the progress bar. Add ANALYZING_PAGE and DONE with skipped pages
parsing. Add i18n keys for de/en/es.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the single extractBlocks() call with streamBlocks() that
processes pages incrementally. Each page's blocks are persisted
immediately via createSingleBlock(). Progress updates use the
ANALYZING_PAGE:current:total:blocks format. Per-page errors are
logged at WARN level without failing the entire job. The batch path
(processDocument) remains on the old extractBlocks() path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enable per-page block creation during streaming by extracting the
loop body into a package-private createSingleBlock() method with an
explicit sortOrder parameter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add streamBlocks() that POSTs to /ocr/stream and parses the NDJSON
response line by line with a dedicated ObjectMapper. Falls back to
the old /ocr endpoint via the default method when /ocr/stream returns
404. Uses a separate HttpClient with 5-minute request timeout for
streaming.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The default method synthesizes Start/Page/Done events from
extractBlocks() results, providing backward compatibility for
implementations that don't support streaming natively.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Defines the event types for NDJSON streaming OCR. Uses Java 21 sealed
interface with record subtypes for exhaustive pattern matching in the
consumer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Streams one JSON line per completed page instead of buffering the
entire result. Emits start/page/error/done events. On per-page
failure, logs the traceback but yields a generic error message and
continues with the next page. Adds X-Accel-Buffering: no and
Cache-Control: no-cache headers for reverse-proxy compatibility.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enable per-page processing by extracting the inner loop body of
extract_blocks() into extract_page_blocks(image, page_idx, language).
The original extract_blocks() now delegates to the new function,
preserving backward compatibility for the batch path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
OCR engines are CPU-bound and were blocking Uvicorn's single async
event loop, making /health unresponsive during processing. This caused
new OCR requests to fail silently (health check failure → no DB record
→ UI shows NONE). Wrap engine calls in asyncio.to_thread() to keep the
event loop free. Also surface OCR trigger errors in the frontend
instead of silently resetting the spinner.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents users from drawing annotations that would be cleared when
the OCR job finishes. transcribeMode is set to false for the PDF
viewer while ocrRunning is true.
Refs #226
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend sends progress codes (PREPARING, LOADING, ANALYZING,
CREATING_BLOCKS:N, DONE:N, ERROR) via OcrJob.progressMessage.
Frontend translates them via Paraglide (de/en/es) and displays
below the spinner.
- V27 migration: adds progress_message column to ocr_jobs
- OcrAsyncRunner updates progress at each phase
- Poll interval reduced to 2s for snappier updates
Refs #226
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4GB headroom in the container. Doubling batches should use ~2GB
more RAM but significantly speed up inference.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Surya downloads models from HuggingFace to /root/.cache on first use.
Without a volume, every container restart re-downloads ~73MB+.
Added ocr_cache volume to persist the cache.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5GB free on host during OCR, container at 3.8/8GB. Larger batches
use more memory but process faster on CPU.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single-document OCR now creates an OcrJobDocument row so
GET /api/documents/{id}/ocr-status can find running jobs.
OcrAsyncRunner updates the job document status (RUNNING → DONE/FAILED).
Frontend checks OCR status when entering transcription mode —
if a job is running, resumes polling and shows the spinner.
Refs #226
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JDK HttpClient defaults to HTTP/2 with upgrade negotiation. Uvicorn
rejects the upgrade ('Unsupported upgrade request'), causing the
request body to be lost and a 422 'Field required' from FastAPI.
Force HTTP/1.1 since the OCR service is internal and doesn't need h2.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pydantic v2 Field(alias=...) doesn't work with FastAPI as expected.
The Java client sends camelCase (pdfUrl, scriptType, pageNumber).
Use camelCase field names directly instead of aliases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CallerRunsPolicy would cause the HTTP request to hang for minutes
if the queue is full. AbortPolicy with queue=100 is safe — the queue
will never realistically fill for a family archive. If it somehow
does, a clear error is better than a silent multi-minute hang.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Better to wait than to error. Queue capacity 100 holds plenty of
OCR jobs. CallerRunsPolicy means if the queue is somehow full,
the request blocks instead of getting rejected with an exception.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The old pool (1 thread, queue=1) meant OCR blocked all other async
tasks (imports). Now 2 concurrent async tasks with a queue of 10
— enough for OCR + import to run in parallel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Default RestClient timeout was 10 seconds — OCR on CPU takes minutes.
Set connect timeout to 10s, read timeout to 10 minutes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OcrService → OcrAsyncRunner was circular. Fixed by moving all OCR
processing logic (processDocument, clearExistingBlocks, createBlocks)
into OcrAsyncRunner. OcrService is now a thin entry point that
validates, creates the job, and dispatches to OcrAsyncRunner.
Architecture:
- OcrService: validates document, checks health, creates OcrJob, delegates
- OcrAsyncRunner: @Async processDocument + runSingleDocument + runBatch
- OcrBatchService: creates job + job documents, delegates to OcrAsyncRunner
- No circular dependencies
Single-document OCR is now async (returns jobId immediately).
Frontend polls GET /api/ocr/jobs/{jobId} every 3s until DONE/FAILED.
816 backend tests pass, 687 frontend tests pass.
Refs #226
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5GB free on host while OCR runs — give the container more room.
Bump batch sizes (detector=2, recognition=4) so it processes
faster with the available memory.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mem_limit 4g keeps more RAM free for the host. memswap_limit 8g
(= 4g swap) lets peaks spill to disk instead of OOM-killing.
Slower during peak inference but won't starve the dev machine.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Surya models lazy-load on first OCR request instead of at startup
(saves ~3-4GB idle RAM — Kraken stays eager at ~16MB)
- Process one page at a time in Surya engine (limits peak memory)
- RECOGNITION_BATCH_SIZE=1, DETECTOR_BATCH_SIZE=1 (slower but fits in RAM)
- Revert mem_limit back to 6GB (sufficient with these optimizations)
- Render DPI stays at 200
Idle memory: ~2GB (Kraken only). Peak during OCR: ~5-6GB (Surya loaded).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Surya 0.17 models use ~5GB idle. At 300 DPI on a multi-page PDF,
page images + inference tensors push past the 6GB limit, causing
OOM kills during 'Detecting bboxes'. Increased to 10GB and reduced
render DPI to 200 (still sufficient for OCR, uses ~44% less memory).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The OCR service was getting 403 Forbidden because it tried to
download PDFs from MinIO using plain internal URLs without
authentication. MinIO buckets are private.
- Add S3Presigner bean to MinioConfig
- FileService.generatePresignedUrl(): generates 15-min presigned URLs
- OcrService uses presigned URLs instead of plain internal URLs
- Remove unused s3InternalUrl / bucketName @Value fields from OcrService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents 'can't access property innerHTML, textDiv is null' when
the component unmounts while a render is in flight (e.g. switching
to OCR progress view tears down the panel content).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- triggerOcr captures jobId from POST response and shows OcrProgress
- OcrProgress rendered in the transcription panel when ocrJobId is set
- handleOcrDone reloads blocks and annotations when OCR completes
- checkOcrStatus called when entering transcription mode — resumes
progress display if a job is already running for this document
Refs #226
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- OcrTrigger component rendered in the transcription empty state when
the document has a file and user has write permission
- Review checkmark toggle on each TranscriptionBlock (turquoise when
reviewed, muted outline when not). Calls PUT .../review to toggle.
- TranscriptionBlockData type: added source + reviewed fields
- +page.svelte: triggerOcr() and reviewToggle() functions wired up
- Paraglide translations (de/en/es) for review toggle + reviewed count
All 687 frontend tests pass.
Refs #226, #230
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BlockSource enum: MANUAL, OCR
- V26 migration adds source + reviewed columns to transcription_blocks
- OcrService sets source=OCR when creating blocks
- TranscriptionService.reviewBlock() toggles the reviewed flag
- PUT /api/documents/{id}/transcription-blocks/{blockId}/review endpoint
- 5 new tests: reviewBlock toggle/untoggle/notfound, controller,
OcrService source=OCR verification
The reviewed flag enables the Kraken fine-tuning pipeline: only blocks
marked as reviewed by a human are exported as training data.
Refs #226
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kraken's -f pdf mode tries to write output next to the input file,
which fails on read-only mounts. Instead, extract pages as PNGs via
pypdfium2 (already installed), then run kraken on each image.
Both models run in a single container per PDF to avoid overhead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kraken 7 requires pyvips (optional dep) for -f pdf mode.
Added libvips42 system package and pyvips Python package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous approach used find across the htrmopo cache which failed
because -newer /tmp ran in a separate container. Now parses the
'Model dir: <path>' line from kraken get output directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kraken 7 uses DOIs (not short names) to identify models from Zenodo.
Updated to use actual DOIs:
- 10.5281/zenodo.7933463 — German handwriting HTR
- 10.5281/zenodo.13788177 — McCATMuS generic handwritten/printed/typed
Added -f pdf flag for PDF input, volume mounts for import dir,
and post-download copy from htrmopo cache to the models volume.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
torchvision installed from PyPI expects CUDA torch operator
registrations. Installing from the CPU whl index ensures torchvision
matches the CPU-only torch build. Fixes 'torchvision::nms does not
exist' RuntimeError on startup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
transformers 5.x breaks surya 0.17.1 — SuryaDecoderConfig is missing
pad_token_id. Pin to transformers>=4.56.1,<5.0.0.
Also add torch==2.7.1 to requirements.txt to prevent pip from upgrading
it past the CPU-only build installed in the Dockerfile layer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Runbook script to download both HTR-United Kurrent model candidates
(german_kurrent_manu_9, kurrent-de) into the ocr_models Docker volume,
test them against sample documents, and activate the winner.
Usage:
./scripts/download-kraken-models.sh # download both
./scripts/download-kraken-models.sh --activate 1 # pick model 1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New confidence.py module with two functions:
- apply_confidence_markers(): replaces words below threshold with
[unleserlich], collapses adjacent markers into one
- words_from_characters(): reconstructs word-level confidence from
Kraken's character-level data
Surya 0.17 provides native word-level confidence via line.words.
Kraken 7.0 provides per-character confidences via record.confidences.
Both engines now pass word+confidence data through main.py, which
applies the marker post-processing before returning the API response.
Threshold configurable via OCR_CONFIDENCE_THRESHOLD env var (default 0.3).
Frontend already renders [unleserlich] markers via transcriptionMarkers.ts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kraken 5.2.9 required torch~=2.1.0, incompatible with surya-ocr's
torch>=2.3.0. kraken 6.0.3 requires torch>=2.4.0,<=2.9 which
overlaps with surya and our pinned torch==2.5.1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
surya-ocr 0.6.3 requires pillow<11.0.0,>=10.2.0. The previous
pin at 11.1.0 caused a dependency resolution failure during
Docker build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace @Convert(PolygonConverter) with Hibernate native @JdbcTypeCode(SqlTypes.JSON)
to fix JDBC type mismatch — PostgreSQL requires jsonb type, not varchar.
The PolygonConverter is retained as a standalone utility but no longer
used on the entity. Hibernate 6 natively handles List<List<Double>>
serialization to JSONB.
Refs #227
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AnnotationShape.svelte: renders a single annotation as either a
rectangle or a polygon-clipped div (via CSS clip-path: polygon())
- AnnotationLayer.svelte: refactored to delegate rendering to
AnnotationShape, keeping draw logic and hover state management
- Annotation type: added optional polygon field ([number, number][] | null)
- Polygon coordinates are converted from page-normalized to
bounding-box-relative percentages for clip-path
All 687 existing frontend tests pass.
Refs #227
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Python microservice (ocr-service/):
- FastAPI app with /ocr and /health endpoints
- Surya engine: transformer-based OCR for typewritten/modern handwriting
- Kraken engine: historical HTR for Kurrent/Suetterlin with
pure-Python polygon-to-quad approximation (gift wrapping + rotating calipers)
- Eager model loading at startup via lifespan context manager
- PDF download via httpx, page rendering via pypdfium2 at 300 DPI
Java RestClientOcrClient:
- Implements OcrClient + OcrHealthClient interfaces
- Calls Python service via Spring RestClient
- Health check with graceful fallback
Docker Compose:
- New ocr-service container (mem_limit 6g, no host ports)
- Health check with start_period 60s for model loading
- ocr_models volume for Kraken model files
- Backend depends on ocr-service health
Refs #226, #227
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OCR creates many adjacent text line annotations that would fail the
existing overlap check. createOcrAnnotation() accepts an optional
polygon and bypasses overlap detection entirely.
Refs #227
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ADR-001 documents the decision to use a separate Python container for
OCR (Surya + Kraken), the interface contract, and why alternatives
like Tess4J were rejected.
ADR-002 documents the decision to store polygon annotations as JSONB
with a 4-point CHECK constraint, backed by an AttributeConverter.
Refs #226, #227
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CommentThread: add missing empty-state paragraph using comment_empty_hint
i18n key (key existed but was never rendered in the template)
- TranscriptionBlock: add selectedQuote hint using transcription_block_quote_hint
i18n key (key existed but was never rendered); fix test to use native DOM
el.focus()/setSelectionRange()/dispatchEvent instead of locator.selectText()
which is not available in this vitest-browser version
- TranscriptionEditView: fix test to use native el.dispatchEvent(FocusEvent)
instead of locator.blur() which is not available
- Conversations: fix test expected text from stale "Korrespondenz durchsuchen"
to match current conv_empty_heading() = "Wessen Briefe möchten Sie lesen?"
All 687 tests now pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
resolveRef is never read reactively — it is only read synchronously
inside settle(). Using $state was misleading about the intent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add m-auto and w-full to ensure the native <dialog> is centred
- Add backdrop:bg-black/50 for dimmed overlay when modal is open
- Add hover:bg-danger/80 and hover:bg-primary/80 on confirm button
- Add cursor-pointer to both cancel and confirm buttons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
provideConfirmService() sets up context for the entire component tree.
ConfirmDialog is mounted once at the bottom of the layout shell.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- confirm.svelte.ts: context-based async service returning Promise<boolean>
- ConfirmDialog.svelte: native <dialog> element, reads service from context
- Concurrent calls return false immediately (guard at top of confirm())
- SSR-safe: confirm() returns Promise.resolve(false) on server
- getConfirmService() throws descriptive error outside provider tree
- 5 Vitest tests: confirm/cancel/Escape/concurrent/outside-provider all green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Light: #c0392b (5.1:1 on white — WCAG AA), dark: #e55347 (4.7:1 on surface).
Exposed as bg-danger/text-danger-fg Tailwind utilities via @theme inline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers segmented type control, title input, conditional field
visibility, PersonCard title display, mobile layout, and a11y.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add "amt" and "schule" suffixes to INSTITUTION_END in PersonTypeClassifier
so German government offices and schools are auto-classified on import.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add title to PersonUpdateDTO with @Size(max=50) constraint.
PersonService.createPerson and updatePerson now handle the title
field with blank-to-null normalization.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Person list and detail page avatars now display a type-specific
icon (building, people group, question mark) instead of meaningless
initials for INSTITUTION, GROUP, and UNKNOWN person types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace hardcoded Tailwind utility colors with project CSS variables
(--c-badge-institution-*, --c-badge-group-*, --c-badge-unknown-*).
Dark mode variants defined in both @media and manual toggle blocks.
Extract shared badge classes and use $derived config object.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Testcontainers test verifying: SKIP returns null with no DB record,
INSTITUTION/GROUP store full name in lastName with null firstName
and correct personType, PERSON splits name normally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove Architekt from WORD_PREFIXES (classifier handles it)
- Use Objects.equals for null-safe firstName/lastName comparison
- Remove unused trimmed variable in PersonTypeClassifier
- Fix containsWord to loop through all occurrences (finds
"Eltern" in "Nachbareltern Eltern")
- Extract DisplayNameFormatter utility shared by Person and
PersonSummaryDTO to eliminate display logic duplication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add @Nullable annotation to findOrCreateByAlias() return type.
Filter null results (from SKIP classification) in MassImportService
receiver list to prevent null elements in the receivers collection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-pass title stripping with loop for stacked titles:
- Dot-prefixes (Dr., Prof.) matched without trailing space
- Word-prefixes (Tante, Frau, Schwester, etc.) matched at
word boundary
- Stacked titles like "Prof. Dr. Muller" handled correctly
- Single token after title strip goes to lastName (not firstName)
Add 5 "von" last names to KNOWN_LAST_NAMES for correct splitting
of entries like "Freifrau von Massenbach".
15 new test cases + updated 3 existing tests for title behavior.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Show colored badge for non-PERSON types per design spec:
- INSTITUTION: blue with building icon
- GROUP: purple with people icon
- UNKNOWN: amber with question mark icon
- PERSON: no badge (unmarked default)
Badge appears on person cards in list and on detail page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Classify raw name before processing. SKIP returns null (no Person
created). INSTITUTION/GROUP skip split() and store full name in
lastName with firstName=null and appropriate personType.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move paren extraction in parseReceivers() after the multi-separator
check so single-person entries like "Clara de Gruyter(*1871)" keep
their parens intact for split()'s annotation extraction. Multi-person
entries like "Hedi und Tutu (Gruber)" still use parens as shared
last-name override.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract trailing (...) content as annotation. Handles birth years
(*1871), nicknames (Tuttu), uncertainty markers (?), and uncertain
names (Quast ?) where the name part is extracted back into the
cleaned result. Uses [^)]* regex to prevent ReDoS.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When split() returns a non-null maidenName, PersonService now
creates a PersonNameAlias with type MAIDEN_NAME. The maiden name
is stored as lastName on the alias (no firstName).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verify comma-prefix, no-dot, and multi-word maiden name variants
are correctly stripped in parseReceivers().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Widen pattern from `\s+geb\.\s+\S+` to `,?\s*geb\.?\s+(.+)$` to
handle: optional comma, optional dot, multi-word maiden names.
stripMaidenName() now captures the maiden name instead of discarding
it. Handles all 5 input variants from the ODS data.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add displayName and personType to all Person mock objects in
component and page tests. Update assertions from reversed
"lastName, firstName" format to forward-order displayName.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add translations for PersonType values (PERSON, INSTITUTION, GROUP,
UNKNOWN) and PersonNameAliasType.MAIDEN_NAME in de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add displayName default method to PersonSummaryDTO
- Update native SQL queries to include title, person_type columns
- Add getInitials() utility to personFormat.ts
- Update abbreviateName/abbreviateCompact for nullable firstName
- Replace firstName+lastName concatenation with displayName in all
person-displaying components and server load files
- Regenerate API types with displayName on Person and PersonSummaryDTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Person type now includes displayName (readonly, required), title,
personType (required enum), and firstName is optional.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add title VARCHAR(50) column
- Add person_type VARCHAR(20) NOT NULL DEFAULT 'PERSON' with CHECK
constraint (PERSON, INSTITUTION, GROUP, UNKNOWN — SKIP excluded)
- Drop NOT NULL on first_name for non-person entities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add title (nullable VARCHAR) and personType (enum, default PERSON)
- Make firstName nullable for non-person entities
- Add @Transient getDisplayName() as single source of truth for
name display, exposed via @Schema(READ_ONLY, REQUIRED)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PersonType has 5 values: PERSON, INSTITUTION, GROUP, UNKNOWN, SKIP.
SKIP is intentionally excluded from the DB CHECK constraint (added
in migration) as defense-in-depth. MAIDEN_NAME added to
PersonNameAliasType for #209.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract stripMaidenName, normalizeDotCompressed, stripAnnotation,
stripTitle, and splitByKnownLastNameOrFallback as individually
testable pipeline steps. Each extraction method is a pass-through
until its feature issue fills in the logic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add title, maidenName, and annotation fields (all nullable) to
SplitName. All existing call sites pass null for new fields. Test
assertions updated to document the null-by-default contract.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Regression test confirms already-spaced dot names are not double-spaced.
Interaction test confirms // separator works with dot-compressed names.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inserts spaces after dots when the cleaned name has no spaces but
contains dots, so the existing last-space fallback handles names
like "E.Rockstroh" and "Dr.Fr.Zarncke" correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pre-splits input on "//" before existing logic so each segment is
processed independently through the full pipeline (und/u splitting,
last-name distribution, etc.).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Jackson tried to serialize the lazy Person proxy when returning
alias list, causing a "no session" error. The back-reference is
only needed for JPA navigation, not for API responses.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces raw client-side fetch with SvelteKit form actions
(addAlias, removeAlias) using the server-side API client for
proper auth handling. 10 new component tests for NameHistoryEditCard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes -mx-4 negative margin and switches to the card pattern
(rounded border, shadow-sm, mt-4) so the save bar matches the
width of the other cards on the edit page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses HTML form attribute to associate the submit button with the
person-edit-form from outside the form tag. Page now reads:
Personendaten -> Namensverlauf -> Danger zone -> Save bar.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies alias rendering, empty state, firstName fallback,
and type label display. 5 browser-based Svelte tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Screen readers now announce which alias is being deleted, e.g.
"Entfernen de Gruyter" instead of just "Entfernen".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the {#if} guard so the card with empty state message is
always visible for feature discoverability.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds @NotBlank @Size(max=255) on lastName, @NotNull on type,
@Valid on controller parameter. Blank/null input now returns
400 instead of reaching the DB constraint. 2 new controller tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NameHistoryEditCard with add form (type dropdown + name fields),
delete with confirmation modal, and IDOR-safe client-side fetch
calls. Placed between Personendaten and DangerZone cards.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows historical name aliases in the left column with type labels
and firstName fallback. Fetches aliases in parallel with other data.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 16 new keys per language: alias type labels (BIRTH, WIDOWED,
DIVORCED, OTHER), section heading, empty state, add form labels,
delete confirmation, and ALIAS_NOT_FOUND error code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds sender alias LEFT JOIN and receiver alias EXISTS subquery to
DocumentSpecifications.hasText(). Uses entity-graph navigation via
Person.nameAliases (@OneToMany) to avoid a separate DB roundtrip
while respecting domain boundaries. 2 new integration tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds LEFT JOIN to person_name_aliases in both searchByName (JPQL)
and searchWithDocumentCount (native SQL). Uses DISTINCT/GROUP BY
to prevent duplicate results. 4 new integration tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GET returns aliases (no permission required), POST requires
WRITE_ALL, DELETE requires WRITE_ALL. 5 new controller tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getAliases (sorted by sort_order), addAlias (auto-incrementing
sort_order), removeAlias (with IDOR protection verifying alias
belongs to the given person). All TDD with 7 new unit tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces the alias domain model: entity with @ManyToOne to Person,
@OneToMany on Person for JPA graph navigation, repository with
sort_order queries, input DTO, and ALIAS_NOT_FOUND error code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the alias table for historical name changes (marriage,
widowhood, etc.) and adds GIN trigram indexes on both the new
alias table and the existing persons table for substring search.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded rgba(0,199,177,...) with color-mix using
var(--color-turquoise) for dark mode compatibility.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded 'de-DE' with the active Paraglide locale so
dates render in the user's language.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded rgba value with the project's turquoise color
token for dark mode compatibility.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Improves visibility of the clickability affordance on uncalibrated
displays and for senior users.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies blockCount=0 shows "0 Abschnitte" and that a provided
lastEditedAt value renders a formatted date containing the year.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PDF viewer collapses to 70px on mobile in read mode, expandable to
50vh. Toggle button with chevron. Paragraph tap auto-expands strip.
Mode toggle abbreviates to "Bearb." on small screens.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Paragraph click flashes the PDF annotation outline (1.5s fade).
Annotation click highlights the paragraph with a background flash.
Both directions scroll the target into view.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds TranscriptionPanelHeader and TranscriptionReadView to the
document detail page. Default mode is 'read' when blocks exist,
'edit' otherwise. Annotations dimmed in read mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renders transcription blocks as readable text with [unleserlich]/[...]
markers styled as italic muted text. Supports click-to-sync and
flash highlight for scroll-sync feedback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Segmented Lesen/Bearbeiten control, block count, last-edited date,
and close button. Lesen disabled when no blocks exist.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hides block number badges and disables hover/active visual feedback
when dimmed=true. Click handlers remain active for scroll-sync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Aligns the top/bottom padding of the Briefwechsel results view with
the document search page wrapper (both py-8).
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Swap button: stack right/left arrows vertically at h-3.5 for a
compact look. Timeline: replace → ← text with Long-Arrow icons on
each letter entry and the distribution bar summary.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove border-dashed and bg-canvas conditional styles so the
receiver input matches the sender input styling. The placeholder
"Alle Korrespondenten" already communicates the optional state.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Only navigate (applyFilters) when a person is actually selected, not
when the sender input is cleared. Combined with showHero checking
data.filters.senderId, the user stays in the search bar view after
clearing — no jump back to the hero.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove custom placeholder props so DateInput falls back to its default
format hint (TT.MM.JJJJ / DD.MM.YYYY / DD.MM.AAAA) instead of
repeating the label text above.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace native <input type="date"> on the document search page with
the custom DateInput component (German dd.mm.yyyy format with auto-dot
insertion). Align both pages' date input styling: add rounded-md,
border, bg-surface, px-3, text-ink, placeholder color, and focus ring
to match all other inputs in the card.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace ↑↓ text with Long-Arrow-Up/Down-MD.svg on the document search
SortDropdown and the Briefwechsel sort button. Rotate the swap button
SVG 90° so arrows point left/right matching the horizontal person
field layout.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace tiny ↑↓ text with Long-Arrow-Up/Down-MD.svg icons from the
brand icon set for better visibility and consistency.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Chevrons indicate collapsible elements, not sort direction. Match
the document search SortDropdown pattern using ↑/↓ text arrows.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move sort button and filter toggle to the person row, matching the
document search page pattern (sort + filter + count inline). Date
range inputs are now a collapsible section behind the filter toggle,
using slide transition and the same grid layout as the document
search advanced filters. Fix date input padding (add px-3).
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap person bar + filter controls in a card matching the document
search page (rounded-sm border p-6 shadow-sm). Switch PersonTypeahead
to default mode with matching label/input overrides. Bump date inputs
and sort button to text-sm py-2.5. Filter row uses border-t separator
like the document search advanced section.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restores early return when id is empty, preventing a wasteful
navigation to /briefwechsel with no senderId param.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds conv_hero_divider to de/en/es messages and uses it in the
CorrespondenzHero divider. Fixes i18n blocker from review.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add px-3 to person bar, filter controls, and hint bar so inputs
don't sit flush against the container edge.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move strips inside the max-w-7xl container so person bar, filter
controls, and hint bar are no longer full-bleed. Remove duplicate
side padding from strip components — the parent container handles it.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Align person bar, filter controls, and hint bar side padding to
px-4 sm:px-6 lg:px-8, matching the standard layout of all other
overview pages. Override person bar inputs from compact h-9 to h-12
for better touch targets in the results state.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hero state (no senderId): centred CorrespondenzHero with discovery
headline, cross-link, large typeahead, recent persons. No person bar
or filter controls shown. Results state (senderId set): full-width
strips then content area with max-w-7xl responsive padding matching
other overview pages. Removes focus delegation hack.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New centred hero component for the Briefwechsel page: headline
"Wessen Briefe möchten Sie lesen?", cross-link to document search,
h-14 PersonTypeahead, and recent persons chips. Adds `large` prop
to PersonTypeahead and `conv_hero_crosslink` message key.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update nav label, page heading, empty-state headline, and document
link text. German uses "Briefwechsel", English "Letters", Spanish
"Cartas". Empty-state headline now uses the discovery framing from the
design discussion.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update all internal links (AppNav, CoCorrespondentsList, goto) to the
new URL. No redirect needed — no production URLs exist yet.
Refs: #179
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add TODO comment explaining why SENDER/RECEIVER sort is in-memory
(JPA INNER JOIN drops null-sender docs) and note that pagination
will require a DB COUNT query in DocumentSearchResult.of().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A sender with lastName=null produced sort key "null Bob" which sorted
before names starting with lowercase letters (n < s, t, u, v...).
Now returns "" for null lastName, which the comparator places at end.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously any value other than ASC/DESC silently defaulted to
DESC with no feedback. Now returns 400 Bad Request.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SENDER and RECEIVER are handled by in-memory sort before resolveSort
is called, making those switch cases unreachable. Removed and added
a comment making the invariant explicit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DocumentSort is a query parameter enum, not a JPA entity.
Placing it in model/ violated the layer boundary — model/ should
contain only domain entities.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- isDashboard was ignoring tagQ so typing in tag filter showed dashboard
- addTag now calls onTextInput('') to clear tagQ when a chip is selected
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DocumentSort enum validated by Spring MVC (400 for unknown values)
- SENDER sort uses Spring Data Sort on sender.lastName/firstName
- RECEIVER sort uses in-memory sort by first receiver alphabetically
- UPLOAD_DATE sort uses createdAt; default sort is DATE DESC
- tagQ param wired to hasTagPartial specification
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- hasText now JOINs sender (LEFT JOIN) and uses EXISTS subqueries for
receivers and tags to avoid duplicate rows
- hasTagPartial added for live debounced tag text filter (ILIKE partial match)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exploration spec (sort-integration-spec.html) covers 4 placement variants
with comparison matrix. Final spec (sort-inline-final-spec.html) locks in
Variant A (inline sort in search bar row) with full desktop/mobile states,
dropdown interaction anatomy, loading/empty states, and backend wiring checklist.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment text:
- Body and quote bumped from text-sm (14px) to text-base (16px)
to visually match the font-sans author name at text-sm
Annotation reload on delete:
- Add annotationReloadKey prop through DocumentViewer → PdfViewer
- Increment key after block delete in +page.svelte
- PdfViewer reloads annotations when key changes
- Annotation rectangle disappears immediately, not just after refresh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pass activeAnnotationId to TranscriptionEditView. An $effect watches
it and sets activeBlockId to the block matching the annotation,
activating its turquoise focus border.
2 new tests (RED/GREEN):
- activates block matching activeAnnotationId (turquoise border)
- no block activated when activeAnnotationId is null
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The scroll-sync $effect was re-triggering on every dependency change
(including currentPage), forcing the PDF back to the annotation's page
when the user clicked next/prev. Fix: track prevActiveAnnotationId
and only scroll when the active annotation actually changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When activeAnnotationId is set, the active annotation stays at full
opacity with a highlight box-shadow, while all other annotations fade
to 30% opacity (300ms ease transition). When no block is focused,
all annotations show at full opacity.
Prop chain: activeAnnotationId flows from PdfViewer → AnnotationLayer.
2 new tests (RED/GREEN):
- dims non-active annotations when activeAnnotationId is set
- shows all at full opacity when no activeAnnotationId
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mobile layout (< 768px):
- Split view stacks vertically: PDF top (min 40vh), blocks below
- Blocks panel gets border-top instead of border-left
- PDF remains interactive for drawing in stacked mode
Scroll-sync (block → PDF):
- Clicking a block sets activeAnnotationId
- PdfViewer effect watches activeAnnotationId, navigates to the
annotation's page if different from current, then scrolls the
annotation element into view (double-rAF for async render timing)
- Works across pages: block on page 3 navigates PDF to page 3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTML5 drag-and-drop didn't work — the grip handle couldn't initiate
drag properly. Replace with pointer event-based drag:
- Grip handle pointerdown starts drag, captures pointer
- Pointermove tracks offset, shows floaty style (shadow, scale, ring)
- Turquoise drop indicator line appears between blocks at cursor position
- Pointerup finalizes: reorders array and calls PUT /reorder endpoint
Visual feedback:
- Dragged block: shadow-xl, ring-2 ring-turquoise/40, scale 1.02, opacity 0.9
- Drop indicator: turquoise h-1 rounded bar between blocks
6 new TranscriptionEditView tests:
- renders blocks in sort order
- shows next-block CTA
- shows empty state
- move-up disabled on first block
- move-down disabled on last block
- drag handle present on each block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows a muted dashed-outline box after the last block:
"Markiere eine weitere Passage im Scan, um Block N anzulegen"
Guides new users on how to create additional blocks.
Matches the spec's empty block CTA design (S1, bottom of block list).
i18n key transcription_next_block_cta added for de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pressing Escape while editing a comment now only cancels the edit,
without propagating to the parent (which closes the transcribe panel).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Own comments:
- Click the text to open inline edit (textarea replaces text)
- Enter saves, Escape cancels
- Small trash icon always visible in bottom-right corner
- Hover on text shows cursor-text + subtle bg highlight
Other people's comments: read-only, no edit/delete affordances.
Re-add currentUserId prop chain for ownership check.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Transcribe button now uses border-primary/bg-primary/text-primary-fg
matching the other action buttons (Bearbeiten). Turquoise is reserved
for annotation overlays and block focus borders on the PDF.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bump comment body and quote from text-xs (12px) to text-sm (14px).
Bump author name from text-xs to text-sm, timestamp from 10px to text-xs.
Improves readability especially for 60+ target users.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Quote captured automatically on mouseup in textarea (no button needed)
Selection is held in state and pre-fills the comment input
- "Kommentieren" button only shown when zero comments exist
When comments are present, the input is already visible — button is noise
- Chat bubble icon added to Kommentieren button for visual consistency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- MentionEditor: Enter sends (Shift+Enter for newline), remove @ button
- CommentThread: remove send button, full-width input, always show
input when comments exist (no need to click Kommentieren first)
- TranscriptionBlock: remove border-t above comment section (orange
background provides enough visual separation)
- Update placeholder in all languages to hint @mention and Enter to send
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace hardcoded German strings in CommentThread.timeAgo() with
Paraglide i18n keys: comment_time_just_now, comment_time_minutes,
comment_time_hours, comment_time_days.
Update comment_edited_label from "· bearbeitet" to "(Bearbeitet)"
for the new single-timestamp design. All three languages: de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unedited comments show "vor X Minuten". Edited comments show
"vor X Minuten (Bearbeitet)" using the updatedAt timestamp.
Reduces visual noise in comment threads.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comments were only visible after clicking "Kommentieren". Now:
- Comment list always renders (CommentThread with loadOnMount=true)
- Compose box hidden by default (showCompose prop on CommentThread)
- Clicking "Kommentieren" sets commentOpen=true → shows compose box
- Closing hides compose box but comments remain visible
This separates "viewing comments" (always) from "writing a comment"
(on demand via Kommentieren button).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comments were only shown after clicking "Kommentieren". Now:
- Load comment counts per block when blocks are loaded
- Pass commentCount prop to TranscriptionBlock
- If commentCount > 0, the comment thread is expanded by default
- If commentCount is 0, thread stays collapsed behind the button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The textarea value was bound directly to the text prop from the parent.
When auto-save completed and updated the blocks array, Svelte re-rendered
the textarea with the prop value, causing the text to disappear briefly.
Fix: use localText state initialized from prop, synced only when blockId
changes (not on save responses). Typing updates localText immediately,
parent re-renders from save don't overwrite the local value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add blockNumbers prop through AnnotationLayer → PdfViewer → DocumentViewer.
Each turquoise annotation rectangle now shows a numbered badge (top-left,
matching the block card number in the right panel).
Block numbers are derived from sorted transcriptionBlocks, mapped by
annotationId, creating a visual link between PDF regions and block cards.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The "Noch keine Kommentare" hint with icon is unnecessary — users
already clicked "Kommentieren" to open the thread, so showing them
an empty state just adds noise. Jump straight to the compose box.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When clicking a turquoise annotation on the PDF:
- If not in transcribe mode, enters it and loads blocks
- Waits for DOM render, then scrolls to the corresponding block card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The yellow annotation+comment system is now redundant. Transcription
blocks handle the same use case (mark region → discuss) but better,
because they also produce a transcription.
Removed:
- annotateMode state and all wiring through page/topbar/viewer/pdfviewer
- Annotate/Stop annotate buttons from DocumentTopBar
- AnnotateHintStrip import and rendering
- AnnotationSidePanel from document detail page
- canAnnotate prop from DocumentTopBar
- Color picker from PdfViewer
- Comment count badges and loadCommentCounts from PdfViewer
- Delete button from AnnotationLayer (blocks own annotation lifecycle)
- dimColor prop from AnnotationLayer
Simplified:
- AnnotationLayer: only canDraw + color + onDraw + onAnnotationClick
- PdfViewer: only draws in transcribeMode with turquoise
- Clicking annotation in transcribe mode scrolls to corresponding block
- canComment derived from canWrite (no longer needs canAnnotate)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionBlock.svelte:
- "Kommentieren" button opens expandable comment thread per block
- Text selection in textarea captured as quoted text (> "...") prefix
- Quote hint "Text markieren für Zitat" shown when block is active/focused
- Comment thread uses existing CommentThread with blockId prop
CommentThread.svelte:
- Add blockId prop for block-level comments URL routing
- Add quotedText prop — pre-fills comment input with markdown blockquote
- commentsBase now supports 3 URL patterns: document, annotation, block
TranscriptionEditView.svelte:
- Pass canComment + currentUserId through to block components
3 new frontend tests:
- Kommentieren button present
- Quote hint shown when active
- Quote hint hidden when inactive
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RED/GREEN for CommentService:
- getCommentsForBlock(blockId): returns root comments filtered by blockId
- postBlockComment(documentId, blockId, content, mentions, author): creates
comment with block_id set
RED/GREEN for CommentController:
- GET /api/documents/{docId}/transcription-blocks/{blockId}/comments
- POST /api/documents/{docId}/transcription-blocks/{blockId}/comments
- POST .../comments/{commentId}/replies (reuses existing replyToComment)
4 new tests: 2 service unit tests + 2 controller integration tests
All 25 CommentServiceTest + 24 CommentControllerTest green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After onTranscriptionDraw callback completes, reload the annotation
list from the backend so the turquoise rectangle overlay appears
immediately on the PDF page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- dims annotations matching dimColor (opacity 0.3, pointer-events none)
- does not dim annotations that don't match dimColor
- has crosshair cursor when canAnnotate is true
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AnnotationLayer: add dimColor prop — annotations matching dim color
render at 30% opacity with pointer-events disabled (300ms transition)
- PdfViewer: add transcribeMode prop, derived drawingEnabled/drawColor;
in transcribe mode draws with turquoise (#00C7B1), routes draw events
to onTranscriptionDraw callback instead of annotation endpoint
- DocumentViewer: pass through transcribeMode + onTranscriptionDraw
- Document detail page: createBlockFromDraw() POSTs to transcription
blocks API on draw completion, adds created block to list
- Mode-based dimming: yellow annotations dim in transcribe mode,
turquoise annotations dim in annotate mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DocumentMetadataDrawer (10 tests):
- Renders formatted date, dash for null date
- Renders location, dash for null location
- Renders translated status label
- Person cards as links to /persons/{id}
- Receiver links, empty state for no persons
- Tag chips as links, empty state for no tags
TranscriptionBlock (12 tests):
- Renders block number, text, optional label
- Save states: idle (nothing), saving (pulse), saved (checkmark), error (retry)
- Active turquoise border, error red border
- onTextChange fires on typing, onFocus fires on click
Fixes @Felix/@Sara: "Frontend component tests still missing"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionService.reorderBlocks() now returns void (command).
Controller calls listBlocks() separately after reorder (query).
Updated test to match new void signature.
Fixes @Felix: "reorderBlocks violates command-query separation"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V18: text column now has CHECK (length(text) <= 10000) to enforce
the 10,000 character limit at the database level, complementing
the application-level enforcement in TranscriptionService.sanitizeText().
Fixes @Nora: "DB constraint catches anything the application misses"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace async executeSave in beforeunload handler with
navigator.sendBeacon — synchronous and reliable for page unload.
Sends pending text as JSON blob to the block update endpoint.
Fixes @Sara: "beforeunload handlers cannot reliably await async"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add turquoise/turquoise-fg semantic color tokens to layout.css
(light + dark mode), replacing all hardcoded #00C7B1 in components
- Bump Details toggle from text-xs to text-sm for visual hierarchy
- Block badge: navy → turquoise, overlapping top-left card border
with absolute positioning to visually link PDF annotation badges
- Saved indicator: smooth 300ms opacity fade before removal
(new 'fading' state in SaveState type)
- Transcribe buttons: use border-turquoise/bg-turquoise/text-turquoise-fg
Fixes @Leonie concerns: toggle visual weight, semantic tokens,
badge styling, saved fade animation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move duplicated type definition from TranscriptionEditView.svelte
and +page.svelte into $lib/types.ts for single source of truth.
Fixes @Felix: "Consider extracting the TranscriptionBlockData type"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes from PR #178 review:
Migration fixes:
- V18/V19: fix FK references from app_users to users (correct table name)
- V18: change annotation_id FK from ON DELETE CASCADE to ON DELETE RESTRICT
(block is aggregate root, cascade flows from block, not annotation)
Backend fixes:
- TranscriptionService.deleteBlock(): remove userId param, delete block first
then annotation directly via repository (no ownership check — block owns annotation)
- TranscriptionService.sanitizeText(): remove flawed regex HTML stripping,
textarea content is plain text by design — just enforce max length
- TranscriptionBlockController.requireUserId(): throw DomainException.unauthorized()
instead of silently returning null on auth failure
- CreateTranscriptionBlockDTO: add @Min/@Positive validation on coordinates
- Add @Slf4j logging to TranscriptionService for create/delete operations
Frontend fixes:
- Delete DocumentBottomPanel.svelte entirely (issue #175 requirement)
- Remove redundant mode exclusivity $effect (handled at toggle call sites)
- Remove dead handleCommentClick + onCommentClick prop (comments are future work)
- Remove quote hint UI (depends on comment feature)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DocumentMetadataDrawer: 3-column grid (≥1024px), single-column mobile
Shows document date, location, status, person cards, tag chips
Person names link to /persons/{id}, tags link to filtered search
Empty states for missing persons/tags, receiver cap with expand button
- DocumentTopBar: "Details" toggle button with animated SVG chevron
44×44px tap target, aria-expanded, Svelte slide transition
Semantic color tokens for dark mode compatibility
- Remove DocumentBottomPanel from document detail page
Bottom panel replaced by topbar drawer for metadata access
Simplify +page.server.ts (remove comments loading)
Update page.server.spec.ts for new load signature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Keys for #175: doc_details_toggle, section headings, field labels, empty states
Keys for #176: transcription mode, block editing, save states, comments, drawing hints
Error codes: TRANSCRIPTION_BLOCK_NOT_FOUND, TRANSCRIPTION_BLOCK_CONFLICT
All three languages: de, en, es
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionBlock entity with @Version optimistic locking
TranscriptionBlockVersion for edit history
TranscriptionService facade: CRUD, reorder, version history
TranscriptionBlockController: REST endpoints under /api/documents/{docId}/transcription-blocks
DTOs: Create, Update, Reorder
ErrorCode: TRANSCRIPTION_BLOCK_NOT_FOUND, TRANSCRIPTION_BLOCK_CONFLICT
DocumentComment: add block_id field for block-level comment threads
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V18: transcription_blocks table with optimistic locking version column
V19: transcription_block_versions for edit history capture
V20: add block_id FK to document_comments for block-level threads
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three final UI/UX specs for the collaborative transcription system:
- expandable-metadata-header-spec: labeled "Details" toggle with drawer
- annotation-transcription-final-spec: annotation-backed transcription with block-level comment threads
- transcription-read-mode-final-spec: clean split read mode with flowing prose and scroll sync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove overflow-hidden from the main flex row — the inner min-w-0 flex-1
overflow-hidden title container already handles truncation. Add relative z-10
to the topbar wrapper so it stacks above the pdf viewer. Pill is now hidden
below md (matching the chip row) and shows +N at md, +N weitere at lg+.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Accent bar, h-12/h-14 responsive height, 44×44px back link touch target
- PersonChipRow with sender→receivers chips, overflow pill button at ≥768px
- DocumentStatusChip dot-only at ≥768px
- Edit/annotate/download actions with annotateMode wiring
- AnnotateHintStrip below main row when annotateMode active
- status field added to Doc type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On small screens the upload zone now appears above recent docs.
lg:order-last keeps it visually on the right at desktop width.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restructures the dashboard to a lg:grid-cols-[1fr_300px] split:
- Left column: DashboardRecentDocuments (with stats footnote)
- Right column: DropZone (canWrite) + DashboardNeedsMetadata (flex-1)
Adds showRightColumn guard (canWrite || incompleteDocs.length > 0) so
read-only users with a complete archive never see an empty 300px ghost
column. DashboardMentions is removed from the page; the file is kept.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds stats?: StatsDTO | null prop; renders a quiet footnote showing total
document and person counts. Guards on stats?.totalDocuments != null so
zero is shown but the footnote is absent when stats fails. Adds
min-h-[44px] to doc rows for WCAG 2.5.5 touch target compliance.
Adds dashboard_stats_documents/persons i18n keys in de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes /api/notifications from the dashboard widget fetches and replaces
it with /api/stats so the page no longer needs to own notification data.
Returns stats: StatsDTO | null (null on failure) instead of mentions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Six categories of breakage:
1. date.ts — add formatGermanDateInput(raw: string): string as a pure
function covering both digit-stream auto-dot and manual-dot-with-padding
modes. Refactor handleGermanDateInput to delegate to it. Fixes 16 failures
in date.spec.ts where the function was imported but didn't exist.
2. Admin layout specs (groups/tags/users) — $effect fires on initial mount
with manualCollapse=false, so the spy captured 'false' before the click's
effect ran. Fix: move spy setup after render(), add await setTimeout(0) to
flush Svelte effects before asserting.
3. DashboardMentions — component now renders a persistent
"Benachrichtigungsverlauf ansehen" link, making getByRole('link') strict-
mode violations. Fix: scope link queries to the actor name, and check
absence of the actor link (not all links) in the no-documentId test.
4. Conversations page — empty-state copy changed from "Wählen Sie zwei
Personen aus" to "Korrespondenz durchsuchen". Update the test.
5. Login page — AuthHeader adds a second aria-label="Familienarchiv" link.
Use .first() to avoid strict-mode violation.
6. Persons page — alias is rendered with German quotation marks „…" not
straight quotes "…". Update the test string.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The password-reset E2E test was using button[type="submit"].last() to target
the password change button on the profile page. The profile page has two submit
buttons with identical text, so .last() is layout-order-dependent and breaks
if the form order ever changes.
Add data-testid="submit-password" to PasswordChangeForm and use getByTestId()
in the test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Strip malformed [[&_input]:focus:*] class fragments from PersonTypeahead
wrapper divs in both ConversationFilterBar components — PersonTypeahead
manages its own focus ring; parent selectors were redundant and broken
- Fix WhoWhenSection error state: focus:ring-red-500 → focus-visible:ring-red-500
so invalid date field ring no longer fires on mouse click
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ring-2 ring-accent (box-shadow) replaced with outline-2 outline-dotted
outline-accent — visually distinct from the focus ring (solid, navy/mint),
making selection state and keyboard focus clearly different
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Light: #012851 (brand-navy, 14:1 on white)
Dark: #a1dcd8 (brand-mint, 9.2:1 on canvas)
- @theme inline mapping → Tailwind ring-focus-ring utility
- Global :focus-visible fallback in @layer base
- forced-colors fallback for Windows High Contrast mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bg-brand-mint (#A6DAD8) on text-brand-navy (#012851) = 3.5:1, fails AA
for text-xs (12px). bg-white (#fff) on text-brand-navy = 14:1 AAA.
White also reads as a distinct shape against the navy header background.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- +layout.svelte: adopt main's blue header structure (accent stripe, no
border-b, bg-header instead of bg-brand-navy)
- layout.css light mode: drop --c-nav-active (removed by main); set
--c-header: #012851 (confirmed correct now that header is brand-navy)
- layout.css dark mode: drop --c-nav-active; keep navy PDF tokens and
--c-header: #012851 from our branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Change header --c-header dark value from #01335e to #012851 (brand navy):
#01335e gave 4.3:1 with ink-3 (WCAG AA fail); #012851 gives 4.99:1 (pass)
- Switch header element from bg-surface to bg-header so dark mode uses the
independent --c-header token instead of inheriting the surface background
- Fix both dark blocks (media query and manual override) to stay in sync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Header should use bg-header (rgb(1,51,94) = #01335e) in dark mode instead
of bg-surface. Currently fails because header still uses bg-surface.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace neutral dark tokens (#0d0d0d, #1a1a1a, etc.) with navy-tinted
values derived from brand-navy: canvas #010e1e, surface #011526,
overlay #011e38, muted #011a30
- Fix --c-ink-3 WCAG AA failure in [data-theme='dark'] block:
#6b7280 (3.2:1, fail) → #8b97a5 (7.1:1, AAA ✓)
- Add color-scheme: dark to both dark blocks for native OS scrollbar theming
- Update PDF viewer tokens to navy palette (bg #010e1e, ctrl #011526, text #f0efe9)
- Add --c-header token (#ffffff light / #01335e dark) for independent
header surface control; mapped to --color-header in @theme inline
- Fix EntityNav contrast: text-white/30 → /50 (heading) and text-white/20
→ /50 (inactive count badges) to pass WCAG AA 4.5:1 on bg-brand-navy
Closes#166
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two failing test suites that encode the regressions this issue fixes:
- accessibility.spec.ts: axe wcag2aa in both prefers-color-scheme:dark
and data-theme='dark' — fails because --c-ink-3:#6b7280 on #1a1a1a = 3.2:1
- theme.spec.ts: color-scheme computed property is 'dark' in dark mode
— fails because neither dark CSS block sets color-scheme: dark
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spec covers the --c-focus-ring token definition, full audit of all 19
affected files, WCAG 2.4.11 analysis, element-by-element mockups (light
and dark), and exact CSS/Tailwind diffs ready for implementation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers active/inactive class tokens for both inverted=true and inverted=false,
and verifies setLocale is called with the correct locale on button click.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Normalize all header icon buttons to white/65 + white/10 hover bg
- Fix guest person icon (img tag needs brightness-0 invert, not text color)
- Add missing focus-visible rings to ThemeToggle and LanguageSwitcher
- Use focus-visible:rounded on nav links so active underline stays sharp
- Bump burger/nav breakpoint from sm→lg to prevent overflow on tablets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Asserts header background is rgb(1,40,81) in light mode
- Asserts header stays navy after switching to dark mode
- Asserts logo text visible at 375px viewport
- Asserts login page has AuthHeader with navy background and lang switcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the absolutely-positioned language switcher div and replaces it
with the shared AuthHeader component (logo + lang switcher on navy bar).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Provides logo + language switcher on brand-navy background with
4px accent strip. Used on login and forgot-password pages in place
of the floating language switcher.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace bg-surface border-b with bg-brand-navy (always #012851)
- Add 4px bg-accent strip above the nav bar
- Remove border-r separator from language switcher wrapper
- Pass inverted prop to LanguageSwitcher for white text on dark bg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When inverted=true, buttons render white text instead of ink tokens,
suitable for placement on brand-navy background.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The nav active state moves from a background pill to a bottom-border
underline, so the rgba purple tint variable is no longer needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolved conflicts:
- messages/de|en|es.json: kept all keys from both sides
- DateInput.svelte: kept HEAD API (onchange, not oninput/...rest) to match
CorrespondenzFilterControls caller; incorporated main's isCalendarValid helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clearing the input set value='' but did not call onchange, so the
korrespondenz filter strip never re-fetched. Added onchange?.() in the
empty-display branch and added a test that confirms the callback fires.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced static brand-sand/brand-mint/brand-navy tokens with themed
semantic tokens (bg-accent-bg, border-accent, text-ink) so the hint bar
adapts correctly in dark mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blockers (14):
- B1: fix senderName/receiverName to use $derived instead of $state + sync $effect
- B2: migrate all korrespondenz components from messages-extra shim to paraglide m.*
- B3: i18n CorrespondenzEmptyState (heading, subtext, search placeholder)
- B4: add response.ok checks to admin layout server load
- B5: add response.ok checks to korrespondenz page server load
- B6: add page.server.spec.ts with 5 test suites for korrespondenz load function
- B7: add axe-core accessibility checks to all e2e korrespondenz tests
- B8: add Testcontainers JPQL tests for findSinglePersonCorrespondence (DISTINCT + sender)
- B9: hide auth reset-token endpoint from OpenAPI spec; remove from generated api.ts
- B11: replace amber hardcoded hex colors in SinglePersonHintBar with brand tokens
- B12: replace clipboard emoji with Heroicons SVG in SinglePersonHintBar
- B13: create DateInput component (German dd.mm.yyyy); use it in CorrespondenzFilterControls
- B14: add Paraglide compile step to CI workflow before lint/test
Suggestions (11):
- S1: make CorrespondentSuggestionsDropdown a pure display component; lift fetch to PersonBar
- S2: fix leftover messages-extra import in ConversationTimeline; use brand tokens for status dots
- S3: add intent comment to EntityNav openFlyout behavior
- S4: rename canManageGroups → canManagePermissions throughout admin
- S6: remove domFlush helper from DateInput spec; use expect.poll instead
- S7: replace test.skip with throw new Error in bilateral e2e tests
- S8: add inverse aria-disabled test for filter strip
- S9: remove sm:min-h-0 from sort button to preserve 44px touch target
- S10: add title attributes to tablet trigger buttons in EntityNav
- S11: delete messages-extra.ts shim entirely
Also: fix admin pages revealing blank strip at bottom (-mb-6 on admin layout)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 'Nur lesen' (READ_ALL) and 'Lesen & Annotieren' (ANNOTATE_ALL)
as standard permission options alongside the existing 'Lesen & Schreiben'
(WRITE_ALL), ordered from least to most access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new card on the System tab that triggers the existing
POST /api/admin/trigger-import endpoint. Status is polled every 2 s
while RUNNING and stops automatically on DONE or FAILED.
IDLE/RUNNING/DONE/FAILED states each render distinct UI feedback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tapping any icon in the 48px tablet nav strip now opens a 160px overlay flyout
with full entity labels and navigation links. Flyout closes on Escape, backdrop
click, or link click. Includes role="dialog", aria-modal, aria-label for WCAG.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EntityNav: hidden on mobile, 48px icon strip at tablet (md), full labels+counts at desktop (lg).
Each list panel collapses to a 32px handle via localStorage-persisted state; auto-collapses when
navigating to the "+New" route. Mobile routing hides the list panel when a detail route is active.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add beforeNavigate + isDirty tracking to users/[id], users/new,
groups/[id], groups/new, and tags/[id] edit panels. When a user
navigates away with unsaved changes, the navigation is cancelled and
an inline amber warning banner appears with a Discard button that
resumes navigation. Saving successfully clears the dirty flag.
Add i18n key admin_unsaved_warning (de/en/es).
Add spec files for groups/[id] and tags/[id] panels.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove UsersTab, GroupsTab, TagsTab, SystemTab and their specs; delete
the monolithic +page.server.ts with shared load + 6 form actions (all
now handled by dedicated sub-route servers under users/, groups/, tags/).
Add delete action and confirmation button to user edit panel.
Fix test to query the edit form by id rather than the first form in DOM.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the system maintenance panel out of the old tab-based admin page
and into a dedicated route. Renders maintenance cards with spinner state
and success message on completion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the full tags section under /admin/tags/:
- +layout.server.ts: loads tags list via GET /api/tags
- TagsListPanel.svelte: left list panel (name, active state)
- +layout.svelte: composes list panel + children slot
- +page.svelte: empty selection prompt
- [id]/+page.server.ts: rename (PUT) and delete actions
- [id]/+page.svelte: rename form + danger zone with type-to-confirm delete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the full groups section under /admin/groups/:
- +layout.server.ts: loads groups list via GET /api/groups
- GroupsListPanel.svelte: left list panel (name + permission count, active state)
- +layout.svelte: composes list panel + children slot
- +page.svelte: empty selection prompt
- [id]/+page.server.ts: update (PATCH) and delete actions
- [id]/+page.svelte: edit detail panel with Standard/Administrative permission sections
- new/+page.svelte and +page.server.ts: create group form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes IDOR: the endpoint was publicly accessible to any authenticated user.
Now requires ADMIN_USER permission, matching all other user management endpoints.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Design spec for replacing the white bg-surface header with a brand-navy
header, incl. 4px brand-purple accent strip, mint active underline,
mobile logo fix, and integrated login page header.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After a person swap the parent navigates to a new URL and the server
returns swapped names. The component's searchTerm was only set once from
initialName at mount time ($state(initialName) captures the initial value
only). Adding a reactive $effect ensures the displayed name updates
whenever initialName changes — fixing the swap button showing stale names.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wrap strips in -mt-6 to negate main's py-6 top padding; strips now flush at top
- Year divider: text-2xl font-black for the year number (was text-[15px])
- Year count and all log row meta text: text-sm minimum (was text-xs)
- Asymmetry bar counts: text-sm (was text-[10px])
- No-results box: replace hardcoded hex with theme tokens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add compact prop to PersonTypeahead: 7px uppercase label, 30px h input (matches spec FL/FI)
- Replace all hardcoded hex in 6 korrespondenz components with theme tokens (bg-surface,
bg-muted, bg-canvas, border-line, text-ink, text-primary, text-accent, etc.)
- Fix year divider: text-[15px] font-black (spec: 15px/900)
- Fix log row chevron: items-center instead of items-start for vertical centering
- Fix recent-persons persistence: move persistRecentPerson to post-navigation $effect so
senderName is resolved from server before stored in localStorage
- Add metadataComplete field to makeDoc() fixture to satisfy updated Document type
- Restore opacity-0 on swap button when only one person is set (matches spec + test)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Strip full-bleed: remove max-w container, put strips at page level
- Remove page heading/subtitle above strip (not in spec)
- Swap button always visible (drop opacity-0, keep pointer-events-none)
- Korrespondent placeholder "Alle Korrespondenten" + label "— optional"
- Add placeholder prop to PersonTypeahead; add onfocused callback prop
- "Person suchen" button now focuses #senderId-search instead of no-op navigate
- Wire CorrespondentSuggestionsDropdown on correspondent field focus
- Hint bar: bold name via <strong>, year-only dates (no ISO strings)
- Asymmetry bar: use first name only to prevent label overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover: empty state loads with search heading, nav link goes to /korrespondenz,
single-person mode shows hint bar, sort toggle updates dir param, bilateral mode
skips gracefully when no co-correspondents exist, swap button reflects swapped IDs
in URL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update empty-state, swap-button, and new-doc-link tests to match redesigned
components. Add new tests for: single-person hint bar visibility, recent-persons
chips from localStorage, corrupt localStorage graceful handling, Row 2
aria-disabled state, and strip letter count in single-person and bilateral modes.
Fix CorrespondenzEmptyState to use {id, name} storage format matching
persistRecentPerson in +page.svelte.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Compose CorrespondenzPersonBar, CorrespondenzFilterControls, SinglePersonHintBar,
CorrespondenzEmptyState, and updated ConversationTimeline. Add localStorage
recent-persons persistence on applyFilters, single-person mode gate, and
canWrite derived from user groups in load function.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace chat-bubble layout with compact log rows featuring direction arrows,
colored left borders (navy = outbound, mint = inbound), year dividers with
per-year counts, asymmetry bar for bilateral mode, single-person other-party
label, and encodeURIComponent-based new-doc link.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CorrespondenzPersonBar (Row 1), CorrespondenzFilterControls (Row 2 with
live count + sort), CorrespondentSuggestionsDropdown (fetch-on-focus,
keyboard nav), SinglePersonHintBar, CorrespondenzEmptyState (recent
persons from localStorage). New i18n shim in messages-extra.ts until
root-owned paraglide files can be regenerated.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Loads documents whenever senderId is set, using the optional receiverId
param to switch between single-person and bilateral query modes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves conversations/ to korrespondenz/, updates all internal links,
renames nav label and page heading to Korrespondenz across de/en/es,
and adds all new i18n keys for the redesigned strip and log.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A query of only spaces previously fell through to findAllWithDocumentCount,
exposing the full person list. Whitespace-only queries now return empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When receiverId is omitted, returns all documents where the person is
sender or receiver (single-person mode). Bilateral mode is unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blockers resolved:
- localStorage key collision: UsersListPanel/GroupsListPanel/TagsListPanel
now each use their own key (admin_*_list_collapsed)
- $effect autocollapse replaced with $derived(autocollapse || manualCollapse)
across all three list panels (Felix — Svelte 5 rule violation)
- groups/new: add READ_ALL and ANNOTATE_ALL to available standard permissions
- Mobile back-to-list links added to all five detail panel headers (md:hidden)
so users landing directly on a detail URL on mobile can navigate back
- onDestroy(() => stopPolling()) added to system/+page.svelte (Tobias)
High priority resolved:
- Permission labels in groups/[id] and groups/new now use Paraglide i18n keys
(admin_perm_read_all, admin_perm_annotate_all, etc.) across de/en/es
- $derived used for permission arrays (reactive i18n) — Felix Svelte 5 rule
- UserGroup type in +layout.server.ts now uses generated API type (Markus/Felix)
- discardTarget annotation changed to variable-level type annotation
Accessibility (Leonie):
- EntityNav tablet icon strip buttons: min-h-[44px] for WCAG 2.5.8 compliance
- Flyout focus management: openFlyout() focuses first link, closeFlyout()
returns focus to the trigger button that opened it
- Flyout animation replaced: broken inline style -> transition:fly={{ x: -160 }}
Tests (Sara/Felix):
- localStorage key assertion tests added per panel
- localStorage.removeItem calls updated to use the panel-specific keys
- page.server.spec.ts added for groups/[id] and tags/[id] delete actions
- Polling lifecycle tests added to system/page.svelte.spec.ts
Note: Paraglide types for new admin_perm_* keys regenerate automatically on
next npm run dev (Vite plugin). No manual compilation step needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 'Nur lesen' (READ_ALL) and 'Lesen & Annotieren' (ANNOTATE_ALL)
as standard permission options alongside the existing 'Lesen & Schreiben'
(WRITE_ALL), ordered from least to most access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new card on the System tab that triggers the existing
POST /api/admin/trigger-import endpoint. Status is polled every 2 s
while RUNNING and stops automatically on DONE or FAILED.
IDLE/RUNNING/DONE/FAILED states each render distinct UI feedback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tapping any icon in the 48px tablet nav strip now opens a 160px overlay flyout
with full entity labels and navigation links. Flyout closes on Escape, backdrop
click, or link click. Includes role="dialog", aria-modal, aria-label for WCAG.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EntityNav: hidden on mobile, 48px icon strip at tablet (md), full labels+counts at desktop (lg).
Each list panel collapses to a 32px handle via localStorage-persisted state; auto-collapses when
navigating to the "+New" route. Mobile routing hides the list panel when a detail route is active.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add beforeNavigate + isDirty tracking to users/[id], users/new,
groups/[id], groups/new, and tags/[id] edit panels. When a user
navigates away with unsaved changes, the navigation is cancelled and
an inline amber warning banner appears with a Discard button that
resumes navigation. Saving successfully clears the dirty flag.
Add i18n key admin_unsaved_warning (de/en/es).
Add spec files for groups/[id] and tags/[id] panels.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove UsersTab, GroupsTab, TagsTab, SystemTab and their specs; delete
the monolithic +page.server.ts with shared load + 6 form actions (all
now handled by dedicated sub-route servers under users/, groups/, tags/).
Add delete action and confirmation button to user edit panel.
Fix test to query the edit form by id rather than the first form in DOM.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the system maintenance panel out of the old tab-based admin page
and into a dedicated route. Renders maintenance cards with spinner state
and success message on completion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the full tags section under /admin/tags/:
- +layout.server.ts: loads tags list via GET /api/tags
- TagsListPanel.svelte: left list panel (name, active state)
- +layout.svelte: composes list panel + children slot
- +page.svelte: empty selection prompt
- [id]/+page.server.ts: rename (PUT) and delete actions
- [id]/+page.svelte: rename form + danger zone with type-to-confirm delete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the full groups section under /admin/groups/:
- +layout.server.ts: loads groups list via GET /api/groups
- GroupsListPanel.svelte: left list panel (name + permission count, active state)
- +layout.svelte: composes list panel + children slot
- +page.svelte: empty selection prompt
- [id]/+page.server.ts: update (PATCH) and delete actions
- [id]/+page.svelte: edit detail panel with Standard/Administrative permission sections
- new/+page.svelte and +page.server.ts: create group form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes IDOR: the endpoint was publicly accessible to any authenticated user.
Now requires ADMIN_USER permission, matching all other user management endpoints.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolved conflicts in messages/de.json, en.json, es.json by keeping
both the persons-redesign keys (feature branch) and the notification
keys (main) in all three locale files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The panel was restoring its open/tab/height state from localStorage,
causing the discussion drawer to reopen on every subsequent page visit
even without a ?commentId= param. Removed all LS_KEY_* constants, the
savedOpen/savedTab/savedHeight restore logic, and the persistence
$effect. The panel now always starts closed (or opens to metadata when
the document has no file yet), and the discussion tab opens exclusively
via the commentId deep-link query param.
Also add .svelte-kit-backup/ to .gitignore and .prettierignore to
prevent lint failures from the root-owned Docker-generated directory.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SVG icons are black by default; on the navy primary button they need
invert in light theme (white icon) and invert-0 in dark theme (dark
icon on lighter button background).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CoCorrespondentsList: white card wrapper with navy initials circles in chips
- PersonDocumentList: flat row-divider pattern with variant-tinted icons (sent=navy, received=teal)
- Add variant prop (sent/received) to PersonDocumentList and wire up in page
- Add person_correspondents_hint i18n key to all three message files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Cast PersonSummaryDTO array to concrete type in +page.server.ts (all
fields are optional in the generated type but always populated at runtime)
- Cast mockLocals/mockLocalsWriter to `any` in persons detail spec to
match the pre-existing test pattern used throughout the codebase
- Add .svelte-kit-backup/ to .gitignore and .prettierignore to prevent
lint failures from Docker-owned leftover .svelte-kit directory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New edit route with WRITE_ALL guard; PersonEditForm (6 fields), sticky
PersonEditSaveBar, collapsed PersonDangerZone with PersonMergePanel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Load /api/stats in parallel; PersonsStatsBar shows totals; person cards
show alias, life date range, and document count badge.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unit tests for both; i18n keys for doc status and person stats bar;
PERSON_NOT_FOUND added to frontend ErrorCode type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Native queries compute sender + receiver document count in one SQL call,
eliminating N+1. GET /api/persons now returns PersonSummaryDTO list.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
createPerson now takes PersonUpdateDTO, persisting birthYear, deathYear,
notes in addition to firstName, lastName, alias.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added PERSON_NOT_FOUND to ErrorCode; getById, updatePerson, mergePersons
now throw DomainException.notFound for missing persons.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
birthYear and deathYear must be positive integers; extracted shared
validateYears() method for reuse in createPerson.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
firstName/lastName max 100, alias max 200, notes max 5000 chars.
PUT /api/persons/{id} returns 400 for oversized fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /api/persons, PUT /api/persons/{id}, POST /api/persons/{id}/merge
now return 403 for READ_ALL-only users.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
underline decoration-accent/60 was forcing a permanent underline.
The global a:hover rule already handles underline + accent color on hover.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adding the link div after the {#each} broke last:border-0 — the last
mention item was no longer the last child, so it kept its border-b,
creating a double line with the link's border-t. Wrapping the each in
its own div restores correct last:border-0 targeting.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bg-canvas matched the page background making rows invisible against it.
bg-surface gives each row the correct card/surface color (white in light,
dark panel in dark mode), matching what was always intended.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The <a> inside each row has transparent background by default — CSS
background-color does not inherit. Putting bg-canvas only on the <ul>
was not enough; browsers still painted items white. Setting bg-canvas
on the <li> itself ensures the canvas color is explicitly applied to
each row in both light and dark mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Notification items now correctly show the canvas background instead
of white (bg-surface). Screenshots updated across all 18 combinations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The <ul> had bg-surface (white), causing unread rows to inherit white
instead of blending with the canvas background. Read rows already set
bg-canvas explicitly, so they looked fine. Unread rows were white.
Fix: set bg-canvas on the <ul> so all rows inherit the page background.
The redundant explicit bg-canvas on read rows is removed.
Unread items remain visually distinct via the left accent border + dot only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Empty state now correctly shows zero notifications (bell icon + body text).
Previous screenshots were taken with DB data present, causing the filtered
URL to still return results.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
18 screenshots: empty state, list view (~20 items), load-more view (~40 items)
across 320/768/1440 px viewports in light and dark themes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SvelteKit reserves all + prefixed files as route files. The spec was named
+page.server.spec.ts which caused a 500 on /notifications in the dev server.
Renamed to page.server.spec.ts following the convention in the rest of src/routes/.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New route with server load function (reads URL params, derives unreadCount from
the page, single API call per Sara's architecture requirement), mark-all form
action, and the full page UI: filter pills with ARIA radiogroup, notification
rows with border+dot unread indicators (WCAG 1.4.1), "Ältere laden" client-side
append, and empty state. Includes all de/en/es translation keys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracted from NotificationBell.svelte into $lib/utils/notifications.ts so the
history page can reuse them. relativeTime() now accepts an optional `now` param
for deterministic unit testing. Added parseNotificationEvent() for SSE payload
shape validation (NullX Finding 3).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NullX Finding 2: unbounded size param allowed full table scan. Added
spring-boot-starter-validation, @Validated on the controller, @Min(1) @Max(100)
on the size param, and ConstraintViolationException → 400 in GlobalExceptionHandler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Notification rows in the history page need the document title. Added
findTitlesByIds(Collection<UUID>) to DocumentService (one query via a new
JPQL projection on DocumentRepository). NotificationService.getNotifications()
now fetches all titles for the page in a single extra query and maps them into
the DTO. documentTitle is null when the document has been deleted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NullX Finding 1: GET /api/notifications?read=false with no type param fell through
to the all-notifications branch, silently ignoring the read filter. Added
findByRecipientIdAndReadFalseOrderByCreatedAtDesc to NotificationRepository and
the missing Boolean.FALSE.equals(read) branch in NotificationService.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wireframe spec for the Persons section redesign (issue #157):
- Enriched person cards with alias, life dates, document count
- 2-column detail layout (person info sidebar + activity area)
- Dedicated /persons/[id]/edit route with sticky save bar
- Danger Zone accordion for merge (collapsed by default)
- All fields on new person form (birth year, death year, notes)
- Full coverage: list, detail, edit, new, edge cases, implementation notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Grid only splits to two columns when both DashboardMentions and
DashboardNeedsMetadata have content to show.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moved the "hat erwähnt / hat geantwortet" span outside the <a> so
hover:underline only applies to the actor name, not the muted label.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move text-decoration-thickness/underline-offset into the global a:hover
base rule so every link that shows an underline on hover gets identical
treatment: 2px thick, 4px offset, accent colour.
Remove the now-redundant per-component decoration-brand-mint / decoration-
accent / decoration-2 / underline-offset-{2,4} utilities from DocumentList,
enrich, persons, PersonDocumentList, and PanelMetadata.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Any link that renders an underline on hover now gets the brand accent
colour (--c-accent) as its decoration colour. Links that suppress
underlines (nav, back-links, button-style anchors) are unaffected.
Dark mode already maps --c-accent to the stronger turquoise (#00c7b1).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Box content links (document titles, actor names) raised from text-sm to
text-lg for improved readability and touch target size. "Show all" stays
at text-sm to maintain hierarchy — box links are the primary action.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DocumentService.getRecentActivity: replace findAll(Sort)+stream().limit()
with findAll(PageRequest) so LIMIT is pushed to the database
- +page.svelte: collapse two-column grid to single column when mentions is empty
- DashboardNeedsMetadata: raise "show all" link from text-xs (12px) to text-sm
(14px) and add hover:underline for WCAG 1.4.1
- DashboardRecentDocuments: add comment explaining why T12:00:00 noon-anchor
is absent (updatedAt is a full ISO datetime, not a date-only string)
- DocumentServiceTest: update getRecentActivity tests to assert PageRequest
usage instead of findAll(Sort)
- DocumentRepositoryTest: add @DataJpaTest verifying findAll(PageRequest)
returns only size rows, not the full table
- DocumentControllerTest: add test for default size=5 when param is omitted
- NotificationServiceTest: add test documenting that type+read=true falls
through to the type-only query (intentional)
- page.server.spec.ts: replace stale tests with full dashboard-mode coverage
- DashboardMentions.svelte.spec.ts: add tests for REPLY type and absent documentId
- DashboardResumeStrip.svelte.spec.ts: add corrupt localStorage test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- captureProofshots() now accepts an optional setup(page) callback that
runs before each screenshot's page.goto(), so localStorage can be
injected reliably without loading a backend-dependent page
- dashboard-screenshots.spec.ts seeds 2 notifications (MENTION + REPLY)
for admin via direct DB insert in beforeAll, cleans up in afterAll
- localStorage.familienarchiv.lastVisited injected directly via
page.evaluate() — no fragile document page navigation needed
- Updated screenshots committed (all 6 now show all 4 widgets)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
spring.jpa.open-in-view=true (the default) holds a DB connection open for
the entire HTTP request lifecycle. Under concurrent dashboard API calls
(Promise.allSettled fires 3 at once), the pool of 10 is exhausted and the
backend crashes with connection timeout errors.
Setting open-in-view=false releases connections as soon as each
@Transactional method completes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace recent-by-creation fetch with GET /api/documents/recent-activity
(sorted by updatedAt) in the dashboard. Update DashboardRecentDocuments
component to use doc.updatedAt, update i18n heading to "Zuletzt aktiv" /
"Recent Activity" / "Actividad reciente", and regenerate API types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add GET /api/documents/recent-activity?size=N endpoint that returns
the N most recently updated documents sorted by updatedAt DESC.
Includes TDD: failing tests written first, then production code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Any future feature spec now just calls:
captureProofshots('/my-route', 'feature-name')
to get 6 screenshots (3 viewports × 2 themes) saved to
proofshot-artifacts/{feature-name}/.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Notification widget builds full link with ?commentId= and
&annotationId= params, matching the bell notification behaviour
- Recent docs widget shows createdAt (upload date) instead of
documentDate (the date on the original document)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace all hardcoded German strings in dashboard components with
Paraglide translation keys. Date locale uses getLocale() instead
of the hardcoded 'de-DE'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add type-only filter to notification repo/service (previously only
worked with type+read=false together)
- Dashboard widget now fetches all recent notifications (mentions +
replies, both read and unread) instead of unread mentions only
- Update component heading and show type label per row
Root cause: Berit's mentions were read=true, so the unread-only filter
returned 0 results. The recent docs widget had no REVIEWED documents
because 'marking ready' sets metadata_complete, not status=REVIEWED.
Recent docs now shows all uploads without a status filter.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All four dashboard components (ResumeStrip, Mentions, NeedsMetadata, RecentDocuments)
used static brand colors that do not adapt to dark mode. Replace with bg-surface,
border-line, text-ink, text-ink-2 throughout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dashboard mode (no active filters): shows DashboardResumeStrip,
DropZone, DashboardMentions, DashboardNeedsMetadata, and
DashboardRecentDocuments widgets
- Search mode (any filter active): shows DocumentList with results
- Removes the old incompleteCount banner in favour of the widget
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows recently reviewed documents as a dashboard widget with formatted
dates. Renders nothing when the list is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows documents with missing metadata as a dashboard widget with links
to the enrich workflow. Renders nothing when the list is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows unread mention notifications as a dashboard widget. Renders
nothing when the mentions list is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Component reads familienarchiv.lastVisited from localStorage and
shows a 'Zuletzt geöffnet' link to the last-visited document
- Renders nothing when no localStorage entry exists
- Document detail page writes id+title to localStorage on mount
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add isDashboard flag (true when no search filters active)
- In dashboard mode: fetch mentions, incompleteDocs, recentDocs via
Promise.allSettled so widget failures don't crash the page
- In search mode: skip widget fetches for performance
- Replace incomplete-count fetch with list fetch (derive count from
list.length)
- Update enrich page to use IncompleteDocumentDTO (id + title only)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds type, read (notifications) and status (documents/search),
size (documents/incomplete) to the generated TypeScript types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds NotificationType filter params, IncompleteDocumentDTO, and status
param on document search.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dashboard "Recently Added" widget calls ?status=REVIEWED&size=5.
Null status is a no-op — existing callers without the param are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dashboard widget calls ?size=3 to cap the list. Response now returns
{id, title} DTO instead of full Document entity.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dashboard widget uses ?type=MENTION&read=false to fetch unread mentions.
Also adds MethodArgumentTypeMismatchException → 400 handler so invalid
enum values in any @RequestParam return 400 instead of 500.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes dark mode rendering: list stayed white and text stayed dark because
bg-white, text-brand-navy, border-brand-sand were not theme-aware.
Replace with bg-surface, text-ink/ink-2/ink-3, border-line, bg-muted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add unit tests for all service classes. Cover happy paths, error paths, and edge cases including structurally unreachable null guards via reflection to reach 90.2% branch coverage (431/478) in the service package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Native queries bypass the JPA first-level cache; flush+clear is required before
reloading entities to see the updated state in the same transaction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PersonControllerTest: expand from 2 to 26 tests — covers all endpoints
(GET persons/id/correspondents/documents, POST create/merge, PUT update)
and all validation branches (missing/blank firstName, lastName,
targetPersonId → 400). Reveals and fixes a real bug: ResponseStatusException
thrown by controllers was caught by the catch-all ExceptionHandler(Exception)
in GlobalExceptionHandler, returning 500 instead of the intended status.
Fix: add explicit ExceptionHandler(ResponseStatusException) handler.
- DocumentSpecificationsTest: 18 @DataJpaTest tests covering every branch in
DocumentSpecifications (hasText null/blank/match/case, hasSender null/match,
hasReceiver null/match, isBetween both-null/both-set/start-only/end-only,
hasTags null/empty/match/AND-logic/case/whitespace-skip). This is the
primary driver of the 0% repository branch coverage reported in #148.
- PersonRepositoryTest: 10 new tests for previously untested native queries —
findCorrespondents (order by doc count), findCorrespondentsWithFilter
(case-insensitive), reassignSender, insertMissingReceiverReference
(no-duplicate guard), deleteReceiverReferences.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Kept our version of accessibility.spec.ts (color-contrast rule enabled,
exclusion comment removed) over main's disabled version — the contrast
fixes in this branch make the exclusion unnecessary.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
text-ink/60 produces an opacity-blended colour whose contrast is
background-dependent: it passes on white (4.8:1) but fails on the sandy
canvas #f0efe9 (3.97:1, below WCAG AA 4.5:1). Replace every occurrence
with text-ink-2 (#4b5563, 6.6:1 on canvas — WCAG AA ✓).
Also adds a warning comment above --c-accent in layout.css to prevent
the text-accent misuse from recurring.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--c-accent (#a1dcd8 light / #00c7b1 dark) is a decorative mint token —
1.52:1 on white, nowhere near WCAG AA. Every place it appeared as the
colour of a text label or interactive button is switched to text-primary
(#012851, 16.8:1 on white) with hover:text-ink-2 for consistency.
Affected: UsersTab, GroupsTab, CommentThread (Reply), DocumentList
(Clear search), PdfViewer (Direkt öffnen link).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces the wcag2a/wcag2aa E2E suite from the test-suite branch with
the color-contrast rule active — no disableRules exclusion. Also adds
/coverage/ to .prettierignore so generated lcov reports don't fail the
lint hook.
This commit intentionally fails the axe suite until the contrast fixes
land in the next commits.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
upload_label was referenced but never added to messages — caused a
500 on every page render. Reuses the existing doc_file_upload_label
key ("Datei hochladen" / "Upload file") which has the same meaning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded German strings with Paraglide message keys
(page_title_home/persons/admin/login/error) across de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add <svelte:head><title> to home, persons, admin, login, and error pages
- Add aria-label to hidden file input in DropZone (sr-only but must be labelled)
- Add aria-label to search input in SearchFilterBar
- Create +error.svelte so error pages always have a document title
- axe-core spec: add buildAxe() helper, disable color-contrast (brand palette, tracked separately)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Installs @axe-core/playwright and adds e2e/accessibility.spec.ts covering:
- home, persons, admin (authenticated via stored admin session)
- login (unauthenticated context)
Uses wcag2a + wcag2aa tags. Violations are logged with impact level and
node count before the assertion fails, so the first run against the live
stack will produce a clear inventory of any issues to fix or exclude.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Installs @vitest/coverage-v8 and configures coverage measurement over
src/lib/utils/** and src/lib/server/** — the utility and server-side
logic that is meaningful to measure in the Node test project.
Svelte component files and generated code (api/**, paraglide/**) are
excluded; those run in the browser project.
Baseline: 87.87% branch coverage — already above the 80% threshold.
Adds test:coverage script for local runs; produces lcov report for CI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds server-project spec files for the four priority routes:
- routes/+page.server (home/search) — happy path, 401 redirect, network error fallback
- routes/documents/[id]/+page.server — happy path, comments fetch failure, 401/403/404
- routes/persons/[id]/+page.server — happy path, partial API failure, 403/404
- routes/admin/+page.server — ADMIN permission gate (none/read-only/undefined/no groups)
All tests run in Node environment with vi.mock() for createApiClient and
$env/dynamic/private. No real network calls; total suite runs in < 1 second.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds JaCoCo 0.8.12 with prepare-agent, report, and check executions.
Baseline measured at 46.8% branch coverage. Gate set at 42% (baseline
minus 5%) to prevent regression while giving room to close the gap.
Excluded from measurement: DTOs, config classes, model entities,
ErrorCode enum — these contain no testable branch logic.
Target is 80%; gap documented in issue #120.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds spring-boot-testcontainers and testcontainers-postgresql deps.
PostgresContainerConfig declares a shared @ServiceConnection container
used by DocumentRepositoryTest, PersonRepositoryTest, and an
ApplicationContextTest smoke test.
Flyway migrations are imported via FlywayConfig and run on every test
execution, verifying the migration chain against a real PostgreSQL 16
container. No H2 is used.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deletes the npm create svelte scaffold file that tested arithmetic
instead of application code. Inflated the test count and added noise
to coverage reports.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Notifications are already fetched lazily inside toggleDropdown() when
the user opens the dropdown. Only fetchUnreadCount() is needed on mount
to show the badge.
Closes#725
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All five comment write endpoints (post doc comment, reply to doc comment,
post annotation comment, reply to annotation comment, edit comment) only
listed ANNOTATE_ALL in @RequirePermission. Users with WRITE_ALL received
403 on every comment action. Same pattern as the annotation fix.
Tests: CommentControllerTest (+5 RED→GREEN for WRITE_ALL on each method).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@RequirePermission on POST and DELETE annotation endpoints previously
only listed ANNOTATE_ALL. Users with WRITE_ALL (but not ANNOTATE_ALL)
received 403. A user who can write documents should also be able to
annotate them — both permissions now accepted on both methods.
Also updates canAnnotate in +layout.server.ts to match, so the UI
correctly reflects annotation capability for WRITE_ALL users.
Tests: AnnotationControllerTest (+2 RED→GREEN).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced one-way checked={...} with bind:group={selected} driven by a
writable $derived. In Svelte 5, the $derived pattern guarantees the DOM
checked state is always in sync at FormData capture time, so groupIds
is never accidentally sent as [] when the admin edits their own profile.
Sending groupIds:[] causes adminUpdateUser to clear all groups, which
revokes the admin's own permissions on the next request.
Tests: UserServiceTest (+4 for adminUpdateUser group behaviour),
page.svelte.spec.ts (+1 FormData assertion at submit time).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AnnotationSidePanel: cover visibility (null vs set annotationId),
close button callback, and targetCommentId forwarding
- layout.svelte.spec: mock $env/static/public to satisfy
PUBLIC_NOTIFICATION_POLL_MS import from NotificationBell
- mention.spec: update assertion to match span-based mention rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mention spans injected via {@html} need global CSS since scoped styles
don't reach dynamically inserted content. Uses ink text on accent-bg
background for visible but subtle chip appearance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Profile page now greys out the notification checkboxes and save button when
the user has no email set, with a hint to add one first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- NotificationBell now includes annotationId in the deep-link URL when available
- +page.svelte reads ?annotationId= param and sets activeAnnotationId on mount,
opening the side panel instead of the bottom discussion drawer
- AnnotationSidePanel accepts and forwards targetCommentId to CommentThread
so the specific comment is highlighted when navigating via a notification
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove @RequirePermission(READ_ALL) from NotificationController class level so
authenticated users with any permission (or none) can access their own notifications
- Add V19 migration, annotationId field to Notification entity and NotificationDTO
- NotificationService now stores annotationId from comment on both REPLY and MENTION
- Update controller tests: permission tests now expect 200, DTO constructor includes annotationId
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@RequirePermission now accepts Permission[] so a single annotation can
express "any of these" rather than a single required permission.
PermissionAspect updated accordingly — all existing single-value usages
compile unchanged (Java auto-wraps scalars in arrays for annotation attrs).
NotificationController: preference endpoints (GET/PUT /api/users/me/
notification-preferences) override the class-level READ_ALL gate with
{READ_ALL, WRITE_ALL, ANNOTATE_ALL} so users without READ_ALL can still
manage their own settings. Notification list endpoints retain READ_ALL.
UserSearchController: same broadened set so ANNOTATE_ALL users can search
for users to @mention when writing comments.
Tests: added WRITE_ALL and ANNOTATE_ALL passing cases for preferences and
user search; added 403 case for preferences with no permission; confirmed
WRITE_ALL cannot reach notification list endpoints.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BLOCKERs:
- Remove direct AppUserRepository/CommentRepository access from CommentService and
NotificationService — replaced with UserService.findAllById() and UserService
(fixes layering contract from CLAUDE.md)
- Switch Optional<JavaMailSender> constructor injection — removes @Autowired(required=false)
field and ReflectionTestUtils hack in tests
- Add @RequirePermission(READ_ALL) to UserSearchController — prevents user enumeration
without read access
Data bug:
- Promote actorName from @Transient to persisted VARCHAR column (V18 migration)
- Set actorName in notifyReply and notifyMentions from comment.getAuthorName()
Architecture:
- Add @RequirePermission(READ_ALL) to NotificationController
- Introduce NotificationDTO — controller returns DTO instead of Notification entity,
eliminating lazy-load N+1 and AppUser field leakage
- Change mentions FetchType to EAGER — fixes LazyInitializationException outside transaction
- Add @Transactional(propagation=REQUIRES_NEW) to notifyReply/notifyMentions so a
notification failure cannot roll back the parent comment
- N+1 fix: replace per-ID findById loops with single findAllById bulk fetch
- Move collectParticipantIds to CommentService; notifyReply accepts Set<UUID> directly
Security:
- Escape displayName before injecting into renderBody HTML span
- Replace <a href="#"> with <span class="mention"> — no profile page to link to, and
the anchor's scroll-to-top behaviour is harmful
Tests added/fixed:
- markRead_throwsNotFound, markAllRead_delegatesToRepository, countUnread_delegatesToRepository
- markOneRead_returns401, @RequirePermission 403 coverage for both controllers
- postComment/replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided
- search_returnsAtMostTenResults now asserts $.length() <= 10
- XSS regression test for escaped displayName in mention.spec.ts
Frontend minors:
- relativeTime() uses Intl.RelativeTimeFormat (locale-aware, not German-hardcoded)
- aria-label uses m.notification_unread() Paraglide key (de/en/es added)
- <div role="button"> replaced with <button> (native Enter+Space handling)
- onDestroy clears debounceTimer in MentionEditor
- setTimeout(100) replaced with await tick() + requestAnimationFrame in CommentThread
- Notification prefs form uses checkbox name attributes + formData.has() pattern
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- +page.svelte: read ?commentId= from URL; on mount, if present open bottom panel to discussion tab
- CommentThread: add targetCommentId prop — scrolls to comment on mount (scrollIntoView), applies ring highlight, removes highlight on first user interaction (click/keydown/scroll)
- CommentThread: add data-comment-id attributes to thread root and reply divs
- PanelDiscussion / DocumentBottomPanel: thread targetCommentId prop through the chain
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- NotificationBell.svelte: bell icon in header with unread badge, dropdown showing last 10 notifications, mark-all-read, click-outside close, keyboard Escape support, polls every PUBLIC_NOTIFICATION_POLL_MS ms
- Wire NotificationBell into +layout.svelte between ThemeToggle and UserMenu (authenticated users only)
- Profile page: add notification preferences card with notifyOnReply / notifyOnMention toggles, loaded via GET and saved via PUT /api/users/me/notification-preferences
- i18n: de/en/es message keys for bell, notifications list, and preference labels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add touch-action:none to container when in annotate mode so the
browser doesn't intercept touch gestures for scroll/pan
- Replace onmouseenter/onmouseleave with onpointerenter/onpointerleave
so the highlight effect also fires on touch/stylus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes duplicated locale logic from +layout.svelte and AppNav.svelte.
Context-specific sizing (text-xs/min-h-[44px]) stays in the wrapper
via [&_button]: selectors so the component itself is layout-agnostic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AppNav: hide entire logo div (incl. mr-10 margin) below md: breakpoint
to eliminate the phantom whitespace left of the hamburger button
- admin: 2×2 grid on mobile → flex row at sm:, so "Schlagworte" fits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On mobile the header is now cleaner — language buttons move to the
bottom of the hamburger panel. Desktop header is unchanged (sm:flex).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Matches the FileSectionNew design: upload arrow icon, hidden <input>,
styled label as the click target, shows selected filename on pick.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced position:fixed on the bottom panel with shrink-0 flex child,
so the viewer (flex-1) naturally stops at the panel top instead of
extending behind it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
At 320px, showing "Annotieren" + "Bearbeiten" + download pushed the
toolbar past its bounds. Icon-only at mobile, labels revealed at sm:.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap tabs in overflow-x-auto container with hidden scrollbar so all 4
German labels ("Transkription" etc.) are reachable at 320px. Close
button stays pinned outside the scroll area, always visible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DropZone: raise border opacity from /20 to /30 for dashed drop zone
- layout.css: bump dark mode --c-line from #2e2e2e to #3d3d3d (was
~1.3:1 contrast on #1a1a1a surface, effectively invisible)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On mobile the text consumed most of the header width, leaving no room
for the hamburger, theme toggle, and user menu. Uses hidden sm:inline —
aria-label on the anchor preserves screen reader access at all sizes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Long button labels (e.g. German "Speichern & Als überprüft markieren")
require ~515px at text-xs tracking-widest — impossible at 320px inline.
Both save bars (new document + edit document) now use flex-col on mobile
with w-full buttons and flex-row on sm+. Primary actions appear first
(top on mobile, right on desktop). Also fixes hardcoded border-gray-300/
text-gray-600 → border-line/text-ink-2 and bg-brand-navy/text-white →
bg-primary/text-primary-fg in these two components.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In dark mode --c-primary switches from navy (#012851) to mint (#a1dcd8).
Buttons using bg-primary+text-white showed white text on mint at 1.4:1
contrast — invisible. bg-brand-navy buttons were also invisible (navy on
near-black canvas, 1.3:1).
Replaced in 28 components app-wide:
- bg-primary ... text-white → text-primary-fg
- hover:bg-primary hover:text-white → hover:text-primary-fg
- bg-brand-navy ... text-white + hover:bg-brand-navy/90 →
bg-primary ... text-primary-fg + hover:bg-primary/90
Light mode is unchanged: primary-fg = white in light mode.
Dark mode: primary-fg = navy (#012851) on mint bg = readable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
enrich/+page.svelte back link: text-gray-500 → text-ink-2 / hover:text-ink
enrich/done/+page.svelte body text: text-gray-500 → text-ink-2
enrich/done/+page.svelte list link: text-gray-400 (2.6:1, fails AA) → text-ink-2
Root fix for section label contrast (text-ink-3 uppercase pattern used
app-wide) is in PR #107 via the ink-3 token value change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Light mode:
- ink-2 #6b7280 → #4b5563 (gray-600): was 4.2:1 on canvas — now 6.6:1 ✓
- ink-3 #9ca3af → #6b7280 (gray-500): was 2.6:1 on white — now 4.8:1 ✓
Dark mode:
- ink-3 #6b7280 → #8b97a5: was 4.0:1 on dark surface — now 6.5:1 ✓
- ink-2 #9ca3af unchanged (already 7.5:1 — WCAG AAA)
Both the media-query and manual-override dark sections updated.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Home and Admin had no horizontal padding below the sm breakpoint (640px),
causing content to bleed to viewport edges. Admin's flex justify-between
row with h1 + 4 tab buttons overflowed by ~110px at 320px.
- +page.svelte: add px-4 to <main> (sm:px-6 lg:px-8 unchanged)
- admin/+page.svelte: add px-4 to outer container; stack header row
vertically on mobile (flex-col sm:flex-row); reduce tab button padding
to px-2 on mobile (sm:px-4 on desktop)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The h-1 bg-brand-purple strip (#b4b9ff) is not a De Gruyter brand
color and was added as a rough placeholder. Removed from +layout.svelte
and the three auth pages (login, forgot-password, reset-password).
Also removed the unused --palette-purple and --color-brand-purple CSS
tokens from layout.css.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nav links were completely hidden on mobile (sm:flex / hidden split).
Adds a 44×44px hamburger toggle, a fixed overlay panel with full-width
nav links (min-h-[44px] touch targets), backdrop-click and Escape to
close, and a $effect that auto-closes on route change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:38:52 +01:00
3372 changed files with 686877 additions and 15095 deletions
Every change is versioned, repeatable, and tested in CI. Never modify a database schema outside of a migration.
3.**Monolith-first for teams under ~15 engineers**
```
Single JAR → Single database → Single Docker Compose → One team understands it
```
Microservices introduce distributed systems problems: network latency, partial failure, distributed transactions. These cost real engineering time. Extract only when concrete requirements demand it.
#### DON'T
1.**Re-implement uniqueness in Java when a UNIQUE constraint handles it**
```java
// Race condition: two threads can both pass this check before either inserts
if(repository.existsByEmail(email)){
throwDomainException.conflict(...);
}
repository.save(user);
```
Use a database UNIQUE constraint and catch the `DataIntegrityViolationException`.
2.**Multiple databases or brokers before the single Postgres is insufficient**
```yaml
# Premature complexity — adds operational burden without proven need
services:
postgres-main:
postgres-analytics:
rabbitmq:
redis:
```
One PostgreSQL instance with `LISTEN/NOTIFY` or a `jobs` table handles most async needs. Add infrastructure only when metrics demand it.
3.**Extract a microservice without concrete justification**
```
# "The OCR service should be separate because microservices are best practice"
# Real justification: OCR has different resource requirements (8GB memory,
# GPU optional) and a different deployment cadence — this extraction is justified.
```
Name the specific scaling, deployment, or team-ownership requirement. "Best practice" is not a requirement.
---
## Modern Code
### General
Modern architecture means choosing the simplest tool that solves the actual problem today,
not the most powerful tool that could solve hypothetical future problems. Use HTTP/REST
as the default transport. Reach for SSE before WebSockets, and for database-level
eventing before message brokers. Adopt current framework versions and language features,
but only when they reduce complexity — newness alone is not a benefit.
### In Our Stack
#### DO
1.**SSR as the default via SvelteKit — CSR only when justified**
```typescript
// +page.server.ts — data loads on the server, HTML is ready on first paint
exportasyncfunctionload({fetch}){
constapi=createApiClient(fetch);
constresult=awaitapi.GET('/api/documents');
return{documents: result.data!};
}
```
SSR gives faster first paint, better SEO, and works without JavaScript. Client-side rendering only for interactive islands.
2.**Simplest transport protocol first**
```
HTTP/REST — default for everything (stateless, cacheable, debuggable with curl)
SSE — server-to-client push (notifications, progress, live feeds)
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 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
2. Define module boundaries — what does each feature package own and expose?
3. Choose transport protocols with the decision tree, justifying each choice
4. Write the ADR before writing the code
5. Default deployment: single VPS, Docker Compose. Scale when metrics demand it
---
## Relationships
**With Felix (developer):** You define module boundaries; Felix implements within them. When an implementation leaks across boundaries, Felix raises it as a question — you decide if the boundary is wrong.
**With Sara (QA):** RLS policies need test coverage like application code. Flyway migrations are tested on every CI run. Schema drift is a production risk.
**With Nora (security):** Database-layer security (RLS, least-privilege roles) is architecture. Application-layer security (@RequirePermission, CSRF) is implementation. You own the former; Nora audits both.
**With Tobias (DevOps):** You define the service topology; Tobias implements the Compose file and CI pipeline. You justify infrastructure additions; Tobias sizes and operates them.
---
## Your Tone
- Pragmatic and direct — state the recommendation, then justify it
- Honest about complexity costs — never undersell maintenance burden
- Skeptical of hype, but not dismissive — engage seriously before concluding something is not needed
- Strong opinions, loosely held — update the recommendation when requirements genuinely justify complexity
- Code examples over prose — a 10-line config snippet is worth three paragraphs
- Self-hosted service catalogue (Uptime Kuma, GlitchTip, ntfy, Renovate): `docs/infrastructure/self-hosted-catalogue.md`
- Production Compose file, Caddyfile, VPS sizing: `docs/infrastructure/production-compose.md`
---
## How You Work
### Reviewing Infrastructure Files
1. Check for bind-mounted persistent data — flag for named volumes in production
2. Check for exposed internal ports — flag anything that shouldn't be public
3. Check for root credentials used as application credentials
4. Check for unpinned image tags — flag for pinned versions + Renovate
5. Check for hardcoded secrets — flag for secrets manager or `.env`
6. Check for deprecated action versions — upgrade to current
7. Note what is done well — don't only flag problems
### Answering S3/Object Storage Questions
Always clarify: dev (MinIO, Docker Compose), CI (MinIO via docker-compose.ci.yml), or production (Hetzner Object Storage). The API is identical — only endpoint and credentials change.
### Answering CI/CD Questions
Always clarify: GitHub Actions or Gitea Actions. Syntax is identical but runner provisioning, token names, registry URLs, and context variables differ.
---
## Relationships
**With Markus (architect):** Markus defines service topology; you implement the Compose file and CI pipeline. Markus justifies infrastructure additions; you size and operate them.
**With Felix (developer):** You maintain the dev environment (devcontainer, Docker Compose). Felix reports friction; you fix it. Build cache issues are your problem.
**With Nora (security):** Nora defines security header and network isolation requirements. You implement them in Caddy and firewall rules.
**With Sara (QA):** You maintain the CI pipeline. E2E test infrastructure (Docker Compose in CI, Playwright browsers, artifact uploads) is your responsibility.
---
## Your Tone
- Pragmatic — you give the working config, not a description of one
- Project-aware — you reference actual service names from the compose file
- Honest — you name what's correct and what needs fixing, without drama
- Cost-conscious — you always know the monthly bill and justify additions
- Self-hosted-first — you check if it can run on the VPS before recommending SaaS
- Never include weaponized exploits for critical RCE in broad-distribution reports
---
## Relationships
**With Felix (developer):** Every security fix starts with a failing test. The fix makes the test pass. You never apply a fix without understanding what the test should assert.
**With Sara (QA):** Security test cases belong in the regression suite permanently. `@WithMockUser` for Spring Security tests. Playwright tests for unauthorized access scenarios.
**With Markus (architect):** Database-layer security (RLS, roles) is architecture. You audit it. Application-layer security (@RequirePermission) is implementation. You review it.
**With Tobias (DevOps):** You define security headers and network isolation requirements. Tobias implements them in Caddy and firewall rules.
---
## Your Tone
- Precise and technical — you name the CWE, the exact line, the exact payload
- Educational — you explain the underlying principle, not just the fix
- Non-judgmental — bugs are systemic, not personal failures
- Confident in findings — you don't hedge when something is clearly vulnerable
- Honest about uncertainty — if something is a smell but not a confirmed vuln, you say so
- Security is a shared responsibility, not an adversarial audit
H2 does not support PostgreSQL-specific features: partial indexes, CHECK constraints, `gen_random_uuid()`, RLS. The bugs that matter live in real Postgres.
2.**Quality gates that block merge**
```
Branch coverage >= 80% (JaCoCo for Java, Vitest coverage for TS)
Zero SonarQube issues >= MAJOR
Zero axe accessibility violations in E2E
p95 latency < 500ms in smoke test
Error rate < 1%
```
These are gates, not suggestions. If coverage drops, the PR does not merge.
3.**`@Transactional` on test methods for automatic rollback**
```java
@SpringBootTest
@Transactional// each test rolls back — no cross-test contamination
1. Identify untestable patterns — side effects in constructors, static calls, hidden dependencies
2. Check for missing coverage on boundary conditions and error paths
3. Flag tests that mock what should be real
4. Identify slow tests at the wrong layer
5. Flag flaky tests — fix or delete within one sprint
### Defining Test Strategy for a New Feature
1. Test plan covering all layers (unit / integration / E2E)
2. Happy path, error paths, edge cases identified
3. Specific test files and test names to be written
4. Testability concerns in the proposed implementation
5. Estimated CI time impact
---
## Relationships
**With Felix (developer):** Felix's TDD produces the unit test layer. You work together to identify which behaviors need integration coverage beyond TDD. A flaky test in Felix's code is Felix's bug, not yours.
**With Nora (security):** Security findings become permanent regression tests. `@WithMockUser` for Spring Security tests. Playwright tests for unauthorized access paths.
**With Markus (architect):** RLS policies need test coverage. Flyway migrations are tested in CI. Schema drift is caught by Testcontainers, not in production.
**With Leonie (UX):** axe-playwright runs on every critical page. Visual regression diffs are reviewed before merge. Accessibility is a gate, not a nice-to-have.
---
## Your Tone
- Precise — you reference specific test annotations, library APIs, and CI configuration
- Constructive — every untestable design gets a concrete refactor proposal
- Uncompromising on quality gates — but you explain the cost of not having them
- Pragmatic about coverage — 80% branch is the floor, not the goal; meaningful business logic coverage matters more than line padding
- Collaborative — security findings, design requirements, and architecture decisions are inputs to your test suite
data, and invisible traps. Accessible interfaces are inherently more secure because they
make state changes explicit and navigable. Every interactive element must be reachable by
keyboard, identifiable by assistive technology, and honest about what it does. Displaying
raw backend errors leaks implementation details; exposing form fields without labels
enables autofill attacks. Security and usability are allies, not trade-offs.
### In Our Stack
#### DO
1.**ARIA labels on every icon-only button**
```svelte
<buttonaria-label={m.close_dialog()}class="p-2">
<svgclass="w-5 h-5"><!-- X icon --></svg>
</button>
```
Without `aria-label`, screen readers announce "button" with no indication of purpose. This is also a security concern — users must understand what an action does before confirming.
2.**`rel="noopener noreferrer"` on all external links**
- Seniors: 16px minimum body text (prefer 18px), 44px touch targets (prefer 48px), redundant cues, calm layouts, persistent navigation, no timed interactions
- Millennials: dark mode, high info density, gesture-native, progressive disclosure
- **Core insight**: designing for the senior constraint improves the millennial experience
### Design Spec Format
Specs follow the Two-Layer Rule: scaled visual mockup (~55% size) for humans, `impl-ref` table with real Tailwind classes and pixel values for developers. See `docs/specs/` for reference templates.
2. Flag accessibility failures with the specific WCAG criterion
3. Assess mobile usability at 320px (touch targets, scroll, overflow)
4. Prioritize: Critical (blocks use) > High (degrades experience) > Medium > Low
5. Every finding gets a concrete fix with exact CSS/Tailwind values
### Producing Designs
1. Define the mobile layout first (320px)
2. Reference exact brand colors by token name
3. Annotate touch targets and interaction states (hover, focus, active, disabled)
4. Call out dark mode behavior for every color
---
## Relationships
**With Felix (developer):** You define the visual boundaries; Felix implements the component structure. When a design implies a component doing two visual jobs, flag it before coding.
**With Sara (QA):** axe-playwright runs on every critical page in E2E. Visual regression diffs are reviewed before merge. Accessibility is a quality gate.
**With Nora (security):** Focus indicators and ARIA labels are security controls — users must understand actions before confirming. Coordinate on form field labeling.
---
## Your Tone
- Direct and specific — you name the exact property, hex value, or WCAG criterion
- Constructive — every problem comes with a solution
- Empathetic — you explain *why* something matters for real users
- Fluent in both design and code — you move between Figma annotations and Tailwind without switching gears
- You care about users who are often forgotten: the senior researcher on a slow phone in bright daylight
- [Shell environment setup](./feedback_shell_env.md) — source SDKMAN and nvm before running java/mvn/node/npm
- [Gitea instance](./reference_gitea.md) — self-hosted Gitea at 192.168.178.71:3005, MCP server configured as "gitea"
- [Issue workflow](./feedback_issue_workflow.md) — create Gitea issues not todo files; feature/bug/devops labels with title formats
- [Branch and PR workflow](./feedback_branch_pr.md) — always branch + PR, never commit directly to main
- [Docker commands one line](./feedback_docker_commands.md) — always write docker commands on a single line for easy copy-paste
- [Red/Green TDD](./feedback_tdd.md) — always write failing test first before any production code
- [TDD red/green flow](./feedback_tdd_flow.md) — write failing test then immediately go green, no pausing between phases
- [Atomic commits](./feedback_atomic_commits.md) — one logical change per commit, never bundle multiple things
- [Single-family access model](./project_single_family_access.md) — no multi-tenancy, no ownership, no row-level security; role-based access is sufficient
description: Familienarchiv is used by one family — no multi-tenancy, no document ownership, no row-level security needed
type: project
---
The archive serves a single family. There is no multi-tenant isolation, no document ownership, and no row-level access control. Everyone with the correct role (READ_ALL / WRITE_ALL) can read and edit all documents. Do not suggest row-level security, per-user document ownership, or tenant filtering.
**Why:** Single-family use case — all authenticated users with the right role are trusted equally.
**How to apply:** Skip IDOR / ownership-check recommendations. Role-based access via `@RequirePermission` is the correct and sufficient access control model for this app.
description: Full end-to-end delivery of a Gitea issue for the Familienarchiv project — six-persona review → theme-grouped discussion walking through EVERY raised point with the user → isolated git worktree → TDD implementation → PR → review+fix loop until all personas approve (max 10 cycles). Use this skill whenever the user references a Gitea issue URL along with any of "deliver issue", "ship issue", "full cycle", "take it all the way", "review and implement", "do issue X end to end", or any phrasing implying review → discuss → implement → PR → review loop. This replaces ship-issue for this project — prefer deliver-issue unless the user explicitly asks for ship-issue.
Own the full lifecycle for a Gitea issue. Two human checkpoints, everything else autonomous. The loop in Phase 7 is driven directly by this skill — do **not** delegate PR fixes to the `implement` skill, because its PR mode has a known issue of stopping after the first review cycle.
## Input
A Gitea issue URL. Both hostnames refer to the same instance:
Invoke the `review-issue` skill with the issue URL. It reads the issue, loads all six personas from `.claude/personas/`, and posts one comment per persona to the Gitea issue.
Wait for it to finish. Do not proceed until the six comments are posted.
**Why autonomous:** the review is pure input-gathering — no decisions are made yet. The next phase is where the human gets involved.
---
## Phase 1 — Consolidate Every Point by Theme (autonomous)
Re-read the issue and every persona comment from Phase 0 using `mcp__gitea__issue_read` (method `get_comments`).
Extract **every** point raised — questions, concerns, suggestions, observations, even casual asides. Do not pre-filter to "open items only"; the user has specifically said past results are better when every raised point is walked through.
Group points by **theme**, not by persona. A theme is a topical cluster — what the point is *about*, not who said it. Examples from past issues: `Auth model`, `Data migration`, `Accessibility`, `Testing strategy`, `Error handling`, `API surface`, `Rollback plan`.
For each theme:
1. Pick a short, specific theme name (not "Architecture concerns" — try "Service boundary between Document and Tag")
2. List the points under it, each one prefixed with the persona(s) who raised it
3. Dedupe near-identical points across personas but preserve attribution — if Felix and the tester both asked the same thing, note both
Order themes by blast radius / blocking potential:
- **First**: anything that shapes the data model, API, or irreversible architectural decisions
Work through the themes **in order**, and within each theme walk through **every point**.
For each point:
1. State the point in your own words — what the persona was asking, why it matters from their angle
2. Offer your read of the sensible answer, or if you genuinely don't know, say so
3. Ask a focused, specific question — one question, not three
4. Wait for the user's response
5. React: accept, push back, propose an alternative if something the user said has an implication they may not have seen
6. When the point feels resolved, record the decision internally and move to the next point
Stay substantive. The value of this phase is the back-and-forth — don't rush through it. If the user says "skip" or "next", acknowledge and move on, marking the point as skipped.
After the last point of the last theme, show a summary:
```
## Summary of Decisions
### Theme 1 — Service boundary between Document and Tag
- TagService owns cascade-delete. Document calls TagService.detachAll(docId) on deletion.
- Tag reuse: add `tag_count` materialized field on documents table for fast badge render.
### Theme 2 — Permission model
- Admins-only for tag create. Reuse is open to all WRITE_ALL users.
- @RequirePermission goes on controller methods (matches existing pattern in DocumentController).
...
```
Then ask:
> Ready to post these resolutions to the issue as a consolidated comment?
Wait for explicit confirmation ("yes", "post it", "go ahead") before moving to Phase 3. If the user wants edits, loop back and adjust.
---
## Phase 3 — Post Consolidated Resolutions (autonomous)
Post a single comment on the issue via `mcp__gitea__issue_write` (method `add_comment`).
Format:
```markdown
# 🎯 Discussion Resolutions
After reviewing the persona feedback with the user, here are the agreed decisions:
## Theme 1 — <name>
- **Decision**: ...
- **Rationale**: ...
## Theme 2 — <name>
...
---
These resolutions now act as the authoritative design for implementation. The `implement` skill will read this comment alongside the original issue.
```
Include every resolved theme. For skipped points, note them under a `## Open / Skipped` section at the end so they're not lost.
Derive a short slug from the issue title: lowercase, hyphens instead of spaces, drop punctuation, max ~40 chars. E.g. "Admin: tag overhaul for bulk operations" → `admin-tag-overhaul`.
From the project root (`/home/marcel/Desktop/familienarchiv`):
**Why a sibling worktree:** the user's main workspace stays untouched so other work can continue in parallel. The worktree gets its own branch from a fresh `origin/main` — no stale state carried over.
Report the worktree path to the user in one line before moving on. All subsequent phases run inside this worktree.
---
## Phase 5 — Implement (HUMAN CHECKPOINT — plan approval)
Invoke the `implement` skill with the issue URL.
The `implement` skill will:
1. Re-read the issue including the `Discussion Resolutions` comment just posted
2. Ask any clarification questions (usually few or none — the discussion covered most)
3. Present an implementation plan as a numbered TDD task list
4.**Pause for plan approval** — this is the second human checkpoint
**Why keep this pause** even after the full discussion: the plan is where abstract decisions meet concrete test order and file touches. A one-minute skim catches plan-level mistakes (wrong order, missing task, over-scoped item) that are cheap to fix before code is written and expensive to unwind afterward.
After the user approves, `implement` does autonomous TDD through every task and commits atomically (red → green → refactor → commit).
When `implement` reports "all tests green ✅", **continue immediately** to Phase 6 without pausing for acknowledgment.
---
## Phase 6 — Open Pull Request (autonomous)
From inside the worktree:
1. Push: `git push -u origin HEAD`
2. Fetch issue title via `mcp__gitea__issue_read` (method `get`)
3. Create PR via `mcp__gitea__pull_request_write` (method `create`):
```
owner: marcel
repo: familienarchiv
head: feat/issue-<N>-<slug>
base: main
title: <exact issue title>
body: |
Closes #<N>
## Summary
<one paragraph summarizing what was built, referencing the Discussion Resolutions>
## Phase 7 — Review + Fix Loop (autonomous, max 10 cycles, owned by this skill)
Initialize `cycle = 1`. The loop runs without pausing unless a genuine technical blocker is hit.
### Step A — Run review-pr
Announce: `🔍 Review cycle <cycle>/10`
Invoke the `review-pr` skill with the PR URL. It posts six persona reviews, each with a verdict (`✅ Approved`, `⚠️ Approved with concerns`, or `🚫 Changes requested`).
Read the summary `review-pr` reports back.
- **All six personas approved** (no `🚫`, no `⚠️`) → exit loop, go to Phase 8 **immediately**.
- **Any concerns or blockers** → proceed to Step B **immediately**, no pause.
### Step B — Address Every Concern (don't delegate to implement)
If `cycle == 10`: stop, go to the cycle-limit handoff at the end of this phase.
**Do the work in this skill directly.** The `implement` skill has a known bug where it sometimes stops after the first PR review cycle; routing fixes through it breaks the loop. Apply the same TDD discipline inline:
**1. Collect all open concerns** — read every PR review comment posted since the last push via `mcp__gitea__pull_request_read` / `issue_read` on the PR. Build a flat list:
- Blockers
- Suggestions / concerns
- Unanswered questions
Tag each with the persona who raised it and a short quote so the commit + summary comment can reference them.
**2. Fix every addressable concern** — the user has explicitly rejected the defer-concerns-and-nits strategy. Within the 10-cycle budget, fix everything that is *addressable in this PR*. For each concern:
- **Red**: write a failing test that captures the required behavior (for code concerns) or a check that fails today (for config/infra concerns)
- **Green**: minimum code to pass; run the full test suite
- **Refactor**: only if there's actual duplication or naming cleanup
- **Commit**: atomic per concern, message referencing the persona and excerpt:
**3. Create new issues only for genuinely out-of-scope concerns** — concerns that require architectural rework this PR can't contain, or that belong to a different domain entirely. Use `mcp__gitea__issue_write` (method `create`):
```
title: <short description>
body: |
## Background
Raised during PR #<pr_index> review cycle <cycle>.
Do not merge the PR automatically — merge is the user's final gate.
---
## Operating Notes
- **Two human checkpoints, nothing else.** Phase 2 (walk-through) and Phase 5 (plan approval). Every other phase runs without pausing, including the full review→fix loop.
- **Genuine blockers pause the flow.** If a test setup is missing, an API doesn't exist, or the worktree can't be created, stop and surface it — don't burn cycles working around it silently.
- **Worktree isolation means other work continues.** The main workspace at `/home/marcel/Desktop/familienarchiv` is untouched. The user can keep working there while `deliver-issue` runs the pipeline in the sibling worktree.
- **Posting side effects are real.** Phase 0 posts six comments to Gitea. Phase 3 posts the resolutions comment. Phase 6 opens a PR. Each review cycle posts six review comments plus one summary comment. Don't run this skill on an issue you're still drafting.
- **If the user interrupts mid-loop**, honor it. Stop where you are and let them redirect.
description: Single-persona interactive discussion of a Gitea issue. The persona reads the issue and all comments, lists open items in their scope, and walks through each with the user. When done, posts the discussion result as a Gitea comment.
---
# Single-Persona Issue Discussion
You will adopt a single persona, read a Gitea issue in full, and have an interactive discussion with the user — working through every open item in that persona's scope. At the end you post the agreed outcomes as a comment on the issue.
## Arguments
The user provides an issue URL and a persona shorthand, e.g.:
Map the persona shorthand to a file in `.claude/personas/`:
| Shorthand | File |
|---|---|
| `dev` | `developer.md` |
| `arch` | `architect.md` |
| `ui` | `ui_expert.md` |
| `ops` | `devops.md` |
| `qa` or `tester` | `tester.md` |
| `sec` or `security` | `security_expert.md` |
If the shorthand doesn't match any of the above, tell the user the valid options and stop.
---
## Step 1 — Gather Issue Context
Use the Gitea MCP tools in parallel:
1. Full issue (title, body, labels) via `issue_read` with method `get`
2. All existing comments via `issue_read` with method `get_comments`
Read both before proceeding.
---
## Step 2 — Read the Persona
Read the persona file from `.claude/personas/`. Fully internalize their identity, priorities, domain focus, and blind spots as described.
---
## Step 3 — Identify Open Items
As the persona, read the entire issue body and all existing comments. From your domain perspective, build a numbered list of **open items** — questions, risks, gaps, decisions, or ambiguities that you would want to resolve before or during implementation.
An open item is anything the persona would genuinely care about that is either:
- Not answered in the issue or its comments, or
- Answered but in a way that raises follow-up questions from this persona's perspective
Be specific and reference the issue text. Do not repeat observations that are already fully resolved in the comments. Do not produce generic items — each must be grounded in the actual issue content.
**Present this list to the user** in the persona's voice, with a short intro in character. Format:
```
## [Persona emoji + Name] — [Role]
I've read through the issue and comments. Here are the open items I want to work through with you:
1. **[Short title]** — [One-sentence description of the concern or question]
2. **[Short title]** — ...
...
Let's go through them one by one. Ready to start with item 1?
```
Then **stop and wait for the user to respond** before proceeding.
---
## Step 4 — Interactive Discussion
Work through the open items **one at a time**:
1. Present the item in full from the persona's perspective — their concern, why it matters to them, what they want to understand or decide
2. Ask a focused, specific question (not multiple questions at once)
3. Wait for the user's response
4. React as the persona — accept, push back, propose alternatives, or note follow-up implications
5. When the item feels resolved (the user has answered and you've responded), mark it as done and move to the next item
Stay in character throughout. The persona's tone, priorities, and blind spots should be evident in every message.
If the user says "skip", "next", or similar — acknowledge it briefly and move on. Mark the item as skipped (unresolved).
When all items are done, show a brief summary:
- Resolved items (what was agreed or decided)
- Skipped / unresolved items (noted for the comment)
Ask: **"Ready to post the discussion summary to the issue?"**
Wait for explicit confirmation before posting.
---
## Step 5 — Post the Comment
After user confirmation, post a single comment to the issue using the Gitea MCP `issue_write` tool with method `add_comment`.
The comment should:
- Open with the persona header: `## [emoji] [Name] — [Role]` and a one-liner about what this comment captures
- List resolved items with the agreed outcome or decision
- List unresolved / skipped items briefly, noting they were raised but not settled
- Close with a short sentence from the persona about their overall read of the issue
Keep it scannable — bullet points per item, no walls of text.
---
## Step 6 — Report Back
After posting, tell the user:
- The comment was posted (with the Gitea URL if available)
- A one-line summary of the most important thing that came out of the discussion
description: Felix Brandt reads a Gitea issue or Pull Request, clarifies ambiguities with the user, presents an implementation plan for approval, then works autonomously using red/green TDD until every task is done and committed.
---
# Implement — Felix Brandt's Issue/PR-Driven TDD Workflow
You are Felix Brandt. Read your full persona from `.claude/personas/developer.md` before doing anything else.
## Argument
The user provides a Gitea issue **or** pull request URL, e.g.:
Parse the URL to determine the type (`issues` → **issue mode**, `pulls` → **PR mode**) and extract:
-`owner` — e.g. `marcel`
-`repo` — e.g. `familienarchiv`
-`number` — e.g. `162` / `174`
---
## Phase 1 — Read Everything
### Issue mode
Use the Gitea MCP tools to collect:
1. The full issue (title, body, labels, milestone, assignees) via `issue_read`
2. Every comment on the issue in order — read them all, do not skip any
### PR mode
Use the Gitea MCP tools to collect:
1. PR metadata (title, description, base branch, head branch) via `pull_request_read`
2. Every review comment and inline code comment on the PR — read them all, do not skip any
3. The full content of every changed file (read each file at the head branch using `get_file_contents`)
**In PR mode your job is to address the team's open concerns, not to invent new work.**
Build a complete list of every reviewer concern that has not yet been resolved:
- Blockers (reviewer requested changes)
- Suggestions the author acknowledged or agreed to
- Unanswered questions in the review thread
Mark each concern with its source: reviewer name + comment excerpt.
### Both modes
Also read:
-`CLAUDE.md` for project conventions
- Any relevant existing source files mentioned in the issue/comments
- The current branch state (`git status`, `git log --oneline -10`)
Do not start Phase 2 until you have read everything.
---
## Phase 2 — Clarification
### Issue mode
After reading, identify every point that is genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
- Scope questions (is X in or out of this issue?)
- Design decisions with multiple valid approaches where the choice affects architecture
- Missing acceptance criteria (how do we know when this is done?)
- Conflicting statements between the issue body and the comments
- Dependencies on external things (backend changes needed? migration required?)
### PR mode
For each open reviewer concern where **no clear fix path exists**, present it to the user and ask how to resolve it. Be specific — quote the reviewer comment and explain why the fix isn't obvious. Do **not** ask about concerns that have a clear, unambiguous fix.
---
Present all your clarifying questions to the user as a numbered list in a single message. Reference the exact passage you're asking about.
**Do not ask about things you can decide yourself** using the project conventions, existing code patterns, or common sense. Only ask when the answer genuinely changes what you build.
Wait for the user to answer before continuing.
---
## Phase 3 — Implementation Plan
Once clarifications are resolved, present a numbered implementation plan as a task list. Each item must be:
- A single atomic unit of work (one behavior, one file change, one migration)
- Written as a sentence that implies the test name: "Tag detail page returns 404 when tag does not exist"
- Ordered so each item builds on the previous ones
- Prefixed with the layer: `[backend]`, `[frontend]`, `[migration]`, `[test]`, `[refactor]`
**In PR mode**, each task must reference the reviewer concern it addresses, e.g.:
```
3. [frontend] Extract magic number 42 into named constant MAX_RESULTS — fixes @anna: "avoid magic numbers"
```
Format:
```
## Implementation Plan
1. [backend] PersonController returns 404 when person id does not exist
2. [migration] Add index on documents.sender_id for performance
3. [frontend] PersonCard renders full name from firstName + lastName props
4. [frontend] PersonCard shows placeholder when both names are null
...
```
End with:
```
Does this plan look right? Reply **approved** to start, or tell me what to change.
```
**Do not write a single line of code until the user approves the plan.**
---
## Phase 4 — Autonomous Implementation
Once the user approves (any message clearly indicating agreement — "approved", "yes", "go ahead", "looks good", etc.), work through every item in the plan **without stopping to ask for permission**.
### Branch setup
Check the current branch.
- **Issue mode**: If already on a feature branch for this issue, stay there. Otherwise create:
```
git checkout -b feat/issue-{number}-{short-slug}
```
- **PR mode**: Check out the PR's head branch and stay on it. All fixes go on that same branch.
### For each task — red/green/refactor
**Red:**
1. Write a failing test for exactly this one behavior
2. Run the test suite
3. Confirm the new test fails with a clear assertion failure (not a compile error or NPE)
4. If the failure message is unclear, fix the test first before proceeding
**Green:**
1. Write the minimum code to make the failing test pass — nothing more
2. Run the full test suite (not just the new test)
3. All tests must be green before committing
**Refactor:**
1. Check for naming, duplication, function size violations
2. Apply any needed clean-up — no new behavior
3. Run the full suite again to confirm still green
**Commit:**
Commit atomically after each task using the project's commit conventions:
```
feat(scope): short imperative description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
```
Move to the next task immediately.
### Test commands
- Frontend unit tests: `cd frontend && npm run test`
- Frontend type check: `cd frontend && npm run check`
- Backend tests: `cd backend && ./mvnw test`
- Single backend test class: `cd backend && ./mvnw test -Dtest=ClassName`
### Rules during autonomous implementation
- Never skip the red step — if you cannot write a failing test for a task, stop and explain why to the user before writing any implementation code
- Never add behavior beyond what the current task requires
- Never bundle two tasks into one commit
- If a test that was passing starts failing during a later task, fix it before continuing — do not leave broken tests
- If you hit a genuine blocker (missing API, infrastructure not available, etc.) that prevents completing a task, stop and report it to the user rather than working around it silently
---
## Phase 5 — Completion Report
After all tasks are done:
1. Run the full test suite one final time and confirm all green
2. Run `npm run check` (frontend) and `./mvnw clean package -DskipTests` (backend) to confirm no type or build errors
### Issue mode
3. Post a completion comment on the Gitea issue summarising what was implemented, listing all commits made
4. Report back to the user: every task ✅, any skipped/deferred tasks (with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
### PR mode
3. Push the updated branch
4. Post a comment on the PR summarising every concern that was addressed, referencing the relevant commits
5. Report back to the user: every concern resolved ✅, any concerns deferred (with reason), and the push status
description: Multi-persona feature issue review. Each persona from .claude/personas/ reads the issue and posts constructive feedback as a separate Gitea comment.
---
# Multi-Persona Feature Issue Review
You will perform a thorough multi-persona review of the given Gitea issue URL and post each persona's constructive feedback as a **separate comment** on the issue.
Personas give **advisory input only** — no blocking, no verdicts. The goal is to surface blind spots, risks, and improvement ideas before implementation starts.
1. The full issue (title, body, labels, milestone, assignees) via `issue_read`
2. All existing comments on the issue via `issue_read` — read them so personas don't repeat what's already been said
Read everything before starting any review.
## Step 2 — Read Every Persona
Read all six persona files from `.claude/personas/`:
-`developer.md` → Felix Brandt
-`architect.md` → architect persona
-`tester.md` → tester persona
-`security_expert.md` → security persona
-`ui_expert.md` → UI/UX persona
-`devops.md` → DevOps persona
## Step 3 — Write Each Review
For each persona, fully adopt their identity, priorities, and thinking style as described in their persona file. Write feedback that:
- Is **constructive and forward-looking** — no blockers, no verdicts, no approval stamps
- Asks clarifying questions the persona would genuinely want answered before or during implementation
- Points out risks, edge cases, or gaps the persona sees from their domain
- Offers concrete suggestions or alternative approaches where relevant
- References the issue text specifically — don't write generic advice
- Stays focused on what the persona would actually care about (e.g. Felix asks about test strategy and naming; the architect asks about layer boundaries and coupling; the security expert asks about auth, input validation, and data exposure; the tester asks about acceptance criteria and edge cases; the UI expert asks about interaction patterns and accessibility; DevOps asks about deployment, config, and observability)
Format each comment in Markdown with a persona header, e.g.:
```
## 👨💻 Felix Brandt — Senior Fullstack Developer
### Questions & Observations
...
### Suggestions
...
```
Keep each comment focused and scannable. Use bullet points. Avoid walls of text.
## Step 4 — Post Comments
Post each persona's feedback as a **separate comment** on the issue using the Gitea MCP `issue_write` tool.
Post all six comments. If a persona genuinely has nothing to add (rare), write a short "No concerns from my angle" with one sentence explaining what they checked — so the team knows that perspective was considered.
## Step 5 — Report Back
After all comments are posted, tell the user:
- Which personas posted feedback
- A brief summary of the most important cross-cutting themes (questions or risks that multiple personas flagged)
1. PR metadata (title, description, base branch, head branch) via `pull_request_read`
2. The list of changed files via `get_dir_contents` or the PR files endpoint
3. The full diff / file contents of every changed file — read each file at the head commit using `get_file_contents`
Read ALL changed files completely before starting any review. Do not skip files.
## Step 2 — Read Every Persona
Read all six persona files from `.claude/personas/`:
-`developer.md` → Felix Brandt
-`architect.md` → architect persona
-`tester.md` → tester persona
-`security_expert.md` → security persona
-`ui_expert.md` → UI/UX persona
-`devops.md` → DevOps persona
## Step 3 — Write Each Review
For each persona, fully adopt their identity, priorities, and review lens as described in their persona file. Write a review that:
- Opens with a one-line verdict: **✅ Approved**, **⚠️ Approved with concerns**, or **🚫 Changes requested**
- Lists concrete findings with file paths and line references where relevant
- Distinguishes blockers (must fix) from suggestions (nice to have)
- Uses the persona's voice and priorities (e.g. Felix cares about TDD and clean code; the security expert checks for injection, auth, and data exposure; the architect checks layer boundaries and coupling)
- Stays focused — only comment on what the persona would actually care about
Format each comment in Markdown with a persona header, e.g.:
```
## 👨💻 Felix Brandt — Senior Fullstack Developer
**Verdict: ⚠️ Approved with concerns**
### Blockers
...
### Suggestions
...
```
## Step 4 — Post Comments
Post each persona's review as a **separate comment** on the PR using the Gitea MCP `issue_write` tool (issues and PRs share the comment API in Gitea).
Post all six comments. Do not skip any persona even if their domain has nothing to flag — in that case write a brief "LGTM" with a short explanation of what they checked.
## Step 5 — Report Back
After all comments are posted, summarize to the user:
- Which personas posted comments
- The overall verdict across all personas (worst-case wins: if any said "Changes requested", the overall is "Changes requested")
- A bullet list of the top blockers found (if any)
**Important:** When passing code with runes (`$state`, `$derived`, etc.) via the terminal, escape the `$` character as `\$` to prevent shell variable substitution.
## Workflow
1. **Uncertain about syntax?** Run `list-sections` then `get-documentation` for relevant topics
2. **Reviewing/debugging?** Run `svelte-autofixer` on the code to detect issues
3. **Always validate** - Run `svelte-autofixer` before finalizing any Svelte component
description: Transcribe a document's PDF by visually analyzing each page, creating annotation-backed transcription blocks via the API with paragraph-level bounding boxes and OCR text.
1. A **document URL**, e.g. `http://localhost:5173/documents/{id}` — extract the document UUID from the path.
2. A **PDF file path**, e.g. `@import/C-1654.pdf` — the source file to read and transcribe.
---
## Phase 1 — Gather Context
1.**Read the PDF** using the Read tool to get the visual content of every page.
2.**Check the API** — the transcription blocks endpoint is:
```
POST /api/documents/{documentId}/transcription-blocks
```
with Basic Auth (`admin:admin123`) and JSON body:
```json
{
"pageNumber": <1-based>,
"x": <0-1 normalized>,
"y": <0-1 normalized>,
"width": <0-1 normalized>,
"height": <0-1 normalized>,
"text": "transcribed text",
"label": "optional label or null"
}
```
3. **Check for existing blocks** — `GET /api/documents/{documentId}/transcription-blocks`. If blocks already exist, ask the user whether to delete them first or abort. Do not silently overwrite.
### Coordinate system
- All coordinates are **normalized 0-1 fractions** of page width and height.
- `x`, `y` is the **top-left corner** of the annotation rectangle.
1. **Identify the script type**: typewritten, Kurrent/Sutterlin, Latin handwriting, mixed, printed, etc.
2. **Segment into logical blocks** — each block is one visual paragraph or logical section:
- Header / letterhead / date line
- Salutation / greeting
- Body paragraphs (split at natural paragraph breaks)
- Closing / signature
- Address fields (postcards)
- Margin notes, annotations, stamps
- Rotated text sections (note the rotation in the label)
3. **Estimate bounding boxes** for each block as normalized 0-1 coordinates. The rectangle should tightly enclose all the text in that block with a small margin.
4. **Assign labels** to structural blocks:
- `Briefkopf` — letterhead / header with date and location
- `Anrede` — salutation line
- `Gruss` — closing and signature
- `Adresse` — address field (postcards)
- `Fortsetzung (gedreht)` — rotated continuation text
- `null` — regular body paragraphs (no label needed)
---
## Phase 3 — Transcription
For each identified block, transcribe the text:
### Rules
- **Never guess**. If a word or passage is not clearly readable, use `[unleserlich]` as a placeholder.
- Preserve the original spelling, punctuation, and line breaks where they indicate structure (e.g. address lines, signature blocks). Do not "correct" old German spelling.
- For typewritten text with handwritten corrections/additions above or below the line, note them inline, e.g. `statt [unleserlich]` or describe in brackets: `[handschriftliche Erganzung: ...]`.
- For Kurrent/Sutterlin script: be especially conservative. It is better to mark something `[unleserlich]` than to guess incorrectly. If an entire block is unreadable, use: `[unleserlich - Kurrentschrift, kurze Beschreibung des Inhaltsbereichs]`.
- For rotated text, note the rotation in the label field.
- Use `\n` for line breaks within a block (e.g. multi-line addresses, signature blocks).
### Script-specific guidance
| Script | Confidence threshold | Notes |
|--------|---------------------|-------|
| Typewritten (Schreibmaschine) | High — most words should be readable | Watch for corrections, strikethroughs, carbon copy artifacts |
| Latin handwriting | Medium — depends on hand | Easier than Kurrent but still variable |
| Kurrent / Sutterlin | Low — expect heavy `[unleserlich]` usage | Angular strokes, long-s, distinctive letter forms. Context helps (dates, place names, salutations are easier) |
| Mixed | Per-section | Common on postcards: Latin address + Kurrent message |
---
## Phase 4 — Create Blocks via API
1. **Delete existing blocks** if user approved it in Phase 1.
2. **Create blocks in reading order** using `curl` with Basic Auth:
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.
> 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)
- 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.
`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
thrownewResponseStatusException(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)
publicDocumentupdateDocument(...){...}
```
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.
---
@@ -203,145 +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
**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.
- **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.
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
@@ -349,4 +281,4 @@ HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client
## Dev Container
A `.devcontainer/` config is available (Java 21 + Node 24, ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" for a pre-configured environment.
→ See [.devcontainer/README.md](./.devcontainer/README.md)
@@ -180,8 +180,47 @@ When in doubt, commit more often rather than less.
See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack.
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
- No backwards-compatibility shims for code that has no callers
- Validate at system boundaries only (user input, external APIs)
## Frontend Domain Boundaries
The frontend mirrors the backend's package-by-domain structure. Each Tier-1 folder under `src/lib/` is a domain with a hard import boundary:
```
document person tag user geschichte notification ocr
activity conversation shared
```
The `boundaries/dependencies` ESLint rule enforces this. The full allow-list lives in `frontend/eslint.config.js`. The rule fires at error severity and blocks `npm run lint`.
| `person`, `tag`, `user`, `notification`, `conversation` | `shared` only |
| `shared` | `shared` only |
| `routes` | any domain |
### When you need to cross a boundary
1.**Move the code to `$lib/shared/`** — the correct fix when the code is truly generic (a UI primitive, a pure utility, a formatting helper).
2.**Add an explicit rule** — if a cross-domain dependency is architecturally justified (e.g., `document` importing `PersonTypeahead`), add the allow entry to `eslint.config.js` with a comment explaining the reason.
3.**Use `// eslint-disable-next-line boundaries/dependencies`** — last resort, only for cases where neither option is practical. Leave a comment explaining why.
### Verifying the rule works
```bash
npm run lint:boundary-demo # exits 1 — shows the rule firing on a deliberate tag→person violation
```
The fixture lives at `src/lib/tag/__fixtures__/cross-domain.fixture.ts` and is excluded from `npm run lint` via `--ignore-pattern`.
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).
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
- **Service** `CitationService.java` — `@Service @RequiredArgsConstructor`; write methods `@Transactional`, read methods unannotated; cross-domain data goes through the other domain's service, never its repository
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:
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.
-`backend/` — Spring Boot 4 (Java 21) REST API; handles documents, persons, search, and user management
-`ocr-service/` — Python FastAPI microservice for OCR and handwritten text recognition (HTR); single-node by design — see [ADR-001](docs/adr/001-ocr-python-microservice.md). Not part of the default dev stack (see Quick start below)
-`infra/` — Gitea Actions CI/CD config; future home for infrastructure-as-code
-`scripts/` — operational and data-pipeline helpers (`reset-db.sh`, `clean-e2e-data.sh`, import scripts)
---
## Quick start
**Prerequisites:** Java 21, Node 24, Docker with the `docker compose` plugin (V2).
### 1. Configure environment
```bash
cp .env.example .env
# The defaults in .env.example work for local development without changes.
```
### 2. Start infrastructure
```bash
# Starts PostgreSQL, MinIO (object storage), and Mailpit (dev mail catcher)
docker compose up -d db minio mailpit
```
### 3. Start the backend
```bash
cd backend
./mvnw spring-boot:run
# Starts on http://localhost:8080
# API docs (dev profile, auto-enabled): http://localhost:8080/v3/api-docs
```
### 4. Start the frontend
```bash
cd frontend
npm install
npm run dev
# Starts on http://localhost:5173
```
Open **http://localhost:5173** — you should see the Familienarchiv login screen.
Default development credentials:
```
# local dev only — change before any network-exposed deployment
Email: admin@familyarchive.local
Password: admin123
```
> **Development setup only.** The default `docker compose` config exposes the database port and uses root MinIO credentials. Do not connect this to a network without first reading `docs/DEPLOYMENT.md` _(coming: [DOC-5, #399](http://heim-nas:3005/marcel/familienarchiv/issues/399))_.
### Running the full stack via Docker (optional)
To run everything including the backend and frontend in containers:
```bash
docker compose up -d
```
Note: the OCR service (`ocr-service/`) builds its Docker image locally and downloads ~6 GB of ML models on first start. Expect 30–60 minutes on a first run. The rest of the stack starts independently; OCR can be excluded with `--scale ocr-service=0` on memory-constrained machines (requires ≥ 12 GB RAM).
---
## Where to go next
| Resource | Purpose |
|---|---|
| [docs/architecture/c4-diagrams.md](docs/architecture/c4-diagrams.md) | C4 container and component diagrams (current system view) |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) _(coming: [DOC-2, #396](http://heim-nas:3005/marcel/familienarchiv/issues/396))_ | Full architecture guide with domain list |
| [docs/GLOSSARY.md](docs/GLOSSARY.md) | Overloaded terms: Person vs AppUser, Chronik vs Aktivität, etc. |
| [CONTRIBUTING.md](CONTRIBUTING.md) _(coming: [DOC-4, #398](http://heim-nas:3005/marcel/familienarchiv/issues/398))_ | How to add a domain, endpoint, or SvelteKit route |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) _(coming: [DOC-5, #399](http://heim-nas:3005/marcel/familienarchiv/issues/399))_ | Production deployment checklist and secrets guide |
| [docs/adr/](docs/adr/) | Architecture Decision Records — the "why" behind key choices |
| [Gitea issue tracker](http://heim-nas:3005/marcel/familienarchiv/issues) _(internal — home network only)_ | Bug reports, feature requests, and project planning |
---
## License
Private project — all rights reserved. Not licensed for redistribution.
For per-domain ownership and public surface, see each domain's `README.md`.
## Layering Rules
→ See [docs/ARCHITECTURE.md §Layering rule](../docs/ARCHITECTURE.md#layering-rule)
**LLM reminder:** controllers never call repositories directly; services never reach into another domain's repository — always call the other domain's service.
-`@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
- Collections use `@Builder.Default` with `new HashSet<>()` as default.
- Timestamps use `@CreationTimestamp` / `@UpdateTimestamp`.
## Services
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
- Write methods: `@Transactional`.
- Read methods: no annotation (default non-transactional).
- Cross-domain access goes through the other domain's service, never its repository.
## Error Handling
→ See [CONTRIBUTING.md §Error handling](../CONTRIBUTING.md#error-handling)
**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
→ See [docs/ARCHITECTURE.md §Permission system](../docs/ARCHITECTURE.md#permission-system)
**LLM reminder:**`@RequirePermission(Permission.WRITE_ALL)` is **required** on every `POST`, `PUT`, `PATCH`, `DELETE` endpoint — not optional. Do not mix with Spring Security's `@PreAuthorize`. Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`.
## OCR Integration
The backend orchestrates OCR by calling the Python `ocr-service` microservice via `RestClient`:
-`OcrClient` interface — mockable for tests
-`RestClientOcrClient` — implementation using Spring `RestClient`
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.
description="Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
)
UUIDcommentId,
@Nullable
@Schema(
requiredMode=Schema.RequiredMode.NOT_REQUIRED,
description="Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
)
UUIDannotationId,
@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."
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`
Activity feed and Pulse widget are assembled in `frontend/src/lib/shared/dashboard/` and in the `aktivitaeten` route; no dedicated `dashboard/` lib folder.
log.warn("Could not resolve user for annotation: {}",e.getMessage());
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.