Compare commits

...

4 Commits

Author SHA1 Message Date
Marcel
11f6f9e2a2 refactor(frontend): apply formatDate utility and fix derived/error handling
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m37s
CI / Backend Unit Tests (push) Successful in 2m2s
CI / E2E Tests (push) Has started running
- Replace 5 inline Intl.DateTimeFormat blocks with formatDate() across
  home, conversations, persons detail, and document detail pages
- Fix coCorrespondents: $derived(() => ...) → $derived.by(...) —
  the old form typed the value as a function, breaking template call sites
- Persons list: throw error on API failure instead of silently returning []

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 12:11:42 +01:00
Marcel
4771832492 refactor(frontend): extract toActionResult helper and formatDate utility
- Admin page: replace 7 identical error-handling blocks with a single
  toActionResult() helper — DRY without over-abstraction
- New date.ts util: formatDate(isoDate) centralises the T12:00:00
  timezone guard and Intl.DateTimeFormat locale config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 12:10:04 +01:00
Marcel
c006113db9 refactor(backend): replace 7-parameter updatePerson with PersonUpdateDTO
Reduces parameter count from 7 to 2 (id + dto), keeping all validation
and trimming logic in the service. Controller now binds request JSON
directly to the DTO via @RequestBody.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:52:16 +01:00
Marcel
5160009175 refactor(backend): fix typos, dead debug logs, and bare orElseThrow calls
- UserService: remove debug log dumping all DB groups ("Groupds in DB"),
  fix indentation of createUserOrUpdate, clean up log messages
- DocumentService: fix typo reciever → receiver in searchDocuments parameter,
  remove broken log.info("Tags", tags) with missing format specifier,
  replace bare orElseThrow() with DomainException in updateDocumentTags
  and createDocument, remove what-comments on Lombok annotations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:50:16 +01:00
13 changed files with 132 additions and 108 deletions

View File

@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.service.DocumentService;
@@ -54,17 +55,14 @@ public class PersonController {
}
@PutMapping("/{id}")
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
String firstName = body.get("firstName");
String lastName = body.get("lastName");
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
Integer birthYear = body.get("birthYear") != null && !body.get("birthYear").isBlank()
? Integer.parseInt(body.get("birthYear")) : null;
Integer deathYear = body.get("deathYear") != null && !body.get("deathYear").isBlank()
? Integer.parseInt(body.get("deathYear")) : null;
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias"), body.get("notes"), birthYear, deathYear));
dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
return ResponseEntity.ok(personService.updatePerson(id, dto));
}
@PostMapping("/{id}/merge")

View File

@@ -0,0 +1,13 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class PersonUpdateDTO {
private String firstName;
private String lastName;
private String alias;
private String notes;
private Integer birthYear;
private Integer deathYear;
}

View File

@@ -29,8 +29,8 @@ import java.util.UUID;
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
@Service
@RequiredArgsConstructor // Lombok: Erzeugt Constructor für 'final' Felder (Dependency Injection)
@Slf4j // Lombok: Logging
@RequiredArgsConstructor
@Slf4j
public class DocumentService {
private final DocumentRepository documentRepository;
@@ -102,8 +102,10 @@ public class DocumentService {
.filter(s -> !s.isEmpty())
.toList();
}
updateDocumentTags(doc.getId(), tags);
doc = documentRepository.findById(doc.getId()).orElseThrow();
UUID savedId = doc.getId();
updateDocumentTags(savedId, tags);
doc = documentRepository.findById(savedId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found after save: " + savedId));
// Sender
if (dto.getSenderId() != null) {
@@ -180,7 +182,8 @@ public class DocumentService {
}
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(docId).orElseThrow();
Document doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
Set<Tag> newTags = new HashSet<>();
@@ -217,12 +220,11 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID reciever, List<String> tags) {
log.info("Tags", tags);
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) {
Specification<Document> spec = Specification.where(hasText(text))
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(reciever))
.and(hasReceiver(receiver))
.and(hasTags(tags));
// Immer sortiert nach Datum

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.HttpStatus;
@@ -58,18 +59,18 @@ public class PersonService {
}
@Transactional
public Person updatePerson(UUID id, String firstName, String lastName, String alias, String notes, Integer birthYear, Integer deathYear) {
if (birthYear != null && deathYear != null && birthYear > deathYear) {
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
}
Person person = personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
person.setFirstName(firstName);
person.setLastName(lastName);
person.setAlias(alias == null || alias.isBlank() ? null : alias.trim());
person.setNotes(notes == null || notes.isBlank() ? null : notes.trim());
person.setBirthYear(birthYear);
person.setDeathYear(deathYear);
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
return personRepository.save(person);
}

View File

@@ -30,49 +30,45 @@ public class UserService {
private final UserGroupRepository groupRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Versuche neuen User anzulegen: {}", request.getUsername());
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getUsername());
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
List<UserGroup> foundGroups = groupRepository.findAllById(request.getGroupIds());
groups.addAll(foundGroups);
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
}
Optional<AppUser> existingUser = userRepository.findByUsername(request.getUsername());
AppUser user;
if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getUsername());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
} else {
log.info("Creating new user: {}", request.getUsername());
user = AppUser.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
.enabled(true)
.build();
}
return userRepository.save(user);
}
log.info("GroupsIds {}", groups.toString());
log.info("Groupds in DB {}", groupRepository.findAll().toString());
Optional<AppUser> dbUser = userRepository.findByUsername(request.getUsername());
AppUser user;
if (dbUser.isPresent()) {
log.info("Found user in DB. Will update.");
user = dbUser.get().updateFromRequest(request, passwordEncoder, groups);
} else {
log.info("Creating new user.");
user = AppUser.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
.enabled(true)
.build();
}
log.info("Saving new user {}", user.toString());
return userRepository.save(user);
}
@Transactional
public void deleteUser(UUID userId) {
log.info("Delete user {}", userId);
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for id %s", userId)));
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
userRepository.delete(user);
}
public AppUser findByUsername(String username) {
return userRepository.findByUsername(username).orElseThrow(
() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, String.format("No user found for username %s", username)));
return userRepository.findByUsername(username)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username));
}
public List<AppUser> getAllUsers() {

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.web.server.ResponseStatusException;
@@ -92,7 +93,9 @@ class PersonServiceTest {
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.updatePerson(id, "Anna", "Alt", null, "Some notes here.", null, null);
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes("Some notes here.");
Person result = personService.updatePerson(id, dto);
assertThat(result.getNotes()).isEqualTo("Some notes here.");
}
@@ -104,7 +107,9 @@ class PersonServiceTest {
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.updatePerson(id, "Anna", "Alt", null, " ", null, null);
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setNotes(" ");
Person result = personService.updatePerson(id, dto);
assertThat(result.getNotes()).isNull();
}
@@ -118,7 +123,9 @@ class PersonServiceTest {
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.updatePerson(id, "Anna", "Alt", null, null, 1890, 1965);
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isEqualTo(1965);
@@ -128,7 +135,9 @@ class PersonServiceTest {
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
UUID id = UUID.randomUUID();
assertThatThrownBy(() -> personService.updatePerson(id, "Anna", "Alt", null, null, 1970, 1950))
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
@@ -141,7 +150,9 @@ class PersonServiceTest {
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.updatePerson(id, "Anna", "Alt", null, null, 1900, 1900);
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1900);
assertThat(result.getDeathYear()).isEqualTo(1900);

View File

@@ -0,0 +1,11 @@
/**
* Format an ISO date string (YYYY-MM-DD) for display.
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
*/
export function formatDate(isoDate: string): string {
return new Intl.DateTimeFormat('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(new Date(isoDate + 'T12:00:00'));
}

View File

@@ -5,6 +5,7 @@ import TagInput from '$lib/components/TagInput.svelte';
import { slide } from 'svelte/transition';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let { data } = $props();
@@ -240,7 +241,7 @@ $effect(() => {
<div class="mb-4 flex flex-wrap gap-6 font-sans text-sm text-gray-500">
<div class="flex items-center">
<img src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg" alt="" aria-hidden="true" class="mr-1.5 h-4 w-4" />
{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : '—'}
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</div>
{#if doc.location}
<div class="flex items-center">

View File

@@ -2,6 +2,16 @@ import { error, fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
type ApiResult = { response: Response; error?: unknown };
function toActionResult(result: ApiResult) {
if (!result.response.ok) {
const code = (result.error as { code?: string } | undefined)?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
}
export async function load({ fetch, locals }) {
const user = locals.user;
const hasAdmin = user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'));
@@ -35,11 +45,7 @@ export const actions = {
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
return toActionResult(result);
},
deleteUser: async ({ request, fetch }) => {
@@ -51,11 +57,7 @@ export const actions = {
params: { path: { id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
return toActionResult(result);
},
updateTag: async ({ request, fetch }) => {
@@ -68,11 +70,7 @@ export const actions = {
body: { name: data.get('name') as string }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
return toActionResult(result);
},
deleteTag: async ({ request, fetch }) => {
@@ -84,11 +82,7 @@ export const actions = {
params: { path: { id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
return toActionResult(result);
},
createGroup: async ({ request, fetch }) => {
@@ -102,11 +96,7 @@ export const actions = {
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
return toActionResult(result);
},
updateGroup: async ({ request, fetch }) => {
@@ -122,11 +112,7 @@ export const actions = {
}
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
return toActionResult(result);
},
deleteGroup: async ({ request, fetch }) => {
@@ -138,10 +124,6 @@ export const actions = {
params: { path: { id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { success: false, message: getErrorMessage(code) });
}
return { success: true };
return toActionResult(result);
}
};

View File

@@ -3,6 +3,7 @@
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let { data } = $props();
@@ -231,7 +232,7 @@
: 'text-gray-500'}"
>
<span class="flex items-center">
{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : '—'}
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
{#if doc.location}
<span class="flex items-center">

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date';
let { data } = $props();
@@ -119,7 +120,7 @@
</span>
<div>
<span class="block font-serif text-lg text-brand-navy">
{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : '—'}
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
</span>
<span class="text-xs font-sans text-gray-500">{m.doc_label_document_date()}</span>
</div>

View File

@@ -1,12 +1,18 @@
import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ url, fetch }) {
const q = url.searchParams.get('q') || '';
const api = createApiClient(fetch);
const { data } = await api.GET('/api/persons', {
const result = await api.GET('/api/persons', {
params: { query: { q: q || undefined } }
});
return { persons: data ?? [], q };
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(undefined));
}
return { persons: result.data!, q };
}

View File

@@ -3,6 +3,7 @@
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import { m } from '$lib/paraglide/messages.js';
import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort';
import { formatDate } from '$lib/utils/date';
let { data, form } = $props();
@@ -38,7 +39,7 @@
const sentYearRange = $derived(yearRange(sentDocuments));
const receivedYearRange = $derived(yearRange(receivedDocuments));
const coCorrespondents = $derived(() => {
const coCorrespondents = $derived.by(() => {
const freq = new Map<string, { id: string; name: string; count: number }>();
for (const doc of sentDocuments) {
@@ -312,11 +313,11 @@
{/if}
<!-- Co-Correspondents Section -->
{#if coCorrespondents().length > 0}
{#if coCorrespondents.length > 0}
<div class="mb-6">
<h3 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-3">{m.person_co_correspondents_heading()}</h3>
<div class="flex flex-wrap gap-2">
{#each coCorrespondents() as c}
{#each coCorrespondents as c}
<a href="/conversations?senderId={person.id}&receiverId={c.id}"
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-brand-sand text-sm font-serif text-brand-navy hover:border-brand-navy transition-colors">
{c.name}
@@ -366,7 +367,7 @@
{doc.title || doc.originalFilename}
</div>
<div class="flex items-center text-xs font-sans text-gray-500 mt-0.5 space-x-2">
<span>{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : m.doc_no_date()}</span>
<span>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span>
{#if doc.location}
<span class="text-brand-mint"></span>
<span>{doc.location}</span>
@@ -438,7 +439,7 @@
{doc.title || doc.originalFilename}
</div>
<div class="flex items-center text-xs font-sans text-gray-500 mt-0.5 space-x-2">
<span>{doc.documentDate ? new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(new Date(doc.documentDate + 'T12:00:00')) : m.doc_no_date()}</span>
<span>{doc.documentDate ? formatDate(doc.documentDate) : m.doc_no_date()}</span>
{#if doc.location}
<span class="text-brand-mint"></span>
<span>{doc.location}</span>