Files
familienarchiv/frontend/src/lib/document/transcription/saveBlockWithConflictRetry.ts
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

64 lines
2.2 KiB
TypeScript

import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import {
BlockConflictResolvedError,
mergeBlockOnConflict
} from '$lib/document/transcription/blockConflictMerge';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type Args = {
fetchImpl: typeof fetch;
documentId: string;
blockId: string;
text: string;
mentionedPersons: PersonMention[];
};
/**
* Persists a transcription block edit, with built-in handling for the
* rename-mid-edit conflict (B12b).
*
* - 200/204 → resolves with the server's updated block.
* - 409 → refetches the latest server block, merges it with the
* transcriber's unsaved input via mergeBlockOnConflict, and
* throws BlockConflictResolvedError carrying the merged
* snapshot. The caller is responsible for updating local
* state with `err.merged` before surfacing the error.
* - other → throws Error('Save failed').
*
* Validates both ids against the UUID pattern before any fetch fires
* (Sina #5505 — defence-in-depth path-injection guard).
*/
export async function saveBlockWithConflictRetry(args: Args): Promise<TranscriptionBlockData> {
const { fetchImpl, documentId, blockId, text, mentionedPersons } = args;
if (!UUID_RE.test(documentId) || !UUID_RE.test(blockId)) {
throw new Error(`Invalid id for save: doc=${documentId} block=${blockId}`);
}
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const res = await fetchImpl(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons })
});
if (res.status === 409) {
const fresh = await fetchImpl(url);
if (!fresh.ok) {
throw new BlockConflictResolvedError(blockId);
}
const serverBlock = (await fresh.json()) as TranscriptionBlockData;
const merged = mergeBlockOnConflict({
serverBlock,
localText: text,
localMentions: mentionedPersons
});
const err = new BlockConflictResolvedError(blockId);
(err as BlockConflictResolvedError & { merged: TranscriptionBlockData }).merged = merged;
throw err;
}
if (!res.ok) throw new Error('Save failed');
return (await res.json()) as TranscriptionBlockData;
}