From aac8250af0090e8a342145acd1e44ffe8f3961b1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 20:51:00 +0200 Subject: [PATCH 01/27] feat(persons): add INVALID_PERSON_TYPE error code with i18n translations Co-Authored-By: Claude Sonnet 4.6 --- .../java/org/raddatz/familienarchiv/exception/ErrorCode.java | 2 ++ frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/lib/errors.ts | 3 +++ 5 files changed, 8 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 187b793d..0db1d92d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -13,6 +13,8 @@ public enum ErrorCode { PERSON_NOT_FOUND, /** A person name alias with the given ID does not exist. 404 */ ALIAS_NOT_FOUND, + /** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */ + INVALID_PERSON_TYPE, // --- Documents --- /** A document with the given ID does not exist. 404 */ diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 0384e776..fa2bf5ab 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -536,6 +536,7 @@ "person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.", "person_alias_btn_delete": "Entfernen", "error_alias_not_found": "Der Namensalias wurde nicht gefunden.", + "error_invalid_person_type": "Der angegebene Personentyp ist ungültig.", "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", "error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.", "error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5e75ef71..dd775f26 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -536,6 +536,7 @@ "person_alias_delete_body": "This name will be removed from search results.", "person_alias_btn_delete": "Remove", "error_alias_not_found": "The name alias was not found.", + "error_invalid_person_type": "The specified person type is not valid.", "error_ocr_service_unavailable": "The OCR service is not available.", "error_ocr_job_not_found": "The OCR job was not found.", "error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 12c2c5c9..79b8aedd 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -536,6 +536,7 @@ "person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.", "person_alias_btn_delete": "Eliminar", "error_alias_not_found": "No se encontro el alias de nombre.", + "error_invalid_person_type": "El tipo de persona especificado no es válido.", "error_ocr_service_unavailable": "El servicio OCR no está disponible.", "error_ocr_job_not_found": "No se encontró el trabajo OCR.", "error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.", diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index eec7a3e1..6acdefbe 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -7,6 +7,7 @@ import * as m from '$lib/paraglide/messages.js'; export type ErrorCode = | 'PERSON_NOT_FOUND' | 'ALIAS_NOT_FOUND' + | 'INVALID_PERSON_TYPE' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' | 'FILE_NOT_FOUND' @@ -73,6 +74,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_person_not_found(); case 'ALIAS_NOT_FOUND': return m.error_alias_not_found(); + case 'INVALID_PERSON_TYPE': + return m.error_invalid_person_type(); case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found(); case 'DOCUMENT_NO_FILE': -- 2.49.1 From 6c117611b811f1fe77f98f41be66075245002dbb Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 20:54:37 +0200 Subject: [PATCH 02/27] feat(persons): add personType to PersonUpdateDTO and wire into createPerson Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/dto/PersonUpdateDTO.java | 4 +++ .../familienarchiv/service/PersonService.java | 1 + .../service/PersonServiceTest.java | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java index b7026c70..b236811e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java @@ -1,10 +1,14 @@ package org.raddatz.familienarchiv.dto; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; +import org.raddatz.familienarchiv.model.PersonType; @Data public class PersonUpdateDTO { + @NotNull + private PersonType personType; @Size(max = 50) private String title; @Size(max = 100) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index e900dd4c..c5b63939 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -111,6 +111,7 @@ public class PersonService { public Person createPerson(PersonUpdateDTO dto) { validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = Person.builder() + .personType(dto.getPersonType()) .title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()) .firstName(dto.getFirstName()) .lastName(dto.getLastName()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index 1095e93f..692e2596 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -114,6 +114,32 @@ class PersonServiceTest { assertThat(result.getAlias()).isEqualTo("Hans Müller"); } + // ─── personType + title in createPerson(PersonUpdateDTO) ───────────────── + + @Test + void createPerson_dto_persistsPersonType() { + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION); + + Person result = personService.createPerson(dto); + + assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION); + } + + @Test + void createPerson_dto_persistsTitle() { + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON); + + Person result = personService.createPerson(dto); + + assertThat(result.getTitle()).isEqualTo("Prof."); + } + // ─── Phase 2.1: createPerson(PersonUpdateDTO) ───────────────────────────── @Test -- 2.49.1 From fef021bf51207efbf4bf15fec9a6718284c765ae Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 20:57:15 +0200 Subject: [PATCH 03/27] feat(persons): createPerson(DTO) rejects SKIP with INVALID_PERSON_TYPE Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/service/PersonService.java | 3 +++ .../familienarchiv/service/PersonServiceTest.java | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index c5b63939..db71d24c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -109,6 +109,9 @@ public class PersonService { @Transactional public Person createPerson(PersonUpdateDTO dto) { + if (dto.getPersonType() == PersonType.SKIP) { + throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation"); + } validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = Person.builder() .personType(dto.getPersonType()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index 692e2596..cb059e4e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -128,6 +128,17 @@ class PersonServiceTest { assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION); } + @Test + void createPerson_dto_throwsInvalidPersonType_whenSkip() { + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP); + + assertThatThrownBy(() -> personService.createPerson(dto)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getStatus().value()) + .isEqualTo(400); + } + @Test void createPerson_dto_persistsTitle() { when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); -- 2.49.1 From 39f722fec04f8fd1b94037234a6a11549caedb80 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 20:59:58 +0200 Subject: [PATCH 04/27] feat(persons): updatePerson persists personType from DTO Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/PersonService.java | 1 + .../service/PersonServiceTest.java | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index db71d24c..10bdda42 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -143,6 +143,7 @@ public class PersonService { validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); + person.setPersonType(dto.getPersonType()); person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()); person.setFirstName(dto.getFirstName()); person.setLastName(dto.getLastName()); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index cb059e4e..8a29a3bc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -182,6 +182,23 @@ class PersonServiceTest { .isEqualTo(400); } + // ─── updatePerson (personType) ─────────────────────────────────────────── + + @Test + void updatePerson_persistsPersonType() { + UUID id = UUID.randomUUID(); + Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build(); + when(personRepository.findById(id)).thenReturn(Optional.of(person)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION); + + Person result = personService.updatePerson(id, dto); + + assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION); + } + // ─── updatePerson (alias) ───────────────────────────────────────────────── @Test -- 2.49.1 From 60a278ad8e54fa1f94828092566b1b16ac9ecf4c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:02:39 +0200 Subject: [PATCH 05/27] feat(persons): updatePerson rejects SKIP with INVALID_PERSON_TYPE Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/PersonService.java | 3 +++ .../familienarchiv/service/PersonServiceTest.java | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index 10bdda42..93ccdd5e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -140,6 +140,9 @@ public class PersonService { @Transactional public Person updatePerson(UUID id, PersonUpdateDTO dto) { + if (dto.getPersonType() == PersonType.SKIP) { + throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing"); + } validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index 8a29a3bc..da2fdde4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -184,6 +184,19 @@ class PersonServiceTest { // ─── updatePerson (personType) ─────────────────────────────────────────── + @Test + void updatePerson_throwsInvalidPersonType_whenSkip() { + UUID id = UUID.randomUUID(); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP); + + assertThatThrownBy(() -> personService.updatePerson(id, dto)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getStatus().value()) + .isEqualTo(400); + } + @Test void updatePerson_persistsPersonType() { UUID id = UUID.randomUUID(); -- 2.49.1 From 58b3dabea2222b11aee445d07ae5a3f2fbc5b6a1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:07:55 +0200 Subject: [PATCH 06/27] feat(persons): relax firstName requirement for non-PERSON types in controller Co-Authored-By: Claude Sonnet 4.6 --- .../controller/PersonController.java | 24 ++++++---- .../controller/PersonControllerTest.java | 48 ++++++++++++------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java index 6210f529..de2b7498 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -63,11 +63,8 @@ public class PersonController { @PostMapping @RequirePermission(Permission.WRITE_ALL) public ResponseEntity createPerson(@Valid @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"); - } - dto.setFirstName(dto.getFirstName().trim()); + validatePersonNames(dto); + if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim()); dto.setLastName(dto.getLastName().trim()); return ResponseEntity.ok(personService.createPerson(dto)); } @@ -75,15 +72,22 @@ public class PersonController { @PutMapping("/{id}") @RequirePermission(Permission.WRITE_ALL) public ResponseEntity updatePerson(@PathVariable UUID id, @Valid @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"); - } - dto.setFirstName(dto.getFirstName().trim()); + validatePersonNames(dto); + if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim()); dto.setLastName(dto.getLastName().trim()); return ResponseEntity.ok(personService.updatePerson(id, dto)); } + private void validatePersonNames(PersonUpdateDTO dto) { + if (dto.getLastName() == null || dto.getLastName().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld"); + } + if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON + && (dto.getFirstName() == null || dto.getFirstName().isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld"); + } + } + @PostMapping("/{id}/merge") @ResponseStatus(HttpStatus.NO_CONTENT) @RequirePermission(Permission.WRITE_ALL) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index 02973927..34a92dbf 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -183,19 +183,19 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") - void createPerson_returns400_whenFirstNameIsMissing() throws Exception { + void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) - .content("{\"lastName\":\"Müller\"}")) + .content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @Test @WithMockUser(authorities = "WRITE_ALL") - void createPerson_returns400_whenFirstNameIsBlank() throws Exception { + void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\" \",\"lastName\":\"Müller\"}")) + .content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @@ -204,7 +204,7 @@ class PersonControllerTest { void createPerson_returns400_whenLastNameIsMissing() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\"}")) + .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @@ -213,7 +213,7 @@ class PersonControllerTest { void createPerson_returns400_whenLastNameIsBlank() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\",\"lastName\":\" \"}")) + .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @@ -225,11 +225,24 @@ class PersonControllerTest { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.firstName").value("Hans")); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createPerson_returns200_forInstitution_withoutFirstName() throws Exception { + Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build(); + when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved); + + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lastName").value("Verlag GmbH")); + } + // ─── PUT /api/persons/{id} ──────────────────────────────────────────────── @Test @@ -242,10 +255,10 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") - void updatePerson_returns400_whenFirstNameIsBlank() throws Exception { + void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception { mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"\",\"lastName\":\"Müller\"}")) + .content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @@ -254,7 +267,7 @@ class PersonControllerTest { void updatePerson_returns400_whenLastNameIsNull() throws Exception { mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\"}")) + .content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @@ -267,7 +280,7 @@ class PersonControllerTest { mockMvc.perform(put("/api/persons/{id}", id) .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.lastName").value("Müller")); } @@ -317,11 +330,10 @@ class PersonControllerTest { @Test @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenLastNameIsBlank() throws Exception { - // firstName valid, lastName blank → second || operand = true → 400 UUID id = UUID.randomUUID(); mockMvc.perform(put("/api/persons/{id}", id) .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\",\"lastName\":\" \"}")) + .content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @@ -339,7 +351,7 @@ class PersonControllerTest { .contentType(MediaType.APPLICATION_JSON) .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," + "\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," + - "\"notes\":\"Some notes\"}")) + "\"notes\":\"Some notes\",\"personType\":\"PERSON\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.firstName").value("Maria")) .andExpect(jsonPath("$.alias").value("Oma Maria")) @@ -355,7 +367,7 @@ class PersonControllerTest { UUID id = UUID.randomUUID(); mockMvc.perform(put("/api/persons/{id}", id) .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}")) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @@ -366,7 +378,7 @@ class PersonControllerTest { UUID id = UUID.randomUUID(); mockMvc.perform(put("/api/persons/{id}", id) .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}")) + .content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isBadRequest()); } @@ -377,7 +389,7 @@ class PersonControllerTest { void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isForbidden()); } @@ -386,7 +398,7 @@ class PersonControllerTest { void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}")) .andExpect(status().isForbidden()); } -- 2.49.1 From bf3138014103645b1a58e5f8392b798cfac6dc66 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:17:06 +0200 Subject: [PATCH 07/27] feat(persons): add radioGroupNav action for keyboard navigation in type selector Co-Authored-By: Claude Sonnet 4.6 --- .../lib/actions/radioGroupNav.svelte.spec.ts | 87 +++++++++++++++++++ frontend/src/lib/actions/radioGroupNav.ts | 28 ++++++ 2 files changed, 115 insertions(+) create mode 100644 frontend/src/lib/actions/radioGroupNav.svelte.spec.ts create mode 100644 frontend/src/lib/actions/radioGroupNav.ts diff --git a/frontend/src/lib/actions/radioGroupNav.svelte.spec.ts b/frontend/src/lib/actions/radioGroupNav.svelte.spec.ts new file mode 100644 index 00000000..f624b38f --- /dev/null +++ b/frontend/src/lib/actions/radioGroupNav.svelte.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, afterEach } from 'vitest'; + +const { radioGroupNav } = await import('./radioGroupNav'); + +describe('radioGroupNav action', () => { + const nodes: HTMLElement[] = []; + + function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } { + const container = document.createElement('div'); + container.setAttribute('role', 'radiogroup'); + const buttons: HTMLElement[] = []; + for (let i = 0; i < count; i++) { + const btn = document.createElement('button'); + btn.setAttribute('role', 'radio'); + btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false'); + btn.setAttribute('tabindex', i === 0 ? '0' : '-1'); + container.appendChild(btn); + buttons.push(btn); + } + document.body.appendChild(container); + nodes.push(container); + return { container, buttons }; + } + + afterEach(() => { + nodes.forEach((n) => n.remove()); + nodes.length = 0; + }); + + it('ArrowRight moves focus to next button', () => { + const { container, buttons } = makeGroup(4); + radioGroupNav(container); + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(document.activeElement).toBe(buttons[1]); + }); + + it('ArrowRight wraps from last to first', () => { + const { container, buttons } = makeGroup(4); + radioGroupNav(container); + buttons[3].focus(); + buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(document.activeElement).toBe(buttons[0]); + }); + + it('ArrowLeft moves focus to previous button', () => { + const { container, buttons } = makeGroup(4); + radioGroupNav(container); + buttons[2].focus(); + buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + expect(document.activeElement).toBe(buttons[1]); + }); + + it('ArrowLeft wraps from first to last', () => { + const { container, buttons } = makeGroup(4); + radioGroupNav(container); + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + expect(document.activeElement).toBe(buttons[3]); + }); + + it('ArrowRight updates aria-checked on new button and removes it from old', () => { + const { container, buttons } = makeGroup(4); + radioGroupNav(container); + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(buttons[1].getAttribute('aria-checked')).toBe('true'); + expect(buttons[0].getAttribute('aria-checked')).toBe('false'); + }); + + it('destroy removes keydown listener', () => { + const { container, buttons } = makeGroup(4); + const { destroy } = radioGroupNav(container); + destroy(); + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(document.activeElement).toBe(buttons[0]); + }); + + it('ignores non-arrow keys', () => { + const { container, buttons } = makeGroup(4); + radioGroupNav(container); + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(document.activeElement).toBe(buttons[0]); + }); +}); diff --git a/frontend/src/lib/actions/radioGroupNav.ts b/frontend/src/lib/actions/radioGroupNav.ts new file mode 100644 index 00000000..65164327 --- /dev/null +++ b/frontend/src/lib/actions/radioGroupNav.ts @@ -0,0 +1,28 @@ +export function radioGroupNav(node: HTMLElement): { destroy: () => void } { + function getRadios(): HTMLElement[] { + return Array.from(node.querySelectorAll('[role="radio"]')); + } + + function handleKeydown(event: KeyboardEvent) { + if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return; + + const radios = getRadios(); + const current = radios.indexOf(document.activeElement as HTMLElement); + if (current === -1) return; + + const delta = event.key === 'ArrowRight' ? 1 : -1; + const next = (current + delta + radios.length) % radios.length; + + radios[current].setAttribute('aria-checked', 'false'); + radios[next].setAttribute('aria-checked', 'true'); + radios[next].focus(); + } + + node.addEventListener('keydown', handleKeydown); + + return { + destroy() { + node.removeEventListener('keydown', handleKeydown); + } + }; +} -- 2.49.1 From e7573bbedae389453dae8fcc40b003751f45d11e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:28:10 +0200 Subject: [PATCH 08/27] =?UTF-8?q?feat(persons):=20normalize=20SKIP?= =?UTF-8?q?=E2=86=92UNKNOWN=20in=20edit-route=20load=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../routes/persons/[id]/edit/+page.server.ts | 4 ++- .../persons/[id]/edit/page.server.test.ts | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/persons/[id]/edit/page.server.test.ts diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts index e814737a..dd9fd1af 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.server.ts +++ b/frontend/src/routes/persons/[id]/edit/+page.server.ts @@ -22,7 +22,9 @@ export async function load({ params, fetch, locals }) { throw error(result.response.status, getErrorMessage(code)); } - return { person: result.data!, aliases: aliasesResult.data ?? [] }; + const person = result.data!; + const personType = person.personType === 'SKIP' ? 'UNKNOWN' : person.personType; + return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] }; } export const actions = { diff --git a/frontend/src/routes/persons/[id]/edit/page.server.test.ts b/frontend/src/routes/persons/[id]/edit/page.server.test.ts new file mode 100644 index 00000000..a5e76b1a --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/page.server.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; + +type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN' | 'SKIP'; + +function normalizePersonType(raw: string | undefined | null): PersonType { + return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType); +} + +describe('edit load — SKIP → UNKNOWN normalization', () => { + it('maps SKIP to UNKNOWN', () => { + expect(normalizePersonType('SKIP')).toBe('UNKNOWN'); + }); + + it('passes PERSON through unchanged', () => { + expect(normalizePersonType('PERSON')).toBe('PERSON'); + }); + + it('passes INSTITUTION through unchanged', () => { + expect(normalizePersonType('INSTITUTION')).toBe('INSTITUTION'); + }); + + it('passes GROUP through unchanged', () => { + expect(normalizePersonType('GROUP')).toBe('GROUP'); + }); + + it('passes UNKNOWN through unchanged', () => { + expect(normalizePersonType('UNKNOWN')).toBe('UNKNOWN'); + }); + + it('defaults null to PERSON', () => { + expect(normalizePersonType(null)).toBe('PERSON'); + }); +}); -- 2.49.1 From 8f75552503be995c4d47e0b97a9ba1fbc96dea85 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:31:11 +0200 Subject: [PATCH 09/27] feat(i18n): add form_label_person_type, form_label_name, a11y_type_changed keys Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 3 +++ frontend/messages/en.json | 3 +++ frontend/messages/es.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index fa2bf5ab..f4e6da8c 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -33,6 +33,8 @@ "btn_back_to_overview": "Zurück zur Übersicht", "btn_back": "Zurück", "btn_back_to_document": "Zurück zum Dokument", + "form_label_person_type": "Typ", + "form_label_name": "Name", "form_label_first_name": "Vorname", "form_label_last_name": "Nachname", "form_label_alias": "Rufname / Alias", @@ -527,6 +529,7 @@ "person_type_INSTITUTION": "Institution", "person_type_GROUP": "Gruppe", "person_type_UNKNOWN": "Unbekannt", + "a11y_type_changed": "Typ geändert zu {type}", "person_alias_add_heading": "Name hinzufuegen", "person_alias_label_type": "Art", "person_alias_label_last_name": "Nachname", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index dd775f26..c3202419 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -33,6 +33,8 @@ "btn_back_to_overview": "Back to overview", "btn_back": "Back", "btn_back_to_document": "Back to document", + "form_label_person_type": "Type", + "form_label_name": "Name", "form_label_first_name": "First name", "form_label_last_name": "Last name", "form_label_alias": "Nickname / Alias", @@ -527,6 +529,7 @@ "person_type_INSTITUTION": "Institution", "person_type_GROUP": "Group", "person_type_UNKNOWN": "Unknown", + "a11y_type_changed": "Type changed to {type}", "person_alias_add_heading": "Add name", "person_alias_label_type": "Type", "person_alias_label_last_name": "Last name", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 79b8aedd..644debef 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -33,6 +33,8 @@ "btn_back_to_overview": "Volver al resumen", "btn_back": "Volver", "btn_back_to_document": "Volver al documento", + "form_label_person_type": "Tipo", + "form_label_name": "Nombre", "form_label_first_name": "Nombre", "form_label_last_name": "Apellido", "form_label_alias": "Apodo / Alias", @@ -527,6 +529,7 @@ "person_type_INSTITUTION": "Institución", "person_type_GROUP": "Grupo", "person_type_UNKNOWN": "Desconocido", + "a11y_type_changed": "Tipo cambiado a {type}", "person_alias_add_heading": "Agregar nombre", "person_alias_label_type": "Tipo", "person_alias_label_last_name": "Apellido", -- 2.49.1 From fe830ad64bf513d63e5ad9179cd81075d755296e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:37:13 +0200 Subject: [PATCH 10/27] feat(persons): add PersonTypeSelector segmented control component Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/PersonTypeSelector.svelte | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 frontend/src/lib/components/PersonTypeSelector.svelte diff --git a/frontend/src/lib/components/PersonTypeSelector.svelte b/frontend/src/lib/components/PersonTypeSelector.svelte new file mode 100644 index 00000000..05efe33f --- /dev/null +++ b/frontend/src/lib/components/PersonTypeSelector.svelte @@ -0,0 +1,48 @@ + + +
+ {#each TYPES as type (type)} + + {/each} +
+ + + +
+ {m.a11y_type_changed({ type: labels[selected]() })} +
-- 2.49.1 From 437144174c0e295506f4e17b91679ee9805ce45a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:45:09 +0200 Subject: [PATCH 11/27] feat(persons): add type selector + title + conditional fields to edit form Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/PersonTypeSelector.svelte | 7 +- .../persons/[id]/edit/PersonEditForm.svelte | 167 ++++++++++++------ 2 files changed, 118 insertions(+), 56 deletions(-) diff --git a/frontend/src/lib/components/PersonTypeSelector.svelte b/frontend/src/lib/components/PersonTypeSelector.svelte index 05efe33f..2ddf58cb 100644 --- a/frontend/src/lib/components/PersonTypeSelector.svelte +++ b/frontend/src/lib/components/PersonTypeSelector.svelte @@ -6,7 +6,11 @@ import { m } from '$lib/paraglide/messages.js'; const TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const; type PersonType = (typeof TYPES)[number]; -let { value = 'PERSON', name = 'personType' }: { value?: string; name?: string } = $props(); +let { + value = 'PERSON', + name = 'personType', + onchange +}: { value?: string; name?: string; onchange?: (type: PersonType) => void } = $props(); let selected = $state( untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON')) @@ -21,6 +25,7 @@ const labels: Record string> = { function select(type: PersonType) { selected = type; + onchange?.(type); } diff --git a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte index 9d88464d..f583f9ec 100644 --- a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte +++ b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte @@ -1,10 +1,16 @@
-
- - +

+ {m.form_label_person_type()} +

+ (selectedType = type)} />
-
+ + {#if isPerson} +
+ + +
+
+ + +
+ {/if} + +
{lastNameLabel} *
-
- - -
-
- - -
-
- - -
+ + {#if isPerson} +
+ + +
+
+ + +
+
+ + +
+ {/if} +
Date: Sat, 25 Apr 2026 21:47:09 +0200 Subject: [PATCH 12/27] feat(persons): extract personType + title in edit action; relax firstName for non-PERSON Co-Authored-By: Claude Sonnet 4.6 --- .../routes/persons/[id]/edit/+page.server.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts index dd9fd1af..2d01ce1f 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.server.ts +++ b/frontend/src/routes/persons/[id]/edit/+page.server.ts @@ -30,6 +30,12 @@ export async function load({ params, fetch, locals }) { export const actions = { update: async ({ request, params, fetch }) => { const formData = await request.formData(); + const personType = (formData.get('personType')?.toString() ?? 'PERSON') as + | 'PERSON' + | 'INSTITUTION' + | 'GROUP' + | 'UNKNOWN'; + const title = formData.get('title')?.toString().trim() || undefined; const firstName = formData.get('firstName')?.toString().trim(); const lastName = formData.get('lastName')?.toString().trim(); const alias = formData.get('alias')?.toString().trim() || undefined; @@ -39,15 +45,20 @@ export const actions = { const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined; const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined; - if (!firstName || !lastName) { - return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' }); + if (!lastName) { + return fail(400, { updateError: 'Nachname ist Pflichtfeld.' }); + } + if (personType === 'PERSON' && !firstName) { + return fail(400, { updateError: 'Vorname ist Pflichtfeld.' }); } const api = createApiClient(fetch); const result = await api.PUT('/api/persons/{id}', { params: { path: { id: params.id } }, body: { - firstName, + personType, + ...(title ? { title } : {}), + ...(firstName ? { firstName } : {}), lastName, ...(alias ? { alias } : {}), ...(notes ? { notes } : {}), -- 2.49.1 From 8770ca874b71e378e927d113c8cd25ad0129550f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 21:50:39 +0200 Subject: [PATCH 13/27] feat(persons): add type selector + title + conditional fields to new-person form Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/persons/new/+page.server.ts | 44 ++++- frontend/src/routes/persons/new/+page.svelte | 162 +++++++++++------- 2 files changed, 138 insertions(+), 68 deletions(-) diff --git a/frontend/src/routes/persons/new/+page.server.ts b/frontend/src/routes/persons/new/+page.server.ts index eae1424c..367cc4fc 100644 --- a/frontend/src/routes/persons/new/+page.server.ts +++ b/frontend/src/routes/persons/new/+page.server.ts @@ -1,5 +1,6 @@ import { error, fail, redirect } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; export async function load({ locals }: { locals: App.Locals }) { const canWrite = @@ -12,6 +13,12 @@ export async function load({ locals }: { locals: App.Locals }) { export const actions = { default: async ({ request, fetch }) => { const formData = await request.formData(); + const personType = (formData.get('personType')?.toString() ?? 'PERSON') as + | 'PERSON' + | 'INSTITUTION' + | 'GROUP' + | 'UNKNOWN'; + const title = formData.get('title')?.toString().trim() || undefined; const firstName = formData.get('firstName')?.toString().trim(); const lastName = formData.get('lastName')?.toString().trim(); const alias = formData.get('alias')?.toString().trim() || undefined; @@ -19,8 +26,25 @@ export const actions = { const deathYearStr = formData.get('deathYear')?.toString().trim(); const notes = formData.get('notes')?.toString().trim() || undefined; - if (!firstName || !lastName) { - return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' }); + if (!lastName) { + return fail(400, { + error: 'Nachname ist Pflichtfeld.', + personType, + title, + firstName, + lastName: '', + alias + }); + } + if (personType === 'PERSON' && !firstName) { + return fail(400, { + error: 'Vorname ist Pflichtfeld.', + personType, + title, + firstName: '', + lastName, + alias + }); } const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined; @@ -29,8 +53,10 @@ export const actions = { const api = createApiClient(fetch); const result = await api.POST('/api/persons', { body: { - firstName, - lastName, + personType, + ...(title ? { title } : {}), + ...(firstName ? { firstName } : {}), + lastName: lastName!, ...(alias ? { alias } : {}), ...(birthYear ? { birthYear } : {}), ...(deathYear ? { deathYear } : {}), @@ -39,7 +65,15 @@ export const actions = { }); if (!result.response.ok) { - return fail(result.response.status, { error: 'Person konnte nicht gespeichert werden.' }); + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { + error: getErrorMessage(code), + personType, + title, + firstName, + lastName: lastName!, + alias + }); } throw redirect(303, `/persons/${result.data!.id}`); diff --git a/frontend/src/routes/persons/new/+page.svelte b/frontend/src/routes/persons/new/+page.svelte index a6387ac5..cda6e7f7 100644 --- a/frontend/src/routes/persons/new/+page.svelte +++ b/frontend/src/routes/persons/new/+page.svelte @@ -1,11 +1,35 @@
-

{m.persons_new_heading()}

@@ -22,79 +46,92 @@ let { form } = $props();
-
- - +

{m.form_label_person_type()}

+ (selectedType = type)} />
-
- + {#if isPerson} +
+ + +
+
+ + +
+ {/if} + +
+
+ {#if isPerson} +
+ + +
+
+ + +
+
+ + +
+ {/if} +
- - -
- -
- - -
- -
- - -
- -
- +