Compare commits

..

11 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -79,7 +79,6 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
EOF
- name: Verify backend /import:ro mount is wired

View File

@@ -25,14 +25,11 @@ import java.util.UUID;
@NamedEntityGraph(name = "Document.full", attributeNodes = {
@NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"),
@NamedAttributeNode("tags"),
@NamedAttributeNode("trainingLabels")
@NamedAttributeNode("tags")
})
@NamedEntityGraph(name = "Document.list", attributeNodes = {
@NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"),
@NamedAttributeNode("tags"),
@NamedAttributeNode("trainingLabels")
@NamedAttributeNode("tags")
})
@Entity
@Table(name = "documents")

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

@@ -252,8 +252,6 @@ services:
OTEL_METRICS_EXPORTER: none
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
SENTRY_DSN: ${SENTRY_DSN:-}
LOGGING_STRUCTURED_FORMAT_CONSOLE: ecs
networks:
- archiv-net
healthcheck:
@@ -268,10 +266,6 @@ services:
build:
context: ./frontend
target: production
args:
# Vite build-time variable — baked into the JS bundle at build time.
# Empty default so deploys succeed before the secret is configured.
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
restart: unless-stopped
depends_on:
backend:

View File

@@ -16,10 +16,6 @@ CMD ["npm", "run", "dev"]
# Compiles the SvelteKit Node-adapter output to /app/build.
FROM node:20.19.0-alpine3.21 AS build
WORKDIR /app
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
# Passed via docker-compose build.args; empty string disables the SDK.
ARG VITE_SENTRY_DSN
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

View File

@@ -634,9 +634,6 @@
"transcription_block_review": "Als geprüft markieren",
"transcription_block_unreview": "Markierung aufheben",
"transcription_reviewed_count": "{reviewed} von {total} geprüft",
"transcription_mark_all_reviewed": "Alle als fertig markieren",
"transcription_mark_all_reviewed_disabled": "Alle Blöcke sind bereits als fertig markiert",
"transcription_mark_all_reviewed_error": "Markierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"training_ocr_heading": "Kurrent-Erkennung trainieren",
"training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.",
"training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente",

View File

@@ -634,9 +634,6 @@
"transcription_block_review": "Mark as reviewed",
"transcription_block_unreview": "Unmark as reviewed",
"transcription_reviewed_count": "{reviewed} of {total} reviewed",
"transcription_mark_all_reviewed": "Mark all as reviewed",
"transcription_mark_all_reviewed_disabled": "All blocks are already marked as reviewed",
"transcription_mark_all_reviewed_error": "Failed to mark all as reviewed. Please try again.",
"training_ocr_heading": "Train Kurrent recognition",
"training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.",
"training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents",

View File

@@ -634,9 +634,6 @@
"transcription_block_review": "Marcar como revisado",
"transcription_block_unreview": "Desmarcar como revisado",
"transcription_reviewed_count": "{reviewed} de {total} revisados",
"transcription_mark_all_reviewed": "Marcar todo como revisado",
"transcription_mark_all_reviewed_disabled": "Todos los bloques ya están marcados como revisados",
"transcription_mark_all_reviewed_error": "Error al marcar como revisado. Intente de nuevo.",
"training_ocr_heading": "Entrenar reconocimiento Kurrent",
"training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.",
"training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos",

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

@@ -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;
@@ -50,7 +49,6 @@ let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false);
let markAllError = $state<string | null>(null);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0);
@@ -69,11 +67,8 @@ $effect(() => {
async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return;
markingAllReviewed = true;
markAllError = null;
try {
await onMarkAllReviewed();
} catch {
markAllError = m.transcription_mark_all_reviewed_error();
} finally {
markingAllReviewed = false;
}
@@ -114,14 +109,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) {
@@ -177,7 +169,7 @@ async function handleLabelToggle(label: string) {
<button
onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed}
title={allReviewed ? m.transcription_mark_all_reviewed_disabled() : undefined}
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
>
{#if markingAllReviewed}
@@ -215,7 +207,7 @@ async function handleLabelToggle(label: string) {
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{/if}
{m.transcription_mark_all_reviewed()}
Alle als fertig markieren
</button>
{/if}
</div>
@@ -225,31 +217,6 @@ async function handleLabelToggle(label: string) {
style="width: {reviewProgress}%"
></div>
</div>
{#if markAllError}
<div
role="alert"
class="mt-1.5 flex items-center gap-2 rounded-sm border border-red-200 bg-red-50 px-3 py-2 font-sans text-sm text-red-700"
>
<span class="flex-1">{markAllError}</span>
<button
onclick={() => (markAllError = null)}
aria-label={m.comp_dismiss()}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-red-600 hover:text-red-700 focus-visible:ring-2 focus-visible:ring-red-500"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div>
<div class="p-4">
<!-- svelte-ignore a11y_no_static_element_interactions -->

View File

@@ -3,7 +3,6 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import { m } from '$lib/paraglide/messages.js';
afterEach(cleanup);
@@ -313,14 +312,14 @@ describe('TranscriptionEditView — mark all reviewed', () => {
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument();
});
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument();
});
@@ -330,7 +329,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled();
});
@@ -344,7 +343,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
// handlers when a TipTap editor is mounted in the same component tree.
const btn = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.getByRole('button', { name: /Alle als fertig markieren/ })
.element()) as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
@@ -362,83 +361,12 @@ describe('TranscriptionEditView — mark all reviewed', () => {
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.getByRole('button', { name: /Alle als fertig markieren/ })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled();
resolveMarkAll();
});
it('shows error message when onMarkAllReviewed callback rejects', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.transcription_mark_all_reviewed_error());
});
it('clears error when dismiss button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const dismissEl = (await page
.getByRole('button', { name: m.comp_dismiss() })
.element()) as HTMLButtonElement;
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('clears error on next successful markAllReviewed call', async () => {
const onMarkAllReviewed = vi
.fn()
.mockRejectedValueOnce(new Error('INTERNAL_ERROR'))
.mockResolvedValue(undefined);
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
// Wait for the button to be re-enabled before the second click — ensures the first
// async rejection has fully settled and Svelte has flushed state changes
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('re-enables button after markAllReviewed failure', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
});
});

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

@@ -259,15 +259,12 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true);
});
it('throws and leaves blocks unchanged when PUT returns non-OK', async () => {
it('is a no-op when PUT returns non-OK', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') {
return new Response(JSON.stringify({ code: 'INTERNAL_ERROR' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
return new Response('', { status: 500 });
}
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200,
@@ -277,26 +274,7 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR');
expect(ctrl.blocks[0].reviewed).toBe(false);
});
it('throws INTERNAL_ERROR when PUT returns non-JSON body (e.g. nginx 502)', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') {
return new Response('Bad Gateway', { status: 502 });
}
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR');
await ctrl.markAllReviewed();
expect(ctrl.blocks[0].reviewed).toBe(false);
});
});

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);
@@ -120,11 +119,7 @@ export function createTranscriptionBlocks(
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, {
method: 'PUT'
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
// Never render body.message — route through getErrorMessage() to prevent leaking backend internals
throw new Error((body as { code?: string })?.code ?? 'INTERNAL_ERROR');
}
if (!res.ok) return;
const updated = (await res.json()) as { id: string; reviewed: boolean }[];
for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id);

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

@@ -196,7 +196,7 @@
},
"targets": [
{
"expr": "{job=\"$app\"} |= \"$search\" | json",
"expr": "{job=\"$app\"} |= \"$search\" | logfmt",
"hide": false,
"legendFormat": "",
"refId": "A"