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

@@ -6,6 +6,8 @@ import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.security.Permission;
@@ -15,7 +17,6 @@ import org.raddatz.familienarchiv.service.FileService;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -27,7 +28,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -47,10 +47,10 @@ public class DocumentController {
public ResponseEntity<InputStreamResource> getDocumentFile(@PathVariable UUID id) {
// 1. Look up path in DB
Document doc = documentRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
if (doc.getFilePath() == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No file attached");
throw DomainException.notFound(ErrorCode.DOCUMENT_NO_FILE, "Document has no file attached: " + id);
}
// 2. Delegate Retrieval to FileService
@@ -62,7 +62,7 @@ public class DocumentController {
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + doc.getOriginalFilename() + "\"")
.body(download.resource());
} catch (FileService.StorageFileNotFoundException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "File missing in storage");
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "File missing in storage: " + doc.getFilePath());
}
}
@@ -70,7 +70,7 @@ public class DocumentController {
@GetMapping("/{id}")
public Document getDocument(@PathVariable UUID id) {
return documentRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}
@PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -82,7 +82,7 @@ public class DocumentController {
try {
return documentService.updateDocument(id, dto, file);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Fehler beim Upload");
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Failed to upload file: " + e.getMessage());
}
}

View File

@@ -2,11 +2,12 @@ package org.raddatz.familienarchiv.controller;
import java.util.stream.Collectors;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import lombok.extern.slf4j.Slf4j;
@@ -14,11 +15,11 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleStatus(ResponseStatusException ex) {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
return ResponseEntity
.status(ex.getStatusCode())
.body(new ErrorResponse(ex.getReason()));
.status(ex.getStatus())
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@@ -26,15 +27,15 @@ public class GlobalExceptionHandler {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(new ErrorResponse(message));
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.internalServerError()
.body(new ErrorResponse("Ein Fehler ist aufgetreten"));
.body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred"));
}
public record ErrorResponse(String message) {}
public record ErrorResponse(ErrorCode code, String message) {}
}

View File

@@ -52,24 +52,15 @@ public class UserController {
@PostMapping("/users")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
try {
AppUser createdUser = userService.createUserOrUpdate(request);
return ResponseEntity.ok(createdUser);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
public ResponseEntity<AppUser> createUser(@RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(request));
}
@DeleteMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<?> deleteUser(@PathVariable UUID id) {
try {
userService.deleteUser(id);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
userService.deleteUser(id);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,49 @@
package org.raddatz.familienarchiv.exception;
import org.springframework.http.HttpStatus;
/**
* Exception for domain-level errors that should be surfaced to the API caller
* with a machine-readable ErrorCode and an English developer message.
*/
public class DomainException extends RuntimeException {
private final ErrorCode code;
private final HttpStatus status;
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
super(developerMessage);
this.code = code;
this.status = status;
}
public ErrorCode getCode() {
return code;
}
public HttpStatus getStatus() {
return status;
}
// --- Static factories for common cases ---
public static DomainException notFound(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.NOT_FOUND, message);
}
public static DomainException forbidden(String message) {
return new DomainException(ErrorCode.FORBIDDEN, HttpStatus.FORBIDDEN, message);
}
public static DomainException unauthorized(String message) {
return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message);
}
public static DomainException conflict(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.CONFLICT, message);
}
public static DomainException internal(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
}
}

View File

@@ -0,0 +1,40 @@
package org.raddatz.familienarchiv.exception;
/**
* Machine-readable error codes returned in API responses.
* The frontend uses these to display localised messages to the user.
* Every code must be documented here with its meaning and the HTTP status
* it is paired with in DomainException.
*/
public enum ErrorCode {
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,
/** The document exists but has no file attached yet. 404 */
DOCUMENT_NO_FILE,
/** The file referenced by the document is missing in object storage. 404 */
FILE_NOT_FOUND,
/** An error occurred while uploading a file to object storage. 500 */
FILE_UPLOAD_FAILED,
// --- Users ---
/** A user with the given ID or username does not exist. 404 */
USER_NOT_FOUND,
// --- Import ---
/** A mass import is already in progress; only one can run at a time. 409 */
IMPORT_ALREADY_RUNNING,
// --- Auth ---
/** The request is not authenticated. 401 */
UNAUTHORIZED,
/** The authenticated user lacks the required permission. 403 */
FORBIDDEN,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,
/** An unexpected server-side error occurred. 500 */
INTERNAL_ERROR,
}

View File

@@ -4,11 +4,10 @@ import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.http.HttpStatus;
import org.raddatz.familienarchiv.exception.DomainException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Method;
@@ -48,14 +47,14 @@ public class PermissionAspect {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Nicht authentifiziert");
throw DomainException.unauthorized("Not authenticated");
}
boolean hasPermission = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(requiredPerm.name()));
if (!hasPermission) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Fehlende Berechtigung: " + requiredPerm);
throw DomainException.forbidden("Missing required permission: " + requiredPerm.name());
}
}
}

View File

@@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.repository.DocumentRepository;
@@ -63,6 +65,9 @@ public class MassImportService {
@Async
public void runImportAsync() {
if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
}
currentStatus = new ImportStatus(State.RUNNING, "Import läuft...", 0, LocalDateTime.now());
try {
File excelFile = findExcelFile();

View File

@@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
@@ -63,13 +65,13 @@ public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Delete user {}", userId);
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException(String.format("No User found for id %", userId)));
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for id %s", userId)));
userRepository.delete(user);
}
public AppUser findByUsername(String username) {
return userRepository.findByUsername(username).orElseThrow(
() -> new IllegalArgumentException(String.format("No User found for userrname %", username)));
() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for username %s", username)));
}
public List<AppUser> getAllUsers() {