Compare commits

..

11 Commits

Author SHA1 Message Date
Marcel
b5239f515f fix(notification): address review suggestions
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m23s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m17s
CI / fail2ban Regex (pull_request) Successful in 40s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
- ChronikFuerDichBox: move update() inside the failure branch so success
  path skips it, matching NotificationDropdown's pattern
- NotificationDropdown test: add role=alert assertion for mark-all-read
  failure to match existing dismiss-failure coverage in ChronikFuerDichBox
- +page.server.ts: use getErrorMessage(undefined) instead of null so the
  missing-notificationId 400 goes through the same i18n pipeline as other errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 07:31:26 +02:00
Marcel
f2bb58e294 fix(chronik): surface action failures in ChronikFuerDichBox with accessible error banner
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m35s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m17s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Add $state errorMessage + role=alert banner to ChronikFuerDichBox. Both enhance callbacks
now inspect result.type and set the error message on 'failure' or 'error'; errorMessage
is cleared on each new submit attempt.

Upgrade both test files to the mockFormResult pattern (via vi.hoisted) so the result
callback is exercised. Add a failing-action test in each file that asserts role=alert
appears after a form submit with type='failure'.

Fix bare Function cast → explicit typed cast to satisfy @typescript-eslint/no-unsafe-function-type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 07:06:58 +02:00
Marcel
2adb98895d fix(aktivitaeten): narrow File cast and use null payload for missing notificationId
Replace 'as string | null' cast (which silently accepts File values) with an explicit
typeof check. Use error: null instead of hardcoded German so the client falls through
to the generic i18n-keyed error banner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 07:06:15 +02:00
Marcel
6049dcadd3 fix(notification-dropdown): handle error result type, add role=alert, fix update ordering
- Add role="alert" to error banner so screen-reader users hear failures
- Handle result.type === 'error' (network failure) alongside 'failure' in both enhance callbacks
- Clear errorMessage at the start of each submit so stale errors don't persist on retry
- On dismiss success: skip update() entirely since goto() navigates away from the page
- On dismiss failure: await update() then set error message
- On mark-all success: skip update() (optimistic state already applied)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 07:05:50 +02:00
Marcel
7fe8842b57 fix(notifications): surface action failures as an error banner
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m23s
CI / fail2ban Regex (pull_request) Successful in 39s
CI / Semgrep Security Scan (pull_request) Successful in 18s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
When dismiss-notification or mark-all-read returns a failure the dropdown
now shows a localised error message above the list. Added
notification_error_generic key (de/en/es) as the fallback when the
action response carries no explicit error string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:56:33 +02:00
Marcel
f9340366d1 fix(notifications): move onClose/goto into enhance result callback
onClose() and goto() were firing before the server responded, making it
impossible for a fail() response to cancel navigation. Moved them inside
the result callback behind a result.type !== 'failure' guard.

Updated the $app/forms enhance mock to always invoke the returned async
callback with a configurable mockFormResult, and added three tests:
- success path calls onClose + goto with the correct deep-link URL
- failure path skips onClose and goto
- annotationId is appended to the URL when present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:54:15 +02:00
Marcel
af84ffc379 fix(notifications): guard against null notificationId in dismiss action
Casting null to string caused PATCH to fire against /api/notifications/null/read
when the field was absent. Added an early-return fail(400) and a test that
submitting an empty form returns 400 without calling the API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:48:37 +02:00
Marcel
23439e581a refactor(chronik): replace callback props with form actions in ChronikFuerDichBox
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m22s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
Dismiss (X) button and mark-all-read button now submit forms to
/aktivitaeten?/dismiss-notification and /aktivitaeten?/mark-all-read respectively.
Props renamed onMarkRead/onMarkAllRead → optimisticMarkRead/optimisticMarkAllRead.

aktivitaeten/+page.svelte drops the now-deleted onMarkRead/onMarkAllRead wrapper functions
and passes notificationStore.optimisticMarkRead/optimisticMarkAllRead directly to the box.

Tests: $app/forms enhance mock added to both spec files so dismiss and mark-all assertions
work synchronously against form-submit events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:16:58 +02:00
Marcel
2c6b59d0c7 refactor(notification): replace callback props with form actions in Dropdown and Bell
NotificationDropdown now wraps each row in a <form action="/aktivitaeten?/dismiss-notification">
and the mark-all control in <form action="/aktivitaeten?/mark-all-read">, wired via use:enhance
for optimistic UI. Props renamed onMarkRead/onMarkAllRead → optimisticMarkRead/optimisticMarkAllRead
to match the simplified store API. NotificationBell passes the store helpers directly; handleMarkRead
is removed.

Test mocks updated: $app/forms enhance mock fires SubmitFunction synchronously on form submit so
callback assertions work without a real HTTP round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:15:56 +02:00
Marcel
c0a7408ef4 refactor(notification): rename markRead/markAllRead to optimistic helpers without fetch
Removes raw fetch() calls from the store. optimisticMarkRead(id) and
optimisticMarkAllRead() now only mutate local $state — the actual API
calls move to SvelteKit form actions on /aktivitaeten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:59:01 +02:00
Marcel
9d283c4500 feat(notification): add dismiss-notification and mark-all-read form actions to aktivitaeten
Adds two SvelteKit form actions to /aktivitaeten/+page.server.ts so the
notification bell can POST there instead of calling the backend directly
from the browser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:51:08 +02:00
29 changed files with 713 additions and 1106 deletions

View File

@@ -39,12 +39,6 @@ PORT_PROMETHEUS=9090
# Grafana admin password — change this before exposing Grafana beyond localhost # Grafana admin password — change this before exposing Grafana beyond localhost
GRAFANA_ADMIN_PASSWORD=changeme GRAFANA_ADMIN_PASSWORD=changeme
# Password for the read-only grafana_reader PostgreSQL role used by the PO
# Overview dashboard. Consumed by Flyway V68 (to set the role's password) and
# by Grafana's PostgreSQL datasource (to connect). REQUIRED in production —
# generate with: openssl rand -hex 32
GRAFANA_DB_PASSWORD=changeme-generate-with-openssl-rand-hex-32
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost) # GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
GLITCHTIP_DOMAIN=http://localhost:3002 GLITCHTIP_DOMAIN=http://localhost:3002

View File

@@ -31,7 +31,6 @@ name: nightly
# STAGING_APP_ADMIN_USERNAME # STAGING_APP_ADMIN_USERNAME
# STAGING_APP_ADMIN_PASSWORD # STAGING_APP_ADMIN_PASSWORD
# GRAFANA_ADMIN_PASSWORD # GRAFANA_ADMIN_PASSWORD
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
# GLITCHTIP_SECRET_KEY # GLITCHTIP_SECRET_KEY
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled) # SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
@@ -80,7 +79,6 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
POSTGRES_USER=archiv POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
EOF EOF
- name: Verify backend /import:ro mount is wired - name: Verify backend /import:ro mount is wired
@@ -144,7 +142,6 @@ jobs:
cp docker-compose.observability.yml /opt/familienarchiv/ cp docker-compose.observability.yml /opt/familienarchiv/
cat > /opt/familienarchiv/obs-secrets.env <<'EOF' cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }} GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-staging-db-1 POSTGRES_HOST=archiv-staging-db-1

View File

@@ -35,7 +35,6 @@ name: release
# MAIL_USERNAME # MAIL_USERNAME
# MAIL_PASSWORD # MAIL_PASSWORD
# GRAFANA_ADMIN_PASSWORD # GRAFANA_ADMIN_PASSWORD
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
# GLITCHTIP_SECRET_KEY # GLITCHTIP_SECRET_KEY
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled) # SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
@@ -78,7 +77,6 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-production/import IMPORT_HOST_DIR=/srv/familienarchiv-production/import
POSTGRES_USER=archiv POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
EOF EOF
- name: Build images - name: Build images
@@ -112,7 +110,6 @@ jobs:
cp docker-compose.observability.yml /opt/familienarchiv/ cp docker-compose.observability.yml /opt/familienarchiv/
cat > /opt/familienarchiv/obs-secrets.env <<'EOF' cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }} GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-production-db-1 POSTGRES_HOST=archiv-production-db-1

View File

@@ -7,15 +7,12 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource; import javax.sql.DataSource;
import java.util.Map;
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class FlywayConfig { public class FlywayConfig {
private static final String GRAFANA_DB_PASSWORD_FALLBACK = "changeme-grafana-db-password";
private final DataSource dataSource; private final DataSource dataSource;
@Bean(name = "flyway") @Bean(name = "flyway")
@@ -24,7 +21,6 @@ public class FlywayConfig {
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(dataSource) .dataSource(dataSource)
.locations("classpath:db/migration") .locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
.baselineOnMigrate(true) .baselineOnMigrate(true)
.baselineVersion("4") .baselineVersion("4")
.load(); .load();
@@ -32,14 +28,4 @@ public class FlywayConfig {
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted); log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
return flyway; return flyway;
} }
private String resolveGrafanaDbPassword() {
String value = System.getenv("GRAFANA_DB_PASSWORD");
if (value == null || value.isBlank()) {
log.warn("GRAFANA_DB_PASSWORD is not set; the grafana_reader role will use a non-secret fallback. "
+ "Set GRAFANA_DB_PASSWORD in production to enable the Grafana PostgreSQL datasource.");
return GRAFANA_DB_PASSWORD_FALLBACK;
}
return value;
}
} }

View File

@@ -1,17 +0,0 @@
-- Read-only role used by the Grafana PostgreSQL datasource for the PO Overview
-- dashboard (issue #651). Password is injected at migration time via the Flyway
-- placeholder ${grafanaDbPassword}, supplied by FlywayConfig from the
-- GRAFANA_DB_PASSWORD environment variable.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN
EXECUTE format('CREATE ROLE grafana_reader WITH LOGIN PASSWORD %L', '${grafanaDbPassword}');
ELSE
EXECUTE format('ALTER ROLE grafana_reader WITH LOGIN PASSWORD %L', '${grafanaDbPassword}');
END IF;
END
$$;
GRANT CONNECT ON DATABASE ${flyway:database} TO grafana_reader;
GRANT USAGE ON SCHEMA public TO grafana_reader;
GRANT SELECT ON audit_log, documents, transcription_blocks TO grafana_reader;

View File

@@ -1,47 +0,0 @@
package org.raddatz.familienarchiv.config;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class GrafanaReaderRoleIntegrationTest {
@Autowired JdbcTemplate jdbc;
@Test
void grafana_reader_has_select_on_audit_log() {
assertThat(hasSelect("audit_log")).isTrue();
}
@Test
void grafana_reader_has_select_on_documents() {
assertThat(hasSelect("documents")).isTrue();
}
@Test
void grafana_reader_has_select_on_transcription_blocks() {
assertThat(hasSelect("transcription_blocks")).isTrue();
}
@Test
void grafana_reader_has_no_select_on_app_users() {
assertThat(hasSelect("app_users")).isFalse();
}
private boolean hasSelect(String table) {
Boolean result = jdbc.queryForObject(
"SELECT has_table_privilege('grafana_reader', ?, 'SELECT')",
Boolean.class,
table);
return Boolean.TRUE.equals(result);
}
}

View File

@@ -147,9 +147,6 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
GF_USERS_ALLOW_SIGN_UP: "false" GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003} GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003}
# Read-only password for the grafana_reader PostgreSQL role; interpolated
# into the provisioned PostgreSQL datasource (see datasources.yml).
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
volumes: volumes:
- grafana_data:/var/lib/grafana - grafana_data:/var/lib/grafana
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro - ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
@@ -168,7 +165,6 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- obs-net - obs-net
- archiv-net # PO Overview dashboard queries archive-db via the grafana_reader role
# --- Error Tracking: GlitchTip --- # --- Error Tracking: GlitchTip ---

View File

@@ -227,9 +227,6 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
SPRING_DATASOURCE_USERNAME: archiv SPRING_DATASOURCE_USERNAME: archiv
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
# the read-only grafana_reader role's password.
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
# Application uses the bucket-scoped service account, not MinIO root. # Application uses the bucket-scoped service account, not MinIO root.
S3_ENDPOINT: http://minio:9000 S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: archiv-app S3_ACCESS_KEY: archiv-app

View File

@@ -163,9 +163,6 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB} SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
# the read-only grafana_reader role's password.
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
S3_ENDPOINT: http://minio:9000 S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER} S3_ACCESS_KEY: ${MINIO_ROOT_USER}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD} S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}

View File

@@ -152,7 +152,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — | | `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — |
| `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — | | `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — |
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES | | `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES |
| `GRAFANA_DB_PASSWORD` | Password for the read-only `grafana_reader` PostgreSQL role used by the PO Overview dashboard (issue #651). Consumed by Flyway V68 and the Grafana PostgreSQL datasource. Generate with `openssl rand -hex 32`. | — | YES (prod) | YES |
| `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — | | `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — |
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — | | `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — |
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES | | `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES |
@@ -257,7 +256,6 @@ git.raddatz.cloud A <server IP>
| `MAIL_USERNAME` | release.yml | SMTP user | | `MAIL_USERNAME` | release.yml | SMTP user |
| `MAIL_PASSWORD` | release.yml | SMTP password | | `MAIL_PASSWORD` | release.yml | SMTP password |
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password | | `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
| `GRAFANA_DB_PASSWORD` | both | Read-only `grafana_reader` role password — `openssl rand -hex 32` |
| `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` | | `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` |
| `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled | | `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
| `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled | | `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
@@ -359,7 +357,6 @@ Both files are passed explicitly via `--env-file` to the compose command, so the
| Gitea secret | Notes | | Gitea secret | Notes |
|---|---| |---|---|
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release | | `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
| `GRAFANA_DB_PASSWORD` | `openssl rand -hex 32`; shared by nightly and release — read-only DB role for the PO Overview dashboard |
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release | | `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container | | `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |

View File

@@ -46,7 +46,6 @@ Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 431
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090") Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100") Rel(grafana, loki, "Queries logs", "HTTP 3100")
Rel(grafana, tempo, "Queries traces", "HTTP 3200") Rel(grafana, tempo, "Queries traces", "HTTP 3200")
Rel(grafana, db, "Read-only dashboard queries via grafana_reader role", "PostgreSQL / archiv-net")
Rel(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net") Rel(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net")
Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net") Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net")

View File

@@ -522,6 +522,7 @@
"notification_filter_unread": "Ungelesen", "notification_filter_unread": "Ungelesen",
"notification_filter_mention": "Erwähnung", "notification_filter_mention": "Erwähnung",
"notification_filter_reply": "Antwort", "notification_filter_reply": "Antwort",
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
"notification_load_more": "Ältere laden", "notification_load_more": "Ältere laden",
"notification_empty_history": "Keine Benachrichtigungen", "notification_empty_history": "Keine Benachrichtigungen",

View File

@@ -522,6 +522,7 @@
"notification_filter_unread": "Unread", "notification_filter_unread": "Unread",
"notification_filter_mention": "Mention", "notification_filter_mention": "Mention",
"notification_filter_reply": "Reply", "notification_filter_reply": "Reply",
"notification_error_generic": "Action failed. Please try again.",
"notification_mark_all_read_aria": "Mark all notifications as read", "notification_mark_all_read_aria": "Mark all notifications as read",
"notification_load_more": "Load older", "notification_load_more": "Load older",
"notification_empty_history": "No notifications", "notification_empty_history": "No notifications",

View File

@@ -522,6 +522,7 @@
"notification_filter_unread": "No leídas", "notification_filter_unread": "No leídas",
"notification_filter_mention": "Mención", "notification_filter_mention": "Mención",
"notification_filter_reply": "Respuesta", "notification_filter_reply": "Respuesta",
"notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.",
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas", "notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
"notification_load_more": "Cargar anteriores", "notification_load_more": "Cargar anteriores",
"notification_empty_history": "Sin notificaciones", "notification_empty_history": "Sin notificaciones",

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
@@ -6,11 +7,13 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
interface Props { interface Props {
unread: NotificationItem[]; unread: NotificationItem[];
onMarkRead: (n: NotificationItem) => void; optimisticMarkRead: (id: string) => void;
onMarkAllRead: () => void; optimisticMarkAllRead: () => void;
} }
const { unread, onMarkRead, onMarkAllRead }: Props = $props(); const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props();
let errorMessage: string | null = $state(null);
function verb(type: NotificationItem['type'], actor: string): string { function verb(type: NotificationItem['type'], actor: string): string {
return type === 'REPLY' return type === 'REPLY'
@@ -24,6 +27,9 @@ function href(n: NotificationItem): string {
</script> </script>
<section class="rounded-sm border border-line bg-surface p-5"> <section class="rounded-sm border border-line bg-surface p-5">
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
{#if unread.length === 0} {#if unread.length === 0}
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center"> <div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
<svg <svg
@@ -66,14 +72,28 @@ function href(n: NotificationItem): string {
{m.chronik_for_you_count({ count: unread.length })} {m.chronik_for_you_count({ count: unread.length })}
</span> </span>
</div> </div>
<button <form
type="button" action="/aktivitaeten?/mark-all-read"
data-testid="chronik-mark-all-read" method="POST"
onclick={onMarkAllRead} use:enhance={() => {
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink" errorMessage = null;
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
{m.chronik_mark_all_read()} <button
</button> type="submit"
data-testid="chronik-mark-all-read"
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.chronik_mark_all_read()}
</button>
</form>
</div> </div>
<ul role="list" class="flex flex-col gap-2"> <ul role="list" class="flex flex-col gap-2">
@@ -89,7 +109,7 @@ function href(n: NotificationItem): string {
aria-hidden="true" aria-hidden="true"
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent" class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
> >
{n.type === 'MENTION' ? '@' : '\u21A9'} {n.type === 'MENTION' ? '@' : ''}
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="font-sans text-sm leading-snug text-ink"> <p class="font-sans text-sm leading-snug text-ink">
@@ -100,25 +120,40 @@ function href(n: NotificationItem): string {
</p> </p>
</div> </div>
</a> </a>
<button <form
type="button" action="/aktivitaeten?/dismiss-notification"
data-testid="chronik-fuerdich-dismiss" method="POST"
aria-label={m.chronik_mark_read_aria()} use:enhance={() => {
onclick={() => onMarkRead(n)} errorMessage = null;
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none" optimisticMarkRead(n.id);
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<svg <input type="hidden" name="notificationId" value={n.id} />
xmlns="http://www.w3.org/2000/svg" <button
class="h-4 w-4" type="submit"
fill="none" data-testid="chronik-fuerdich-dismiss"
viewBox="0 0 24 24" aria-label={m.chronik_mark_read_aria()}
stroke="currentColor" class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
stroke-width="2"
aria-hidden="true"
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <svg
</svg> xmlns="http://www.w3.org/2000/svg"
</button> class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -5,7 +5,36 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
afterEach(cleanup); const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
function notif(partial: Partial<NotificationItem>): NotificationItem { function notif(partial: Partial<NotificationItem>): NotificationItem {
return { return {
@@ -26,8 +55,8 @@ describe('ChronikFuerDichBox', () => {
it('renders inbox-zero state when there are no unread items', async () => { it('renders inbox-zero state when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]'); const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
expect(zero).not.toBeNull(); expect(zero).not.toBeNull();
@@ -37,8 +66,8 @@ describe('ChronikFuerDichBox', () => {
it('links to the archived mentions in the inbox-zero state', async () => { it('links to the archived mentions in the inbox-zero state', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]'); const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
expect(link).not.toBeNull(); expect(link).not.toBeNull();
@@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
it('renders the count badge with correct total when unread exists', async () => { it('renders the count badge with correct total when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' }), notif({ id: 'b' })], unread: [notif({ id: 'a' }), notif({ id: 'b' })],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('2 neu')).toBeInTheDocument(); await expect.element(page.getByText('2 neu')).toBeInTheDocument();
}); });
@@ -56,8 +85,8 @@ describe('ChronikFuerDichBox', () => {
it('count badge has aria-live=polite when unread exists', async () => { it('count badge has aria-live=polite when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
// Wait for render // Wait for render
await expect.element(page.getByText('1 neu')).toBeInTheDocument(); await expect.element(page.getByText('1 neu')).toBeInTheDocument();
@@ -69,8 +98,8 @@ describe('ChronikFuerDichBox', () => {
it('does not render the "Alle gelesen" button when there are no unread items', async () => { it('does not render the "Alle gelesen" button when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
const all = document.querySelector('[data-testid="chronik-mark-all-read"]'); const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
@@ -80,38 +109,38 @@ describe('ChronikFuerDichBox', () => {
it('renders the "Alle gelesen" button when unread exists', async () => { it('renders the "Alle gelesen" button when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument(); await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
}); });
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => { it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => {
const onMarkAllRead = vi.fn(); const optimisticMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead optimisticMarkAllRead
}); });
await userEvent.click(page.getByText('Alle gelesen')); await userEvent.click(page.getByText('Alle gelesen'));
expect(onMarkAllRead).toHaveBeenCalledTimes(1); expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1);
}); });
it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => { it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => {
const onMarkRead = vi.fn(); const optimisticMarkRead = vi.fn();
const n = notif({ id: 'xyz' }); const n = notif({ id: 'xyz' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [n], unread: [n],
onMarkRead, optimisticMarkRead,
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]' '[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
dismiss?.click(); dismiss?.click();
expect(onMarkRead).toHaveBeenCalledTimes(1); expect(optimisticMarkRead).toHaveBeenCalledTimes(1);
expect(onMarkRead.mock.calls[0][0]).toEqual(n); expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz');
}); });
it('mention row href includes both commentId and annotationId when annotationId is present', async () => { it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
@@ -124,8 +153,8 @@ describe('ChronikFuerDichBox', () => {
annotationId: 'annot-9' annotationId: 'annot-9'
}) })
], ],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
const link = document.querySelector( const link = document.querySelector(
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]' 'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
@@ -136,8 +165,8 @@ describe('ChronikFuerDichBox', () => {
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => { it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })], unread: [notif({ id: 'x' })],
onMarkRead: vi.fn(), optimisticMarkRead: vi.fn(),
onMarkAllRead: vi.fn() optimisticMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]'); const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
@@ -145,4 +174,22 @@ describe('ChronikFuerDichBox', () => {
// Prevents the senior-audience tap-drag bug flagged by Leonie. // Prevents the senior-audience tap-drag bug flagged by Leonie.
expect(dismiss?.closest('a')).toBeNull(); expect(dismiss?.closest('a')).toBeNull();
}); });
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
unread: [notif({ id: 'err-1' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull();
dismiss?.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
}); });

View File

@@ -4,7 +4,36 @@ import { page } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
afterEach(cleanup); const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n-1', id: 'n-1',
@@ -22,7 +51,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
describe('ChronikFuerDichBox', () => { describe('ChronikFuerDichBox', () => {
it('renders the inbox-zero state when there are no unread', async () => { it('renders the inbox-zero state when there are no unread', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} } props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} }
}); });
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible(); await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
@@ -34,8 +63,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })], unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {} optimisticMarkAllRead: () => {}
} }
}); });
@@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })], unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {} optimisticMarkAllRead: () => {}
} }
}); });
@@ -62,8 +91,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ actorName: 'Bertha' })], unread: [mention({ actorName: 'Bertha' })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {} optimisticMarkAllRead: () => {}
} }
}); });
@@ -76,8 +105,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ type: 'REPLY', actorName: 'Carl' })], unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {} optimisticMarkAllRead: () => {}
} }
}); });
@@ -86,11 +115,11 @@ describe('ChronikFuerDichBox', () => {
.toBeVisible(); .toBeVisible();
}); });
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => { it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => {
const onMarkRead = vi.fn(); const optimisticMarkRead = vi.fn();
const item = mention({ id: 'n-7' }); const item = mention({ id: 'n-7' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} } props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} }
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
@@ -98,35 +127,55 @@ describe('ChronikFuerDichBox', () => {
) as HTMLElement; ) as HTMLElement;
dismiss.click(); dismiss.click();
expect(onMarkRead).toHaveBeenCalledWith(item); expect(optimisticMarkRead).toHaveBeenCalledWith('n-7');
}); });
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => { it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn(); const optimisticMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention()], unread: [mention()],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead optimisticMarkAllRead
} }
}); });
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement; const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
btn.click(); btn.click();
expect(onMarkAllRead).toHaveBeenCalledOnce(); expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
}); });
it('builds a deep-link href to the comment for each notification', async () => { it('builds a deep-link href to the comment for each notification', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })], unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {} optimisticMarkAllRead: () => {}
} }
}); });
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toContain('doc-x'); expect(link.getAttribute('href')).toContain('doc-x');
}); });
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'err-1' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
}
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLElement;
dismiss.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
}); });

View File

@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside'; import { clickOutside } from '$lib/shared/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte'; import { notificationStore } from '$lib/notification/notifications.svelte';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import NotificationDropdown from './NotificationDropdown.svelte'; import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false); let open = $state(false);
@@ -30,17 +28,6 @@ function closeDropdown() {
bellButtonEl?.focus(); bellButtonEl?.focus();
} }
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
await stream.markRead(notification);
const url = buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
);
closeDropdown();
goto(url);
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) { if (event.key === 'Escape' && open) {
event.stopPropagation(); event.stopPropagation();
@@ -113,8 +100,8 @@ onDestroy(() => {
{#if open} {#if open}
<NotificationDropdown <NotificationDropdown
notifications={stream.notifications} notifications={stream.notifications}
onMarkRead={handleMarkRead} optimisticMarkRead={stream.optimisticMarkRead}
onMarkAllRead={stream.markAllRead} optimisticMarkAllRead={stream.optimisticMarkAllRead}
onClose={closeDropdown} onClose={closeDropdown}
/> />
{/if} {/if}

View File

@@ -3,10 +3,18 @@ import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte'; import NotificationBell from './NotificationBell.svelte';
const gotoMock = vi.hoisted(() => vi.fn()); vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() })); vi.mock('$app/forms', () => ({
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
const handler = (e: Event) => {
e.preventDefault();
submit?.({ formData: new FormData(node) } as never);
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] })); const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
vi.mock('$lib/notification/notifications.svelte', () => ({ vi.mock('$lib/notification/notifications.svelte', () => ({
@@ -17,18 +25,17 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
get unreadCount() { get unreadCount() {
return mockNotificationList.value.length; return mockNotificationList.value.length;
}, },
markRead: mockMarkRead, optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn(),
fetchNotifications: vi.fn().mockResolvedValue(undefined), fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(), init: vi.fn(),
destroy: vi.fn(), destroy: vi.fn()
markAllRead: vi.fn()
} }
})); }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
gotoMock.mockClear(); vi.clearAllMocks();
mockMarkRead.mockClear();
mockNotificationList.value = []; mockNotificationList.value = [];
}); });
@@ -45,16 +52,6 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides ...overrides
}); });
async function openDropdownAndClickFirstNotification() {
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
bellButton.click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => { describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => { it('bell button has cursor-pointer class', async () => {
render(NotificationBell); render(NotificationBell);
@@ -82,29 +79,3 @@ describe('NotificationBell — cursor and tooltip', () => {
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
}); });
}); });
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith(
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
);
});
});
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
mockNotificationList.value = [makeNotification({ annotationId: null })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
});
});
});

View File

@@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
type Props = { type Props = {
notifications: NotificationItem[]; notifications: NotificationItem[];
onMarkRead: (notification: NotificationItem) => void; optimisticMarkRead: (id: string) => void;
onMarkAllRead: () => void; optimisticMarkAllRead: () => void;
onClose: () => void; onClose: () => void;
}; };
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props(); let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props();
let errorMessage = $state<string | null>(null);
function handleViewAll() { function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition onClose(); // close first — avoids stale dropdown during navigation transition
@@ -31,16 +35,35 @@ function handleViewAll() {
{m.notification_bell_label()} {m.notification_bell_label()}
</span> </span>
{#if notifications.length > 0} {#if notifications.length > 0}
<button <form
type="button" action="/aktivitaeten?/mark-all-read"
onclick={onMarkAllRead} method="POST"
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink" use:enhance={() => {
errorMessage = null;
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
{m.notification_mark_all_read()} <button
</button> type="submit"
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
</form>
{/if} {/if}
</div> </div>
<!-- Error banner (shown when a dismiss or mark-all action fails) -->
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
<!-- Notification list --> <!-- Notification list -->
{#if notifications.length === 0} {#if notifications.length === 0}
<!-- Empty state --> <!-- Empty state -->
@@ -66,67 +89,93 @@ function handleViewAll() {
<ul role="list" class="max-h-[24rem] overflow-y-auto"> <ul role="list" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)} {#each notifications as notification (notification.id)}
<li> <li>
<button <form
type="button" action="/aktivitaeten?/dismiss-notification"
onclick={() => onMarkRead(notification)} method="POST"
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas class="contents"
{!notification.read ? 'bg-accent-bg/20' : ''}" use:enhance={() => {
errorMessage = null;
optimisticMarkRead(notification.id);
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
} else {
// Navigate away — no need to update the store since we're leaving the page
onClose();
goto(
buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
)
);
}
};
}}
> >
<!-- Type icon --> <input type="hidden" name="notificationId" value={notification.id} />
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true"> <button
{#if notification.type === 'REPLY'} type="submit"
<!-- Reply icon --> class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3.5 text-left last:border-b-0 hover:bg-canvas
<svg {!notification.read ? 'bg-accent-bg/20' : ''}"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <!-- Type icon -->
fill="none" <span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
viewBox="0 0 24 24" {#if notification.type === 'REPLY'}
stroke="currentColor" <!-- Reply icon -->
stroke-width="2" <svg
> xmlns="http://www.w3.org/2000/svg"
<path class="h-4 w-4"
stroke-linecap="round" fill="none"
stroke-linejoin="round" viewBox="0 0 24 24"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" stroke="currentColor"
/> stroke-width="2"
</svg> >
{:else} <path
<!-- Mention icon --> stroke-linecap="round"
<svg stroke-linejoin="round"
xmlns="http://www.w3.org/2000/svg" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
class="h-4 w-4" />
fill="none" </svg>
viewBox="0 0 24 24" {:else}
stroke="currentColor" <!-- Mention icon -->
stroke-width="2" <svg
> xmlns="http://www.w3.org/2000/svg"
<path class="h-4 w-4"
stroke-linecap="round" fill="none"
stroke-linejoin="round" viewBox="0 0 24 24"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" stroke="currentColor"
/> stroke-width="2"
</svg> >
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if} {/if}
</span> </button>
</form>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if}
</button>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -6,9 +6,38 @@ import NotificationDropdown from './NotificationDropdown.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Configurable result for the enhance mock — tests that need failure set
// mockFormResult.type = 'failure' before clicking.
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
// Invoke the SubmitFunction and always call the returned result callback with
// mockFormResult so tests can exercise both success and failure branches.
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await cb({ result: mockFormResult, update: async () => {} } as never);
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
}); });
const makeNotification = (overrides: Record<string, unknown> = {}) => ({ const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -29,8 +58,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -42,8 +71,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -55,8 +84,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -70,8 +99,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -83,8 +112,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -98,8 +127,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })], notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -116,8 +145,8 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: true }) makeNotification({ id: 'n2', read: true })
], ],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -126,37 +155,100 @@ describe('NotificationDropdown', () => {
expect(unreadDots.length).toBe(1); expect(unreadDots.length).toBe(1);
}); });
it('calls onMarkRead with the notification when an item is clicked', async () => { it('each notification row is wrapped in a form posting to the dismiss action', async () => {
const onMarkRead = vi.fn(); render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/dismiss-notification"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('the dismiss form has a hidden notificationId input with the notification id', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const input = document.querySelector<HTMLInputElement>(
'form[action="/aktivitaeten?/dismiss-notification"] input[name="notificationId"]'
);
expect(input?.value).toBe('n42');
});
it('calls optimisticMarkRead with the notification id when a row is submitted', async () => {
const optimisticMarkRead = vi.fn();
const n = makeNotification({ id: 'n42', actorName: 'Anna' }); const n = makeNotification({ id: 'n42', actorName: 'Anna' });
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [n], notifications: [n],
onMarkRead, optimisticMarkRead,
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click(); await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(onMarkRead).toHaveBeenCalledWith(n); expect(optimisticMarkRead).toHaveBeenCalledWith('n42');
}); });
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => { it('the mark-all-read control is a form posting to the mark-all-read action', async () => {
const onMarkAllRead = vi.fn();
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead, optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/mark-all-read"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('calls optimisticMarkAllRead when the mark-all-read button is submitted', async () => {
const optimisticMarkAllRead = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead,
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /alle gelesen/i }).click(); await page.getByRole('button', { name: /alle gelesen/i }).click();
expect(onMarkAllRead).toHaveBeenCalledOnce(); expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
});
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
mockFormResult.type = 'failure';
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle gelesen/i }).click();
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
}); });
it('calls onClose when the view-all button is clicked', async () => { it('calls onClose when the view-all button is clicked', async () => {
@@ -164,8 +256,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -179,8 +271,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -193,12 +285,15 @@ describe('NotificationDropdown', () => {
it('calls onClose before navigating to /aktivitaeten', async () => { it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = []; const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close')); const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => callOrder.push('goto')); vi.mocked(goto).mockImplementation(() => {
callOrder.push('goto');
return Promise.resolve();
});
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -212,8 +307,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })], notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -225,8 +320,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -242,14 +337,78 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', actorName: 'First' }), makeNotification({ id: 'n1', actorName: 'First' }),
makeNotification({ id: 'n2', actorName: 'Second' }) makeNotification({ id: 'n2', actorName: 'Second' })
], ],
onMarkRead: () => {}, optimisticMarkRead: () => {},
onMarkAllRead: () => {}, optimisticMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
const items = document.querySelectorAll('button[type="button"]'); const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]');
// At least 2 items + mark-all button expect(forms.length).toBe(2);
expect(items.length).toBeGreaterThanOrEqual(2); });
it('calls onClose and goto with the deep-link URL after a successful dismiss', async () => {
const onClose = vi.fn();
const n = makeNotification({
id: 'n42',
documentId: 'd1',
referenceId: 'c1',
annotationId: null,
actorName: 'Anna'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(onClose).toHaveBeenCalledOnce();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1');
});
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
const onClose = vi.fn();
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Bob hat auf deinen/i }).click();
expect(onClose).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
it('calls goto with annotationId appended when the notification has an annotationId', async () => {
const n = makeNotification({
id: 'n55',
documentId: 'd1',
referenceId: 'c1',
annotationId: 'a1',
actorName: 'Eva'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /Eva hat auf deinen/i }).click();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1&annotationId=a1');
}); });
}); });

View File

@@ -108,12 +108,46 @@ describe('notificationStore (singleton)', () => {
expect(notificationStore.unreadCount).toBe(1); expect(notificationStore.unreadCount).toBe(1);
}); });
it('markAllRead resets unreadCount', async () => { it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 })); notificationStore.init();
await notificationStore.markAllRead(); const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' }); notificationStore.optimisticMarkRead('sse-1');
expect(notificationStore.notifications[0].read).toBe(true);
expect(notificationStore.unreadCount).toBe(0); expect(notificationStore.unreadCount).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
});
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
notificationStore.optimisticMarkRead('sse-1');
expect(notificationStore.unreadCount).toBe(0);
});
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
notificationStore.init();
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n1', read: false }))
);
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n2', read: false }))
);
mockFetch.mockReset();
notificationStore.optimisticMarkAllRead();
expect(notificationStore.unreadCount).toBe(0);
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -35,28 +35,19 @@ async function fetchUnreadCount(): Promise<void> {
} }
} }
async function markRead(notification: NotificationItem): Promise<void> { function optimisticMarkRead(id: string): void {
if (!notification.read) { const notification = notifications.find((n) => n.id === id);
try { if (notification && !notification.read) {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' }); notification.read = true;
notification.read = true; unreadCount = Math.max(0, unreadCount - 1);
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
} }
} }
async function markAllRead(): Promise<void> { function optimisticMarkAllRead(): void {
try { for (const n of notifications) {
await fetch('/api/notifications/read-all', { method: 'POST' }); n.read = true;
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
} }
unreadCount = 0;
} }
function init(): void { function init(): void {
@@ -123,8 +114,8 @@ export const notificationStore = {
}, },
fetchNotifications, fetchNotifications,
fetchUnreadCount, fetchUnreadCount,
markRead, optimisticMarkRead,
markAllRead, optimisticMarkAllRead,
init, init,
destroy destroy
}; };

View File

@@ -1,4 +1,6 @@
import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components, operations } from '$lib/generated/api'; import type { components, operations } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -65,3 +67,31 @@ export async function load({ fetch, url }) {
loadError loadError
}; };
} }
export const actions = {
'dismiss-notification': async ({ request, fetch }) => {
const data = await request.formData();
const raw = data.get('notificationId');
const notificationId = typeof raw === 'string' ? raw : null;
if (!notificationId) return fail(400, { error: getErrorMessage(undefined) });
const api = createApiClient(fetch);
const result = await api.PATCH('/api/notifications/{id}/read', {
params: { path: { id: notificationId } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
},
'mark-all-read': async ({ fetch }) => {
const api = createApiClient(fetch);
const result = await api.POST('/api/notifications/read-all');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
}
};

View File

@@ -76,14 +76,6 @@ async function onFilterChange(v: FilterValue) {
}); });
} }
async function onMarkRead(n: NotificationItem) {
await notificationStore.markRead(n);
}
async function onMarkAllRead() {
await notificationStore.markAllRead();
}
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter)); const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
const isEmpty = $derived(displayFeed.length === 0); const isEmpty = $derived(displayFeed.length === 0);
@@ -108,7 +100,11 @@ function retry() {
{#if data.loadError === 'activity'} {#if data.loadError === 'activity'}
<ChronikErrorCard onRetry={retry} /> <ChronikErrorCard onRetry={retry} />
{:else} {:else}
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} /> <ChronikFuerDichBox
unread={unread}
optimisticMarkRead={notificationStore.optimisticMarkRead}
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
/>
<div class="mt-6"> <div class="mt-6">
<ChronikFilterPills value={data.filter} onChange={onFilterChange} /> <ChronikFilterPills value={data.filter} onChange={onFilterChange} />

View File

@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load } from './+page.server'; import { load, actions } from './+page.server';
const mockApi = { const mockApi = {
GET: vi.fn() GET: vi.fn(),
PATCH: vi.fn(),
POST: vi.fn()
}; };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({
@@ -173,3 +175,84 @@ describe('aktivitaeten/load — kinds param per filter', () => {
expect(call[1].params.query.kinds).toHaveLength(2); expect(call[1].params.query.kinds).toHaveLength(2);
}); });
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function makeActionEvent(formData: FormData): any {
return {
request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }),
fetch
};
}
describe('aktivitaeten/actions — dismiss-notification', () => {
it('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
expect(result).toMatchObject({ status: 400 });
expect(mockApi.PATCH).not.toHaveBeenCalled();
});
it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => {
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
const fd = new FormData();
fd.set('notificationId', 'n-abc');
await actions['dismiss-notification'](makeActionEvent(fd));
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
params: { path: { id: 'n-abc' } }
});
});
it('returns { success: true } when the API responds ok', async () => {
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
const fd = new FormData();
fd.set('notificationId', 'n-abc');
const result = await actions['dismiss-notification'](makeActionEvent(fd));
expect(result).toEqual({ success: true });
});
it('returns fail(status, { error }) when the API responds non-ok', async () => {
mockApi.PATCH.mockResolvedValue({
response: { ok: false, status: 403 },
error: { code: 'NOTIFICATION_NOT_FOUND' }
});
const fd = new FormData();
fd.set('notificationId', 'n-abc');
const result = await actions['dismiss-notification'](makeActionEvent(fd));
expect(result).toMatchObject({ status: 403 });
});
});
describe('aktivitaeten/actions — mark-all-read', () => {
it('calls POST /api/notifications/read-all', async () => {
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
});
it('returns { success: true } when the API responds ok', async () => {
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(result).toEqual({ success: true });
});
it('returns fail(status, { error }) when the API responds non-ok', async () => {
mockApi.POST.mockResolvedValue({
response: { ok: false, status: 500 },
error: { code: 'INTERNAL_ERROR' }
});
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(result).toMatchObject({ status: 500 });
});
});

View File

@@ -1,702 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "grafana" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Product owner overview — system health, user activity, archive progress, and OCR quality at a weekly glance.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 100,
"title": "System Health",
"type": "row",
"panels": []
},
{
"id": 1,
"title": "Backend Status",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"targets": [
{
"expr": "up{job=\"spring-boot\"}",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" } } },
{ "type": "value", "options": { "1": { "text": "UP", "color": "green" } } }
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "value"
}
},
{
"id": 2,
"title": "Server Errors (5xx)",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"targets": [
{
"expr": "sum(increase(http_server_requests_seconds_count{status=~\"5..\"}[$__range]))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 6 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 3,
"title": "Response Time (p95)",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[$__range])) by (le))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"decimals": 2,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 2 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 4,
"title": "Error Log Count",
"type": "stat",
"datasource": { "type": "loki", "uid": "loki" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"targets": [
{
"expr": "sum(count_over_time({compose_service=\"backend\"} | json | level=\"ERROR\" [$__range]))",
"queryType": "instant",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 10 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 5,
"title": "CPU Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 5 },
"targets": [
{
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 6,
"title": "Memory Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 8, "y": 5 },
"targets": [
{
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 7,
"title": "Disk Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 16, "y": 5 },
"targets": [
{
"expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"})) * 100",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 80 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 },
"id": 101,
"title": "User Activity",
"type": "row",
"panels": []
},
{
"id": 8,
"title": "Active Users",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(DISTINCT actor_id) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 9,
"title": "Total Logins",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 10,
"title": "Failed Login Attempts",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind IN ('LOGIN_FAILED', 'LOGIN_RATE_LIMITED')",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 4 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 11,
"title": "Daily Logins (last 7 days)",
"type": "barchart",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 7, "w": 24, "x": 0, "y": 15 },
"targets": [
{
"rawSql": "SELECT DATE_TRUNC('day', happened_at) AS time, COUNT(*) AS logins FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS' GROUP BY 1 ORDER BY 1",
"format": "time_series",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"legend": { "displayMode": "hidden" },
"orientation": "auto",
"showValue": "auto",
"stacking": "none",
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 },
"id": 102,
"title": "Archive Progress",
"type": "row",
"panels": []
},
{
"id": 12,
"title": "Transcription Coverage",
"type": "bargauge",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 5, "w": 24, "x": 0, "y": 23 },
"targets": [
{
"rawSql": "SELECT (COUNT(*) FILTER (WHERE text IS NOT NULL AND text <> ''))::float * 100.0 / NULLIF(COUNT(*), 0) AS percent_complete FROM transcription_blocks",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 25 },
{ "color": "green", "value": 75 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 13,
"title": "Total Documents",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM documents WHERE status <> 'PLACEHOLDER'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 14,
"title": "Uploads This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'FILE_UPLOADED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 15,
"title": "Blocks Transcribed This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'TEXT_SAVED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 16,
"title": "Blocks Reviewed This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'BLOCK_REVIEWED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 },
"id": 103,
"title": "OCR Health",
"type": "row",
"panels": []
},
{
"id": 17,
"title": "OCR Jobs",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_jobs_total[$__range]))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 18,
"title": "OCR Page Error Rate",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_skipped_pages_total[$__range])) / clamp_min(sum(increase(ocr_pages_total[$__range])), 1)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.01 },
{ "color": "red", "value": 0.05 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 19,
"title": "Illegible Word Rate",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_illegible_words_total[$__range])) / clamp_min(sum(increase(ocr_words_total[$__range])), 1)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 0.25 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 20,
"title": "OCR Service Status",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 33 },
"targets": [
{
"expr": "ocr_models_ready",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "NOT READY", "color": "red" } } },
{ "type": "value", "options": { "1": { "text": "READY", "color": "green" } } }
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "value"
}
}
],
"refresh": "",
"schemaVersion": 39,
"tags": ["po-overview", "familienarchiv"],
"templating": { "list": [] },
"time": { "from": "now-7d", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "PO Overview",
"uid": "po-overview",
"version": 1,
"weekStart": ""
}

View File

@@ -36,19 +36,3 @@ datasources:
datasourceUid: prometheus datasourceUid: prometheus
nodeGraph: nodeGraph:
enabled: true enabled: true
# Read-only PostgreSQL datasource for the PO Overview dashboard (issue #651).
# Uses the grafana_reader role provisioned by Flyway V68. Traffic stays inside
# archiv-net, so sslmode=disable is the deliberate, accepted setting.
- name: PostgreSQL
type: postgres
uid: postgres
url: archive-db:5432
user: grafana_reader
editable: false
secureJsonData:
password: ${GRAFANA_DB_PASSWORD}
jsonData:
database: ${POSTGRES_DB}
sslmode: disable
postgresVersion: 1600

View File

@@ -16,11 +16,6 @@ GLITCHTIP_DOMAIN=https://glitchtip.archiv.raddatz.cloud
POSTGRES_USER=archiv POSTGRES_USER=archiv
# Note: GRAFANA_DB_PASSWORD is a secret and is injected by CI from
# obs-secrets.env (see .env.example for the local-dev declaration).
# It is consumed by both archive-backend (Flyway V68 placeholder) and
# obs-grafana (PostgreSQL datasource).
# PostgreSQL hostname for GlitchTip db-init and workers. # PostgreSQL hostname for GlitchTip db-init and workers.
# The actual value depends on the Compose project name — it is not a fixed string. # The actual value depends on the Compose project name — it is not a fixed string.
# CI sets POSTGRES_HOST in obs-secrets.env per environment: # CI sets POSTGRES_HOST in obs-secrets.env per environment: