Compare commits

..

59 Commits

Author SHA1 Message Date
84f9bbadeb Merge pull request 'feat(observability): add Grafana with provisioned datasources and dashboards' (#589) from feat/issue-577-grafana into main
Some checks failed
CI / Unit & Component Tests (push) Successful in 5m22s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 21m45s
CI / fail2ban Regex (push) Successful in 2m2s
CI / Compose Bucket Idempotency (push) Successful in 1m50s
feat(observability): add Grafana with provisioned datasources and dashboards (#589)
2026-05-15 04:35:10 +02:00
Marcel
457c1d3aee fix(observability): add grafana healthcheck and service_healthy depends_on
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m19s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 5m32s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 04:09:13 +02:00
Marcel
c99321e5cf docs(observability): document Grafana in DEPLOYMENT.md and C4 diagram
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m7s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 5m41s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Add Grafana row to the observability services table, Grafana access details
(URL, credentials, auto-provisioned datasources, pre-loaded dashboards), and
GRAFANA_ADMIN_PASSWORD to the env vars table in DEPLOYMENT.md.
Update C4 l2-containers.puml: replace placeholder Grafana entry with pinned
image version, expand observability boundary with node_exporter and cadvisor
containers, and add Rel() edges for Grafana → Prometheus, Loki, and Tempo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 04:04:09 +02:00
Marcel
f3f8345b03 feat(observability): add Grafana with provisioned datasources and dashboards
Add obs-grafana service (grafana/grafana-oss:11.6.1) to docker-compose.observability.yml.
Datasources (Prometheus, Loki, Tempo) are auto-provisioned via
infra/observability/grafana/provisioning/datasources/datasources.yml with
cross-datasource linking (Loki traceId → Tempo, Tempo → Loki, service map via Prometheus).
Three dashboards are pre-loaded: Node Exporter Full (1860), Spring Boot Observability (17175),
Loki Logs (13639) — datasource template variables replaced with provisioned UIDs.
GRAFANA_ADMIN_PASSWORD added to .env.example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 04:03:33 +02:00
c3b477c609 Merge pull request 'devops(backend): expose Prometheus metrics endpoint + OTLP trace export from Spring Boot' (#588) from feat/issue-576-backend-instrumentation into main
Some checks failed
CI / Unit & Component Tests (push) Successful in 3m19s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m43s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Successful in 57s
nightly / deploy-staging (push) Failing after 2m6s
devops(backend): expose Prometheus metrics endpoint + OTLP trace export from Spring Boot (#588)
2026-05-15 03:57:14 +02:00
Marcel
3a67f7820e fix(backend): disable OTel SDK in tests + exclude azure-resources to fix semconv conflict
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m45s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
opentelemetry-spring-boot-starter:2.27.0 pulls in AzureAppServiceResourceProvider which
references ServiceAttributes.SERVICE_INSTANCE_ID — a field absent from the semconv version
used by this project. This caused every integration test to fail with NoSuchFieldError during
Spring context startup.

Fix 1 (application-test.yaml): set otel.sdk.disabled=true so the OTel auto-configuration
never runs during tests at all.

Fix 2 (pom.xml): exclude opentelemetry-azure-resources from the starter dependency to remove
the problematic provider from the dependency graph entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 03:45:08 +02:00
Marcel
6ce6122384 docs: add OTEL and tracing env vars to DEPLOYMENT.md
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Failing after 2m33s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 54s
2026-05-15 03:29:38 +02:00
Marcel
b3e49a9504 devops(backend): expose Prometheus metrics endpoint + OTLP trace export from Spring Boot
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Failing after 2m35s
CI / fail2ban Regex (pull_request) Successful in 37s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
- Add micrometer-registry-prometheus (BOM-managed) to expose /actuator/prometheus
- Add micrometer-tracing-bridge-otel (BOM-managed) for Micrometer → OTel tracing bridge
- Add opentelemetry-spring-boot-starter 2.27.0 (pinned — not in Spring Boot BOM)
- Move management to port 8081 so Prometheus scrapes directly inside archiv-net,
  bypassing both Caddy and Spring Security's session-authenticated filter chain
- Configure otel.service.name and OTLP endpoint (default localhost:4317 for CI safety)
- Set tracing sampling probability to 1.0 in base config; override via env var in compose
- Add OTEL_EXPORTER_OTLP_ENDPOINT + MANAGEMENT_TRACING_SAMPLING_PROBABILITY to docker-compose.yml
- Expose management port 8081 inside archiv-net for Prometheus scraping
- Disable trace export in application-test.yaml (probability: 0.0) for deterministic CI

OTLP export failures are non-fatal; app starts cleanly without Tempo running.
Closes #576

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 03:24:35 +02:00
2eff1ab14c Merge pull request 'devops(observability): add Tempo for distributed trace storage (OTLP receiver)' (#587) from feat/issue-575-tempo into main
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m21s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m38s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 57s
devops(observability): add Tempo for distributed trace storage (#587)
2026-05-15 03:21:11 +02:00
Marcel
de08ffe989 devops(observability): add Tempo for distributed trace storage (OTLP receiver)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m32s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 56s
Closes #575

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 03:01:22 +02:00
5ed24cb6eb Merge pull request 'devops(observability): add Loki + Promtail for centralised container log aggregation' (#586) from feat/issue-574-loki-promtail into main
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m22s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m36s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 57s
devops(observability): add Loki + Promtail for centralised container log aggregation (#586)
2026-05-15 02:58:20 +02:00
Marcel
c1406a32f1 devops(observability): fix C4 diagram, security comment, and add Loki compactor block
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m33s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 02:25:34 +02:00
Marcel
22e1b25398 devops(observability): add Loki + Promtail for centralised container log aggregation
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m31s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
- Add obs-loki (grafana/loki:3.4.2) to docker-compose.observability.yml
  with healthcheck (wget /ready), expose-only port 3100, named volume loki_data
- Add obs-promtail (grafana/promtail:3.4.2) bridging archiv-net + obs-net,
  depends_on loki service_healthy, docker.sock:ro, promtail_positions volume
  for restart-safe position tracking
- Create infra/observability/loki/loki-config.yml: single-node TSDB schema v13,
  30-day retention, auth disabled (obs-net only), telemetry off
- Create infra/observability/promtail/promtail-config.yml: Docker SD scrape,
  container_name / compose_service / compose_project / logstream labels
- Update docs/DEPLOYMENT.md §4 with service table and Loki quick-check commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 02:18:22 +02:00
6a118589c2 Merge pull request 'devops(observability): add Prometheus + Node Exporter + cAdvisor for host and container metrics' (#585) from feat/issue-573-prometheus-metrics into main
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m27s
CI / OCR Service Tests (push) Successful in 18s
CI / Backend Unit Tests (push) Successful in 4m32s
CI / fail2ban Regex (push) Successful in 41s
CI / Compose Bucket Idempotency (push) Successful in 56s
devops(observability): add Prometheus + Node Exporter + cAdvisor (#585)
2026-05-15 02:15:09 +02:00
Marcel
0c66f6298b devops(observability): fix Prometheus port binding, scrape port, and update DEPLOYMENT.md
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m35s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
- Fix spring-boot scrape target from backend:8080 to backend:8081 (actuator/management port)
- Restrict Prometheus host port binding to 127.0.0.1 to prevent unintended external exposure
- Add observability stack (Prometheus, Node Exporter, cAdvisor) to topology description
- Add PORT_PROMETHEUS env var to DEPLOYMENT.md reference table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 01:52:28 +02:00
Marcel
0c9973fdff devops(observability): add Prometheus + Node Exporter + cAdvisor for host and container metrics
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m22s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m40s
CI / fail2ban Regex (pull_request) Successful in 39s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 01:47:07 +02:00
52508e9dea Merge pull request 'devops(observability): scaffold docker-compose.observability.yml and infra/observability/ structure' (#584) from feat/issue-572-observability-scaffold into main
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m21s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m34s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 57s
devops(observability): scaffold docker-compose.observability.yml and infra/observability/ structure (#584)
2026-05-15 01:45:14 +02:00
Marcel
cf8d22d81b docs: update DEPLOYMENT.md and C4 diagram for observability scaffold
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m31s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
Replace the stale "no monitoring infrastructure in place yet" note in
§4 with a brief description of the observability compose file and a
pointer to issue #581 for full docs.

Add a placeholder System_Boundary block for Prometheus + Loki + Grafana
to l2-containers.puml, showing the stack joins archiv-net.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 01:28:14 +02:00
Marcel
1d42be9882 devops(observability): scaffold docker-compose.observability.yml and infra/observability/ structure
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m28s
CI / fail2ban Regex (pull_request) Successful in 39s
CI / Compose Bucket Idempotency (pull_request) Successful in 55s
Creates the skeleton observability stack (no running services yet) that all
subsequent Grafana LGTM + GlitchTip issues depend on:

- docker-compose.observability.yml: external archiv-net join, obs-net bridge,
  named volumes for all five services, placeholder comments for each service
  group (Metrics/Logs/Traces/Dashboards/Error Tracking), startup-order note
- infra/observability/{prometheus,loki,promtail,tempo,grafana/provisioning/{datasources,dashboards}}/.gitkeep
- .env.example: new # --- Observability --- section with PORT_GRAFANA,
  PORT_GLITCHTIP, PORT_PROMETHEUS, GLITCHTIP_DOMAIN, GLITCHTIP_SECRET_KEY
  (with generation hint), SENTRY_DSN, VITE_SENTRY_DSN

Verified: docker compose -f docker-compose.observability.yml config exits 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 01:23:03 +02:00
Marcel
33c738db3b fix(docker): skip postinstall in production image
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m9s
CI / OCR Service Tests (pull_request) Successful in 15s
CI / Backend Unit Tests (pull_request) Successful in 4m31s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
The production stage runs npm ci --omit=dev to install runtime deps for
the pre-built SvelteKit app. The postinstall script calls patch-package,
which is a devDependency, so it is absent and causes exit code 127.

--ignore-scripts is the correct npm-native fix: no lifecycle scripts are
needed when installing into a pre-built image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:42:52 +02:00
Marcel
62c807b7fe fix(invites): resolve svelte-check warnings in UserGroupsSection and page.server.test
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m11s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m22s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Successful in 56s
Use untrack() for intentional one-time prop seed in UserGroupsSection.
Add explicit LoadData type alias in page.server.test to avoid void|Record<string,any> union.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
82f0f7b82c test(invites): verify groupIds are forwarded from request body in InviteController
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
4994d28a20 feat(invites): show empty state when no groups exist in invite form
When groups load successfully but the list is empty, render a quiet
"Keine Gruppen vorhanden." message rather than a blank section that
leaves users uncertain whether groups failed to load.

Adds admin_new_invite_no_groups i18n key to de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
15d91da174 docs(invites): explain InviteTokenRepository injection in UserService
Spring Framework 7 prohibits constructor injection cycles. InviteService
already injects UserService, so UserService cannot inject InviteService
for the deleteGroup guard — repository injection is the correct workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
ae6d7a5467 fix(invites): deduplicate groupIds before size check in createInvite
Client-submitted duplicate UUIDs were causing a false GROUP_NOT_FOUND:
size(deduplicated_db_result)==1 != size(submitted)==2. Deduplicate input
with HashSet before calling findGroupsByIds so the size comparison is
always against unique IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
24a398a0d8 fix(invites): i18n legend + touch target in UserGroupsSection
- legend uses m.admin_new_invite_groups() instead of hardcoded "Gruppen"
  so screen readers announce the correct string in en/es locales
- label gets min-h-[44px] for WCAG 2.2 touch target compliance
- add test asserting fieldset accessible name comes from i18n key
- add test documenting empty-groups-no-error renders no checkboxes/banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
e2632a556d docs: align ErrorCode 4-step checklist in CLAUDE.md; note frontend sync in ARCHITECTURE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
be741ff9a2 test(invites): add InviteTokenRepository integration tests for existsActiveWithGroupId + V66 group_id index
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
4995c3139e fix(invites): validate groupIds existence in createInvite — throw GROUP_NOT_FOUND
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
0a5d4fb950 feat(errors): add GROUP_NOT_FOUND error code + i18n keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
e4303baa40 test(invites): import real +page.server module via vi.mock env
Replace hand-copied load/action replicas with direct imports of the
real module. Mock $env/dynamic/private so the tests cover the actual
production code paths, not a duplicate that can drift.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
46c8d4553b fix(invites): add role="alert" to groups-load-error banner
Screen readers now announce the amber warning when it appears after
the form expands, without requiring the user to navigate to it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
3fc0ec95ef fix(invites): make group checkboxes writable — $derived → $state
bind:group requires a writable $state variable; $derived is read-only
in Svelte 5, so every click was silently reset to unchecked, making
the group picker non-functional.

Also wraps checkboxes in <fieldset>/<legend> for WCAG 1.3.1 compliance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
510fa5e398 feat(invites): group picker in new-invite form
- load() fetches /api/groups in parallel with /api/invites; returns
  sorted groups array and groupsLoadError for partial failures
- create action forwards groupIds[] to POST /api/invites so invited
  users are placed in the selected groups on registration
- +page.svelte: group checkboxes via UserGroupsSection inside the form;
  amber warning banner when groups could not be loaded
- page.svelte.test.ts: groups checkboxes + warning banner tests
- page.server.test.ts: parallel fetch, sorting, error fallback,
  groupIds in POST body

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
75453bed51 feat(frontend): add GROUP_HAS_ACTIVE_INVITES error code + i18n keys
Adds the error code to the ErrorCode union and getErrorMessage() switch.
Adds admin_new_invite_groups, admin_invite_groups_load_error, and
error_group_has_active_invites to all three locale files (de/en/es).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
78e3acaeb7 feat(groups): prevent deletion of groups referenced by active invites
Adds GROUP_HAS_ACTIVE_INVITES error code and guards UserService.deleteGroup()
with a 409 conflict when any active (non-revoked, non-expired, non-exhausted)
invite token still holds the group UUID.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:26:53 +02:00
Marcel
0f4c844002 fix(admin/system): address second-round review concerns
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m9s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m29s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 55s
CI / Unit & Component Tests (push) Successful in 3m8s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m25s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Successful in 55s
- Extract ImportStatus type to types.ts — removes duplication across
  +page.svelte, ImportStatusCard.svelte, and test file (Felix blocker)
- Fix H2 to match CLAUDE.md card pattern: text-xs uppercase tracking-widest
  text-ink-3 mb-5 (Leonie blocker 1)
- Add font-sans to RUNNING and DONE status labels (Leonie blocker 2)
- Add data-testid="processed-count" to count elements in both states
- Replace document.querySelector with locator API in spinner tests
- Tighten getByText('7') to getByTestId('processed-count') (Felix/Sara)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:27:33 +02:00
Marcel
4dba268a04 test(import): add IMPORT_DONE statusCode service test
Covers the success path — previously untested per Sara's review.
Creates a minimal empty XLSX via XSSFWorkbook so processRows returns 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:16:22 +02:00
Marcel
b0cf35cf06 fix(test): replace toBeAttached() with querySelector not-null check for spinner
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m10s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m25s
CI / fail2ban Regex (pull_request) Successful in 40s
CI / Compose Bucket Idempotency (pull_request) Successful in 55s
toBeAttached() is not in the vitest-browser matcher set; toBeVisible() was
previously ruled out because the spinner is 0x0 px. Mirror the querySelector
pattern already used for the negative case in the same file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:24:27 +02:00
Marcel
0d934a1b44 fix(test): use m() calls and toBeAttached() in ImportStatusCard tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / OCR Service Tests (pull_request) Successful in 15s
CI / Backend Unit Tests (pull_request) Successful in 4m21s
CI / fail2ban Regex (pull_request) Successful in 37s
CI / Compose Bucket Idempotency (pull_request) Successful in 56s
CI Chromium runs with German locale so hardcoded English strings like
'No spreadsheet file found.' never matched. Use m.admin_system_import_*()
to assert whatever locale the browser resolves to.

Spinner test used toBeVisible() on an empty <span> whose dimensions come
entirely from Tailwind CSS. Without layout CSS the span is 0×0 and fails
the visibility check; toBeAttached() asserts DOM presence, which is the
right semantic here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:14:50 +02:00
Marcel
f4bda546a0 fix(test): update import-status test mocks and imports for statusCode-based i18n
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m6s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m23s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
Three test files were written against the old API shape (raw `message` field) before
the statusCode i18n field was introduced, or used the wrong `expect` import path:

- ImportStatusCard.svelte.test.ts: `@vitest/browser/context` does not export `expect`
  in this project's Vitest setup — use `vitest` like every other test file.
- page.svelte.spec.ts: FAILED mock lacked `statusCode`; assertion matched old German
  raw message instead of the i18n string for IMPORT_FAILED_NO_SPREADSHEET.
- page.svelte.test.ts: same pattern — mock lacked `statusCode`; assertion checked for
  raw backend string "database error" instead of the rendered i18n text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
b7744667f2 fix(admin/system): address review concerns in ImportStatusCard
- Remove dead `message` field from both frontend ImportStatus types
  (field is now @JsonIgnore'd on the backend)
- Extract failure message ternary into `$derived` — business logic off
  the template (Felix)
- Add motion-reduce:animate-none to spinner — WCAG 2.1 SC 2.3.3 (Leonie)
- Replace text-green-600 with text-green-800 — WCAG AA contrast 6.1:1
  on bg-green-50 (Leonie)
- Add min-h-[44px] to all three buttons — WCAG 2.2 44px touch target (Leonie)
- Add 6 missing tests: IMPORT_FAILED_INTERNAL path, IDLE state text,
  null importStatus, ontrigger called on DONE/FAILED/IDLE buttons (Sara)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
3d36c26226 fix(import): exclude message field from API response; add auth boundary tests
- @JsonIgnore on ImportStatus.message — stops internal directory paths and
  raw exception text leaking through the admin import-status endpoint (CWE-209)
- Add importStatus_messageField_notPresentInApiResponse test (red/green verified)
- Add importStatus_returns401/403 auth boundary tests — documents and guards
  the @RequirePermission(ADMIN) protection against configuration drift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
375fd3893c feat(admin/system): extract ImportStatusCard — spinner, text-base count, statusCode i18n
Extracts the mass-import block from +page.svelte into ImportStatusCard.svelte.

Changes per the three UX fixes from issue #533:
- RUNNING: animated spinner (animate-spin) + processed count at text-base;
  auto-poll at 2 s was already in place
- DONE: processed count at text-base, label at text-xs uppercase tracking-widest
- FAILED: maps statusCode (IMPORT_FAILED_NO_SPREADSHEET / IMPORT_FAILED_INTERNAL)
  to Paraglide messages — no raw German backend string rendered

Adds vitest-browser tests covering spinner visibility, count display,
and per-statusCode FAILED message selection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
c5d482bead feat(i18n): add structured import failure keys; split DONE display
Replaces the {message} interpolation (raw German backend string) with
two distinct error keys: IMPORT_FAILED_NO_SPREADSHEET and
IMPORT_FAILED_INTERNAL. Also removes the {count} parameter from the
done message and adds admin_system_import_status_done_label so the
processed count can be rendered separately at text-base size.

All three locales (de / en / es) updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
31eacb6d06 feat(import): add structured statusCode to ImportStatus — replaces raw German message
Adds a statusCode field (IMPORT_IDLE / IMPORT_RUNNING / IMPORT_DONE /
IMPORT_FAILED_NO_SPREADSHEET / IMPORT_FAILED_INTERNAL) to ImportStatus.
The frontend will map these codes to localized strings via Paraglide
instead of rendering the backend's German message verbatim.

NoSpreadsheetException distinguishes a missing spreadsheet from other
I/O failures so the frontend can show a specific error without raw text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:37:04 +02:00
Marcel
636900110a fix(ci): raise Surefire JVM ceiling 120→600 s — suite takes ~4 min
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m10s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m25s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Successful in 57s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:35:49 +02:00
Marcel
d78ee4397b devops(ci): add testTimeout + hookTimeout to browser vitest config
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
testTimeout: 30_000 causes Vitest to fail a hanging browser test
within 30 s when Chromium crashes mid-load instead of silently
occupying the CI slot for 14+ min.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:25:37 +02:00
Marcel
ebdb36b7d0 devops(ci): upload surefire XML reports as CI artifact
Captures all 102 test results independent of log verbosity.
if: always() ensures reports are available on failure — exactly
when they're needed most.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:25:37 +02:00
Marcel
93ff6cfb67 devops(ci): add Surefire per-test timeout and JVM ceiling
forkedProcessTimeoutInSeconds=120 caps the JVM on catastrophic hangs.
junit.jupiter.execution.timeout.default=90s times out each hanging
JUnit 5 test individually, letting healthy tests continue — replaces
the deprecated <timeout> alias that conflicted with the JVM ceiling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:25:37 +02:00
Marcel
ed4c4a52eb devops(ci): silence Spring Boot INFO noise in test log
Set logging.level.root=WARN + logging.level.org.raddatz=INFO in
backend/src/test/resources/application.properties to keep the full
test run under Gitea's 1.4 MB log cap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:25:37 +02:00
Marcel
2ca8428be4 refactor(test): hoist SubmitFn to file-level type in unsaved-guard specs
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m9s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 5m8s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
CI / Unit & Component Tests (push) Successful in 3m24s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m24s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:12:08 +02:00
Marcel
6fffc06c28 fix(test): allow extra result properties in enhance callback type
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m8s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
CI / Backend Unit Tests (pull_request) Failing after 17m19s
Use [key: string]: unknown index signature so TS does not reject the
extra fields (location, status) passed to the redirect/failure result
in the spec helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:09:14 +02:00
Marcel
ffcb901376 fix(admin): clear unsaved-changes guard before redirect on users/new
Mirror the groups/new fix: replace inline beforeNavigate/isDirty with
createUnsavedWarning() + UnsavedWarningBanner and add an enhance callback
that calls clearOnSuccess() before update() on redirect results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:09:14 +02:00
Marcel
30469e74c9 fix(admin): clear unsaved-changes guard before redirect on groups/new
Use createUnsavedWarning() + UnsavedWarningBanner to replace the inline
beforeNavigate/isDirty pattern, and add an enhance callback that calls
clearOnSuccess() before update() so the guard is disarmed before
SvelteKit's internal goto() fires on a redirect result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:09:14 +02:00
Marcel
5646e739c2 fix(ci): run svelte-kit sync before lint to fix cache-hit tsconfig miss
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m8s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m25s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
CI / Unit & Component Tests (push) Successful in 3m7s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m15s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Successful in 58s
When the node_modules cache hits, npm ci is skipped and the prepare
lifecycle (svelte-kit sync) never runs. frontend/tsconfig.json extends
.svelte-kit/tsconfig.json which only exists after svelte-kit sync —
so ESLint fails at tsconfig resolution on every cache-warm run.

Adding an unconditional svelte-kit sync step after Paraglide compile
and before Lint ensures .svelte-kit/tsconfig.json is always present
regardless of cache state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:07:15 +02:00
Marcel
bbbdf8cd09 ci: restrict push trigger to main — eliminate duplicate runs on feature branches
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m5s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m27s
CI / fail2ban Regex (push) Successful in 40s
CI / Compose Bucket Idempotency (push) Successful in 58s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:12:24 +02:00
Marcel
f727429699 fix(ci): run client coverage even when server coverage fails
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
Replace && with ; in test:coverage so the client vitest run is not
short-circuited when the server run exits non-zero (e.g. threshold
violation or test failure). Without this the upload-artifact step
only ever sees coverage/server.

Also updates the stale CLAUDE.md comment that said server-only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:07:34 +02:00
Marcel
e268e2dbca fix(tests): use native element clicks in layout dropdown spec
Some checks failed
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CDP-based Playwright clicks (locator.click()) do not reliably trigger
Svelte 5 onclick handlers — documented in commit 0c765d81 which fixed
13 other specs. The layout dropdown tests were missed in that pass.

Applies the same pattern: ((await locator.element()) as HTMLElement).click()
for button interactions, and native KeyboardEvent dispatch for the Escape
test (dispatched on the button so it bubbles to the parent div's onkeydown).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:07:22 +02:00
60 changed files with 18674 additions and 158 deletions

View File

@@ -26,6 +26,33 @@ PORT_MAILPIT_SMTP=1025
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
OCR_TRAINING_TOKEN=change-me-in-production
# --- Observability ---
# Optional stack — start with: docker compose -f docker-compose.observability.yml up -d
# Requires the main stack to already be running (docker compose up -d creates archiv-net).
# Ports for host access
PORT_GRAFANA=3001
PORT_GLITCHTIP=3002
PORT_PROMETHEUS=9090
# Grafana admin password — change this before exposing Grafana beyond localhost
GRAFANA_ADMIN_PASSWORD=changeme
# GlitchTip domain — production: use https://grafana.raddatz.cloud (must match Caddy vhost)
GLITCHTIP_DOMAIN=http://localhost:3002
# GlitchTip secret key — Django SECRET_KEY equivalent, used to sign sessions and tokens.
# REQUIRED in production — must not be empty or 'changeme'. Fail-closed: GlitchTip will
# refuse to start with an invalid key.
# Generate with: python3 -c "import secrets; print(secrets.token_hex(50))"
GLITCHTIP_SECRET_KEY=changeme-generate-a-real-secret
# Error reporting DSNs — leave empty to disable the SDK (safe default).
# SENTRY_DSN: backend (Spring Boot) — used by the GlitchTip/Sentry Java SDK
SENTRY_DSN=
# VITE_SENTRY_DSN: frontend (SvelteKit) — injected at build time via Vite
VITE_SENTRY_DSN=
# Production SMTP — uncomment and fill in to send real emails instead of catching them
# APP_BASE_URL=https://your-domain.example.com
# MAIL_HOST=smtp.example.com

View File

@@ -2,6 +2,7 @@ name: CI
on:
push:
branches: [main]
pull_request:
jobs:
@@ -32,6 +33,10 @@ jobs:
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend
- name: Sync SvelteKit
run: npx svelte-kit sync
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
@@ -192,6 +197,14 @@ jobs:
./mvnw clean test
working-directory: backend
- name: Upload surefire reports
if: always()
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
uses: actions/upload-artifact@v3
with:
name: surefire-reports
path: backend/target/surefire-reports/
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
# the JSON keys would silently break it (fail2ban-regex would return

View File

@@ -159,7 +159,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**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`.
**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) 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`.
### Security / Permissions

View File

@@ -197,6 +197,33 @@
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
</dependency>
<!-- Observability: Prometheus metrics scrape endpoint (version managed by Spring Boot BOM) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Observability: Micrometer → OpenTelemetry tracing bridge (version managed by Spring Boot BOM) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<!-- Observability: OTel Spring Boot auto-instrumentation — NOT in Spring Boot BOM, pinned explicitly -->
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-spring-boot-starter</artifactId>
<version>2.27.0</version>
<exclusions>
<!-- Excludes AzureAppServiceResourceProvider which references ServiceAttributes.SERVICE_INSTANCE_ID
that does not exist in the semconv version pulled by this project. -->
<exclusion>
<groupId>io.opentelemetry.contrib</groupId>
<artifactId>opentelemetry-azure-resources</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
@@ -273,6 +300,16 @@
</profiles>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
<systemPropertyVariables>
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>

View File

@@ -30,6 +30,8 @@ public enum ErrorCode {
// --- Users ---
/** A user with the given ID or username does not exist. 404 */
USER_NOT_FOUND,
/** A group with the given ID does not exist. 404 */
GROUP_NOT_FOUND,
/** The supplied email address is already used by another account. 409 */
EMAIL_ALREADY_IN_USE,
/** The supplied current password does not match the stored hash. 400 */
@@ -52,6 +54,8 @@ public enum ErrorCode {
INVITE_REVOKED,
/** The invite has passed its expiry date. 410 */
INVITE_EXPIRED,
/** A group cannot be deleted because one or more active invites reference it. 409 */
GROUP_HAS_ACTIVE_INVITES,
// --- Auth ---
/** The request is not authenticated. 401 */

View File

@@ -1,5 +1,6 @@
package org.raddatz.familienarchiv.importing;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
@@ -52,9 +53,9 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED }
public record ImportStatus(State state, String message, int processed, LocalDateTime startedAt) {}
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "Kein Import gestartet.", 0, null);
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
public ImportStatus getStatus() {
return currentStatus;
@@ -116,20 +117,29 @@ public class MassImportService {
if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
}
currentStatus = new ImportStatus(State.RUNNING, "Import läuft...", 0, LocalDateTime.now());
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
try {
File spreadsheet = findSpreadsheetFile();
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
int processed = processRows(readSpreadsheet(spreadsheet));
currentStatus = new ImportStatus(State.DONE,
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
processed, currentStatus.startedAt());
} catch (NoSpreadsheetException e) {
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
} catch (Exception e) {
log.error("Massenimport fehlgeschlagen", e);
currentStatus = new ImportStatus(State.FAILED, "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
}
}
private static class NoSpreadsheetException extends RuntimeException {
NoSpreadsheetException(String message) { super(message); }
}
private File findSpreadsheetFile() throws IOException {
try (Stream<Path> files = Files.list(Paths.get(importDir))) {
return files
@@ -138,7 +148,7 @@ public class MassImportService {
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
})
.findFirst()
.orElseThrow(() -> new RuntimeException(
.orElseThrow(() -> new NoSpreadsheetException(
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!"))
.toFile();
}

View File

@@ -52,7 +52,11 @@ public class InviteService {
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
Set<UUID> groupIds = new HashSet<>();
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
List<UserGroup> groups = userService.findGroupsByIds(dto.getGroupIds());
Set<UUID> uniqueIds = new HashSet<>(dto.getGroupIds());
List<UserGroup> groups = userService.findGroupsByIds(new ArrayList<>(uniqueIds));
if (groups.size() != uniqueIds.size()) {
throw DomainException.notFound(ErrorCode.GROUP_NOT_FOUND, "One or more group IDs do not exist");
}
groups.forEach(g -> groupIds.add(g.getId()));
}

View File

@@ -24,4 +24,7 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID>
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
List<InviteToken> findAllOrderedByCreatedAt();
@Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END FROM InviteToken t JOIN t.groupIds g WHERE g = :groupId AND t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses)")
boolean existsActiveWithGroupId(@Param("groupId") UUID groupId);
}

View File

@@ -37,6 +37,9 @@ public class UserService {
private final AppUserRepository userRepository;
private final UserGroupRepository groupRepository;
// Injected directly (not via InviteService) to avoid a constructor injection cycle:
// InviteService → UserService → InviteService. Spring Framework 7 forbids such cycles.
private final InviteTokenRepository inviteTokenRepository;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
@@ -288,6 +291,10 @@ public class UserService {
@Transactional
public void deleteGroup(UUID id) {
if (inviteTokenRepository.existsActiveWithGroupId(id)) {
throw DomainException.conflict(ErrorCode.GROUP_HAS_ACTIVE_INVITES,
"Cannot delete group " + id + " — referenced by one or more active invites");
}
groupRepository.deleteById(id);
}
}

View File

@@ -45,9 +45,34 @@ server:
forward-headers-strategy: native
management:
server:
# Management port is separate from the app port so that:
# (a) Caddy never proxies /actuator/* (it only routes :8080 → the app port)
# (b) Prometheus scrapes backend:8081 directly inside archiv-net, not via Caddy
# (c) Spring Security's session-authenticated filter chain on :8080 never sees actuator requests
port: 8081
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
prometheus:
enabled: true
health:
mail:
enabled: false
tracing:
sampling:
probability: 1.0 # 100% in dev; override via MANAGEMENT_TRACING_SAMPLING_PROBABILITY in prod compose
# OpenTelemetry trace export — failures are non-fatal (app starts cleanly without Tempo running)
# The default http://localhost:4317 ensures CI compatibility when no observability stack is present.
otel:
service:
name: familienarchiv-backend
exporter:
otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
springdoc:
api-docs:

View File

@@ -0,0 +1,3 @@
-- The composite PK (invite_token_id, group_id) does not support efficient lookups by group_id alone.
-- Add a dedicated index to support existsActiveWithGroupId queries.
CREATE INDEX idx_itg_group_id ON invite_token_group_ids (group_id);

View File

@@ -20,7 +20,10 @@ import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.File;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
@@ -70,14 +73,20 @@ class MassImportServiceTest {
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
}
@Test
void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() {
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE");
}
// ─── runImportAsync ───────────────────────────────────────────────────────
@Test
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
// /import directory doesn't exist in test environment → findSpreadsheetFile throws
// /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL
service.runImportAsync();
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL");
}
@Test
@@ -93,10 +102,35 @@ class MassImportServiceTest {
assertThat(service.getStatus().message()).contains(tempDir.toString());
}
@Test
void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) {
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
service.runImportAsync();
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET");
}
@Test
void runImportAsync_setsStatusCode_IMPORT_DONE_whenSpreadsheetHasNoDataRows(@TempDir Path tempDir) throws Exception {
Path xlsx = tempDir.resolve("import.xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook()) {
wb.createSheet("Sheet1");
try (OutputStream out = Files.newOutputStream(xlsx)) {
wb.write(out);
}
}
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
service.runImportAsync();
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_DONE");
}
@Test
void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now());
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
ReflectionTestUtils.setField(service, "currentStatus", running);
assertThatThrownBy(() -> service.runImportAsync())

View File

@@ -40,6 +40,47 @@ class AdminControllerTest {
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/admin/import-status ─────────────────────────────────────────
@Test
@WithMockUser(authorities = "ADMIN")
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.state").value("IDLE"))
.andExpect(jsonPath("$.statusCode").value("IMPORT_IDLE"))
.andExpect(jsonPath("$.processed").value(0));
}
@Test
@WithMockUser(authorities = "ADMIN")
void importStatus_messageField_notPresentInApiResponse() throws Exception {
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
when(massImportService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").doesNotExist());
}
@Test
void importStatus_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void importStatus_returns403_whenUserLacksAdminPermission() throws Exception {
mockMvc.perform(get("/api/admin/import-status"))
.andExpect(status().isForbidden());
}
@Test
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-versions"))

View File

@@ -20,10 +20,13 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -147,6 +150,30 @@ class InviteControllerTest {
.andExpect(jsonPath("$.label").value("Für Familie"));
}
@Test
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
void createInvite_forwardsGroupIdsToService() throws Exception {
UUID groupId = UUID.randomUUID();
AppUser admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
when(userService.findByEmail("admin@test.com")).thenReturn(admin);
InviteToken savedToken = InviteToken.builder()
.id(UUID.randomUUID()).code("ABCDE12345").useCount(0).build();
when(inviteService.createInvite(any(), eq(admin))).thenReturn(savedToken);
when(inviteService.toListItemDTO(any(), anyString()))
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
mockMvc.perform(post("/api/invites")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated());
ArgumentCaptor<CreateInviteRequest> captor = ArgumentCaptor.forClass(CreateInviteRequest.class);
verify(inviteService).createInvite(captor.capture(), eq(admin));
assertThat(captor.getValue().getGroupIds()).containsExactly(groupId);
}
// ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
@Test

View File

@@ -156,6 +156,35 @@ class InviteServiceTest {
assertThat(result.getGroupIds()).contains(g.getId());
}
@Test
void createInvite_throwsGroupNotFound_whenSubmittedGroupIdDoesNotExist() {
UUID unknownGroupId = UUID.randomUUID();
when(userService.findGroupsByIds(anyList())).thenReturn(List.of());
CreateInviteRequest req = new CreateInviteRequest();
req.setGroupIds(List.of(unknownGroupId));
assertThatThrownBy(() -> inviteService.createInvite(req, admin))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.GROUP_NOT_FOUND);
}
@Test
void createInvite_doesNotThrowGroupNotFound_whenDuplicateGroupIdsSubmitted() {
UUID groupId = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(groupId).name("Familie").build();
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
when(userService.findGroupsByIds(anyList())).thenReturn(List.of(group));
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
CreateInviteRequest req = new CreateInviteRequest();
req.setGroupIds(List.of(groupId, groupId)); // same UUID submitted twice
// before deduplication: size(groups)==1 != size(submitted)==2 → false GROUP_NOT_FOUND
assertThatCode(() -> inviteService.createInvite(req, admin)).doesNotThrowAnyException();
}
// ─── redeemInvite ─────────────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,78 @@
package org.raddatz.familienarchiv.user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class InviteTokenRepositoryIntegrationTest {
@Autowired InviteTokenRepository inviteTokenRepository;
@Autowired UserGroupRepository userGroupRepository;
@Autowired AppUserRepository appUserRepository;
private UserGroup group;
private AppUser admin;
@BeforeEach
void setUp() {
inviteTokenRepository.deleteAll();
userGroupRepository.deleteAll();
appUserRepository.deleteAll();
admin = appUserRepository.save(AppUser.builder().email("admin@test.com").password("pw").build());
group = userGroupRepository.save(UserGroup.builder().name("Familie").build());
}
// ─── existsActiveWithGroupId ──────────────────────────────────────────────
@Test
void existsActiveWithGroupId_returnsTrueForActiveInviteLinkedToGroup() {
inviteTokenRepository.save(token(t -> t));
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isTrue();
}
@Test
void existsActiveWithGroupId_returnsFalseWhenInviteIsRevoked() {
inviteTokenRepository.save(token(t -> t.revoked(true)));
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
}
@Test
void existsActiveWithGroupId_returnsFalseWhenInviteIsExpired() {
inviteTokenRepository.save(token(t -> t.expiresAt(LocalDateTime.now().minusDays(1))));
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
}
@Test
void existsActiveWithGroupId_returnsFalseWhenInviteIsExhausted() {
inviteTokenRepository.save(token(t -> t.maxUses(1).useCount(1)));
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
}
// ─── helpers ─────────────────────────────────────────────────────────────
private InviteToken token(java.util.function.UnaryOperator<InviteToken.InviteTokenBuilder> customizer) {
InviteToken.InviteTokenBuilder builder = InviteToken.builder()
.code(UUID.randomUUID().toString().replace("-", "").substring(0, 10))
.groupIds(new java.util.HashSet<>(Set.of(group.getId())))
.createdBy(admin);
return customizer.apply(builder).build();
}
}

View File

@@ -36,6 +36,7 @@ class UserServiceTest {
@Mock AppUserRepository userRepository;
@Mock UserGroupRepository groupRepository;
@Mock InviteTokenRepository inviteTokenRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock AuditService auditService;
@InjectMocks UserService userService;
@@ -903,6 +904,29 @@ class UserServiceTest {
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
}
// ─── deleteGroup ──────────────────────────────────────────────────────────
@Test
void deleteGroup_throwsConflict_whenActiveInviteReferencesGroup() {
UUID groupId = UUID.randomUUID();
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(true);
assertThatThrownBy(() -> userService.deleteGroup(groupId))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.GROUP_HAS_ACTIVE_INVITES);
}
@Test
void deleteGroup_deletesGroup_whenNoActiveInviteReferencesGroup() {
UUID groupId = UUID.randomUUID();
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(false);
userService.deleteGroup(groupId);
verify(groupRepository).deleteById(groupId);
}
@Test
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();

View File

@@ -13,3 +13,16 @@ spring:
password: test
mail:
host: localhost
# Disable OTel SDK entirely in tests — prevents auto-configuration from loading resource providers
# (e.g. AzureAppServiceResourceProvider) that fail against the semconv version used here.
otel:
sdk:
disabled: true
# Disable trace export in tests — prevents OTLP connection attempts when no Tempo is running.
# Sampling probability 0.0 means no spans are created, so no export is attempted.
management:
tracing:
sampling:
probability: 0.0

View File

@@ -0,0 +1,2 @@
logging.level.root=WARN
logging.level.org.raddatz=INFO

View File

@@ -0,0 +1,191 @@
# Observability stack — Grafana LGTM + GlitchTip
#
# Requires the main stack to be running first:
# docker compose up -d # creates archiv-net
# docker compose -f docker-compose.observability.yml up -d
#
# To validate without starting:
# docker compose -f docker-compose.observability.yml config
services:
# --- Metrics: Prometheus ---
prometheus:
image: prom/prometheus:v3.4.0
container_name: obs-prometheus
restart: unless-stopped
volumes:
- ./infra/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
- '--web.enable-lifecycle'
ports:
- "127.0.0.1:${PORT_PROMETHEUS:-9090}:9090"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 5s
retries: 3
networks:
- archiv-net
- obs-net
node-exporter:
image: prom/node-exporter:v1.9.0
container_name: obs-node-exporter
restart: unless-stopped
# pid: host — required for process-level CPU/memory metrics; cgroup isolation applies
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
# $$ is YAML Compose escaping for a literal $ in the regex alternation
- '--collector.filesystem.ignored-mount-points=^/(sys|proc|dev|host|etc)($$|/)'
expose:
- "9100"
networks:
- obs-net
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.1
container_name: obs-cadvisor
restart: unless-stopped
# privileged: true — required for cgroup and namespace metrics, see cAdvisor docs.
# Accepted risk: cAdvisor is pinned, on Renovate, and not exposed outside obs-net.
privileged: true
volumes:
- /:/rootfs:ro
# /var/run/docker.sock mounted read-only — sufficient for container metadata discovery
- /var/run/docker.sock:/var/run/docker.sock:ro
- /sys:/sys:ro
- /var/lib/docker:/var/lib/docker:ro
expose:
- "8080"
networks:
- obs-net
# --- Logs: Loki + Promtail ---
loki:
image: grafana/loki:3.4.2
container_name: obs-loki
restart: unless-stopped
volumes:
- ./infra/observability/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
- loki_data:/loki
command: -config.file=/etc/loki/loki-config.yml
expose:
- "3100"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready | grep -q ready || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- obs-net
promtail:
image: grafana/promtail:3.4.2
container_name: obs-promtail
restart: unless-stopped
volumes:
- ./infra/observability/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
# :ro restricts file-system access but NOT Docker API permissions — a compromised Promtail has full daemon access. Accepted risk on single-operator self-hosted archive.
- /var/run/docker.sock:/var/run/docker.sock:ro
- promtail_positions:/tmp # persists positions.yaml across restarts — avoids duplicate log ingestion
command: -config.file=/etc/promtail/promtail-config.yml
networks:
- archiv-net # label discovery from application containers via Docker socket
- obs-net # log shipping to Loki
depends_on:
loki:
condition: service_healthy
# --- Traces: Tempo ---
tempo:
image: grafana/tempo:2.7.2
container_name: obs-tempo
restart: unless-stopped
volumes:
- ./infra/observability/tempo/tempo.yml:/etc/tempo.yml:ro
- tempo_data:/var/tempo
command: -config.file=/etc/tempo.yml
expose:
- "3200" # Grafana queries Tempo on this port (obs-net only)
- "4317" # OTLP gRPC — backend sends traces here (archiv-net)
- "4318" # OTLP HTTP — alternative transport (archiv-net)
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3200/ready | grep -q ready || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
- archiv-net # backend (archive-backend) reaches tempo:4317 over this network
- obs-net # Grafana reaches tempo:3200 over this network
# --- Dashboards: Grafana ---
obs-grafana:
image: grafana/grafana-oss:11.6.1
container_name: obs-grafana
restart: unless-stopped
ports:
- "127.0.0.1:${PORT_GRAFANA:-3001}:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana_data:/var/lib/grafana
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q ok || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
prometheus:
condition: service_healthy
loki:
condition: service_healthy
tempo:
condition: service_healthy
networks:
- obs-net
# --- Error Tracking: GlitchTip ---
# glitchtip: (see future issue)
networks:
# Shared network created by the main docker-compose.yml.
# The observability stack joins as a peer so Prometheus can scrape
# archive-backend by container name. The observability stack must NOT
# attempt to create this network — it will fail with a clear error if
# the main stack is not running yet.
archiv-net:
external: true
# Internal network for observability-service-to-service traffic
# (e.g. Grafana → Prometheus, Grafana → Loki, Grafana → Tempo).
obs-net:
driver: bridge
volumes:
prometheus_data:
loki_data:
promtail_positions:
tempo_data:
grafana_data:
glitchtip_data:

View File

@@ -147,8 +147,18 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
APP_OCR_BASE_URL: http://ocr-service:8000
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
# Tempo is defined in docker-compose.observability.yml (future issue).
# OTLP failures are non-fatal — backend starts cleanly without the observability stack.
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
# 10% sampling in this compose (dev + staging) — override locally to 1.0 if needed
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "0.1"
ports:
- "${PORT_BACKEND}:8080"
# Management port — Prometheus scrapes /actuator/prometheus from inside archiv-net.
# Not exposed to the host; Docker service-name DNS (backend:8081) is sufficient.
expose:
- "8081"
networks:
- archiv-net
healthcheck:

View File

@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |

View File

@@ -43,6 +43,7 @@ graph TD
- SSE notifications transit Caddy (browser → Caddy → backend); the backend is never reachable directly from the public internet. The SvelteKit SSR layer is bypassed for SSE, but Caddy is not.
- The Caddyfile responds `404` on `/actuator/*` (defense in depth). Internal monitoring scrapes the backend on the docker network, not through Caddy.
- Production and staging cohabit on the same host via docker compose project names: `archiv-production` (ports 8080/3000) and `archiv-staging` (ports 8081/3001).
- An optional observability stack (Prometheus, Node Exporter, cAdvisor) runs as a separate compose file: `docker compose -f docker-compose.observability.yml up -d`. It joins `archiv-net` and scrapes the backend's management port (`:8081`). Configuration lives under `infra/observability/`.
### OCR memory requirements
@@ -106,6 +107,8 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — |
| `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — |
| `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP gRPC endpoint for distributed traces (Tempo). Set to `http://tempo:4317` via compose. | `http://localhost:4317` | — | — |
| `MANAGEMENT_TRACING_SAMPLING_PROBABILITY` | Micrometer tracing sample rate; overridden to `0.0` in test profile. | `0.1` (compose) / `1.0` (dev) | — | — |
### PostgreSQL container
@@ -134,6 +137,14 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
### Observability stack (`docker-compose.observability.yml`)
| Variable | Purpose | Default | Required? | Sensitive? |
|---|---|---|---|---|
| `PORT_PROMETHEUS` | Host port for the Prometheus UI (bound to `127.0.0.1` only) | `9090` | — | — |
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3001` | — | — |
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES |
---
## 3. Bootstrap from scratch
@@ -256,9 +267,62 @@ docker compose logs --tail=200 <service>
- **Spring Actuator health**: `http://localhost:8080/actuator/health` (internal only in prod — port 8081 for Prometheus scraping)
- **Prometheus scraping**: management port 8081, path `/actuator/prometheus`. Internal only; Caddy blocks `/actuator/*` externally.
### Future observability
### Observability stack
Phase 7 of the Production v1 milestone adds Prometheus + Loki + Grafana. No monitoring infrastructure is in place yet.
An observability stack is available via `docker-compose.observability.yml`. Configuration lives under `infra/observability/`. Start it after the main stack is up (which creates `archiv-net`):
```bash
docker compose up -d # creates archiv-net
docker compose -f docker-compose.observability.yml up -d
```
Current services:
| Service | Image | Purpose |
|---|---|---|
| `obs-prometheus` | `prom/prometheus:v3.4.0` | Scrapes metrics from backend management port 8081 (`/actuator/prometheus`), node-exporter, and cAdvisor |
| `obs-node-exporter` | `prom/node-exporter:v1.9.0` | Host-level CPU / memory / disk / network metrics |
| `obs-cadvisor` | `gcr.io/cadvisor/cadvisor:v0.52.1` | Per-container resource metrics |
| `obs-loki` | `grafana/loki:3.4.2` | Log aggregation — receives log streams from Promtail. Port 3100 is `expose`-only (not host-bound). |
| `obs-promtail` | `grafana/promtail:3.4.2` | Log shipping agent — reads all Docker container logs via the Docker socket and forwards them to Loki with `container_name`, `compose_service`, and `compose_project` labels |
| `obs-tempo` | `grafana/tempo:2.7.2` | Distributed trace storage — OTLP gRPC receiver on port 4317, OTLP HTTP on port 4318 (both `archiv-net`-internal). Grafana queries traces on port 3200 (`obs-net`-internal). All ports are `expose`-only (not host-bound). |
| `obs-grafana` | `grafana/grafana-oss:11.6.1` | Unified observability UI — metrics dashboards, log exploration, trace viewer. Bound to `127.0.0.1:${PORT_GRAFANA:-3001}` on the host. |
#### Grafana
| Item | Value |
|---|---|
| URL | `http://localhost:3001` (or `http://localhost:$PORT_GRAFANA`) |
| Username | `admin` |
| Password | `$GRAFANA_ADMIN_PASSWORD` (default: `changeme`**change before exposing to a network**) |
Datasources are auto-provisioned on first start (Prometheus, Loki, Tempo — no manual setup required). Three dashboards are pre-loaded:
| Dashboard | Grafana ID | Purpose |
|---|---|---|
| Node Exporter Full | 1860 | Host CPU, memory, disk, network |
| Spring Boot Observability | 17175 | JVM metrics, HTTP latency, error rate |
| Loki Logs | 13639 | Log exploration and filtering |
Tempo traces are accessible via Grafana Explore → Tempo datasource, and linked from Loki logs via the `traceId` derived field.
**Loki quick checks** (after ~60 s, run from inside the `obs-loki` container):
```bash
# Loki health
docker exec obs-loki wget -qO- http://localhost:3100/ready
# List labels
docker exec obs-loki wget -qO- 'http://localhost:3100/loki/api/v1/labels'
# Query logs by service (stable across dev and prod environments)
docker exec obs-loki wget -qO- \
'http://localhost:3100/loki/api/v1/query_range?query=%7Bcompose_service%3D%22backend%22%7D&limit=5'
```
**Prefer `compose_service` over `container_name` in LogQL queries**`container_name` differs between dev (`archive-backend`) and prod (`archiv-production-backend-1`), while `compose_service` is stable (`backend`, `db`, `minio`, etc.).
Prometheus port `9090` and Grafana port `3001` are bound to `127.0.0.1` on the host. No other observability ports are host-bound.
---

View File

@@ -17,6 +17,16 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.")
}
System_Boundary(observability, "Observability Stack (docker-compose.observability.yml)") {
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend management port 8081 (/actuator/prometheus), node-exporter, and cAdvisor. Retention: 30 days.")
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
Container(promtail, "Promtail", "grafana/promtail:3.4.2", "Ships Docker container logs to Loki via Docker SD.")
Container(tempo, "Tempo", "grafana/tempo:2.7.2", "Distributed trace storage. OTLP gRPC receiver on port 4317 (archiv-net). Grafana queries traces on port 3200 (obs-net). All ports internal only.")
Container(grafana, "Grafana", "grafana/grafana-oss:11.6.1", "Unified observability UI — dashboards, logs, traces. Datasources (Prometheus, Loki, Tempo) and three dashboards are auto-provisioned.")
}
Rel(user, caddy, "HTTPS", "TLS 1.2/1.3")
Rel(caddy, frontend, "Reverse proxies non-/api requests", "HTTP / loopback:3000")
Rel(caddy, backend, "Reverse proxies /api/*", "HTTP / loopback:8080")
@@ -28,5 +38,10 @@ Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JS
Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP")
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
Rel(mc, storage, "Bootstraps bucket + service account on startup", "MinIO Client CLI")
Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
Rel(backend, tempo, "Sends distributed traces via OTLP", "gRPC / OTLP / port 4317 (archiv-net)")
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100")
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
@enduml

View File

@@ -165,7 +165,7 @@ npm run check # svelte-check (type checking)
```bash
npm run test # Vitest unit + server tests (headless)
npm run test:coverage # Coverage report (server project only)
npm run test:coverage # Coverage report (server + client)
npm run test:e2e # Playwright E2E tests
npm run test:e2e:headed # Playwright E2E with visible browser
npm run test:e2e:ui # Playwright UI mode

View File

@@ -29,6 +29,6 @@ ENV NODE_ENV=production
COPY --from=build /app/build ./build
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/package-lock.json ./package-lock.json
RUN npm ci --omit=dev
RUN npm ci --omit=dev --ignore-scripts
EXPOSE 3000
CMD ["node", "build"]

View File

@@ -345,8 +345,11 @@
"admin_system_import_btn_retry": "Erneut starten",
"admin_system_import_status_idle": "Kein Import gestartet.",
"admin_system_import_status_running": "Import läuft…",
"admin_system_import_status_done": "Import abgeschlossen {count} Dokumente verarbeitet.",
"admin_system_import_status_failed": "Fehler: {message}",
"admin_system_import_status_done": "Import abgeschlossen",
"admin_system_import_status_done_label": "Dokumente verarbeitet",
"admin_system_import_status_failed": "Import fehlgeschlagen",
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
"admin_system_thumbnails_heading": "Thumbnails erzeugen",
"admin_system_thumbnails_description": "Erzeugt Vorschaubilder für Dokumente ohne Thumbnail (z. B. nach dem Massenimport).",
"admin_system_thumbnails_btn_start": "Thumbnails erzeugen",
@@ -703,6 +706,8 @@
"error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.",
"error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.",
"error_invite_expired": "Dieser Einladungslink ist abgelaufen.",
"error_group_has_active_invites": "Diese Gruppe kann nicht gelöscht werden, da sie in einer aktiven Einladung verwendet wird.",
"error_group_not_found": "Die angegebene Gruppe existiert nicht.",
"register_heading": "Konto erstellen",
"register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.",
"register_label_first_name": "Vorname",
@@ -762,6 +767,9 @@
"admin_new_invite_prefill_last": "Nachname vorausfüllen (optional)",
"admin_new_invite_prefill_email": "E-Mail vorausfüllen (optional)",
"admin_new_invite_expires": "Ablaufdatum (optional)",
"admin_new_invite_groups": "Gruppen (optional)",
"admin_new_invite_no_groups": "Keine Gruppen vorhanden.",
"admin_invite_groups_load_error": "Gruppen konnten nicht geladen werden. Die Einladung kann ohne Gruppenauswahl erstellt werden.",
"admin_invite_created_title": "Einladung erstellt",
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",

View File

@@ -345,8 +345,11 @@
"admin_system_import_btn_retry": "Start again",
"admin_system_import_status_idle": "No import started.",
"admin_system_import_status_running": "Import running…",
"admin_system_import_status_done": "Import complete {count} documents processed.",
"admin_system_import_status_failed": "Error: {message}",
"admin_system_import_status_done": "Import complete",
"admin_system_import_status_done_label": "Documents processed",
"admin_system_import_status_failed": "Import failed",
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
"admin_system_import_failed_internal": "Import failed due to an internal error.",
"admin_system_thumbnails_heading": "Generate thumbnails",
"admin_system_thumbnails_description": "Generates preview images for documents without a thumbnail (e.g. after the mass import).",
"admin_system_thumbnails_btn_start": "Generate thumbnails",
@@ -703,6 +706,8 @@
"error_invite_exhausted": "This invite link has already been fully used.",
"error_invite_revoked": "This invite link has been deactivated.",
"error_invite_expired": "This invite link has expired.",
"error_group_has_active_invites": "This group cannot be deleted because it is referenced by one or more active invite links.",
"error_group_not_found": "The specified group does not exist.",
"register_heading": "Create account",
"register_subtext": "You've been invited to join Familienarchiv.",
"register_label_first_name": "First name",
@@ -762,6 +767,9 @@
"admin_new_invite_prefill_last": "Pre-fill last name (optional)",
"admin_new_invite_prefill_email": "Pre-fill email (optional)",
"admin_new_invite_expires": "Expiry date (optional)",
"admin_new_invite_groups": "Groups (optional)",
"admin_new_invite_no_groups": "No groups exist.",
"admin_invite_groups_load_error": "Groups could not be loaded. The invite can still be created without group assignment.",
"admin_invite_created_title": "Invite created",
"admin_invite_created_desc": "Share this link with the person you are inviting:",
"admin_invite_revoke_confirm": "Really revoke this invite?",

View File

@@ -345,8 +345,11 @@
"admin_system_import_btn_retry": "Iniciar de nuevo",
"admin_system_import_status_idle": "No hay importación iniciada.",
"admin_system_import_status_running": "Importación en curso…",
"admin_system_import_status_done": "Importación completada {count} documentos procesados.",
"admin_system_import_status_failed": "Error: {message}",
"admin_system_import_status_done": "Importación completada",
"admin_system_import_status_done_label": "Documentos procesados",
"admin_system_import_status_failed": "Importación fallida",
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
"admin_system_import_failed_internal": "Error interno durante la importación.",
"admin_system_thumbnails_heading": "Generar miniaturas",
"admin_system_thumbnails_description": "Genera imágenes de vista previa para documentos sin miniatura (p. ej. tras la importación masiva).",
"admin_system_thumbnails_btn_start": "Generar miniaturas",
@@ -703,6 +706,8 @@
"error_invite_exhausted": "Este enlace de invitación ya ha sido completamente utilizado.",
"error_invite_revoked": "Este enlace de invitación ha sido desactivado.",
"error_invite_expired": "Este enlace de invitación ha expirado.",
"error_group_has_active_invites": "Este grupo no puede eliminarse porque está referenciado por uno o más enlaces de invitación activos.",
"error_group_not_found": "El grupo especificado no existe.",
"register_heading": "Crear cuenta",
"register_subtext": "Has sido invitado a unirte al Familienarchiv.",
"register_label_first_name": "Nombre",
@@ -762,6 +767,9 @@
"admin_new_invite_prefill_last": "Prellenar apellido (opcional)",
"admin_new_invite_prefill_email": "Prellenar correo (opcional)",
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
"admin_new_invite_groups": "Grupos (opcional)",
"admin_new_invite_no_groups": "No hay grupos disponibles.",
"admin_invite_groups_load_error": "No se pudieron cargar los grupos. La invitación puede crearse sin asignar grupos.",
"admin_invite_created_title": "Invitación creada",
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",

View File

@@ -16,7 +16,7 @@
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",
"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",

View File

@@ -22,6 +22,8 @@ export type ErrorCode =
| 'INVITE_EXHAUSTED'
| 'INVITE_REVOKED'
| 'INVITE_EXPIRED'
| 'GROUP_HAS_ACTIVE_INVITES'
| 'GROUP_NOT_FOUND'
| 'ANNOTATION_NOT_FOUND'
| 'ANNOTATION_UPDATE_FAILED'
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
@@ -108,6 +110,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_invite_revoked();
case 'INVITE_EXPIRED':
return m.error_invite_expired();
case 'GROUP_HAS_ACTIVE_INVITES':
return m.error_group_has_active_invites();
case 'GROUP_NOT_FOUND':
return m.error_group_not_found();
case 'ANNOTATION_NOT_FOUND':
return m.error_annotation_not_found();
case 'ANNOTATION_UPDATE_FAILED':

View File

@@ -1,4 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
let {
groups,
selectedGroupIds = []
@@ -7,12 +10,13 @@ let {
selectedGroupIds?: string[];
} = $props();
let selected = $derived([...selectedGroupIds]);
let selected = $state<string[]>(untrack(() => [...selectedGroupIds]));
</script>
<div class="flex flex-wrap gap-3">
<fieldset class="flex flex-wrap gap-3 border-none p-0">
<legend class="sr-only">{m.admin_new_invite_groups()}</legend>
{#each groups as group (group.id)}
<label class="inline-flex items-center gap-2 text-sm text-ink-2">
<label class="inline-flex min-h-[44px] items-center gap-2 text-sm text-ink-2">
<input
type="checkbox"
name="groupIds"
@@ -23,4 +27,4 @@ let selected = $derived([...selectedGroupIds]);
{group.name}
</label>
{/each}
</div>
</fieldset>

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
const availableStandard = $derived([
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
@@ -18,17 +19,7 @@ const availableAdmin = $derived([
let { form } = $props();
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
const unsaved = createUnsavedWarning();
</script>
<div class="flex flex-1 flex-col overflow-hidden">
@@ -58,23 +49,8 @@ beforeNavigate(({ cancel, to }) => {
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning}
<div
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={() => {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>
{#if unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
{#if form?.error}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
@@ -85,11 +61,11 @@ beforeNavigate(({ cancel, to }) => {
<form
id="new-group-form"
method="POST"
use:enhance
oninput={() => {
isDirty = true;
showUnsavedWarning = false;
use:enhance={() => async ({ result, update }) => {
if (result.type === 'redirect') unsaved.clearOnSuccess();
await update();
}}
oninput={unsaved.markDirty}
class="space-y-5"
>
<!-- Name card -->

View File

@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown }));
vi.mock('$app/forms', () => ({
enhance: (_el: HTMLFormElement, fn?: unknown) => {
enhanceCaptureRef.submitFn = fn;
return { destroy: vi.fn() };
}
}));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation';
afterEach(cleanup);
type SubmitFn = () => Promise<
(opts: {
result: { type: string; [key: string]: unknown };
update: () => Promise<void>;
}) => Promise<void>
>;
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
describe('Admin new group page unsaved-changes guard', () => {
beforeEach(() => {
vi.clearAllMocks();
enhanceCaptureRef.submitFn = undefined;
});
it('does not show unsaved warning initially', async () => {
render(Page, { props: { form: null } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
});
it('cancels navigation and shows banner when form is dirty', async () => {
render(Page, { props: { form: null } });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="name"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
expect(cancel).toHaveBeenCalled();
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
});
it('does not cancel navigation when form is clean', async () => {
render(Page, { props: { form: null } });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
expect(cancel).not.toHaveBeenCalled();
});
it('discard button calls goto with the target URL', async () => {
render(Page, { props: { form: null } });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="name"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
await page.getByRole('button', { name: /verwerfen/i }).click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/groups');
});
it('clears banner when enhance callback receives a redirect result', async () => {
render(Page, { props: { form: null } });
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="name"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
await innerFn({
result: { type: 'redirect', location: '/admin/groups', status: 303 },
update: vi.fn().mockResolvedValue(undefined)
});
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
const cancel = vi.fn();
navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
expect(cancel).not.toHaveBeenCalled();
});
it('keeps banner when enhance callback receives a failure result', async () => {
render(Page, { props: { form: null } });
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="name"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/groups') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
await innerFn({
result: { type: 'failure', status: 400, data: { error: 'Name bereits vergeben' } },
update: vi.fn().mockResolvedValue(undefined)
});
const cancel = vi.fn();
navCallback({ cancel, to: { url: new URL('http://localhost/admin/groups') } });
expect(cancel).toHaveBeenCalled();
});
});

View File

@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { parseBackendError } from '$lib/shared/errors';
import type { Actions, PageServerLoad } from './$types';
import type { components } from '$lib/generated/api';
export interface InviteListItem {
id: string;
@@ -17,22 +18,37 @@ export interface InviteListItem {
shareableUrl: string;
}
export type UserGroup = components['schemas']['UserGroup'];
export const load: PageServerLoad = async ({ url, fetch }) => {
const status = url.searchParams.get('status') ?? 'active';
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`);
if (!res.ok) {
const backendError = await parseBackendError(res);
return {
invites: [] as InviteListItem[],
status,
loadError: backendError?.code ?? 'INTERNAL_ERROR'
};
const [invitesRes, groupsRes] = await Promise.all([
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`),
fetch(`${apiUrl}/api/groups`)
]);
let invites: InviteListItem[] = [];
let loadError: string | null = null;
if (!invitesRes.ok) {
const backendError = await parseBackendError(invitesRes);
loadError = backendError?.code ?? 'INTERNAL_ERROR';
} else {
invites = await invitesRes.json();
}
const invites: InviteListItem[] = await res.json();
return { invites, status, loadError: null };
let groups: UserGroup[] = [];
let groupsLoadError: string | null = null;
if (!groupsRes.ok) {
const backendError = await parseBackendError(groupsRes);
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR';
} else {
const raw: UserGroup[] = await groupsRes.json();
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
}
return { invites, status, loadError, groups, groupsLoadError };
};
export const actions = {
@@ -45,6 +61,7 @@ export const actions = {
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
const expiresAt = (formData.get('expiresAt') as string) || undefined;
const groupIds = formData.getAll('groupIds') as string[];
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const res = await fetch(`${apiUrl}/api/invites`, {
@@ -56,7 +73,8 @@ export const actions = {
prefillFirstName,
prefillLastName,
prefillEmail,
expiresAt
expiresAt,
groupIds
})
});

View File

@@ -2,7 +2,8 @@
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
import type { InviteListItem } from './+page.server.ts';
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
import type { InviteListItem, UserGroup } from './+page.server.ts';
let {
data,
@@ -12,6 +13,8 @@ let {
invites: InviteListItem[];
status: string;
loadError: string | null;
groups: UserGroup[];
groupsLoadError: string | null;
};
form?: {
createError?: string;
@@ -253,6 +256,23 @@ function statusIcon(status: string) {
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="sm:col-span-2">
<p class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_new_invite_groups()}
</p>
{#if data.groupsLoadError}
<div
role="alert"
class="rounded-sm border border-amber-200 bg-amber-50 px-3 py-2 font-sans text-xs text-amber-700"
>
{m.admin_invite_groups_load_error()}
</div>
{:else if data.groups.length === 0}
<p class="font-sans text-xs text-ink-3 italic">{m.admin_new_invite_no_groups()}</p>
{:else}
<UserGroupsSection groups={data.groups} />
{/if}
</div>
{#if form?.createError}
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
{getErrorMessage(form.createError)}

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://localhost:8080' }
}));
import { load, actions } from './+page.server';
import type { UserGroup } from './+page.server';
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
// the void and the Record<string, any> from the generic constraint.
type LoadData = {
invites: unknown[];
status: string;
loadError: string | null;
groups: UserGroup[];
groupsLoadError: string | null;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFetch = (...args: any[]) => any;
function mockResponse(ok: boolean, body: unknown, status = 200) {
return {
ok,
status,
json: async () => body,
text: async () => JSON.stringify(body),
headers: new Headers({ 'content-type': 'application/json' })
} as unknown as Response;
}
describe('admin/invites load()', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
function event(status = 'active') {
return {
url: new URL(`http://localhost/admin/invites?status=${status}`),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
it('returns groups array alongside invites when both succeed', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
mockResponse(true, [
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
])
);
const result = (await load(event())) as LoadData;
expect(result.groups).toHaveLength(2);
expect(result.groupsLoadError).toBeNull();
});
it('returns groups sorted alphabetically by name', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
mockResponse(true, [
{ id: 'g-1', name: 'Zebra', permissions: [] },
{ id: 'g-2', name: 'Alfa', permissions: [] },
{ id: 'g-3', name: 'Mitte', permissions: [] }
])
);
const result = (await load(event())) as LoadData;
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
});
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
const result = (await load(event())) as LoadData;
expect(result.groups).toEqual([]);
expect(result.groupsLoadError).toBe('FORBIDDEN');
});
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(false, null, 500));
const result = (await load(event())) as LoadData;
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
});
it('fetches invites and groups in parallel (both URLs called)', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(true, []))
.mockResolvedValueOnce(mockResponse(true, []));
await load(event());
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups'));
});
});
describe('admin/invites create action', () => {
const mockFetch = vi.fn<AnyFetch>();
beforeEach(() => mockFetch.mockReset());
const successBody = {
id: 'inv-1',
code: 'ABCDE12345',
displayCode: 'ABCDE-12345',
status: 'active',
revoked: false,
useCount: 0,
createdAt: '2026-01-01T00:00:00Z',
shareableUrl: 'http://localhost/register?code=ABCDE12345'
};
it('includes groupIds array in POST body when checkboxes are checked', async () => {
const fd = new FormData();
fd.append('groupIds', 'g-1');
fd.append('groupIds', 'g-2');
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const sent = JSON.parse(init.body as string);
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
});
it('sends groupIds: [] when no checkboxes are checked', async () => {
const fd = new FormData();
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
await actions.create({
request: new Request('http://localhost', { method: 'POST', body: fd }),
fetch: mockFetch as unknown as typeof fetch
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const sent = JSON.parse(init.body as string);
expect(sent.groupIds).toEqual([]);
});
});

View File

@@ -7,12 +7,15 @@ afterEach(cleanup);
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
id: 'i-1',
code: 'XYZ1234567',
displayCode: 'XYZ-1234',
label: 'Familie',
useCount: 0,
maxUses: 5,
expiresAt: '2027-01-01T00:00:00Z',
revoked: false,
status: 'active' as string,
createdAt: '2025-01-01T00:00:00Z',
shareableUrl: 'http://example.com/i/i-1',
...overrides
});
@@ -22,11 +25,15 @@ const baseData = (
invites: ReturnType<typeof makeInvite>[];
status: string;
loadError: string | null;
groups: { id: string; name: string; permissions: string[] }[];
groupsLoadError: string | null;
}> = {}
) => ({
invites: [],
status: 'active',
loadError: null,
groups: [],
groupsLoadError: null,
...overrides
});
@@ -253,4 +260,115 @@ describe('admin/invites page', () => {
const banner = document.querySelector('.bg-red-50');
expect(banner).not.toBeNull();
});
// ─── groups section ───────────────────────────────────────────────────────
it('shows a groups-load warning banner when data.groupsLoadError is set', async () => {
render(AdminInvitesPage, {
props: { data: { ...baseData(), groups: [], groupsLoadError: 'INTERNAL_ERROR' } }
});
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
const banner = document.querySelector('.bg-amber-50');
expect(banner).not.toBeNull();
});
it('renders group checkboxes inside the new-invite form when groups are provided', async () => {
render(AdminInvitesPage, {
props: {
data: {
...baseData(),
groups: [
{ id: 'g-1', name: 'Administratoren', permissions: ['ADMIN'] },
{ id: 'g-2', name: 'Familie', permissions: ['READ_ALL'] }
],
groupsLoadError: null
}
}
});
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
await expect.element(page.getByRole('checkbox', { name: 'Administratoren' })).toBeVisible();
await expect.element(page.getByRole('checkbox', { name: 'Familie' })).toBeVisible();
});
it('group checkbox stays checked after being clicked', async () => {
render(AdminInvitesPage, {
props: {
data: {
...baseData(),
groups: [{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }],
groupsLoadError: null
}
}
});
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
const checkbox = page.getByRole('checkbox', { name: 'Familie' });
await checkbox.click();
await expect.element(checkbox).toBeChecked();
});
it('amber warning banner has role="alert"', async () => {
render(AdminInvitesPage, {
props: { data: { ...baseData(), groups: [], groupsLoadError: 'INTERNAL_ERROR' } }
});
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
it('checkbox group fieldset has accessible name from i18n key (not hardcoded German)', async () => {
render(AdminInvitesPage, {
props: {
data: {
...baseData(),
groups: [{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }],
groupsLoadError: null
}
}
});
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
// m.admin_new_invite_groups() returns "Gruppen (optional)" in de locale
// The hardcoded legend "Gruppen" would not match this accessible name
await expect.element(page.getByRole('group', { name: 'Gruppen (optional)' })).toBeVisible();
});
it('shows no checkboxes and no warning when groups list is empty and no error', async () => {
render(AdminInvitesPage, {
props: { data: { ...baseData(), groups: [], groupsLoadError: null } }
});
await page
.getByRole('button', { name: /neue einladung/i })
.first()
.click();
expect(document.querySelectorAll('input[name="groupIds"]')).toHaveLength(0);
expect(document.querySelector('.bg-amber-50')).toBeNull();
// empty-state message visible — "Keine Gruppen vorhanden." in de locale
await expect.element(page.getByText(/keine gruppen/i)).toBeVisible();
});
});

View File

@@ -1,19 +1,14 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import ImportStatusCard from './ImportStatusCard.svelte';
import type { ImportStatus } from './types.js';
let backfillResult: number | null = $state(null);
let backfillLoading = $state(false);
let backfillHashesResult: number | null = $state(null);
let backfillHashesLoading = $state(false);
type ImportStatus = {
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
message: string;
processed: number;
startedAt: string | null;
};
type ThumbnailStatus = {
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
message: string;
@@ -177,47 +172,7 @@ async function backfillFileHashes() {
</div>
<!-- Mass import -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.admin_system_import_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_description()}</p>
{#if importStatus?.state === 'RUNNING'}
<p class="text-sm text-ink-2">{m.admin_system_import_status_running()}</p>
{:else if importStatus?.state === 'DONE'}
<p class="mb-4 rounded-sm border border-green-200 bg-green-50 p-3 text-sm text-green-700">
{m.admin_system_import_status_done({ count: importStatus.processed })}
</p>
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else if importStatus?.state === 'FAILED'}
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{m.admin_system_import_status_failed({ message: importStatus.message })}
</p>
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else}
{#if importStatus !== null}
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_status_idle()}</p>
{/if}
<button
data-import-trigger
onclick={triggerImport}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_start()}
</button>
{/if}
</div>
<ImportStatusCard importStatus={importStatus} ontrigger={triggerImport} />
<!-- Thumbnail backfill -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { ImportStatus } from './types.js';
let {
importStatus,
ontrigger
}: {
importStatus: ImportStatus | null;
ontrigger: () => void;
} = $props();
const failureMessage = $derived(
importStatus?.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET'
? m.admin_system_import_failed_no_spreadsheet()
: m.admin_system_import_failed_internal()
);
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_system_import_heading()}
</h2>
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_description()}</p>
{#if importStatus?.state === 'RUNNING'}
<div class="mb-4 flex items-center gap-3">
<span
data-testid="spinner"
role="status"
aria-label={m.admin_system_import_status_running()}
class="inline-block h-5 w-5 animate-spin rounded-full border-2 border-ink-3 border-t-brand-mint motion-reduce:animate-none"
></span>
<div>
<p data-testid="processed-count" class="text-base font-bold text-ink">
{importStatus.processed}
</p>
<p class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_system_import_status_running()}
</p>
</div>
</div>
{:else if importStatus?.state === 'DONE'}
<div class="mb-4 rounded-sm border border-green-200 bg-green-50 p-4 text-green-700">
<p data-testid="processed-count" class="text-base font-bold">{importStatus.processed}</p>
<p class="font-sans text-xs font-bold tracking-widest text-green-800 uppercase">
{m.admin_system_import_status_done_label()}
</p>
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
</div>
<button
data-import-trigger
onclick={ontrigger}
class="min-h-[44px] rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else if importStatus?.state === 'FAILED'}
<p class="mb-4 rounded-sm border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{failureMessage}
</p>
<button
data-import-trigger
onclick={ontrigger}
class="min-h-[44px] rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_retry()}
</button>
{:else}
{#if importStatus !== null}
<p class="mb-4 text-sm text-ink-2">{m.admin_system_import_status_idle()}</p>
{/if}
<button
data-import-trigger
onclick={ontrigger}
class="min-h-[44px] rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
>
{m.admin_system_import_btn_start()}
</button>
{/if}
</div>

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { m } from '$lib/paraglide/messages.js';
import ImportStatusCard from './ImportStatusCard.svelte';
import type { ImportStatus } from './types.js';
const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
state: 'IDLE',
statusCode: 'IMPORT_IDLE',
processed: 0,
startedAt: null,
...overrides
});
describe('ImportStatusCard', () => {
it('shows spinner while state is RUNNING', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'RUNNING', statusCode: 'IMPORT_RUNNING', processed: 3 }),
ontrigger: () => {}
}
});
await expect.element(getByTestId('spinner')).toBeInTheDocument();
});
it('shows processed count at text-base while RUNNING', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'RUNNING', statusCode: 'IMPORT_RUNNING', processed: 7 }),
ontrigger: () => {}
}
});
await expect.element(getByTestId('processed-count')).toHaveTextContent('7');
});
it('shows processed count while DONE', async () => {
const { getByText } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 42 }),
ontrigger: () => {}
}
});
await expect.element(getByText('42')).toBeVisible();
});
it('shows no-spreadsheet message when statusCode is IMPORT_FAILED_NO_SPREADSHEET', async () => {
const { getByText } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({
state: 'FAILED',
statusCode: 'IMPORT_FAILED_NO_SPREADSHEET'
}),
ontrigger: () => {}
}
});
await expect.element(getByText(m.admin_system_import_failed_no_spreadsheet())).toBeVisible();
});
it('shows internal error message when statusCode is IMPORT_FAILED_INTERNAL', async () => {
const { getByText } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'FAILED', statusCode: 'IMPORT_FAILED_INTERNAL' }),
ontrigger: () => {}
}
});
await expect.element(getByText(m.admin_system_import_failed_internal())).toBeVisible();
});
it('shows idle text when importStatus is non-null and state is IDLE', async () => {
const { getByText } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'IDLE', statusCode: 'IMPORT_IDLE' }),
ontrigger: () => {}
}
});
await expect.element(getByText(m.admin_system_import_status_idle())).toBeVisible();
});
it('shows no spinner when importStatus is null', async () => {
const { getByTestId } = render(ImportStatusCard, {
props: { importStatus: null, ontrigger: () => {} }
});
await expect.element(getByTestId('spinner')).not.toBeInTheDocument();
});
it('calls ontrigger when retry button is clicked in DONE state', async () => {
const ontrigger = vi.fn();
const { getByRole } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 5 }),
ontrigger
}
});
await getByRole('button').click();
expect(ontrigger).toHaveBeenCalledOnce();
});
it('calls ontrigger when retry button is clicked in FAILED state', async () => {
const ontrigger = vi.fn();
const { getByRole } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'FAILED', statusCode: 'IMPORT_FAILED_INTERNAL' }),
ontrigger
}
});
await getByRole('button').click();
expect(ontrigger).toHaveBeenCalledOnce();
});
it('calls ontrigger when start button is clicked in IDLE state', async () => {
const ontrigger = vi.fn();
const { getByRole } = render(ImportStatusCard, {
props: {
importStatus: makeStatus({ state: 'IDLE', statusCode: 'IMPORT_IDLE' }),
ontrigger
}
});
await getByRole('button').click();
expect(ontrigger).toHaveBeenCalledOnce();
});
});

View File

@@ -163,7 +163,7 @@ describe('Admin system page — mass import card', () => {
ok: true,
json: async () => ({
state: 'FAILED',
message: 'Datei nicht gefunden.',
statusCode: 'IMPORT_FAILED_NO_SPREADSHEET',
processed: 0,
startedAt: '2026-01-01T10:00:00'
})
@@ -182,7 +182,7 @@ describe('Admin system page — mass import card', () => {
})
);
render(Page, {});
await expect.element(page.getByText(/Datei nicht gefunden/i)).toBeInTheDocument();
await expect.element(page.getByText(/Keine Tabellendatei gefunden/i)).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /Erneut starten/i })).toBeInTheDocument();
});
});

View File

@@ -246,7 +246,7 @@ describe('admin/system page', () => {
return new Response(
JSON.stringify({
state: 'FAILED',
message: 'database error',
statusCode: 'IMPORT_FAILED_INTERNAL',
processed: 0,
startedAt: null
}),
@@ -262,7 +262,7 @@ describe('admin/system page', () => {
render(AdminSystemPage, { props: {} });
await vi.waitFor(() => {
expect(document.body.textContent).toContain('database error');
expect(document.body.textContent).toContain('Interner Fehler beim Import');
});
});

View File

@@ -0,0 +1,6 @@
export type ImportStatus = {
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
statusCode: string;
processed: number;
startedAt: string | null;
};

View File

@@ -1,24 +1,15 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import UserProfileSection from '$lib/user/UserProfileSection.svelte';
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
import AccountSection from './AccountSection.svelte';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
let { data, form } = $props();
let isDirty = $state(false);
let showUnsavedWarning = $state(false);
let discardTarget: string | null = $state(null);
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
const unsaved = createUnsavedWarning();
</script>
<div class="flex flex-1 flex-col overflow-hidden">
@@ -44,23 +35,8 @@ beforeNavigate(({ cancel, to }) => {
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning}
<div
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
>
<span>{m.admin_unsaved_warning()}</span>
<button
type="button"
onclick={() => {
isDirty = false;
showUnsavedWarning = false;
if (discardTarget) goto(discardTarget);
}}
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
>
{m.person_discard_changes()}
</button>
</div>
{#if unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
{#if form?.error}
<div class="mb-5 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
@@ -71,11 +47,11 @@ beforeNavigate(({ cancel, to }) => {
<form
id="new-user-form"
method="POST"
use:enhance
oninput={() => {
isDirty = true;
showUnsavedWarning = false;
use:enhance={() => async ({ result, update }) => {
if (result.type === 'redirect') unsaved.clearOnSuccess();
await update();
}}
oninput={unsaved.markDirty}
class="space-y-5"
>
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">

View File

@@ -1,9 +1,19 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
const enhanceCaptureRef = vi.hoisted(() => ({ submitFn: undefined as unknown }));
vi.mock('$app/forms', () => ({
enhance: (_el: HTMLFormElement, fn?: unknown) => {
enhanceCaptureRef.submitFn = fn;
return { destroy: vi.fn() };
}
}));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation';
const groups = [
{ id: 'g1', name: 'Editoren', permissions: ['WRITE_ALL'] },
@@ -20,6 +30,13 @@ const baseData = {
afterEach(cleanup);
type SubmitFn = () => Promise<
(opts: {
result: { type: string; [key: string]: unknown };
update: () => Promise<void>;
}) => Promise<void>
>;
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin new user page rendering', () => {
@@ -66,3 +83,103 @@ describe('Admin new user page error display', () => {
await expect.element(page.getByText('Ein Fehler ist aufgetreten.')).not.toBeInTheDocument();
});
});
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
describe('Admin new user page unsaved-changes guard', () => {
beforeEach(() => {
vi.clearAllMocks();
enhanceCaptureRef.submitFn = undefined;
});
it('does not show unsaved warning initially', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
});
it('cancels navigation and shows banner when form is dirty', async () => {
render(Page, { data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="email"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
expect(cancel).toHaveBeenCalled();
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
});
it('does not cancel navigation when form is clean', async () => {
render(Page, { data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
const cancel = vi.fn();
callback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
expect(cancel).not.toHaveBeenCalled();
});
it('discard button calls goto with the target URL', async () => {
render(Page, { data: baseData, form: null });
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="email"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
callback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
await page.getByRole('button', { name: /verwerfen/i }).click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/users');
});
it('clears banner when enhance callback receives a redirect result', async () => {
render(Page, { data: baseData, form: null });
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="email"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
await innerFn({
result: { type: 'redirect', location: '/admin/users', status: 303 },
update: vi.fn().mockResolvedValue(undefined)
});
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
const cancel = vi.fn();
navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
expect(cancel).not.toHaveBeenCalled();
});
it('keeps banner when enhance callback receives a failure result', async () => {
render(Page, { data: baseData, form: null });
const [navCallback] = vi.mocked(beforeNavigate).mock.calls[0];
document
.querySelector<HTMLInputElement>('input[name="email"]')!
.dispatchEvent(new InputEvent('input', { bubbles: true }));
navCallback({ cancel: vi.fn(), to: { url: new URL('http://localhost/admin/users') } });
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).toBeInTheDocument();
const innerFn = await (enhanceCaptureRef.submitFn as SubmitFn)();
await innerFn({
result: { type: 'failure', status: 400, data: { error: 'E-Mail bereits vergeben' } },
update: vi.fn().mockResolvedValue(undefined)
});
const cancel = vi.fn();
navCallback({ cancel, to: { url: new URL('http://localhost/admin/users') } });
expect(cancel).toHaveBeenCalled();
});
});

View File

@@ -24,6 +24,8 @@ export default defineConfig({
})
],
test: {
testTimeout: 30_000,
hookTimeout: 15_000,
expect: { requireAssertions: true },
browser: {
enabled: true,

View File

@@ -0,0 +1,10 @@
apiVersion: 1
providers:
- name: default
type: file
disableDeletion: true
updateIntervalSeconds: 30
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false

View File

@@ -0,0 +1,284 @@
{
"__inputs": [
{
"name": "DS_LOKI",
"label": "Loki",
"description": "",
"type": "datasource",
"pluginId": "loki",
"pluginName": "Loki"
}
],
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "7.1.0"
},
{
"type": "panel",
"id": "graph",
"name": "Graph",
"version": ""
},
{
"type": "panel",
"id": "logs",
"name": "Logs",
"version": ""
},
{
"type": "datasource",
"id": "loki",
"name": "Loki",
"version": "1.0.0"
}
],
"annotations": {
"list": [
{
"$$hashKey": "object:75",
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Log Viewer Dashboard for Loki",
"editable": false,
"gnetId": 13639,
"graphTooltip": 0,
"id": null,
"iteration": 1608932746420,
"links": [
{
"$$hashKey": "object:59",
"icon": "bolt",
"includeVars": true,
"keepTime": true,
"tags": [],
"targetBlank": true,
"title": "View In Explore",
"type": "link",
"url": "/explore?orgId=1&left=[\"now-1h\",\"now\",\"Loki\",{\"expr\":\"{job=\\\"$app\\\"}\"},{\"ui\":[true,true,true,\"none\"]}]"
},
{
"$$hashKey": "object:61",
"icon": "external link",
"tags": [],
"targetBlank": true,
"title": "Learn LogQL",
"type": "link",
"url": "https://grafana.com/docs/loki/latest/logql/"
}
],
"panels": [
{
"aliasColors": {},
"bars": true,
"dashLength": 10,
"dashes": false,
"datasource": {"type": "loki", "uid": "loki"},
"fieldConfig": {
"defaults": {
"custom": {},
"links": []
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 3,
"w": 24,
"x": 0,
"y": 0
},
"hiddenSeries": false,
"id": 6,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": false,
"total": false,
"values": false
},
"lines": false,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.0",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(count_over_time({job=\"$app\"} |= \"$search\" [$__interval]))",
"legendFormat": "",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:168",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": false
},
{
"$$hashKey": "object:169",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": false
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"datasource": {"type": "loki", "uid": "loki"},
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"gridPos": {
"h": 25,
"w": 24,
"x": 0,
"y": 3
},
"id": 2,
"maxDataPoints": "",
"options": {
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"expr": "{job=\"$app\"} |= \"$search\" | logfmt",
"hide": false,
"legendFormat": "",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "",
"transparent": true,
"type": "logs"
}
],
"refresh": false,
"schemaVersion": 26,
"style": "dark",
"tags": [],
"templating": {
"list": [
{
"allValue": null,
"current": {},
"datasource": {"type": "loki", "uid": "loki"},
"definition": "label_values(job)",
"hide": 0,
"includeAll": false,
"label": "App",
"multi": false,
"name": "app",
"options": [],
"query": "label_values(job)",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"current": {
"selected": false,
"text": "",
"value": ""
},
"hide": 0,
"label": "String Match",
"name": "search",
"options": [
{
"selected": true,
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {
"hidden": false,
"refresh_intervals": [
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Logs / App",
"uid": "sadlil-loki-apps-dashboard",
"version": 13
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: prometheus
url: http://obs-prometheus:9090
isDefault: true
editable: false
- name: Loki
type: loki
uid: loki
url: http://obs-loki:3100
editable: false
jsonData:
derivedFields:
- name: TraceID
matcherRegex: '"traceId":"(\w+)"'
url: "${__value.raw}"
datasourceUid: tempo
- name: Tempo
type: tempo
uid: tempo
url: http://obs-tempo:3200
editable: false
jsonData:
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: "-1m"
spanEndTimeShift: "1m"
filterByTraceID: true
filterBySpanID: false
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true

View File

@@ -0,0 +1,40 @@
auth_enabled: false # safe — loki is not exposed beyond obs-net. Add auth before binding port 3100 to host.
server:
http_listen_port: 3100
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory # correct for single-node — no etcd/consul needed here
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 720h # 30 days — low-volume family archive; revisit if log volume grows
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 150
delete_request_store: filesystem
analytics:
reporting_enabled: false # no telemetry sent to Grafana Labs

View File

View File

@@ -0,0 +1,28 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: node
static_configs:
- targets: ['node-exporter:9100']
- job_name: cadvisor
static_configs:
- targets: ['cadvisor:8080']
- job_name: spring-boot
metrics_path: /actuator/prometheus
static_configs:
# Uses the Docker service name (not container_name) for reliable DNS resolution.
# Target will show as DOWN until backend instrumentation issue adds
# micrometer-registry-prometheus and exposes the endpoint — this is expected.
- targets: ['backend:8081']
- job_name: ocr-service
metrics_path: /metrics
static_configs:
# TODO: remove or add prometheus-client to ocr-service.
# The Python OCR service does not currently expose Prometheus metrics.
# This target will show as DOWN until prometheus-client is added to ocr-service.
- targets: ['ocr:8000']

View File

@@ -0,0 +1,30 @@
server:
http_listen_port: 9080
grpc_listen_port: 0 # gRPC disabled — used for Promtail clustering only; single-node deployment
positions:
filename: /tmp/positions.yaml # /tmp is a named volume (promtail_positions) — persists across restarts
clients:
- url: http://loki:3100/loki/api/v1/push
# Loki HTTP API is unauthenticated internally. Any container on obs-net can push logs.
# Acceptable: only trusted application containers join this network.
scrape_configs:
- job_name: docker-containers
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container_name'
# Note: container_name differs between dev (archive-backend) and prod
# (archiv-production-backend-1). Prefer compose_service for stable LogQL
# queries across environments — it is stable: backend, db, minio, etc.
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: 'compose_service'
- source_labels: ['__meta_docker_container_label_com_docker_compose_project']
target_label: 'compose_project'
- source_labels: ['__meta_docker_container_log_stream']
target_label: 'logstream'

View File

View File

@@ -0,0 +1,51 @@
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
compactor:
compaction:
# 30 days — matches Loki retention. Compactor enforces this automatically;
# no manual intervention needed under normal trace volumes.
block_retention: 720h
storage:
trace:
# Local filesystem storage — single-VPS deployment, no S3 backend needed.
# Both paths are on the same named Docker volume (tempo_data) so they
# survive container restarts without split-brain between WAL and blocks.
backend: local
local:
path: /var/tempo/blocks
wal:
path: /var/tempo/wal
metrics_generator:
registry:
external_labels:
source: tempo
storage:
path: /var/tempo/generator/wal
processors:
- service-graphs
- span-metrics
# Tempo HTTP API (port 3200) is unauthenticated. Access is controlled entirely
# by network isolation: only Grafana (on obs-net) should reach this port.
# The OTLP receivers (4317 gRPC, 4318 HTTP) are internal to archiv-net only.
overrides:
defaults:
metrics_generator:
processors:
- service-graphs
- span-metrics