Compare commits

..

41 Commits

Author SHA1 Message Date
Marcel
5bcb4717b6 docs(transcription): explain why SEARCH_RESULT_LIMIT lives in the shared module
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m21s
CI / fail2ban Regex (pull_request) Successful in 40s
CI / Semgrep Security Scan (pull_request) Successful in 18s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
Round-4 polish from Felix (#1): SEARCH_RESULT_LIMIT only has one consumer
today (PersonMentionEditor), so it risked masquerading as shared. Add a
one-line rationale that the symmetry with MAX_QUERY_LENGTH and
SEARCH_DEBOUNCE_MS — keeping all @mention knobs in one file — is the
intentional motivation, not a missed inlining.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
ec9855f60b a11y(transcription): hide visible @mention empty-state from AT and fold empty-query check
Round-4 polish from Leonie (S-2), Felix (#3), Sara (#4):
- Add aria-hidden="true" to the visible empty-state <p> so VoiceOver does
  not double-announce — the persistent sr-only live region is now the
  sole AT source of truth (NVDA already de-duped, VoiceOver did not).
- Extract `searchQuery.trim() === ''` into an `isQueryEmpty` $derived;
  both the announcer branch and the visible empty-state branch now read
  from the single intent-named alias.
- Cover the singular branch of the persistent live region (1 item ->
  "1 Person gefunden" / "1 person found" / "1 persona encontrada").
  Plural was already covered; this closes the missing-branch gap.
- Extend the existing "no aria-live on visible <p>" test to also assert
  aria-hidden="true" so a regression on the AT-source-of-truth contract
  goes red immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
51cb8e7e22 test(transcription): polish @mention test docstrings and tighten clip assert
Round-4 polish from Sara (#11199) and Felix (#11186):
- Replace setTimeout(50) in stale-response race with tick() — matches
  round-3 pattern Sara verified in the sticky-takeover test.
- Add intent comment above the "clear input" wait — it is a negative
  assertion that must not be optimised away.
- Tighten displayName-clip assert from <=100 to ===100 so the test
  discriminates "clip works" from "clip works AND nothing weakened it".
- JSDoc POST_DEBOUNCE_SLACK_MS with the calibration rationale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
3c5fc6907c chore(lint): forbid *.test-fixture.svelte imports from production code
Add ESLint no-restricted-imports rule banning *.test-fixture.svelte from
non-test files. Tree-shaking already keeps test fixtures out of the
production bundle, but making the boundary lint-enforced catches an
accidental autocomplete-driven import in a route or component. Test
files and the fixtures themselves are exempt. Nora #2 on PR #629
round 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
790f870cac a11y(transcription): persistent aria-live region for @mention dropdown
The aria-live region previously lived inside {#if items.length === 0} so
it remounted whenever items transitioned between empty and populated —
VoiceOver in particular swallows announcements from freshly-mounted live
regions, and the "N persons found" announcement was missing entirely on
the populated branch. Move the live region above the conditional so the
element persists, and announce a localized "1 person found" / "N persons
found" count on the populated branch. The visible empty-state <p> stays
as a visual cue (no aria-live). Leonie #3 on PR #629 round 3.

Adds person_mention_results_count_singular / _plural in de/en/es.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
e0b2db061b i18n(transcription): align @mention search label verb-number across locales
de + es already use singular ("Person suchen", "Buscar persona"); en
was plural ("Search persons"). Switch en to "Search for a person" so
all three locales announce a singular search control to screen-reader
users — cross-locale parity polish. Leonie #1 on PR #629 round 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
d4cfd4c8fd test(transcription): make @mention onKeyDown tests consistent
Wrap all four onKeyDown unit tests (ArrowDown/ArrowUp/Enter/Escape) in
flushSync uniformly so the next reader doesn't have to figure out why
some are wrapped and others aren't. Felix #1 on PR #629 round 3.

Also add a comment above the describe block calling out that these unit
tests do NOT exercise the Tiptap forwarding chain — that is covered by
the 'ArrowDown moves the highlight' integration test. Sara #3 on PR #629
round 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
8a77e64421 refactor(test): complete .test-host -> .test-fixture rename sweep
Round 2 renamed only MentionDropdown's fixture; three siblings retained
the old suffix. Rename PersonMentionEditor, confirm, and TranscriptionBlock
test hosts to the .test-fixture suffix and update the three importers so
the boundary is uniform across the repo. Felix #1 / Tobi #1 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
d2f0eef45e test(transcription): replace setTimeout(50) with tick() in sticky-takeover
Sara on PR #629 round 3: the magic 50 ms in the @mention sticky-takeover
test was anchored to nothing and read as a race-fix it wasn't. Replace
with await tick() so the intent ("flush pending Svelte reactivity") is
explicit. The expect.element polling already covers timing drift.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
5c588caf26 test(transcription): restore strong one-fetch regression guard
Sara on PR #629 round 3: the round-2 fix captured the fetch count AFTER
typing '@', so a regression that re-introduced the legacy per-keystroke
items() callback would have its '@'-keystroke fetch silently absorbed
into the baseline. Drop the baseline subtraction and count every
/api/persons fetch since render — typing '@' + fill('Walter') must
total exactly one fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
4b75ca80cc fix(transcription): clip @mention displayName to MAX_QUERY_LENGTH
The dropdown's editor-mirror clips at 100 chars (CWE-400, Nora #1), but
the host editor previously fed renderProps.query directly to displayName
on selection — so a 200-char @-suffix would search the first 100 chars
but insert 200 chars. Clip once in updateState and use the clipped value
for both the inserted displayName and the dropdown's editorQuery mirror,
keeping "what I searched" and "what got inserted" in sync. Felix #3 on
PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
5f6b896bd2 refactor(transcription): hoist @mention constants to shared module
Single source of truth for MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, and
SEARCH_RESULT_LIMIT — MentionDropdown imports MAX_QUERY_LENGTH;
PersonMentionEditor imports the debounce + result-limit; the spec's
mirror now imports SEARCH_DEBOUNCE_MS so it can never drift. Unblocks
the displayName length-cap fix (Felix #3 on PR #629).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
cb0ce90fd6 a11y(transcription): bump @mention search input to text-base (16 px floor)
The senior-audience body-text floor is 16 px (CLAUDE.md
§Dual-Audience). The search input was the smallest non-metadata
text in the dropdown at text-sm (14 px), even though it is the
primary write surface a 60+ transcriber types into. Bumping to
text-base costs ~2 px of popover header height and closes the
"I can't read what I'm typing" complaint that historically tops
senior-usability tests of search bars. Leonie FINDING-MENTION-006
on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
2057a31018 a11y(transcription): cap @mention listbox width at viewport-1rem (WCAG 1.4.10)
w-72 (288 px) listbox can overflow horizontally on a 320 px viewport
when the caret sits near the right edge — the existing flip logic
only handles vertical overflow. max-w-[calc(100vw-1rem)] adds a
defensive horizontal cap so a senior on a 320 px phone never sees
the dropdown clip off-screen. Leonie FINDING-MENTION-005 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
85770aceac a11y(transcription): give @mention search input its own sr-only label
The sr-only label for the search input was reusing the listbox
"Link person" label — but the input filters a candidate list, it does
not link anything. Screen readers heard a verb mismatch between the
listbox announce and the search-input focus event. New
person_mention_search_label key in de/en/es. The listbox aria-label
stays person_mention_btn_label since that labels the listbox itself.
Leonie FINDING-MENTION-004 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
5580fe9545 fix(transcription): clip @mention editor-mirror to 100 chars (CWE-400 layered)
The <input maxlength=100> attribute capped direct user edits but did
not cover the Tiptap editor-mirror path. A 5000-char @-suffix in the
contenteditable would mirror unchanged into searchQuery and reach
runSearch. Clipping at the mirror keeps both paths bounded. The
literal in the maxlength attribute is also bound to the new
MAX_QUERY_LENGTH constant so the two stay in sync. Server-side cap
tracked separately. Nora #1 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
0f06e959d1 test(transcription): unit-test @mention dropdown onKeyDown export
Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level and
forwards them via the dropdown's exported onKeyDown — the dropdown
itself has no DOM keydown listener. These tests exercise the same
export directly (the full focus-chain E2E is deferred to a separate
Playwright issue). Sara #3 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
b72ac15fec test(transcription): characterize @mention silent failure on 500 / network error
runSearch swallows non-OK responses and fetch rejections to an empty
items list. The user sees "Keine Personen gefunden" identically to a
genuine empty result. These two tests pin that behaviour so a future
distinct-error-UX implementer is forced to update the assertions.
Sara #2 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
cc4a8aed14 test(transcription): de-flake one-fetch @mention test via searchbox fill
userEvent.type(@Walter) types 7 keys; CI jitter can space the gaps past
the 150 ms debounce and fire 2+ fetches, even though the request-token
guard discards the stale response. fill() collapses the input into one
event so the assertion (exactly 1 fetch) becomes deterministic.
Sara #1 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
ed430252d1 refactor(transcription): rename @mention test-host to test-fixture
Test-only helper colocated with production code now has a visible
.test-fixture.svelte boundary so eslint-boundaries and code search
do not confuse it for a production component. The internal alias was
also bumped from *Host to *Fixture for consistency. No behaviour
change. Felix #3 / Nora #3 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
88722e04cd docs(transcription): explain why @mention mirror uses \$state+\$effect
The mirror effect on the dropdown's searchQuery looks like it should be
\$derived but it cannot be: bind:value on the <input> writes to the same
state, so it must remain mutable. Felix #2 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
6a82f4b5b0 fix(transcription): cancel pending @mention debounce in onExit
Without this, a closed dropdown's trailing runSearch could fire against
the next dropdown's state and silently overwrite its items before its
own fetch resolved. Felix #1 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
4d875e7e67 docs(debounce): clarify that cancel() drops, never flushes, the trailing call
Markus on PR #629 — the cancel-not-flush contract is what the
PersonMentionEditor onDestroy path relies on. Spell it out so future
callers can rely on the same guarantee.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
2ca512fc9d refactor(test): drop double-cast on Person fixtures
Drops the `as unknown as Person` double-cast in makePerson and on
AUGUSTE/ANNA in favor of plain return-typed object literals; this
restores the type-system safety net Felix flagged on PR #629 — a
future required field on Person now fails compilation in the fixture
instead of silently slipping through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
fb414183be refactor(test): name the debounce slack and harden against CI jitter
Extracts SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS at the top of the
spec and bumps the post-debounce wait from 250/300 ms to 500 ms.
Addresses Felix's "magic number" suggestion and Sara's flake-risk
concern on PR #629. (Sara's fake-timer alternative collides with
userEvent + vi.waitFor in vitest-browser; the slack bump achieves the
same deterministic outcome with no fragility.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
57be10ccb5 fix(transcription): defensively cap @mention fetch with limit=5
Adds &limit=5 to the /api/persons request so the client signals its
intent and stays consistent with the SEARCH_RESULT_LIMIT slice. Backend
enforcement (and the broader PersonSummaryDTO response-shape audit) is
tracked separately. Markus on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
dd423a22db test(transcription): pin sticky search-input takeover behaviour
Once the user edits the dropdown search input, subsequent editorQuery
changes from the host editor must not overwrite it. Felix on PR #629.
Adds a small test host that exposes a setter for editorQuery so the
test can drive reactive prop changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
9b6561562d a11y(transcription): announce @mention empty state via aria-live
Collapse the two empty-state branches into a single p[aria-live=polite]
whose text derives from the search query. Screen readers now hear the
transition between "Namen eingeben…" and "Keine Personen gefunden".
Leonie FINDING-MENTION-002 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
6a5083f9bc a11y(transcription): enlarge @mention magnifier and darken contrast
Bump h-4 w-4 to h-5 w-5 and text-ink-3 to text-ink-2 so the icon
carries enough visual weight to identify the input region without a
visible text label. Leonie FINDING-MENTION-001 on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
d0f4642bb9 fix(transcription): cap @mention search input at maxlength=100
Soft-cap on the client side mitigates CWE-400 query amplification
(server-side cap remains a separate backend PR).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
b6bdebc449 fix(transcription): guard @mention fetch against stale responses
Tag each runSearch with an incrementing requestId; discard responses
whose id no longer matches the latest onSearch. Prevents a slow fetch
from repopulating the dropdown after the user has cleared the search.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
fd323191dc fix(transcription): neutralize legacy items() to dedupe @mention fetch
Tiptap's suggestion items() callback fired a fetch on every keystroke
after `@`, in parallel with the debounced search-input fetch. Its result
was discarded by updateState, so it was pure waste — doubling the load
on /api/persons and confusing the debounce.

Returning [] from items() routes the entire fetch flow through the
search-input -> debounced onSearch path. New test pins @Walter to
exactly one fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
729a99f8c5 refactor(transcription): consolidate MentionDropdown test files
For issue #380. Drops the redundant MentionDropdown.svelte.spec.ts that
was added earlier in this branch and folds its search-input coverage
into the long-established MentionDropdown.svelte.test.ts. Same
test surface, single file.

While there:
- Updates the empty-state test to match the new behaviour: an empty
  search field shows the "Namen eingeben…" prompt; "Keine Personen
  gefunden" only appears when a query is entered but nothing matches.
- Fixes pre-existing Person-type drift in makePerson (missing
  personType, familyMember).
- Stricten the create-new link rel assertion to cover the new
  noreferrer addition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
f651a41d18 feat(transcription): drive @mention fetch through the dropdown search input
For issue #380 (AC-2, AC-3, AC-4 + NFR debounce).

The search input is now the single fetch trigger. The dropdown's
searchQuery reactivity calls onSearch on every change — whether sourced
from the editor mirror or the user's own input. PersonMentionEditor
debounces these calls at 150 ms, short-circuits on empty queries (no
fetch, items cleared), and tears down pending timers on destroy.

The Tiptap suggestion plugin's items() now returns [] — per-keystroke
fetches in the editor are gone. The same /api/persons?q= endpoint is
used; the difference is in when and how often the request fires.

Adds a cancel() method to the debounce utility so destroyed editors
don't leave trailing fetches alive (which previously polluted the test
ledger and would have wasted bandwidth in production tab-close races).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
344f5cac77 fix(transcription): add noreferrer to mention dropdown create-new link
For issue #380 (Nora CWE-116). The "Neue Person anlegen" link opens in
a new tab and was missing `noreferrer` — the new tab could read
window.opener and the referrer leaked the transcription URL. Same-origin
risk is low but the omission was unintentional.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
c5ad2c366c test(transcription): cover 44px touch target on mention search input
For issue #380 NFR. The transcriber audience is 60+ on laptops/tablets;
the search input must meet WCAG 2.2 AA touch target dimensions just like
the existing person result rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
bdc0b112b6 feat(transcription): wire dropdown search input to editor @-text
For issue #380. The search input mirrors the @-text the user types until
the user takes ownership by typing into the input itself. After that,
the input owns its own state and editor typing no longer overrides it.

Two empty states now exist:
- "Namen eingeben…" when the search input is empty (AC-4)
- "Keine Personen gefunden" when the search input has a query but the
  list is empty (existing behavior)

The dropdown reads editorQuery through the shared $state proxy via a
getter prop, matching the established pattern for model.items.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
ff3e8fb755 test(transcription): cover MentionDropdown onSearch callback wiring
For issue #380. Asserts that typing in the search input invokes the
onSearch prop with the current value — characterising the boundary that
PersonMentionEditor relies on for its debounced fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
38b87f6a9f feat(transcription): add data-test-search-input hook for E2E selectors
For issue #380. Adds an explicit Playwright selector attribute on the
mention search input so E2E tests target a stable hook instead of a
fragile CSS class string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
fa7fe42363 feat(transcription): add search input with initialQuery prefill to MentionDropdown
For issue #380. The dropdown now renders a dedicated search input at the
top, pre-filled with the text typed after @. This decouples the lookup
from the display text — the transcriber can edit the search field to
find a person whose stored name differs from what was typed.

The fetch wiring (onSearch callback) is consumed by PersonMentionEditor
in a follow-up commit; this commit only introduces the input UI and the
prop surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
Marcel
c667a5add8 feat(i18n): add person_mention_search_prompt message key
For issue #380 — the new search input inside the @mention dropdown
needs an empty-state prompt distinct from "no results found".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 12:36:45 +02:00
114 changed files with 756 additions and 3746 deletions

View File

@@ -39,12 +39,6 @@ PORT_PROMETHEUS=9090
# Grafana admin password — change this before exposing Grafana beyond localhost
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=http://localhost:3002

View File

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

View File

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

View File

@@ -5,10 +5,8 @@ import lombok.extern.slf4j.Slf4j;
import org.flywaydb.core.Flyway;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import javax.sql.DataSource;
import java.util.Map;
@Configuration
@RequiredArgsConstructor
@@ -16,7 +14,6 @@ import java.util.Map;
public class FlywayConfig {
private final DataSource dataSource;
private final Environment environment;
@Bean(name = "flyway")
public Flyway flyway() {
@@ -24,7 +21,6 @@ public class FlywayConfig {
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
.baselineOnMigrate(true)
.baselineVersion("4")
.load();
@@ -32,22 +28,4 @@ public class FlywayConfig {
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
return flyway;
}
// Fail-closed: refuse to boot when GRAFANA_DB_PASSWORD is unset. The
// grafana_reader role's password is (re)set on every boot by
// R__grafana_reader_password.sql, so a missing env var means we'd either
// skip the rotation silently or — with a hardcoded fallback — publish a
// well-known credential for a role with SELECT on audit_log, documents,
// and transcription_blocks. Same shape as UserDataInitializer's refusal
// to seed default admin credentials outside dev/test/e2e.
String resolveGrafanaDbPassword() {
String value = environment.getProperty("GRAFANA_DB_PASSWORD");
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"GRAFANA_DB_PASSWORD is required: it is consumed by "
+ "R__grafana_reader_password.sql to (re)set the grafana_reader "
+ "role's password on every boot. Generate with: openssl rand -hex 32");
}
return value;
}
}

View File

@@ -30,7 +30,6 @@ import java.util.UUID;
})
@NamedEntityGraph(name = "Document.list", attributeNodes = {
@NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"),
@NamedAttributeNode("tags")
})
@Entity

View File

@@ -1,36 +0,0 @@
package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
public record DocumentListItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String originalFilename,
String thumbnailUrl,
LocalDate documentDate,
Person sender,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Person> receivers,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Tag> tags,
String archiveBox,
String archiveFolder,
String location,
String summary,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int completionPercentage,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData
) {}

View File

@@ -0,0 +1,18 @@
package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.document.Document;
import java.util.List;
public record DocumentSearchItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Document document,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int completionPercentage,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors
) {}

View File

@@ -7,7 +7,7 @@ import java.util.List;
public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<DocumentListItem> items,
List<DocumentSearchItem> items,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long totalElements,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@@ -21,16 +21,16 @@ public record DocumentSearchResult(
* Single-page convenience factory used by empty-result shortcuts and by tests that
* don't care about paging. Treats the whole list as page 0 of itself.
*/
public static DocumentSearchResult of(List<DocumentListItem> items) {
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
int size = items.size();
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
}
/**
* Paged factory used by the service when it has a real Pageable + full match count
* (e.g. from Spring's Page&lt;T&gt; or from an in-memory sort-then-slice).
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
*/
public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
int pageSize = pageable.getPageSize();
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);

View File

@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.document.DocumentSearchItem;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
@@ -735,7 +736,7 @@ public class DocumentService {
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
}
private List<DocumentListItem> enrichItems(List<Document> documents, String text) {
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
List<Document> colorResolved = resolveDocumentTagColors(documents);
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
@@ -743,7 +744,7 @@ public class DocumentService {
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
return colorResolved.stream().map(doc -> toListItem(
return colorResolved.stream().map(doc -> new DocumentSearchItem(
doc,
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
completionByDoc.getOrDefault(doc.getId(), 0),
@@ -751,26 +752,6 @@ public class DocumentService {
)).toList();
}
private DocumentListItem toListItem(Document doc, SearchMatchData match, int completionPct, List<ActivityActorDTO> contributors) {
return new DocumentListItem(
doc.getId(),
doc.getTitle(),
doc.getOriginalFilename(),
doc.getThumbnailUrl(),
doc.getDocumentDate(),
doc.getSender(),
List.copyOf(doc.getReceivers()),
List.copyOf(doc.getTags()),
doc.getArchiveBox(),
doc.getArchiveFolder(),
doc.getLocation(),
doc.getSummary(),
completionPct,
contributors,
match
);
}
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
return transcriptionBlockQueryService.getCompletionStats(docIds);
}

View File

@@ -43,7 +43,7 @@ public class TranscriptionBlockController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock(
@PathVariable UUID documentId,
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
@@ -53,7 +53,7 @@ public class TranscriptionBlockController {
}
@PutMapping("/{blockId}")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock updateBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@@ -65,7 +65,7 @@ public class TranscriptionBlockController {
@DeleteMapping("/{blockId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.WRITE_ALL)
public void deleteBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId) {
@@ -73,7 +73,7 @@ public class TranscriptionBlockController {
}
@PutMapping("/reorder")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> reorderBlocks(
@PathVariable UUID documentId,
@RequestBody ReorderTranscriptionBlocksDTO dto) {
@@ -82,7 +82,7 @@ public class TranscriptionBlockController {
}
@PutMapping("/{blockId}/review")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock reviewBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@@ -92,7 +92,7 @@ public class TranscriptionBlockController {
}
@PutMapping("/review-all")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> markAllBlocksReviewed(
@PathVariable UUID documentId,
Authentication authentication) {

View File

@@ -56,17 +56,9 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED }
public enum SkipReason {
INVALID_FILENAME_PATH_TRAVERSAL,
INVALID_PDF_SIGNATURE,
FILE_READ_ERROR,
ALREADY_EXISTS,
S3_UPLOAD_FAILED
}
public record SkippedFile(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) SkipReason reason
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
) {}
public record ImportStatus(
@@ -299,11 +291,6 @@ public class MassImportService {
if (index.isBlank()) continue;
String filename = index.contains(".") ? index : index + ".pdf";
if (!isValidImportFilename(filename)) {
log.warn("Skipping import row {}: filename rejected — {}", i, filename);
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_FILENAME_PATH_TRAVERSAL));
continue;
}
Optional<File> fileOnDisk = findFileRecursive(filename);
if (fileOnDisk.isEmpty()) {
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
@@ -313,17 +300,17 @@ public class MassImportService {
try {
if (!isPdfMagicBytes(fileOnDisk.get())) {
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_PDF_SIGNATURE));
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
continue;
}
} catch (IOException e) {
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
skippedFiles.add(new SkippedFile(filename, SkipReason.FILE_READ_ERROR));
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
continue;
}
}
Optional<SkipReason> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
if (skipReason.isPresent()) {
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
} else {
@@ -333,23 +320,6 @@ public class MassImportService {
return new ProcessResult(processed, skippedFiles);
}
private boolean isValidImportFilename(String filename) {
if (filename == null || filename.isBlank()) return false;
if (filename.contains("/")) return false;
if (filename.contains("\\")) return false;
if (filename.contains("")) return false; // U+2215 DIVISION SLASH
if (filename.contains("")) return false; // U+FF0F FULLWIDTH SOLIDUS
if (filename.contains("")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR
if (filename.contains("..")) return false;
if (filename.equals(".")) return false;
if (filename.contains("\0")) return false;
// Paths.get() is safe here on Linux for all inputs that passed the checks above;
// it may throw InvalidPathException for OS-specific illegal chars on Windows,
// but those are not reachable in production.
if (Paths.get(filename).isAbsolute()) return false;
return true;
}
// package-private: Mockito spy in tests can override to inject IOException
InputStream openFileStream(File file) throws IOException {
return new FileInputStream(file);
@@ -372,11 +342,11 @@ public class MassImportService {
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
*/
@Transactional
protected Optional<SkipReason> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
return Optional.of(SkipReason.ALREADY_EXISTS);
return Optional.of("ALREADY_EXISTS");
}
String archiveBox = getCell(cells, colBox);
@@ -412,7 +382,7 @@ public class MassImportService {
status = DocumentStatus.UPLOADED;
} catch (Exception e) {
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
return Optional.of(SkipReason.S3_UPLOAD_FAILED);
return Optional.of("S3_UPLOAD_FAILED");
}
}
@@ -490,18 +460,11 @@ public class MassImportService {
}
private Optional<File> findFileRecursive(String filename) {
File baseDir = new File(importDir);
try (Stream<Path> walk = Files.walk(baseDir.toPath())) {
Optional<Path> match = walk.filter(p -> !Files.isDirectory(p))
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
return walk.filter(p -> !Files.isDirectory(p))
.filter(p -> p.getFileName().toString().equals(filename))
.map(Path::toFile)
.findFirst();
if (match.isEmpty()) return Optional.empty();
File candidate = match.get().toFile();
String baseDirCanonical = baseDir.getCanonicalPath();
if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
}
return Optional.of(candidate);
} catch (IOException e) {
return Optional.empty();
}

View File

@@ -1,14 +0,0 @@
-- Repeatable migration: sets the grafana_reader role's password from the
-- ${grafanaDbPassword} placeholder (resolved by FlywayConfig from the
-- GRAFANA_DB_PASSWORD environment variable). Flyway computes the checksum on
-- the resolved migration content, so any change to GRAFANA_DB_PASSWORD changes
-- the checksum and re-applies this migration on the next boot. That makes
-- password rotation a "change env var + restart" operation — no manual psql.
--
-- V68 created the role itself (without a usable password). This file owns the
-- password lifecycle; nothing else writes it.
DO $$
BEGIN
EXECUTE format('ALTER ROLE grafana_reader WITH PASSWORD %L', '${grafanaDbPassword}');
END
$$;

View File

@@ -1,17 +0,0 @@
-- Read-only role used by the Grafana PostgreSQL datasource for the PO Overview
-- dashboard (issue #651). The role is created here without a usable password
-- (LOGIN-capable but no password set); R__grafana_reader_password.sql sets the
-- password from GRAFANA_DB_PASSWORD on every boot, so rotation is just "bump
-- the env var and restart the backend" — see docs/adr/024-* and the rotation
-- runbook in docs/DEPLOYMENT.md.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN
CREATE ROLE grafana_reader WITH LOGIN;
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,37 +0,0 @@
package org.raddatz.familienarchiv.config;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FlywayConfigTest {
@Test
void resolveGrafanaDbPassword_throws_when_env_unset() {
FlywayConfig config = new FlywayConfig(null, new MockEnvironment());
assertThatThrownBy(config::resolveGrafanaDbPassword)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
}
@Test
void resolveGrafanaDbPassword_throws_when_env_blank() {
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", " ");
FlywayConfig config = new FlywayConfig(null, env);
assertThatThrownBy(config::resolveGrafanaDbPassword)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
}
@Test
void resolveGrafanaDbPassword_returns_value_when_env_set() {
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", "abc");
FlywayConfig config = new FlywayConfig(null, env);
assertThat(config.resolveGrafanaDbPassword()).isEqualTo("abc");
}
}

View File

@@ -1,89 +0,0 @@
package org.raddatz.familienarchiv.config;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
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;
// GRAFANA_DB_PASSWORD is supplied via the global test default in
// src/test/resources/application.properties — FlywayConfig fails closed
// when it is unset, so all tests that load the migration path need it.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class GrafanaReaderRoleIntegrationTest {
@Autowired JdbcTemplate jdbc;
// --- positive grants (SELECT on the three explicitly granted tables) ---
@Test
void grafana_reader_has_select_on_audit_log() {
assertThat(hasPrivilege("audit_log", "SELECT")).isTrue();
}
@Test
void grafana_reader_has_select_on_documents() {
assertThat(hasPrivilege("documents", "SELECT")).isTrue();
}
@Test
void grafana_reader_has_select_on_transcription_blocks() {
assertThat(hasPrivilege("transcription_blocks", "SELECT")).isTrue();
}
// --- write-deny on the granted tables: SELECT-only means SELECT-only.
// A future migration that GRANTs INSERT/UPDATE/DELETE on any of these
// would fail these tests, even though the original positive grants still
// pass. Locks the boundary in both directions.
@Test
void grafana_reader_has_no_INSERT_on_documents() {
assertThat(hasPrivilege("documents", "INSERT")).isFalse();
}
@Test
void grafana_reader_has_no_UPDATE_on_audit_log() {
assertThat(hasPrivilege("audit_log", "UPDATE")).isFalse();
}
@Test
void grafana_reader_has_no_DELETE_on_transcription_blocks() {
assertThat(hasPrivilege("transcription_blocks", "DELETE")).isFalse();
}
// --- negative grants: PII / sensitive tables MUST NOT be readable.
// The parameterized form catches the "someone widened the grant to
// ALL TABLES IN SCHEMA public" footgun — three specific positive grants
// would still pass while this sweep turns red.
@ParameterizedTest
@ValueSource(strings = {
"app_users",
"user_groups",
"persons",
"notifications",
"document_comments",
"document_annotations",
"geschichten"
})
void grafana_reader_has_no_SELECT_on_protected_table(String table) {
assertThat(hasPrivilege(table, "SELECT")).isFalse();
}
private boolean hasPrivilege(String table, String privilege) {
Boolean result = jdbc.queryForObject(
"SELECT has_table_privilege('grafana_reader', ?, ?)",
Boolean.class,
table,
privilege);
return Boolean.TRUE.equals(result);
}
}

View File

@@ -27,6 +27,7 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.raddatz.familienarchiv.document.DocumentSearchItem;
import org.raddatz.familienarchiv.document.SearchMatchData;
import java.time.LocalDateTime;
@@ -129,13 +130,16 @@ class DocumentControllerTest {
@WithMockUser
void search_responseBodyItemsContainMatchData() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder()
.id(docId)
.title("Brief an Anna")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.build();
var matchData = new SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
0, List.of(), matchData))));
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk())
@@ -144,27 +148,6 @@ class DocumentControllerTest {
.value("Er schrieb einen langen Brief"));
}
@Test
@WithMockUser
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
0, List.of(), matchData))));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
// flat id field present at top of item (not nested under $.items[0].document.id)
.andExpect(jsonPath("$.items[0].id").value(docId.toString()))
// sensitive storage fields must never appear in list response
.andExpect(jsonPath("$.items[0].transcription").doesNotExist())
.andExpect(jsonPath("$.items[0].filePath").doesNotExist())
.andExpect(jsonPath("$.items[0].fileHash").doesNotExist());
}
// ─── /api/documents/search pagination ─────────────────────────────────────
@Test

View File

@@ -127,7 +127,7 @@ class DocumentLazyLoadingTest {
PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() ->
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
result.items().forEach(i -> i.document().getSender().getLastName()))
.doesNotThrowAnyException();
}

View File

@@ -1,98 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.ocr.TrainingLabel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* AC #2: Document with trainingLabels does not cause LazyInitializationException in search.
* AC #3: Detail API still returns trainingLabels after the Document.list graph change.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class DocumentListItemIntegrationTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditLogQueryService auditLogQueryService;
@Autowired
DocumentRepository documentRepository;
@Autowired
DocumentService documentService;
@AfterEach
void cleanup() {
documentRepository.deleteAll();
}
@Test
void search_doesNotThrow_whenDocumentHasTrainingLabels() {
documentRepository.save(Document.builder()
.title("Kurrent Brief")
.originalFilename("kurrent.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50)))
.doesNotThrowAnyException();
}
@Test
void search_returns_list_item_without_sensitive_fields_when_document_has_training_labels() {
documentRepository.save(Document.builder()
.title("Kurrent Brief")
.originalFilename("kurrent2.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50));
assertThat(result.totalElements()).isGreaterThan(0);
DocumentListItem item = result.items().get(0);
assertThat(item.id()).isNotNull();
assertThat(item.title()).isEqualTo("Kurrent Brief");
}
@Test
void detail_stillReturnsTrainingLabels() {
Document saved = documentRepository.save(Document.builder()
.title("Detail Test")
.originalFilename("detail_test.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
// Document.full entity graph (used by getDocumentById) must still load trainingLabels
Document loaded = documentService.getDocumentById(saved.getId());
assertThat(loaded.getTrainingLabels()).containsExactly(TrainingLabel.KURRENT_RECOGNITION);
}
}

View File

@@ -125,10 +125,10 @@ class DocumentSearchPagedIntegrationTest {
// No document id should appear on both pages — slicing must be exclusive.
var idsOnPage0 = page0.items().stream()
.map(item -> item.id())
.map(item -> item.document().getId())
.toList();
var idsOnPage1 = page1.items().stream()
.map(item -> item.id())
.map(item -> item.document().getId())
.toList();
for (UUID id : idsOnPage0) {
assertThat(idsOnPage1).doesNotContain(id);

View File

@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.springframework.data.domain.PageRequest;
import java.util.List;
@@ -12,11 +14,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class DocumentSearchResultTest {
private DocumentListItem item(UUID docId) {
return new DocumentListItem(
docId, "Test", "test.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
0, List.of(), SearchMatchData.empty());
private DocumentSearchItem item(UUID docId) {
Document doc = Document.builder()
.id(docId)
.title("Test")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.build();
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
}
@Test
@@ -40,7 +45,7 @@ class DocumentSearchResultTest {
@Test
void paged_factory_populates_paging_fields_from_pageable_and_total() {
List<DocumentListItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
@@ -63,10 +68,9 @@ class DocumentSearchResultTest {
void of_exposes_items_with_completion_and_contributors() {
UUID id = UUID.randomUUID();
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
DocumentListItem item = new DocumentListItem(
id, "T", "t.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
75, List.of(actor), SearchMatchData.empty());
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
.status(DocumentStatus.UPLOADED).build();
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));

View File

@@ -70,7 +70,7 @@ class DocumentServiceSortTest {
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
assertThat(result.items()).hasSize(2);
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
}
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
@@ -104,7 +104,7 @@ class DocumentServiceSortTest {
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
assertThat(result.items().get(0).id()).isEqualTo(id1);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
@Test
@@ -121,7 +121,7 @@ class DocumentServiceSortTest {
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
assertThat(result.items().get(0).id()).isEqualTo(id1);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
@@ -156,7 +156,7 @@ class DocumentServiceSortTest {
DocumentSort.RELEVANCE, null, null, PAGE);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
}
// ─── RELEVANCE sort — text + active filter ────────────────────────────────

View File

@@ -11,7 +11,7 @@ import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
import org.raddatz.familienarchiv.document.DocumentListItem;
import org.raddatz.familienarchiv.document.DocumentSearchItem;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
@@ -1444,7 +1444,7 @@ class DocumentServiceTest {
assertThat(result.totalPages()).isEqualTo(3);
assertThat(result.items()).hasSize(50);
// Page 1 (offset 50) under ascending sender sort should start at L050
assertThat(result.items().get(0).sender().getLastName()).isEqualTo("L050");
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
}
@Test
@@ -1565,7 +1565,7 @@ class DocumentServiceTest {
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
assertThat(result.items()).hasSize(2);
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
}
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
@@ -1584,7 +1584,7 @@ class DocumentServiceTest {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
assertThat(result.items()).extracting(DocumentListItem::title)
assertThat(result.items()).extracting(item -> item.document().getTitle())
.containsExactly("Has Receiver", "No Receivers");
}
@@ -1607,7 +1607,7 @@ class DocumentServiceTest {
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.items()).extracting(DocumentListItem::title)
assertThat(result.items()).extracting(item -> item.document().getTitle())
.containsExactly("smith doc", "Null lastname doc");
}

View File

@@ -154,10 +154,10 @@ class MassImportServiceTest {
.build();
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
Optional<MassImportService.SkipReason> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
verify(documentService, never()).save(any());
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
assertThat(result).isPresent().contains("ALREADY_EXISTS");
}
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
@@ -179,10 +179,10 @@ class MassImportServiceTest {
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(physicalFile, pdfHeader);
Optional<MassImportService.SkipReason> result = service.importSingleDocument(
Optional<String> result = service.importSingleDocument(
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
assertThat(result).isPresent().contains("ALREADY_EXISTS");
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentService, never()).save(any());
}
@@ -204,7 +204,7 @@ class MassImportServiceTest {
assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", MassImportService.SkipReason.S3_UPLOAD_FAILED));
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
}
@Test
@@ -223,7 +223,7 @@ class MassImportServiceTest {
assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly(MassImportService.SkipReason.ALREADY_EXISTS);
.containsExactly("ALREADY_EXISTS");
}
// ─── importSingleDocument — create new document (metadata only) ───────────
@@ -283,11 +283,11 @@ class MassImportServiceTest {
doThrow(new RuntimeException("S3 error"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
Optional<MassImportService.SkipReason> result = service.importSingleDocument(
Optional<String> result = service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentService, never()).save(any());
assertThat(result).isPresent().contains(MassImportService.SkipReason.S3_UPLOAD_FAILED);
assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
}
// ─── importSingleDocument — sender handling ───────────────────────────────
@@ -438,110 +438,6 @@ class MassImportServiceTest {
verify(documentService).findByOriginalFilename("doc002.pdf");
}
// ─── isValidImportFilename — security regression — do not remove ─────────
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsNull() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", (String) null);
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsBlank() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", " ");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsForwardSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "etc/passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsBackslash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..\\etc\\passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsDotDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "doc..evil.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsDotDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsAbsolutePath() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "/etc/passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsNullByte() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "file\0.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameIsPlainBasename() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "document.pdf");
assertThat(result).isTrue();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeDivisionSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsFullwidthSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeReverseSolidus() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameHasLeadingDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", ".hidden.pdf");
assertThat(result).isTrue();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameHasSpaces() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "Brief an Oma.pdf");
assertThat(result).isTrue();
}
@Test
void processRows_skipsRowAndContinues_whenFilenameIsPathTraversal() {
when(documentService.findByOriginalFilename("legitimate.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of(
List.of("header"),
minimalCells("../evil"), // row 1: path traversal — should be skipped
minimalCells("legitimate.pdf") // row 2: valid — should be processed
);
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1);
assertThat(result.skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly(MassImportService.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
}
// ─── importSingleDocument — non-blank optional fields ────────────────────
@Test
@@ -755,22 +651,7 @@ class MassImportServiceTest {
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
assertThat(spyService.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly(MassImportService.SkipReason.FILE_READ_ERROR);
}
// ─── findFileRecursive — symlink escape security regression — do not remove ─
@Test
void findFileRecursive_throwsDomainException_whenSymlinkEscapesImportDir(
@TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
Path outsideFile = outsideDir.resolve("secret.pdf");
Files.writeString(outsideFile, "sensitive content");
Files.createSymbolicLink(importDirPath.resolve("secret.pdf"), outsideFile);
ReflectionTestUtils.setField(service, "importDir", importDirPath.toString());
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "findFileRecursive", "secret.pdf"))
.isInstanceOf(DomainException.class);
.containsExactly("FILE_READ_ERROR");
}
// ─── readOds — XXE security regression ───────────────────────────────────

View File

@@ -1,8 +1,2 @@
logging.level.root=WARN
logging.level.org.raddatz=INFO
# Default test value so FlywayConfig's fail-closed check passes without each
# test having to set GRAFANA_DB_PASSWORD explicitly. The actual value is
# irrelevant in tests — Flyway only uses it to set the grafana_reader role's
# password, which no test connects with.
GRAFANA_DB_PASSWORD=test-grafana-reader-password

View File

@@ -147,9 +147,6 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
GF_USERS_ALLOW_SIGN_UP: "false"
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:
- grafana_data:/var/lib/grafana
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
@@ -168,7 +165,6 @@ services:
condition: service_healthy
networks:
- obs-net
- archiv-net # PO Overview dashboard queries archive-db via the grafana_reader role
# --- Error Tracking: GlitchTip ---

View File

@@ -227,9 +227,6 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
SPRING_DATASOURCE_USERNAME: archiv
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.
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: archiv-app

View File

@@ -163,9 +163,6 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
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_ACCESS_KEY: ${MINIO_ROOT_USER}
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` | — | — |
| `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_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` | — | — |
| `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 |
@@ -257,7 +256,6 @@ git.raddatz.cloud A <server IP>
| `MAIL_USERNAME` | release.yml | SMTP user |
| `MAIL_PASSWORD` | release.yml | SMTP 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` |
| `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 |
@@ -359,7 +357,6 @@ Both files are passed explicitly via `--env-file` to the compose command, so the
| Gitea secret | Notes |
|---|---|
| `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 |
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |
@@ -430,31 +427,6 @@ docker exec obs-loki wget -qO- \
Prometheus port `9090` and Grafana port `3003` (default; configurable via `PORT_GRAFANA`) are bound to `127.0.0.1` on the host. No other observability ports are host-bound.
##### Rotate the `grafana_reader` DB password
The PO Overview dashboard reads `audit_log`, `documents`, and `transcription_blocks` through the SELECT-only `grafana_reader` PostgreSQL role (issue #651, ADR-024). The role's password is owned by `R__grafana_reader_password.sql` — a Flyway *repeatable* migration that re-runs whenever the resolved `${grafanaDbPassword}` placeholder changes. That makes rotation a two-restart operation, no manual `psql` required.
```bash
# 1. Generate a new value
openssl rand -hex 32
# 2. Update both sides:
# - Gitea secret GRAFANA_DB_PASSWORD (nightly + release workflows pick it up)
# - Local .env on the server / dev machine
# 3. Restart the backend. Flyway sees that R__'s resolved checksum changed and
# re-applies it, issuing ALTER ROLE grafana_reader WITH PASSWORD '<new>'.
docker compose restart backend
# 4. Restart obs-grafana so the provisioned datasource picks up the new env value.
docker compose -f docker-compose.observability.yml restart obs-grafana
# 5. Verify the dashboard loads — PO Overview's Postgres panels should populate
# instead of "Data source error".
```
If `GRAFANA_DB_PASSWORD` is unset, the backend **refuses to start** (`IllegalStateException`). That is deliberate — see `FlywayConfig.resolveGrafanaDbPassword()` and the rationale in ADR-024.
#### GlitchTip
| Item | Value |

View File

@@ -80,14 +80,6 @@ _See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
**Sütterlin** — A specific standardized style of Kurrent taught in German schools from 1915 to 1941.
**Illegible word** — a word whose recognition confidence falls below the configured threshold; replaced with the literal token `[unleserlich]` in the rendered block text and counted in the `ocr_illegible_words_total` Prometheus counter.
**Models-ready gauge** — the `ocr_models_ready` Prometheus gauge, flipped from `0` to `1` once the FastAPI lifespan startup has finished loading the Kraken model and the spell-checker. Used both for the `/health` endpoint and as the supervised signal for the `ocr_models_ready < 1 for 2m` alert.
**Recognition model accuracy** — the accuracy reported by `ketos train` for the recognition (text-line) model, exposed as `ocr_model_accuracy{kind="recognition"}`. Sourced from `_parse_best_checkpoint` on the highest-scoring checkpoint after training.
**Segmentation model accuracy** — the accuracy reported by `ketos segtrain` for the baseline layout analysis (`blla`) model, exposed as `ocr_model_accuracy{kind="segmentation"}`. Distinct from recognition accuracy because the two models are trained and improved independently.
---
## Other Domain Terms

View File

@@ -118,14 +118,11 @@ To find a trace for a specific request in staging/production, either increase th
## Metrics (Prometheus → Grafana)
Prometheus scrapes two targets every 15 s:
Prometheus scrapes the backend management endpoint every 15 s:
```
Target: backend:8081/actuator/prometheus
Labels: job="spring-boot", application="Familienarchiv"
Target: ocr:8000/metrics
Labels: job="ocr-service"
```
All Spring Boot metrics carry the `application="Familienarchiv"` tag, which is how the Grafana Spring Boot Observability dashboard (ID 17175) filters to this service.
@@ -149,70 +146,6 @@ jvm_memory_used_bytes{area="heap", application="Familienarchiv"}
hikaricp_connections_active
```
### OCR-service custom metrics
Exposed at `ocr:8000/metrics` by `prometheus-fastapi-instrumentator`. The
`http_*` metrics describe the FastAPI request layer; the `ocr_*` series are
domain-specific. **Never label these with PII or document content** — labels
have unbounded cardinality risk and are visible to anyone with Grafana access.
| Metric | Type | Labels | Unit | What it tracks |
|---|---|---|---|---|
| `ocr_jobs_total` | Counter | `engine` (`surya`/`kraken`), `script_type` | jobs | OCR jobs that started after a successful PDF download |
| `ocr_pages_total` | Counter | `engine` | pages | Successfully OCR'd pages in the streaming generator |
| `ocr_skipped_pages_total` | Counter | — | pages | Pages skipped because the engine raised on them |
| `ocr_words_total` | Counter | — | words | Recognized words summed across every block |
| `ocr_illegible_words_total` | Counter | — | words | Words below the confidence threshold (rendered as `[unleserlich]`) |
| `ocr_processing_seconds` | Histogram | `engine` | seconds | Per-page (stream) or per-document (`/ocr`) engine time, excluding preprocessing |
| `ocr_training_runs_total` | Counter | `kind` (`recognition`/`segmentation`), `outcome` (`success`/`error`) | runs | Completed training runs |
| `ocr_model_accuracy` | Gauge | `kind` | ratio (01) | Latest accuracy reported by a successful training run |
| `ocr_models_ready` | Gauge | — | 0\|1 | 1 once the lifespan startup has finished loading models |
Canonical example queries (the same ones referenced in issue #652):
```promql
# OCR throughput by engine
sum by (engine) (rate(ocr_pages_total[5m]))
# Share of words rendered as [unleserlich]
sum(rate(ocr_illegible_words_total[5m]))
/ sum(rate(ocr_words_total[5m]))
# p95 page processing time per engine
histogram_quantile(0.95, sum by (engine, le) (
rate(ocr_processing_seconds_bucket[5m])
))
# Training error rate
sum(rate(ocr_training_runs_total{outcome="error"}[1h]))
/ sum(rate(ocr_training_runs_total[1h]))
# Latest recognition vs segmentation accuracy
ocr_model_accuracy
```
### Internal-only endpoints
`/metrics` is exposed by the OCR service over plain HTTP without
authentication. The container is reachable only on the internal Docker
network — Caddy never proxies to it directly. If the service is ever
exposed (e.g. a `ports:` mapping is added), block the endpoint at the
reverse proxy:
```caddy
ocr.example.com {
@internal_only path /metrics /health
respond @internal_only 404
reverse_proxy ocr:8000
}
```
The `MetricsPathFilter` in `ocr-service/main.py` suppresses uvicorn's
**stdout** access log lines for `/metrics` and `/health` so the container
console stays focused on real OCR traffic. Promtail/Loki still receive
access lines from any other source. Treat the filter as console
noise-control, not an audit-suppression mechanism.
## Errors (GlitchTip)
GlitchTip receives errors from both the backend (via Sentry Java SDK) and the frontend (via Sentry JavaScript SDK). It groups events by fingerprint, tracks first/last seen times, and links to the release that introduced the error.

View File

@@ -1,94 +0,0 @@
# ADR-023: Prometheus Instrumentator and Metrics Registry Injection
## Status
Accepted
## Context
Until issue #652 the OCR service exposed no `/metrics` endpoint. The
observability stack already scrapes the Spring Boot backend's actuator
endpoint, but it had nothing to scrape on the Python side. Without HTTP-
and domain-level metrics from `ocr-service` we cannot answer questions
like "what is the share of words rendered as `[unleserlich]`" or
"is the training error rate above its budget" from Grafana.
Two implementation requirements influenced the design:
1. **Counter / gauge isolation in tests.** `prometheus_client` collectors
are module-level singletons keyed by name on the global `REGISTRY`.
Re-importing or naively re-instantiating them raises a duplicated-
collector error and cross-test state leaks (a `.inc()` in test A is
still readable by test B). A test harness needs a way to swap the
active container for a fresh per-test instance.
2. **Minimal blast radius on the request path.** We did not want to
hand-instrument every endpoint with FastAPI middleware. The
`prometheus-fastapi-instrumentator` library already provides
`http_requests_total`, `http_request_duration_seconds`, and the
`/metrics` exposition route, all idiomatic Prometheus names.
## Decision
- Add `prometheus-fastapi-instrumentator==7.0.0` and pin its transitive
dependency `prometheus-client==0.25.0` explicitly in
`ocr-service/requirements.txt`.
- Mount the instrumentator once at module load:
`Instrumentator(excluded_handlers=["/health", "/metrics"]).instrument(app).expose(app)`.
This adds `/metrics` and an HTTP-level dashboard surface without
changing any endpoint code.
- Define every domain metric (`ocr_jobs_total`, `ocr_pages_total`,
`ocr_processing_seconds`, …) inside a `build_metrics(registry)`
factory in `ocr-service/metrics.py` that returns a frozen `OcrMetrics`
dataclass. Production code binds the container to the default
`REGISTRY` once: `metrics: OcrMetrics = build_metrics(REGISTRY)`.
- Tests use a `fresh_metrics` fixture that builds a new
`CollectorRegistry()` per test and monkeypatches `main.metrics` with
a container bound to it. The endpoint code keeps reading
`metrics.<name>` without knowing whether it is talking to the global
registry or a per-test one.
## Consequences
**Positive**
- One reusable factory captures the metric definitions; future metrics
go in one place.
- Tests run with full counter isolation. Cross-test state leakage is
impossible because each test sees its own dataclass instance.
- The instrumentator gives us `http_*` metrics for free, including a
Grafana-ready histogram that pairs with the Spring Boot one.
**Negative**
- One extra level of indirection: any test that asserts on metric
values must remember to monkeypatch `main.metrics`, not the registry
directly. Rebinding through the registry is harmless but useless —
the dataclass holds references to the original collectors.
- `prometheus-client` is now pinned. Upgrading it requires an explicit
bump and re-checking the instrumentator's compatibility range.
- `/metrics` is exposed unauthenticated and relies on the Docker
internal network for confidentiality. See
[docs/OBSERVABILITY.md §Internal-only endpoints](../OBSERVABILITY.md)
for the Caddy snippet that must be added if the service ever gets a
host-side port mapping.
## Alternatives considered
- **Hand-roll the `/metrics` endpoint.** Rejected: would have meant
duplicating what `prometheus-fastapi-instrumentator` ships, plus
middleware for the HTTP histograms.
- **Skip the factory; pass `registry` as a function argument
everywhere.** Rejected: clutters every endpoint signature and breaks
the symmetry with the Spring Boot side, which also relies on a
process-global Micrometer registry.
- **Use a `pytest` autouse fixture that resets `REGISTRY` between
tests.** Rejected: `prometheus_client` does not expose a clean
"unregister all" hook, and we would be relying on private APIs.
## References
- Issue: [#652](https://git.raddatz.cloud/marcel/familienarchiv/issues/652)
- Library: <https://github.com/trallnag/prometheus-fastapi-instrumentator>
- Code: `ocr-service/metrics.py`, `ocr-service/main.py`,
`ocr-service/test_metrics.py`

View File

@@ -1,123 +0,0 @@
# ADR-024: Grafana reads archive-db via a bridged network and a SELECT-only role
## Status
Accepted
## Context
Issue #651 (the PO Overview Grafana dashboard) needs aggregates over three
tables in the main application database — `audit_log`, `documents`, and
`transcription_blocks` — to answer the operator's four weekly questions: is
everything working, are people using it, is the archive making progress, is
OCR working well.
Until now, `obs-grafana` and the rest of the observability stack lived on
their own Docker network (`obs-net`) and never touched `archiv-net`, where
`archive-db` runs. The two were intentionally isolated: a compromise of any
observability container could not pivot to the application database.
The PO Overview's archive-progress and user-activity panels need rolling
7-day SQL aggregates that cannot be served by Prometheus or Loki. That
forces a connection from `obs-grafana` to `archive-db` for the first time.
Two implementation requirements shaped the design:
1. **Least privilege on the database side.** The Spring Boot application
role (`archiv`) has full read/write on every table. Letting Grafana
connect with that role would mean a Grafana compromise becomes an
application compromise. The dashboard only needs SELECT on three
tables; the role must reflect that and nothing more.
2. **Operational simplicity of secret rotation.** The role's password is
shared between the migration that sets it and the Grafana datasource
that uses it. A first version of this work put the password in a
versioned Flyway migration (V68), which Flyway only applies once —
leaving rotation as an out-of-band `psql ALTER ROLE` step that no
runbook documented. The shape must support rotation without manual
SQL.
## Decision
- Provision a dedicated PostgreSQL role `grafana_reader` with `LOGIN` plus
`GRANT SELECT` on `audit_log`, `documents`, `transcription_blocks` only.
No INSERT/UPDATE/DELETE on any table, no access to any other table —
enforced by the database, locked in by both positive and parameterized
negative tests in `GrafanaReaderRoleIntegrationTest`.
- Split the role's lifecycle across two migrations:
- `V68__add_grafana_reader_role.sql` — versioned, immutable, idempotent.
Creates the role and applies the grants. Runs exactly once per
database, like every other versioned migration.
- `R__grafana_reader_password.sql` — Flyway *repeatable* migration that
issues `ALTER ROLE grafana_reader WITH PASSWORD '${grafanaDbPassword}'`.
Flyway computes the checksum on the resolved content, so any change
to `GRAFANA_DB_PASSWORD` flips the checksum and re-applies the
migration on the next boot. Rotation becomes "bump env var, restart
backend, restart obs-grafana" — see the runbook in
`docs/DEPLOYMENT.md §4 → Rotate the grafana_reader DB password`.
- Resolve the password through Spring's `Environment` rather than a raw
`System.getenv()` call, so tests inject via `application.properties`
and the resolver is unit-testable with `MockEnvironment`. Fail closed
with `IllegalStateException` when the variable is unset — no fallback
string. Same shape as `UserDataInitializer`'s refusal to seed default
admin credentials outside dev/test/e2e.
- Join `obs-grafana` to `archiv-net` in addition to `obs-net`. Only the
Grafana container crosses the boundary; Loki, Tempo, Prometheus,
GlitchTip, and the worker containers remain `obs-net`-only.
## Consequences
**Positive**
- Database-level least privilege: a Grafana compromise gains SELECT on
three tables. Cannot write, cannot read PII tables like `app_users`,
`persons`, `notifications`, `document_comments`, `geschichten`. The
parameterized PII negative sweep in `GrafanaReaderRoleIntegrationTest`
is the regression gate; new sensitive tables get added to that list.
- Rotation is documented, idempotent, and survives operator turnover.
No "the password set on day 1 is the password forever" failure mode.
- Tests pin down both sides of the boundary: positive grants must hold,
write-deny must hold, and the PII negative list must stay empty.
**Negative / trade-offs**
- `obs-net` is no longer fully isolated from `archiv-net`. A Grafana RCE
(e.g. via a future Grafana CVE) gains a TCP path to `archive-db`
contained, but not impossible. The least-privilege role is the
mitigation; we accept that mitigation as sufficient for a single
bridged container.
- The backend must hold `GRAFANA_DB_PASSWORD` in its environment forever,
so Flyway can resolve the placeholder on every boot. A backend RCE
therefore also leaks the Grafana datasource password. Acceptable
because that password's blast radius is itself bounded by the
least-privilege grants on `grafana_reader`.
## Alternatives considered
- **Prometheus PostgreSQL exporter, no direct connection.** Loses ad-hoc
SQL aggregates — the dashboard would need every metric pre-defined as
an exporter query, with a redeploy to add a new one. The PO Overview
is the type of dashboard that grows panels over time; pre-defining
every aggregate is the wrong shape.
- **Read replica or logical-replication slot dedicated to Grafana.**
Real operational cost (extra Postgres instance, replication monitoring,
storage doubled) disproportionate to a weekly PO glance.
- **Versioned migration with `flyway repair` for rotation.** Rejected:
conflates schema lifecycle with credential lifecycle, requires manual
intervention to rotate, and the repair command's semantics are
surprising to operators unfamiliar with Flyway internals.
- **Hardcoded fallback password when env var is unset.** Rejected as a
security blocker: publishes a known credential for a role with read
access to user activity and full letter text. The fail-closed
behavior is the explicit defense.
## References
- Issue #651 — PO Overview Grafana dashboard
- `backend/src/main/resources/db/migration/V68__add_grafana_reader_role.sql`
- `backend/src/main/resources/db/migration/R__grafana_reader_password.sql`
- `backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java`
- `backend/src/test/java/org/raddatz/familienarchiv/config/GrafanaReaderRoleIntegrationTest.java`
- `infra/observability/grafana/provisioning/datasources/datasources.yml`
- `docker-compose.observability.yml``archiv-net` bridge on `obs-grafana`
- `docs/DEPLOYMENT.md §4` — rotation runbook

View File

@@ -43,12 +43,9 @@ Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
Rel(mc, storage, "Bootstraps bucket + service account on startup", "MinIO Client CLI")
Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100")
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(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net")

View File

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

View File

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

View File

@@ -526,7 +526,6 @@
"notification_filter_unread": "No leídas",
"notification_filter_mention": "Mención",
"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_load_more": "Cargar anteriores",
"notification_empty_history": "Sin notificaciones",

View File

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

View File

@@ -5,36 +5,7 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
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';
});
afterEach(cleanup);
function notif(partial: Partial<NotificationItem>): NotificationItem {
return {
@@ -55,8 +26,8 @@ describe('ChronikFuerDichBox', () => {
it('renders inbox-zero state when there are no unread items', async () => {
render(ChronikFuerDichBox, {
unread: [],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
expect(zero).not.toBeNull();
@@ -66,8 +37,8 @@ describe('ChronikFuerDichBox', () => {
it('links to the archived mentions in the inbox-zero state', async () => {
render(ChronikFuerDichBox, {
unread: [],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
expect(link).not.toBeNull();
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
it('renders the count badge with correct total when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
});
@@ -85,8 +56,8 @@ describe('ChronikFuerDichBox', () => {
it('count badge has aria-live=polite when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
// Wait for render
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
@@ -98,8 +69,8 @@ describe('ChronikFuerDichBox', () => {
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
render(ChronikFuerDichBox, {
unread: [],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
@@ -109,38 +80,38 @@ describe('ChronikFuerDichBox', () => {
it('renders the "Alle gelesen" button when unread exists', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
});
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => {
const optimisticMarkAllRead = vi.fn();
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead
onMarkRead: vi.fn(),
onMarkAllRead
});
await userEvent.click(page.getByText('Alle gelesen'));
expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1);
expect(onMarkAllRead).toHaveBeenCalledTimes(1);
});
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => {
const optimisticMarkRead = vi.fn();
it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
const onMarkRead = vi.fn();
const n = notif({ id: 'xyz' });
render(ChronikFuerDichBox, {
unread: [n],
optimisticMarkRead,
optimisticMarkAllRead: vi.fn()
onMarkRead,
onMarkAllRead: vi.fn()
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull();
dismiss?.click();
expect(optimisticMarkRead).toHaveBeenCalledTimes(1);
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz');
expect(onMarkRead).toHaveBeenCalledTimes(1);
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
});
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
@@ -153,8 +124,8 @@ describe('ChronikFuerDichBox', () => {
annotationId: 'annot-9'
})
],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const link = document.querySelector(
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
@@ -165,8 +136,8 @@ describe('ChronikFuerDichBox', () => {
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
onMarkRead: vi.fn(),
onMarkAllRead: vi.fn()
});
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
expect(dismiss).not.toBeNull();
@@ -174,22 +145,4 @@ describe('ChronikFuerDichBox', () => {
// Prevents the senior-audience tap-drag bug flagged by Leonie.
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,36 +4,7 @@ import { page } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications';
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';
});
afterEach(cleanup);
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n-1',
@@ -51,7 +22,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
describe('ChronikFuerDichBox', () => {
it('renders the inbox-zero state when there are no unread', async () => {
render(ChronikFuerDichBox, {
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} }
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
});
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
@@ -63,8 +34,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
@@ -91,8 +62,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention({ actorName: 'Bertha' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
@@ -105,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
@@ -115,11 +86,11 @@ describe('ChronikFuerDichBox', () => {
.toBeVisible();
});
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn();
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
const onMarkRead = vi.fn();
const item = mention({ id: 'n-7' });
render(ChronikFuerDichBox, {
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} }
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
});
const dismiss = document.querySelector(
@@ -127,55 +98,35 @@ describe('ChronikFuerDichBox', () => {
) as HTMLElement;
dismiss.click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7');
expect(onMarkRead).toHaveBeenCalledWith(item);
});
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => {
const optimisticMarkAllRead = vi.fn();
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, {
props: {
unread: [mention()],
optimisticMarkRead: () => {},
optimisticMarkAllRead
onMarkRead: () => {},
onMarkAllRead
}
});
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
btn.click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('builds a deep-link href to the comment for each notification', async () => {
render(ChronikFuerDichBox, {
props: {
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
onMarkRead: () => {},
onMarkAllRead: () => {}
}
});
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
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

@@ -17,7 +17,6 @@ import PdfViewer from '$lib/document/viewer/PdfViewer.svelte';
import { bulkTitleFromFilename } from '$lib/document/filename';
import type { Tag } from '$lib/tag/TagInput.svelte';
import type { components } from '$lib/generated/api';
import { withCsrf } from '$lib/shared/cookies';
type Person = components['schemas']['Person'];
@@ -184,10 +183,7 @@ async function saveUpload() {
// FormData with per-chunk progress. Session cookie is sent automatically
// by the browser for same-origin requests.
try {
const res = await fetch(
'/api/documents/quick-upload',
withCsrf({ method: 'POST', body: formData })
);
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
const body = await res.json().catch(() => ({ errors: [] }));
const errorFilenames = new Set<string>(
(body.errors ?? []).map((err: { filename: string }) => err.filename)

View File

@@ -5,7 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
import { formatDate } from '$lib/shared/utils/date';
type Document = components['schemas']['Document'];
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
interface Props {
selectedDocuments?: Document[];
@@ -45,12 +45,8 @@ function handleInput() {
try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentListItem[] } = await res.json();
const docs = body.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate
})) as unknown as Document[];
const body: { items: DocumentSearchItem[] } = await res.json();
const docs = body.items.map((it) => it.document);
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {

View File

@@ -10,19 +10,7 @@ const docFactory = (id: string, title: string, date = '1880-01-01') => ({
title,
documentDate: date,
originalFilename: `${title}.pdf`,
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED' as const,
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
@@ -34,7 +22,7 @@ function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items })
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
})
);
}
@@ -103,7 +91,10 @@ describe('DocumentMultiSelect — search and select', () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
items: [docFactory('d1', 'Already attached'), docFactory('d2', 'Not attached')]
items: [
{ document: docFactory('d1', 'Already attached') },
{ document: docFactory('d2', 'Not attached') }
]
})
});
vi.stubGlobal('fetch', fetchMock);

View File

@@ -9,11 +9,11 @@ import ProgressRing from '$lib/shared/primitives/ProgressRing.svelte';
import ContributorStack from '$lib/shared/primitives/ContributorStack.svelte';
import DocumentThumbnail from './DocumentThumbnail.svelte';
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
let { item, canWrite = false }: { item: DocumentListItem; canWrite?: boolean } = $props();
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
const doc = $derived(item);
const doc = $derived(item.document);
const titleText = $derived(doc.title || doc.originalFilename);
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
const titleSegments = $derived(applyOffsets(titleText, titleOffsets));

View File

@@ -14,17 +14,24 @@ afterEach(() => {
bulkSelectionStore.clear();
});
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
return {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
documentDate: '2024-03-15',
sender: undefined,
receivers: [],
tags: [],
document: {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15',
sender: null,
receivers: [],
tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: {
titleOffsets: [],
senderMatched: false,
@@ -48,14 +55,14 @@ describe('DocumentRow title', () => {
});
it('falls back to originalFilename when title is null', async () => {
const item = makeItem({ title: null as unknown as string });
const item = makeItem({ document: { ...makeItem().document, title: null } });
render(DocumentRow, { item });
await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument();
});
it('renders a mark element for highlighted title offsets', async () => {
const item = makeItem({
title: 'Brief an Anna',
document: { ...makeItem().document, title: 'Brief an Anna' },
matchData: {
titleOffsets: [{ start: 0, length: 5 }],
senderMatched: false,
@@ -102,12 +109,9 @@ describe('DocumentRow snippet', () => {
describe('DocumentRow sender', () => {
it('shows sender display name', async () => {
const item = makeItem({
sender: {
id: 's1',
lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
document: {
...makeItem().document,
sender: { id: 's1', displayName: 'Großmutter Maria' }
}
});
render(DocumentRow, { item });
@@ -122,12 +126,9 @@ describe('DocumentRow sender', () => {
it('highlights the sender when senderMatched is true', async () => {
const item = makeItem({
sender: {
id: 's1',
lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
document: {
...makeItem().document,
sender: { id: 's1', displayName: 'Großmutter Maria' }
},
matchData: {
...makeItem().matchData,
@@ -141,15 +142,10 @@ describe('DocumentRow sender', () => {
it('highlights a receiver when matchedReceiverIds includes its id', async () => {
const item = makeItem({
receivers: [
{
id: 'r1',
lastName: 'Karl',
displayName: 'Onkel Karl',
personType: 'PERSON',
familyMember: false
}
],
document: {
...makeItem().document,
receivers: [{ id: 'r1', displayName: 'Onkel Karl' }]
},
matchData: {
...makeItem().matchData,
matchedReceiverIds: ['r1']
@@ -166,7 +162,10 @@ describe('DocumentRow sender', () => {
describe('DocumentRow summary', () => {
it('renders the document summary when present', async () => {
const item = makeItem({
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
document: {
...makeItem().document,
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
}
});
render(DocumentRow, { item });
await expect
@@ -181,7 +180,7 @@ describe('DocumentRow summary', () => {
it('applies summary search-match highlight via summaryOffsets', async () => {
const item = makeItem({
summary: 'Brief über Menton',
document: { ...makeItem().document, summary: 'Brief über Menton' },
matchData: {
...makeItem().matchData,
summaryOffsets: [{ start: 11, length: 6 }]
@@ -197,19 +196,25 @@ describe('DocumentRow summary', () => {
describe('DocumentRow archive chips', () => {
it('renders the archive box chip when set', async () => {
const item = makeItem({ archiveBox: 'K3' });
const item = makeItem({
document: { ...makeItem().document, archiveBox: 'K3' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('K3')).toBeInTheDocument();
});
it('renders the archive folder chip when set', async () => {
const item = makeItem({ archiveFolder: 'Mappe A' });
const item = makeItem({
document: { ...makeItem().document, archiveFolder: 'Mappe A' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('Mappe A')).toBeInTheDocument();
});
it('renders the location chip when meta_location is set', async () => {
const item = makeItem({ location: 'Berlin' });
const item = makeItem({
document: { ...makeItem().document, location: 'Berlin' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
@@ -220,7 +225,10 @@ describe('DocumentRow archive chips', () => {
describe('DocumentRow tags', () => {
it('renders tag buttons', async () => {
const item = makeItem({
tags: [{ id: 't1', name: 'Familie' }]
document: {
...makeItem().document,
tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
@@ -228,7 +236,10 @@ describe('DocumentRow tags', () => {
it('navigates to /documents?tag=… on tag click', async () => {
const item = makeItem({
tags: [{ id: 't1', name: 'Urlaub & Reise' }]
document: {
...makeItem().document,
tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
// Tailwind CSS isn't loaded in the vitest-browser client project, so the
@@ -244,7 +255,10 @@ describe('DocumentRow tags', () => {
it('tag click does not navigate to the document detail page', async () => {
const item = makeItem({
tags: [{ id: 't2', name: 'Familie' }]
document: {
...makeItem().document,
tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
const before = window.location.href;
@@ -267,7 +281,7 @@ describe('DocumentRow bulk selection checkbox', () => {
});
it('checkbox aria-label includes the document title', async () => {
const item = makeItem({ title: 'Brief an Anna' });
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
render(DocumentRow, { item, canWrite: true });
await expect
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
@@ -275,7 +289,7 @@ describe('DocumentRow bulk selection checkbox', () => {
});
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
const item = makeItem({ id: 'doc-42' });
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
render(DocumentRow, { item, canWrite: true });
expect(bulkSelectionStore.has('doc-42')).toBe(false);
@@ -286,7 +300,7 @@ describe('DocumentRow bulk selection checkbox', () => {
it('checked state mirrors the store', async () => {
bulkSelectionStore.add('doc-99');
const item = makeItem({ id: 'doc-99' });
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
render(DocumentRow, { item, canWrite: true });
await expect.element(page.getByRole('checkbox')).toBeChecked();
});

View File

@@ -20,31 +20,10 @@ const { default: DocumentRow } = await import('./DocumentRow.svelte');
afterEach(cleanup);
const sender = {
id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON' as const,
familyMember: false
};
const receiver = {
id: 'r1',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON' as const,
familyMember: false
};
const sender = { id: 's1', displayName: 'Anna Schmidt' };
const receiver = { id: 'r1', displayName: 'Bert Meier' };
const emptyMatchData = {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
};
const baseItem = (overrides: Record<string, unknown> = {}) => ({
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Brief 1923',
originalFilename: 'b.pdf',
@@ -52,16 +31,22 @@ const baseItem = (overrides: Record<string, unknown> = {}) => ({
sender,
receivers: [receiver],
tags: [],
summary: undefined,
archiveBox: undefined,
archiveFolder: undefined,
location: undefined,
matchData: emptyMatchData,
completionPercentage: 0,
contributors: [],
thumbnailUrl: null,
contentType: 'application/pdf',
summary: null,
archiveBox: null,
archiveFolder: null,
location: null,
...overrides
});
const baseItem = (docOverrides: Record<string, unknown> = {}) => ({
document: makeDoc(docOverrides),
matchData: null,
completionPercentage: 0,
contributors: []
});
describe('DocumentRow', () => {
it('renders the title', async () => {
render(DocumentRow, { props: { item: baseItem() } });
@@ -136,9 +121,12 @@ describe('DocumentRow', () => {
it('renders the snippet when matchData provides a transcriptionSnippet', async () => {
render(DocumentRow, {
props: {
item: baseItem({
matchData: { ...emptyMatchData, transcriptionSnippet: 'Hello world snippet' }
})
item: {
document: makeDoc(),
matchData: { transcriptionSnippet: 'Hello world snippet' },
completionPercentage: 50,
contributors: []
}
}
});

View File

@@ -6,7 +6,6 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
import { withCsrf } from '$lib/shared/cookies';
type Props = {
documentId: string;
@@ -114,14 +113,11 @@ function handleDelete(blockId: string) {
async function reorder(newOrder: string[]) {
try {
const res = await fetch(
`/api/documents/${documentId}/transcription-blocks/reorder`,
withCsrf({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blockIds: newOrder })
})
);
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blockIds: newOrder })
});
if (!res.ok) return;
const updated = await res.json();
for (const b of updated) {

View File

@@ -1,6 +1,5 @@
import { SvelteMap } from 'svelte/reactivity';
import type { PersonMention } from '$lib/shared/types';
import { withCsrf } from '$lib/shared/cookies';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
@@ -117,15 +116,12 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
for (const [blockId, text] of pendingTexts) {
const mentions = pendingMentions.get(blockId) ?? [];
clearDebounce(blockId);
void fetch(
`/api/documents/${documentId}/transcription-blocks/${blockId}`,
withCsrf({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons: mentions }),
keepalive: true
})
);
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons: mentions }),
keepalive: true
});
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
}

View File

@@ -2,7 +2,6 @@
lastEditedAt's $derived are scope-local to one computation; they're never
stored on $state. */
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
import { makeCsrfFetch } from '$lib/shared/cookies';
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
import { BlockConflictResolvedError } from './blockConflictMerge';
@@ -42,7 +41,7 @@ export function createTranscriptionBlocks(
options: TranscriptionBlocksOptions
): TranscriptionBlocksController {
const { documentId } = options;
const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch);
const fetchImpl = options.fetchImpl ?? fetch;
let blocks = $state<TranscriptionBlockData[]>([]);
let annotationReloadKey = $state(0);

View File

@@ -2068,20 +2068,12 @@ export interface components {
};
ImportStatus: {
/** @enum {string} */
state: "IDLE" | "RUNNING" | "DONE" | "FAILED";
statusCode: string;
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
statusCode?: string;
/** Format: int32 */
processed: number;
skippedFiles: components["schemas"]["SkippedFile"][];
processed?: number;
/** Format: date-time */
startedAt?: string;
/** Format: int32 */
skipped?: number;
};
SkippedFile: {
filename: string;
/** @enum {string} */
reason: "INVALID_FILENAME_PATH_TRAVERSAL" | "INVALID_PDF_SIGNATURE" | "FILE_READ_ERROR" | "ALREADY_EXISTS" | "S3_UPLOAD_FAILED";
};
BackfillStatus: {
/** @enum {string} */
@@ -2205,10 +2197,10 @@ export interface components {
totalStories: number;
};
PersonSummaryDTO: {
title?: string;
/** Format: uuid */
id?: string;
displayName?: string;
title?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
@@ -2315,14 +2307,14 @@ export interface components {
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
/** Format: int32 */
number?: number;
sort?: components["schemas"]["SortObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
numberOfElements?: number;
empty?: boolean;
@@ -2388,28 +2380,15 @@ export interface components {
/** Format: int32 */
totalPages?: number;
};
DocumentListItem: {
/** Format: uuid */
id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
DocumentSearchItem: {
document: components["schemas"]["Document"];
matchData: components["schemas"]["SearchMatchData"];
/** Format: int32 */
completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
};
DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][];
items: components["schemas"]["DocumentSearchItem"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false);
@@ -28,6 +30,17 @@ function closeDropdown() {
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) {
if (event.key === 'Escape' && open) {
event.stopPropagation();
@@ -100,8 +113,8 @@ onDestroy(() => {
{#if open}
<NotificationDropdown
notifications={stream.notifications}
optimisticMarkRead={stream.optimisticMarkRead}
optimisticMarkAllRead={stream.optimisticMarkAllRead}
onMarkRead={handleMarkRead}
onMarkAllRead={stream.markAllRead}
onClose={closeDropdown}
/>
{/if}

View File

@@ -3,18 +3,10 @@ import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), 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 gotoMock = vi.hoisted(() => vi.fn());
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
vi.mock('$lib/notification/notifications.svelte', () => ({
@@ -25,17 +17,18 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
get unreadCount() {
return mockNotificationList.value.length;
},
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn(),
markRead: mockMarkRead,
fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(),
destroy: vi.fn()
destroy: vi.fn(),
markAllRead: vi.fn()
}
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
gotoMock.mockClear();
mockMarkRead.mockClear();
mockNotificationList.value = [];
});
@@ -52,6 +45,16 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...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', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
@@ -79,3 +82,29 @@ describe('NotificationBell — cursor and tooltip', () => {
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,21 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
type Props = {
notifications: NotificationItem[];
optimisticMarkRead: (id: string) => void;
optimisticMarkAllRead: () => void;
onMarkRead: (notification: NotificationItem) => void;
onMarkAllRead: () => void;
onClose: () => void;
};
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props();
let errorMessage = $state<string | null>(null);
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition
@@ -35,35 +31,16 @@ function handleViewAll() {
{m.notification_bell_label()}
</span>
{#if notifications.length > 0}
<form
action="/aktivitaeten?/mark-all-read"
method="POST"
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 });
}
};
}}
<button
type="button"
onclick={onMarkAllRead}
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
<button
type="submit"
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
</form>
{m.notification_mark_all_read()}
</button>
{/if}
</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 -->
{#if notifications.length === 0}
<!-- Empty state -->
@@ -89,93 +66,67 @@ function handleViewAll() {
<ul role="list" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)}
<li>
<form
action="/aktivitaeten?/dismiss-notification"
method="POST"
class="contents"
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
)
);
}
};
}}
<button
type="button"
onclick={() => onMarkRead(notification)}
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
{!notification.read ? 'bg-accent-bg/20' : ''}"
>
<input type="hidden" name="notificationId" value={notification.id} />
<button
type="submit"
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
{!notification.read ? 'bg-accent-bg/20' : ''}"
>
<!-- Type icon -->
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
{#if notification.type === 'REPLY'}
<!-- Reply icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{:else}
<!-- Mention icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<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>
<!-- Type icon -->
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
{#if notification.type === 'REPLY'}
<!-- Reply icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{:else}
<!-- Mention icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<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}
</button>
</form>
</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}
</button>
</li>
{/each}
</ul>

View File

@@ -6,38 +6,9 @@ import NotificationDropdown from './NotificationDropdown.svelte';
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(() => {
cleanup();
vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
});
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -58,8 +29,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -71,8 +42,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -84,8 +55,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -99,8 +70,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -112,8 +83,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -127,8 +98,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -145,8 +116,8 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: true })
],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -155,100 +126,37 @@ describe('NotificationDropdown', () => {
expect(unreadDots.length).toBe(1);
});
it('each notification row is wrapped in a form posting to the dismiss action', async () => {
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();
it('calls onMarkRead with the notification when an item is clicked', async () => {
const onMarkRead = vi.fn();
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead,
optimisticMarkAllRead: () => {},
onMarkRead,
onMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n42');
expect(onMarkRead).toHaveBeenCalledWith(n);
});
it('the mark-all-read control is a form posting to the mark-all-read action', async () => {
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
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,
onMarkRead: () => {},
onMarkAllRead,
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle gelesen/i }).click();
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();
expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('calls onClose when the view-all button is clicked', async () => {
@@ -256,8 +164,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose
}
});
@@ -271,8 +179,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -285,15 +193,12 @@ describe('NotificationDropdown', () => {
it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => {
callOrder.push('goto');
return Promise.resolve();
});
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
render(NotificationDropdown, {
props: {
notifications: [],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose
}
});
@@ -307,8 +212,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -320,8 +225,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
@@ -337,78 +242,14 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', actorName: 'First' }),
makeNotification({ id: 'n2', actorName: 'Second' })
],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]');
expect(forms.length).toBe(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');
const items = document.querySelectorAll('button[type="button"]');
// At least 2 items + mark-all button
expect(items.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -108,46 +108,12 @@ describe('notificationStore (singleton)', () => {
expect(notificationStore.unreadCount).toBe(1);
});
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
it('markAllRead resets unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
await notificationStore.markAllRead();
notificationStore.optimisticMarkRead('sse-1');
expect(notificationStore.notifications[0].read).toBe(true);
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
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,19 +35,28 @@ async function fetchUnreadCount(): Promise<void> {
}
}
function optimisticMarkRead(id: string): void {
const notification = notifications.find((n) => n.id === id);
if (notification && !notification.read) {
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
async function markRead(notification: NotificationItem): Promise<void> {
if (!notification.read) {
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
}
function optimisticMarkAllRead(): void {
for (const n of notifications) {
n.read = true;
async function markAllRead(): Promise<void> {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
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 {
@@ -114,8 +123,8 @@ export const notificationStore = {
},
fetchNotifications,
fetchUnreadCount,
optimisticMarkRead,
optimisticMarkAllRead,
markRead,
markAllRead,
init,
destroy
};

View File

@@ -2,7 +2,6 @@
import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/ocr/training.js';
import { withCsrf } from '$lib/shared/cookies';
interface TrainingInfo {
availableBlocks?: number;
@@ -34,7 +33,7 @@ async function startTraining() {
successMessage = null;
errorMessage = null;
try {
const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' }));
const res = await fetch('/api/ocr/train', { method: 'POST' });
if (res.ok) {
successMessage = m.training_success();
setTimeout(() => {

View File

@@ -2,7 +2,6 @@
import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/ocr/training.js';
import { withCsrf } from '$lib/shared/cookies';
interface TrainingInfo {
availableSegBlocks?: number;
@@ -28,7 +27,7 @@ async function startTraining() {
training = true;
successMessage = null;
try {
const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' }));
const res = await fetch('/api/ocr/segtrain', { method: 'POST' });
if (res.ok) {
successMessage = m.training_success();
setTimeout(() => {

View File

@@ -1,20 +0,0 @@
import { describe, it, expect } from 'vitest';
import { extractErrorCode } from './api.server';
describe('extractErrorCode', () => {
it('returns the code string when error has a code property', () => {
expect(extractErrorCode({ code: 'DOCUMENT_NOT_FOUND' })).toBe('DOCUMENT_NOT_FOUND');
});
it('returns undefined when error is undefined', () => {
expect(extractErrorCode(undefined)).toBeUndefined();
});
it('returns undefined when error is null', () => {
expect(extractErrorCode(null)).toBeUndefined();
});
it('returns undefined when error is a plain string', () => {
expect(extractErrorCode('oops')).toBeUndefined();
});
it('returns undefined when error object has no code property', () => {
expect(extractErrorCode({ message: 'fail' })).toBeUndefined();
});
});

View File

@@ -23,11 +23,3 @@ export function createApiClient(fetch: typeof globalThis.fetch) {
fetch
});
}
export interface ApiError {
code?: string;
}
export function extractErrorCode(error: unknown): string | undefined {
return (error as ApiError | undefined)?.code;
}

View File

@@ -1,46 +1,3 @@
/**
* Reads the XSRF-TOKEN cookie set by Spring Security's CookieCsrfTokenRepository.
* Returns null outside the browser or when the cookie is absent.
*/
export function getCsrfToken(): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Merges the X-XSRF-TOKEN header into a RequestInit so Spring Security's
* CSRF filter accepts the request. Safe to call server-side (no-op when the
* cookie is absent).
*/
export function withCsrf(init?: RequestInit): RequestInit {
const token = getCsrfToken();
if (!token) return init ?? {};
const headers = new Headers(init?.headers);
headers.set('X-XSRF-TOKEN', token);
return { ...init, headers };
}
/**
* Wraps a fetch implementation so that every state-mutating call (POST, PUT,
* PATCH, DELETE) automatically includes the X-XSRF-TOKEN header. GET/HEAD
* requests pass through unchanged.
*
* Used to CSRF-protect client-side hooks that accept an injectable fetchImpl.
* In unit tests the injected mock is wrapped but getCsrfToken() returns null
* (no browser cookie), so no header is added and existing test expectations
* are unaffected.
*/
export function makeCsrfFetch(inner: typeof fetch): typeof fetch {
return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const method = (init?.method ?? 'GET').toUpperCase();
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
return inner(input, withCsrf(init));
}
return inner(input, init);
};
}
/**
* Extracts the fa_session cookie value from a list of Set-Cookie response headers.
*

View File

@@ -5,7 +5,7 @@ import DocumentRow from '$lib/document/DocumentRow.svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE';
@@ -17,7 +17,7 @@ let {
q = '',
sort = 'DATE'
}: {
items: DocumentListItem[];
items: DocumentSearchItem[];
canWrite: boolean;
error?: string | null;
total?: number;
@@ -31,10 +31,10 @@ const groups = $derived.by(() => {
return groupByYear(items);
});
function groupByYear(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentListItem[]>();
function groupByYear(docItems: DocumentSearchItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>();
for (const item of docItems) {
const label = item.documentDate?.substring(0, 4) ?? m.docs_group_undated();
const label = item.document.documentDate?.substring(0, 4) ?? m.docs_group_undated();
const bucket = map.get(label);
if (bucket) bucket.push(item);
else map.set(label, [item]);
@@ -42,10 +42,10 @@ function groupByYear(docItems: DocumentListItem[]) {
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
}
function groupBySender(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentListItem[]>();
function groupBySender(docItems: DocumentSearchItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>();
for (const item of docItems) {
const label = item.sender?.displayName ?? m.docs_group_unknown_sender();
const label = item.document.sender?.displayName ?? m.docs_group_unknown_sender();
const bucket = map.get(label);
if (bucket) bucket.push(item);
else map.set(label, [item]);
@@ -53,10 +53,10 @@ function groupBySender(docItems: DocumentListItem[]) {
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
}
function groupByReceiver(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentListItem[]>();
function groupByReceiver(docItems: DocumentSearchItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>();
for (const item of docItems) {
const receivers = item.receivers ?? [];
const receivers = item.document.receivers ?? [];
const labels =
receivers.length > 0
? receivers.map((r) => r.displayName)
@@ -99,7 +99,7 @@ function groupByReceiver(docItems: DocumentListItem[]) {
>
</div>
<ul class="divide-y divide-line">
{#each group.items as item (group.label + '-' + item.id)}
{#each group.items as item (group.label + '-' + item.document.id)}
<DocumentRow item={item} canWrite={canWrite} />
{/each}
</ul>

View File

@@ -8,17 +8,24 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => cleanup());
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
return {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
documentDate: '2024-03-15',
sender: undefined,
receivers: [],
tags: [],
document: {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15',
sender: undefined,
receivers: [],
tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: {
titleOffsets: [],
senderMatched: false,
@@ -68,8 +75,8 @@ describe('DocumentList empty state', () => {
describe('DocumentList year grouping', () => {
it('groups documents by year into separate cards', async () => {
const items = [
makeItem({ id: '1', documentDate: '1923-04-12' }),
makeItem({ id: '2', documentDate: '1965-08-03' })
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1923-04-12' } }),
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } })
];
render(DocumentList, { ...baseProps, items, total: 2 });
const groupCards = page.getByTestId('group-card');
@@ -78,15 +85,17 @@ describe('DocumentList year grouping', () => {
});
it('uses undated label for items with no documentDate', async () => {
const items = [makeItem({ id: '1', documentDate: undefined })];
const items = [
makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } })
];
render(DocumentList, { ...baseProps, items, total: 1 });
await expect.element(page.getByText('Undatiert')).toBeInTheDocument();
});
it('single year renders one group-card', async () => {
const items = [
makeItem({ id: '1', documentDate: '1938-01-01' }),
makeItem({ id: '2', documentDate: '1938-06-15' })
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }),
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } })
];
render(DocumentList, { ...baseProps, items, total: 2 });
const groupCards = page.getByTestId('group-card');
@@ -99,7 +108,9 @@ describe('DocumentList year grouping', () => {
describe('DocumentList sort fallback', () => {
it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => {
const items = [makeItem({ id: '1', documentDate: '2024-03-15' })];
const items = [
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '2024-03-15' } })
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' });
await expect
.element(page.getByTestId('group-header').filter({ hasText: '2024' }))
@@ -113,23 +124,29 @@ describe('DocumentList sender grouping', () => {
it('groups by sender displayName when sort is SENDER', async () => {
const items = [
makeItem({
id: '1',
sender: {
id: 's1',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
document: {
...makeItem().document,
id: '1',
sender: {
id: 's1',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
}
}
}),
makeItem({
id: '2',
sender: {
id: 's2',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
document: {
...makeItem().document,
id: '2',
sender: {
id: 's2',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
}
}
})
];
@@ -150,7 +167,10 @@ describe('DocumentList sender grouping', () => {
personType: 'PERSON' as const,
familyMember: false
};
const items = [makeItem({ id: '1', sender }), makeItem({ id: '2', sender })];
const items = [
makeItem({ document: { ...makeItem().document, id: '1', sender } }),
makeItem({ document: { ...makeItem().document, id: '2', sender } })
];
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
const cards = page.getByTestId('group-card');
await expect.element(cards.first()).toBeInTheDocument();
@@ -158,7 +178,7 @@ describe('DocumentList sender grouping', () => {
});
it('places items with no sender under fallback label', async () => {
const items = [makeItem({ id: '1', sender: undefined })];
const items = [makeItem({ document: { ...makeItem().document, id: '1', sender: undefined } })];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' });
await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument();
});
@@ -170,16 +190,19 @@ describe('DocumentList receiver grouping', () => {
it('groups by receiver displayName when sort is RECEIVER', async () => {
const items = [
makeItem({
id: '1',
receivers: [
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
}
]
document: {
...makeItem().document,
id: '1',
receivers: [
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
}
]
}
})
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
@@ -191,24 +214,27 @@ describe('DocumentList receiver grouping', () => {
it('duplicates a document into each receiver group', async () => {
const items = [
makeItem({
id: '1',
title: 'Rundbriefchen',
receivers: [
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
},
{
id: 'r2',
lastName: 'Meier',
displayName: 'Hans Meier',
personType: 'PERSON',
familyMember: false
}
]
document: {
...makeItem().document,
id: '1',
title: 'Rundbriefchen',
receivers: [
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
},
{
id: 'r2',
lastName: 'Meier',
displayName: 'Hans Meier',
personType: 'PERSON',
familyMember: false
}
]
}
})
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
@@ -223,7 +249,7 @@ describe('DocumentList receiver grouping', () => {
});
it('places items with no receivers under fallback label', async () => {
const items = [makeItem({ id: '1', receivers: [] })];
const items = [makeItem({ document: { ...makeItem().document, id: '1', receivers: [] } })];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument();
});
@@ -235,7 +261,7 @@ describe('DocumentList DocumentRow delegation', () => {
it('shows transcription snippet when matchData has one', async () => {
const items = [
makeItem({
id: 'doc1',
document: { ...makeItem().document, id: 'doc1' },
matchData: {
transcriptionSnippet: 'Er schrieb einen langen Brief',
titleOffsets: [],
@@ -252,7 +278,7 @@ describe('DocumentList DocumentRow delegation', () => {
});
it('does not render snippet when matchData has no transcription snippet', async () => {
const items = [makeItem({ id: 'doc1' })];
const items = [makeItem({ document: { ...makeItem().document, id: 'doc1' } })];
render(DocumentList, { ...baseProps, items, total: 1 });
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
});
@@ -260,8 +286,7 @@ describe('DocumentList DocumentRow delegation', () => {
it('renders mark for title highlight when titleOffsets present', async () => {
const items = [
makeItem({
id: 'doc1',
title: 'Brief an Anna',
document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' },
matchData: {
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
senderMatched: false,

View File

@@ -20,46 +20,29 @@ const { default: DocumentList } = await import('./DocumentList.svelte');
afterEach(cleanup);
const sender = {
id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON' as const,
familyMember: false
};
const receiver = {
id: 'r1',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON' as const,
familyMember: false
};
const emptyMatchData = {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
};
const sender = { id: 's1', displayName: 'Anna Schmidt' };
const receiver = { id: 'r1', displayName: 'Bert Meier' };
const makeItem = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Brief 1923',
originalFilename: 'b.pdf',
documentDate: '1923-04-15',
sender,
receivers: [receiver],
tags: [],
summary: undefined,
archiveBox: undefined,
archiveFolder: undefined,
location: undefined,
matchData: emptyMatchData,
document: {
id: 'd1',
title: 'Brief 1923',
originalFilename: 'b.pdf',
documentDate: '1923-04-15',
sender,
receivers: [receiver],
tags: [],
thumbnailUrl: null,
contentType: 'application/pdf',
summary: null,
archiveBox: null,
archiveFolder: null,
location: null,
...overrides
},
matchData: null,
completionPercentage: 0,
contributors: [],
...overrides
contributors: []
});
describe('DocumentList', () => {
@@ -104,26 +87,8 @@ describe('DocumentList', () => {
render(DocumentList, {
props: {
items: [
makeItem({
id: 'd1',
sender: {
id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON',
familyMember: false
}
}),
makeItem({
id: 'd2',
sender: {
id: 's2',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON',
familyMember: false
}
})
makeItem({ id: 'd1', sender: { id: 's1', displayName: 'Anna Schmidt' } }),
makeItem({ id: 'd2', sender: { id: 's2', displayName: 'Bert Meier' } })
],
canWrite: false,
sort: 'SENDER' as const

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api';
@@ -34,16 +34,16 @@ export async function load({ fetch, locals }) {
]);
if (!usersResult.response.ok) {
throw error(usersResult.response.status, getErrorMessage(extractErrorCode(usersResult.error)));
const code = (usersResult.error as unknown as { code?: string })?.code;
throw error(usersResult.response.status, getErrorMessage(code));
}
if (!groupsResult.response.ok) {
throw error(
groupsResult.response.status,
getErrorMessage(extractErrorCode(groupsResult.error))
);
const code = (groupsResult.error as unknown as { code?: string })?.code;
throw error(groupsResult.response.status, getErrorMessage(code));
}
if (!tagsResult.response.ok) {
throw error(tagsResult.response.status, getErrorMessage(extractErrorCode(tagsResult.error)));
const code = (tagsResult.error as unknown as { code?: string })?.code;
throw error(tagsResult.response.status, getErrorMessage(code));
}
let inviteCount = 0;

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ params, parent }) => {
@@ -24,9 +24,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
@@ -39,9 +38,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, '/admin/groups');

View File

@@ -7,8 +7,7 @@ const mockApi = {
};
vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
createApiClient: () => mockApi
}));
beforeEach(() => vi.clearAllMocks());

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+layout.server';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,6 +1,6 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export const actions: Actions = {
@@ -16,9 +16,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, '/admin/groups');

View File

@@ -1,5 +1,5 @@
import { fail } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { Actions, PageServerLoad } from './$types';
import type { components } from '$lib/generated/api';
@@ -25,7 +25,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
let invites: InviteListItem[] = [];
let loadError: string | null = null;
if (!invitesResult.response.ok) {
loadError = extractErrorCode(invitesResult.error) ?? 'INTERNAL_ERROR';
const code = (invitesResult.error as unknown as { code?: string })?.code;
loadError = code ?? 'INTERNAL_ERROR';
} else {
invites = (invitesResult.data ?? []) as InviteListItem[];
}
@@ -33,7 +34,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
let groups: UserGroup[] = [];
let groupsLoadError: string | null = null;
if (!groupsResult.response.ok) {
groupsLoadError = extractErrorCode(groupsResult.error) ?? 'INTERNAL_ERROR';
const code = (groupsResult.error as unknown as { code?: string })?.code;
groupsLoadError = code ?? 'INTERNAL_ERROR';
} else {
const raw = groupsResult.data ?? [];
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
@@ -60,9 +62,8 @@ export const actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
createError: extractErrorCode(result.error) ?? 'INTERNAL_ERROR'
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
}
return { created: result.data! as InviteListItem };
@@ -77,9 +78,8 @@ export const actions = {
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
if (!result.response.ok) {
return fail(result.response.status, {
revokeError: extractErrorCode(result.error) ?? 'INTERNAL_ERROR'
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
}
return { revoked: id };

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+layout.server';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ fetch }) => {
@@ -8,7 +8,8 @@ export const load: PageServerLoad = async ({ fetch }) => {
const result = await api.GET('/api/ocr/training-info');
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { trainingInfo: result.data! };

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ params, fetch }) => {
@@ -10,7 +10,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
});
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { history: result.data!, personId: params.personId };

View File

@@ -3,10 +3,7 @@ import { load } from './+page.server';
const mockApi = { GET: vi.fn() };
vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
beforeEach(() => vi.clearAllMocks());

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ fetch }) => {
@@ -8,7 +8,8 @@ export const load: PageServerLoad = async ({ fetch }) => {
const result = await api.GET('/api/ocr/training-info/global');
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { history: result.data! };

View File

@@ -3,10 +3,7 @@ import { load } from './+page.server';
const mockApi = { GET: vi.fn() };
vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
beforeEach(() => vi.clearAllMocks());

View File

@@ -3,10 +3,7 @@ import { load } from './+page.server';
const mockApi = { GET: vi.fn() };
vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
beforeEach(() => vi.clearAllMocks());

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ params, parent, url }) => {
@@ -25,9 +25,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
@@ -44,9 +43,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, `/admin/tags/${result.data!.id}?merged=1`);
@@ -67,9 +65,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, '/admin/tags');

View File

@@ -8,8 +8,7 @@ const mockApi = {
};
vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
createApiClient: () => mockApi
}));
beforeEach(() => vi.clearAllMocks());

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+layout.server';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api';
@@ -55,9 +55,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
@@ -70,9 +69,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, '/admin/users');

View File

@@ -4,10 +4,7 @@ vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://localhost:8080' }
}));
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
import { load, actions } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+layout.server';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ fetch, locals }) => {
@@ -35,9 +35,8 @@ export const actions: Actions = {
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, '/admin/users');

View File

@@ -1,6 +1,4 @@
import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components, operations } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -67,31 +65,3 @@ export async function load({ fetch, url }) {
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,6 +76,14 @@ 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 isEmpty = $derived(displayFeed.length === 0);
@@ -100,11 +108,7 @@ function retry() {
{#if data.loadError === 'activity'}
<ChronikErrorCard onRetry={retry} />
{:else}
<ChronikFuerDichBox
unread={unread}
optimisticMarkRead={notificationStore.optimisticMarkRead}
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
/>
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
<div class="mt-6">
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />

View File

@@ -1,15 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load, actions } from './+page.server';
import { load } from './+page.server';
const mockApi = {
GET: vi.fn(),
PATCH: vi.fn(),
POST: vi.fn()
GET: vi.fn()
};
vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
createApiClient: () => mockApi
}));
function buildUrl(search = ''): URL {
@@ -176,84 +173,3 @@ describe('aktivitaeten/load — kinds param per filter', () => {
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,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { components } from '$lib/generated/api';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export async function load({ url, fetch, locals }) {
@@ -39,7 +39,8 @@ export async function load({ url, fetch, locals }) {
})
.then((result) => {
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
documents = result.data ?? [];
})
@@ -48,7 +49,8 @@ export async function load({ url, fetch, locals }) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => {
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const p = result.data as { displayName: string } | undefined;
if (p) senderName = p.displayName;
@@ -60,7 +62,8 @@ export async function load({ url, fetch, locals }) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => {
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const p = result.data as { displayName: string } | undefined;
if (p) receiverName = p.displayName;

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+page.server';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
vi.mock('$lib/shared/errors', () => ({
getErrorMessage: (code: string) => code ?? 'Unknown error'
}));

View File

@@ -1,5 +1,5 @@
import { redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api';
@@ -20,7 +20,7 @@ async function resolvePersonName(
}
}
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
type ValidSort = (typeof VALID_SORTS)[number];
@@ -77,7 +77,7 @@ export async function load({ url, fetch }) {
]);
} catch {
return {
items: [] as DocumentListItem[],
items: [] as DocumentSearchItem[],
totalElements: 0,
pageNumber: 0,
pageSize: PAGE_SIZE,
@@ -103,11 +103,12 @@ export async function load({ url, fetch }) {
}
const errorMessage: string | null = !result.response.ok
? (getErrorMessage(extractErrorCode(result.error)) ?? 'Daten konnten nicht geladen werden.')
? (getErrorMessage((result.error as unknown as { code?: string })?.code) ??
'Daten konnten nicht geladen werden.')
: null;
return {
items: (result.data?.items ?? []) as DocumentListItem[],
items: (result.data?.items ?? []) as DocumentSearchItem[],
totalElements: result.data?.totalElements ?? 0,
pageNumber: result.data?.pageNumber ?? page,
pageSize: result.data?.pageSize ?? PAGE_SIZE,

View File

@@ -1,5 +1,5 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { inferredRelationshipLabel } from '$lib/person/relationshipLabels';
@@ -17,7 +17,8 @@ export async function load({ params, fetch }) {
if (docResult.response.status === 401) throw redirect(302, '/login');
if (!docResult.response.ok) {
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
const code = (docResult.error as unknown as { code?: string })?.code;
throw error(docResult.response.status, getErrorMessage(code));
}
const document = docResult.data!;

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
export async function load({
@@ -30,7 +30,8 @@ export async function load({
]);
if (!docResult.response.ok) {
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
const code = (docResult.error as unknown as { code?: string })?.code;
throw error(docResult.response.status, getErrorMessage(code));
}
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
@@ -75,9 +76,8 @@ export const actions = {
// Fetch current document to preserve all existing fields
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } });
if (!docResult.response.ok) {
return fail(docResult.response.status, {
error: getErrorMessage(extractErrorCode(docResult.error))
});
const code = (docResult.error as unknown as { code?: string })?.code;
return fail(docResult.response.status, { error: getErrorMessage(code) });
}
const doc = docResult.data!;

View File

@@ -1,9 +1,6 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
vi.mock('$env/dynamic/private', () => ({ env: { API_INTERNAL_URL: 'http://test-backend:8080' } }));
import { load } from './+page.server';

View File

@@ -1,9 +1,6 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
import { load } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';

View File

@@ -140,12 +140,15 @@ describe('documents/+ page', () => {
data: baseData({
items: [
{
id: 'd1',
title: 'Brief 1899',
documentDate: '1899-04-14',
originalFilename: 'b1.pdf',
receivers: [],
tags: [],
document: {
id: 'd1',
title: 'Brief 1899',
status: 'TRANSCRIBED',
documentDate: '1899-04-14',
summary: '',
originalFilename: 'b1.pdf',
receivers: []
},
matchData: {
titleOffsets: [],
senderMatched: false,

View File

@@ -1,6 +1,6 @@
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
export async function load({
@@ -31,7 +31,8 @@ export async function load({
]);
if (!docResult.response.ok) {
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
const code = (docResult.error as unknown as { code?: string })?.code;
throw error(docResult.response.status, getErrorMessage(code));
}
const incompleteCount = countResult.response.ok ? (countResult.data?.count ?? 0) : 0;

View File

@@ -1,5 +1,5 @@
import { error } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api';
import type { PageServerLoad } from './$types';
@@ -25,7 +25,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
]);
if (!listResult.response.ok) {
throw error(listResult.response.status, getErrorMessage(extractErrorCode(listResult.error)));
const code = (listResult.error as unknown as { code?: string })?.code;
throw error(listResult.response.status, getErrorMessage(code));
}
const personFilters = personResults

View File

@@ -1,5 +1,5 @@
import { error } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { PageServerLoad } from './$types';
@@ -9,7 +9,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
params: { path: { id: params.id } }
});
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { geschichte: result.data! };
};

View File

@@ -1,5 +1,5 @@
import { error, redirect } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { PageServerLoad } from './$types';
@@ -13,7 +13,8 @@ export const load: PageServerLoad = async ({ params, fetch, parent }) => {
params: { path: { id: params.id } }
});
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { geschichte: result.data! };
};

View File

@@ -1,9 +1,6 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
import { load } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,5 +1,5 @@
import { error } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
export async function load({ params, fetch, locals }) {
@@ -32,10 +32,8 @@ export async function load({ params, fetch, locals }) {
]);
if (!personResult.response.ok) {
throw error(
personResult.response.status,
getErrorMessage(extractErrorCode(personResult.error))
);
const code = (personResult.error as unknown as { code?: string })?.code;
throw error(personResult.response.status, getErrorMessage(code));
}
return {

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