feat: replace raw error messages with structured error codes

Backend now returns { code: ErrorCode, message: string } for all errors,
making it language-agnostic. Frontend maps codes to localised strings via
Paraglide (en/de/es), so translations live in messages/*.json.

- Add ErrorCode enum and DomainException with static factory methods
- Update GlobalExceptionHandler to return ErrorResponse(code, message)
- Replace ResponseStatusException throughout controllers/services/aspects
- Add frontend errors.ts with parseBackendError() and getErrorMessage()
- getErrorMessage() delegates to Paraglide m.error_*() functions
- Add error_* keys to messages/en.json, de.json, es.json
- Update all page.server.ts files to use the new error utilities
- Fix hardcoded localhost URLs in admin and login pages
- Fix missing baseUrl in deleteTag action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-15 13:15:28 +01:00
parent ace57e9fc7
commit 4cc86de143
16 changed files with 269 additions and 88 deletions

View File

@@ -1,4 +1,14 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from de!"
"hello_world": "Hello, {name} from de!",
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
"error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.",
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
"error_unauthorized": "Sie sind nicht angemeldet.",
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"error_validation_error": "Die Eingabe ist ungültig.",
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten."
}

View File

@@ -1,4 +1,14 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!"
"hello_world": "Hello, {name} from en!",
"error_document_not_found": "Document not found.",
"error_document_no_file": "No file is associated with this document.",
"error_file_not_found": "The file could not be found in storage.",
"error_file_upload_failed": "The file could not be uploaded.",
"error_user_not_found": "User not found.",
"error_import_already_running": "An import is already running. Please wait for it to finish.",
"error_unauthorized": "You are not logged in.",
"error_forbidden": "You do not have permission for this action.",
"error_validation_error": "The input is invalid.",
"error_internal_error": "An unexpected error occurred."
}

View File

@@ -1,4 +1,14 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from es!"
"hello_world": "Hello, {name} from es!",
"error_document_not_found": "Documento no encontrado.",
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
"error_file_upload_failed": "No se pudo subir el archivo.",
"error_user_not_found": "Usuario no encontrado.",
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
"error_unauthorized": "No ha iniciado sesión.",
"error_forbidden": "No tiene permiso para realizar esta acción.",
"error_validation_error": "La entrada no es válida.",
"error_internal_error": "Se ha producido un error inesperado."
}

View File

@@ -0,0 +1,54 @@
import * as m from '$lib/paraglide/messages.js';
/**
* Mirror of the backend ErrorCode enum.
* Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
*/
export type ErrorCode =
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
| 'FILE_UPLOAD_FAILED'
| 'USER_NOT_FOUND'
| 'IMPORT_ALREADY_RUNNING'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
| 'INTERNAL_ERROR';
export interface BackendError {
code: ErrorCode;
message: string; // English developer message — not shown to users
}
/**
* Attempts to parse a backend ErrorResponse from a failed fetch response.
* Returns null if the body is not valid JSON or does not contain a code field.
*/
export async function parseBackendError(res: Response): Promise<BackendError | null> {
try {
const body = await res.json();
if (body && typeof body.code === 'string') {
return body as BackendError;
}
} catch {
// Body was not JSON
}
return null;
}
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
export function getErrorMessage(code: ErrorCode | string | undefined): string {
switch (code) {
case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found();
case 'DOCUMENT_NO_FILE': return m.error_document_no_file();
case 'FILE_NOT_FOUND': return m.error_file_not_found();
case 'FILE_UPLOAD_FAILED': return m.error_file_upload_failed();
case 'USER_NOT_FOUND': return m.error_user_not_found();
case 'IMPORT_ALREADY_RUNNING':return m.error_import_already_running();
case 'UNAUTHORIZED': return m.error_unauthorized();
case 'FORBIDDEN': return m.error_forbidden();
case 'VALIDATION_ERROR': return m.error_validation_error();
default: return m.error_internal_error();
}
}

View File

@@ -1,5 +1,6 @@
import { error } from '@sveltejs/kit';
import { error, fail } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { parseBackendError, getErrorMessage } from '$lib/errors';
export async function load({ fetch, locals }) {
// 1. Check Permissions (Adapt logic to your user object)
@@ -8,7 +9,7 @@ export async function load({ fetch, locals }) {
// Assuming user.group.permissions is an array of strings
const hasAdmin = user?.groups.some(g => g.permissions.includes("ADMIN"));
if (!hasAdmin) throw error(403, 'Zugriff verweigert');
if (!hasAdmin) throw error(403, getErrorMessage('FORBIDDEN'));
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
@@ -50,8 +51,11 @@ export const actions = {
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Erstellen' };
return { success: true, message: 'User angelegt' };
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { success: false, message: getErrorMessage(backendError?.code) });
}
return { success: true };
},
deleteUser: async ({ request, fetch }) => {
const data = await request.formData();
@@ -63,9 +67,10 @@ export const actions = {
});
if (!res.ok) {
return { success: false, message: 'Fehler beim Löschen des Benutzers' };
const backendError = await parseBackendError(res);
return fail(res.status, { success: false, message: getErrorMessage(backendError?.code) });
}
return { success: true, message: 'Benutzer erfolgreich gelöscht' };
return { success: true };
},
updateTag: async ({ request, fetch }) => {
const data = await request.formData();
@@ -84,8 +89,13 @@ export const actions = {
deleteTag: async ({ request, fetch }) => {
const data = await request.formData();
const id = data.get('id');
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
await fetch(`/api/tags/${id}`, { method: 'DELETE' });
const res = await fetch(baseUrl + `/api/tags/${id}`, { method: 'DELETE' });
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { success: false, message: getErrorMessage(backendError?.code) });
}
return { success: true };
},
@@ -104,7 +114,10 @@ export const actions = {
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Erstellen der Gruppe' };
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { success: false, message: getErrorMessage(backendError?.code) });
}
return { success: true };
},
@@ -124,7 +137,10 @@ export const actions = {
body: JSON.stringify(payload)
});
if (!res.ok) return { success: false, message: 'Fehler beim Aktualisieren' };
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { success: false, message: getErrorMessage(backendError?.code) });
}
return { success: true };
},
@@ -135,7 +151,10 @@ export const actions = {
const id = data.get('id');
const res = await fetch(baseUrl + `/api/groups/${id}`, { method: 'DELETE' });
if (!res.ok) return { success: false, message: 'Gruppe kann nicht gelöscht werden (evtl. noch Benutzer zugeordnet?)' };
if (!res.ok) {
const backendError = await parseBackendError(res);
return fail(res.status, { success: false, message: getErrorMessage(backendError?.code) });
}
return { success: true };
}
};

View File

@@ -1,37 +1,24 @@
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { parseBackendError, getErrorMessage } from '$lib/errors';
export async function load({ params, fetch }) {
const { id } = params;
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
try {
const res = await fetch(`${baseUrl}/api/documents/${id}`);
if (res.status === 404) {
throw error(404, 'Dokument nicht gefunden');
}
if (res.status === 401) {
throw redirect(302, '/login');
}
if (res.status === 401) throw redirect(302, '/login');
if (!res.ok) {
console.error(`Backend Fehler (${res.status}):`, res.statusText);
throw error(500, 'Fehler beim Laden des Dokuments');
const backendError = await parseBackendError(res);
throw error(res.status, getErrorMessage(backendError?.code));
}
const document = await res.json();
return {
document
};
return { document: await res.json() };
} catch (e) {
// Fehlerbehandlung
if (e.status) throw e; // Redirects und HttpErrors durchlassen
console.error("Ladefehler:", e);
throw error(500, 'Verbindung zum Server fehlgeschlagen');
if (e.status) throw e;
throw error(500, getErrorMessage('INTERNAL_ERROR'));
}
}

View File

@@ -1,47 +1,48 @@
import { error, redirect } from '@sveltejs/kit';
import { error, fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { parseBackendError, getErrorMessage } from '$lib/errors';
export async function load({ params, fetch }) {
const { id } = params;
const baseUrl = 'http://localhost:8080';
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
try {
// Parallel Dokument und Personen laden
const [docRes, personsRes] = await Promise.all([
fetch(`${baseUrl}/api/documents/${id}`),
fetch(`${baseUrl}/api/persons`)
]);
if (!docRes.ok) throw error(docRes.status, 'Dokument nicht gefunden');
if (!personsRes.ok) throw error(personsRes.status, 'Personen konnten nicht geladen werden');
if (!docRes.ok) {
const backendError = await parseBackendError(docRes);
throw error(docRes.status, getErrorMessage(backendError?.code));
}
if (!personsRes.ok) {
throw error(personsRes.status, getErrorMessage('INTERNAL_ERROR'));
}
return {
document: await docRes.json(),
persons: await personsRes.json()
};
} catch (e) {
console.error(e);
throw error(500, 'Ladefehler');
if (e.status) throw e;
throw error(500, getErrorMessage('INTERNAL_ERROR'));
}
}
export const actions = {
default: async ({ request, params, fetch }) => {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const formData = await request.formData();
// Sende den FormData Request direkt an das Spring Backend weiter
// (Spring kann Multipart verarbeiten)
const res = await fetch(`${baseUrl}/api/documents/${params.id}`, {
method: "PUT",
method: 'PUT',
body: formData
});
if (!res.ok) {
return { success: false, message: 'Speichern fehlgeschlagen' };
const backendError = await parseBackendError(res);
return fail(res.status, { error: getErrorMessage(backendError?.code) });
}
throw redirect(303, `/documents/${params.id}`);

View File

@@ -1,4 +1,6 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { getErrorMessage } from '$lib/errors';
export const actions = {
login: async ({ request, cookies, fetch }) => {
@@ -17,7 +19,8 @@ export const actions = {
try {
// Test-Request an das Backend (z.B. an den Upload-Endpunkt oder einen speziellen /me Endpunkt)
// Wir nutzen hier http://localhost:8080, da beide Container im selben Netz sind (oder localhost im DevContainer)
const response = await fetch('http://localhost:8080/api/users/me', {
const baseUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const response = await fetch(`${baseUrl}/api/users/me`, {
method: 'GET',
headers: {
'Authorization': authHeader
@@ -25,11 +28,11 @@ export const actions = {
});
if (response.status === 401 || response.status === 403) {
return fail(401, { error: 'Ungültige Zugangsdaten.' });
return fail(401, { error: getErrorMessage('UNAUTHORIZED') });
}
if (!response.ok) {
return fail(500, { error: 'Serverfehler beim Login.' });
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
}
// Login erfolgreich! Wir speichern den Header in einem Cookie.
@@ -42,9 +45,9 @@ export const actions = {
maxAge: 60 * 60 * 24 // 1 Tag
});
} catch (error) {
console.error(error);
return fail(500, { error: 'Verbindung zum Backend fehlgeschlagen.' });
} catch (e) {
console.error(e);
return fail(500, { error: getErrorMessage('INTERNAL_ERROR') });
}
// Weiterleitung zur Startseite