From 93107e7c591b3d1586465384c3401a3ce258db7a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:34:35 +0200 Subject: [PATCH 01/18] feat(persons): add @RequirePermission(WRITE_ALL) to write endpoints POST /api/persons, PUT /api/persons/{id}, POST /api/persons/{id}/merge now return 403 for READ_ALL-only users. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/PersonController.java | 5 ++ .../controller/PersonControllerTest.java | 51 +++++++++++++++---- 2 files changed, 45 insertions(+), 11 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 0b86bbde..919e3ee6 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -7,6 +7,8 @@ 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.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.PersonService; import org.springframework.http.HttpStatus; @@ -52,6 +54,7 @@ public class PersonController { } @PostMapping + @RequirePermission(Permission.WRITE_ALL) public ResponseEntity createPerson(@RequestBody Map body) { String firstName = body.get("firstName"); String lastName = body.get("lastName"); @@ -62,6 +65,7 @@ public class PersonController { } @PutMapping("/{id}") + @RequirePermission(Permission.WRITE_ALL) public ResponseEntity updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) { if (dto.getFirstName() == null || dto.getFirstName().isBlank() || dto.getLastName() == null || dto.getLastName().isBlank()) { @@ -74,6 +78,7 @@ public class PersonController { @PostMapping("/{id}/merge") @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission(Permission.WRITE_ALL) public void mergePerson(@PathVariable UUID id, @RequestBody Map body) { String targetIdStr = body.get("targetPersonId"); if (targetIdStr == null || targetIdStr.isBlank()) { 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 a56df834..b6444de1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -162,7 +162,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenFirstNameIsMissing() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) @@ -171,7 +171,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenFirstNameIsBlank() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) @@ -180,7 +180,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenLastNameIsMissing() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) @@ -189,7 +189,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns400_whenLastNameIsBlank() throws Exception { mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) @@ -198,7 +198,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns200_whenValid() throws Exception { Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); when(personService.createPerson(eq("Hans"), eq("Müller"), any())).thenReturn(saved); @@ -221,7 +221,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenFirstNameIsBlank() throws Exception { mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) @@ -230,7 +230,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns400_whenLastNameIsNull() throws Exception { mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) @@ -239,7 +239,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void updatePerson_returns200_whenValid() throws Exception { UUID id = UUID.randomUUID(); Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build(); @@ -263,7 +263,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception { mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) @@ -272,7 +272,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception { mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) @@ -281,7 +281,7 @@ class PersonControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void mergePerson_returns204_whenValid() throws Exception { UUID sourceId = UUID.randomUUID(); UUID targetId = UUID.randomUUID(); @@ -304,4 +304,33 @@ class PersonControllerTest { .content("{\"firstName\":\"Hans\",\"lastName\":\" \"}")) .andExpect(status().isBadRequest()); } + + // ─── Phase 1.1: @RequirePermission(WRITE_ALL) on write endpoints ────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception { + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { + mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception { + mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}")) + .andExpect(status().isForbidden()); + } } -- 2.49.1 From ef9a85eee81cb7a573800c1bc785e19640f9e21a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:35:59 +0200 Subject: [PATCH 02/18] feat(persons): add @Size constraints to PersonUpdateDTO + @Valid to controller firstName/lastName max 100, alias max 200, notes max 5000 chars. PUT /api/persons/{id} returns 400 for oversized fields. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/PersonController.java | 5 +++- .../familienarchiv/dto/PersonUpdateDTO.java | 5 ++++ .../controller/PersonControllerTest.java | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) 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 919e3ee6..9812c752 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -13,9 +13,12 @@ import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.PersonService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import jakarta.validation.Valid; + import lombok.RequiredArgsConstructor; @RestController @@ -66,7 +69,7 @@ public class PersonController { @PutMapping("/{id}") @RequirePermission(Permission.WRITE_ALL) - public ResponseEntity updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) { + 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"); 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 88d808c0..ff00f1e0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonUpdateDTO.java @@ -1,12 +1,17 @@ package org.raddatz.familienarchiv.dto; +import jakarta.validation.constraints.Size; import lombok.Data; @Data public class PersonUpdateDTO { + @Size(max = 100) private String firstName; + @Size(max = 100) private String lastName; + @Size(max = 200) private String alias; + @Size(max = 5000) private String notes; private Integer birthYear; private Integer deathYear; 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 b6444de1..cb384f69 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -305,6 +305,30 @@ class PersonControllerTest { .andExpect(status().isBadRequest()); } + // ─── Phase 1.2: @Size constraints ───────────────────────────────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception { + String oversizedNotes = "x".repeat(5001); + UUID id = UUID.randomUUID(); + mockMvc.perform(put("/api/persons/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception { + String oversizedFirstName = "x".repeat(101); + UUID id = UUID.randomUUID(); + mockMvc.perform(put("/api/persons/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}")) + .andExpect(status().isBadRequest()); + } + // ─── Phase 1.1: @RequirePermission(WRITE_ALL) on write endpoints ────────── @Test -- 2.49.1 From d1e506135b4b886d7d6dac0dcc55d8c52231b598 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:37:08 +0200 Subject: [PATCH 03/18] feat(persons): add year range bounds validation (> 0) to PersonService birthYear and deathYear must be positive integers; extracted shared validateYears() method for reuse in createPerson. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/service/PersonService.java | 16 ++++-- .../service/PersonServiceTest.java | 50 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) 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 8ac9a3bd..52f6231c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -70,11 +70,21 @@ public class PersonService { return personRepository.save(person); } - @Transactional - public Person updatePerson(UUID id, PersonUpdateDTO dto) { - if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) { + private void validateYears(Integer birthYear, Integer deathYear) { + if (birthYear != null && birthYear <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein"); + } + if (deathYear != null && deathYear <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein"); + } + if (birthYear != null && deathYear != null && birthYear > deathYear) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen"); } + } + + @Transactional + public Person updatePerson(UUID id, PersonUpdateDTO dto) { + validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = personRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden")); person.setFirstName(dto.getFirstName()); 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 f2eb3628..fb0023fe 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -267,6 +267,56 @@ class PersonServiceTest { assertThat(result.getDeathYear()).isEqualTo(1900); } + // ─── Phase 1.3: Year range bounds (> 0) ────────────────────────────────── + + @Test + void updatePerson_throwsBadRequest_whenBirthYearIsZero() { + UUID id = UUID.randomUUID(); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0); + assertThatThrownBy(() -> personService.updatePerson(id, dto)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void updatePerson_throwsBadRequest_whenBirthYearIsNegative() { + UUID id = UUID.randomUUID(); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5); + assertThatThrownBy(() -> personService.updatePerson(id, dto)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void updatePerson_throwsBadRequest_whenDeathYearIsZero() { + UUID id = UUID.randomUUID(); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0); + assertThatThrownBy(() -> personService.updatePerson(id, dto)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void updatePerson_throwsBadRequest_whenDeathYearIsNegative() { + UUID id = UUID.randomUUID(); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10); + assertThatThrownBy(() -> personService.updatePerson(id, dto)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + // ─── findCorrespondents ────────────────────────────────────────────────── @Test -- 2.49.1 From 3987bbc1f9b58e7781a4f5891f9f17c6628ce81d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:38:23 +0200 Subject: [PATCH 04/18] refactor(persons): replace ResponseStatusException with DomainException in PersonService Added PERSON_NOT_FOUND to ErrorCode; getById, updatePerson, mergePersons now throw DomainException.notFound for missing persons. Co-Authored-By: Claude Sonnet 4.6 --- .../raddatz/familienarchiv/exception/ErrorCode.java | 4 ++++ .../familienarchiv/service/PersonService.java | 10 ++++++---- .../familienarchiv/service/PersonServiceTest.java | 13 +++++++------ 3 files changed, 17 insertions(+), 10 deletions(-) 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 5a0cbd73..e40b122c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -8,6 +8,10 @@ package org.raddatz.familienarchiv.exception; */ public enum ErrorCode { + // --- Persons --- + /** A person with the given ID does not exist. 404 */ + PERSON_NOT_FOUND, + // --- Documents --- /** A document with the given ID does not exist. 404 */ DOCUMENT_NOT_FOUND, 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 52f6231c..9926ea1d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -5,6 +5,8 @@ import java.util.Optional; import java.util.UUID; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.http.HttpStatus; @@ -29,7 +31,7 @@ public class PersonService { public Person getById(UUID id) { return personRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden")); + .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); } public List findCorrespondents(UUID personId, String q) { @@ -86,7 +88,7 @@ public class PersonService { public Person updatePerson(UUID id, PersonUpdateDTO dto) { validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = personRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden")); + .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); person.setFirstName(dto.getFirstName()); person.setLastName(dto.getLastName()); person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()); @@ -102,9 +104,9 @@ public class PersonService { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Quelle und Ziel dürfen nicht identisch sein"); } personRepository.findById(sourceId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Quell-Person nicht gefunden")); + .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Source person not found: " + sourceId)); personRepository.findById(targetId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ziel-Person nicht gefunden")); + .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Target person not found: " + targetId)); // Reassign sender references personRepository.reassignSender(sourceId, targetId); 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 fb0023fe..c8cc2624 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -6,6 +6,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.web.server.ResponseStatusException; @@ -33,8 +34,8 @@ class PersonServiceTest { when(personRepository.findById(id)).thenReturn(Optional.empty()); assertThatThrownBy(() -> personService.getById(id)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getStatus().value()) .isEqualTo(404); } @@ -371,8 +372,8 @@ class PersonServiceTest { when(personRepository.findById(sourceId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getStatus().value()) .isEqualTo(404); } @@ -385,8 +386,8 @@ class PersonServiceTest { when(personRepository.findById(targetId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId)) - .isInstanceOf(ResponseStatusException.class) - .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getStatus().value()) .isEqualTo(404); } -- 2.49.1 From a3d750822cf65576548a8e58dda6888497c359d8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:40:53 +0200 Subject: [PATCH 05/18] feat(persons): accept PersonUpdateDTO for POST /api/persons (all 6 fields) createPerson now takes PersonUpdateDTO, persisting birthYear, deathYear, notes in addition to firstName, lastName, alias. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/PersonController.java | 12 ++++--- .../familienarchiv/service/PersonService.java | 14 +++++++++ .../controller/PersonControllerTest.java | 23 +++++++++++++- .../service/PersonServiceTest.java | 31 +++++++++++++++++++ 4 files changed, 74 insertions(+), 6 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 9812c752..24c3de42 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -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; @@ -58,13 +59,14 @@ public class PersonController { @PostMapping @RequirePermission(Permission.WRITE_ALL) - public ResponseEntity createPerson(@RequestBody Map body) { - String firstName = body.get("firstName"); - String lastName = body.get("lastName"); - if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) { + 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"); } - return ResponseEntity.ok(personService.createPerson(firstName.trim(), lastName.trim(), body.get("alias"))); + dto.setFirstName(dto.getFirstName().trim()); + dto.setLastName(dto.getLastName().trim()); + return ResponseEntity.ok(personService.createPerson(dto)); } @PutMapping("/{id}") 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 9926ea1d..5bb41788 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -72,6 +72,20 @@ public class PersonService { return personRepository.save(person); } + @Transactional + public Person createPerson(PersonUpdateDTO dto) { + validateYears(dto.getBirthYear(), dto.getDeathYear()); + Person person = Person.builder() + .firstName(dto.getFirstName()) + .lastName(dto.getLastName()) + .alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim()) + .notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()) + .birthYear(dto.getBirthYear()) + .deathYear(dto.getDeathYear()) + .build(); + return personRepository.save(person); + } + private void validateYears(Integer birthYear, Integer deathYear) { if (birthYear != null && birthYear <= 0) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein"); 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 cb384f69..44950607 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -201,7 +201,7 @@ class PersonControllerTest { @WithMockUser(authorities = "WRITE_ALL") void createPerson_returns200_whenValid() throws Exception { Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); - when(personService.createPerson(eq("Hans"), eq("Müller"), any())).thenReturn(saved); + when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved); mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) @@ -305,6 +305,27 @@ class PersonControllerTest { .andExpect(status().isBadRequest()); } + // ─── Phase 2.2: POST /api/persons with full PersonUpdateDTO ─────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createPerson_returns200_withAllSixFields() throws Exception { + UUID id = UUID.randomUUID(); + Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz") + .alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build(); + when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved); + + mockMvc.perform(post("/api/persons") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," + + "\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," + + "\"notes\":\"Some notes\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.firstName").value("Maria")) + .andExpect(jsonPath("$.alias").value("Oma Maria")) + .andExpect(jsonPath("$.birthYear").value(1901)); + } + // ─── Phase 1.2: @Size constraints ───────────────────────────────────────── @Test 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 c8cc2624..b9208d75 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -110,6 +110,37 @@ class PersonServiceTest { assertThat(result.getAlias()).isEqualTo("Hans Müller"); } + // ─── Phase 2.1: createPerson(PersonUpdateDTO) ───────────────────────────── + + @Test + void createPerson_dto_persistsAllSixFields() { + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria"); + dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes"); + + Person result = personService.createPerson(dto); + + assertThat(result.getFirstName()).isEqualTo("Maria"); + assertThat(result.getLastName()).isEqualTo("Raddatz"); + assertThat(result.getAlias()).isEqualTo("Oma Maria"); + assertThat(result.getBirthYear()).isEqualTo(1901); + assertThat(result.getDeathYear()).isEqualTo(1975); + assertThat(result.getNotes()).isEqualTo("Some notes"); + } + + @Test + void createPerson_dto_yearValidationFires_whenBirthYearNegative() { + PersonUpdateDTO dto = new PersonUpdateDTO(); + dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1); + + assertThatThrownBy(() -> personService.createPerson(dto)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + // ─── updatePerson (alias) ───────────────────────────────────────────────── @Test -- 2.49.1 From 593638482dbded5b5eaf0ce658786e892fa43d39 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:44:16 +0200 Subject: [PATCH 06/18] feat(persons): add PersonSummaryDTO with document count to GET /api/persons Native queries compute sender + receiver document count in one SQL call, eliminating N+1. GET /api/persons now returns PersonSummaryDTO list. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/PersonController.java | 3 +- .../familienarchiv/dto/PersonSummaryDTO.java | 19 +++++ .../repository/PersonRepository.java | 28 ++++++++ .../familienarchiv/service/PersonService.java | 7 +- .../controller/PersonControllerTest.java | 19 ++++- .../repository/PersonRepositoryTest.java | 72 +++++++++++++++++++ .../service/PersonServiceTest.java | 25 +++---- 7 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java 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 24c3de42..59921087 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Person; @@ -31,7 +32,7 @@ public class PersonController { private final DocumentService documentService; @GetMapping - public ResponseEntity> getPersons(@RequestParam(required = false) String q) { + public ResponseEntity> getPersons(@RequestParam(required = false) String q) { return ResponseEntity.ok(personService.findAll(q)); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java new file mode 100644 index 00000000..d31539ac --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java @@ -0,0 +1,19 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.UUID; + +/** + * Projection returned by the /api/persons list endpoint. + * Includes document count to avoid N+1 queries in the UI. + * Uses interface projection for compatibility with native queries. + */ +public interface PersonSummaryDTO { + UUID getId(); + String getFirstName(); + String getLastName(); + String getAlias(); + Integer getBirthYear(); + Integer getDeathYear(); + String getNotes(); + long getDocumentCount(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index 3c1411b1..6e2bd033 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.model.Person; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -31,6 +32,33 @@ public interface PersonRepository extends JpaRepository { // Exact first+last name match, used for filename-based sender lookup Optional findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName); + // --- PersonSummaryDTO with document count --- + + @Query(value = """ + SELECT p.id, p.first_name AS firstName, p.last_name AS lastName, + p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + FROM persons p + ORDER BY p.last_name ASC, p.first_name ASC + """, + nativeQuery = true) + List findAllWithDocumentCount(); + + @Query(value = """ + SELECT p.id, p.first_name AS firstName, p.last_name AS lastName, + p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + FROM persons p + WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%')) + OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%')) + OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%')) + ORDER BY p.last_name ASC, p.first_name ASC + """, + nativeQuery = true) + List searchWithDocumentCount(@Param("query") String query); + // --- Correspondent queries --- @Query(value = """ 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 5bb41788..db5249a1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; @@ -22,11 +23,11 @@ public class PersonService { private final PersonRepository personRepository; - public List findAll(String q) { + public List findAll(String q) { if (q != null && !q.isBlank()) { - return personRepository.searchByName(q); + return personRepository.searchWithDocumentCount(q); } - return personRepository.findAllByOrderByLastNameAscFirstNameAsc(); + return personRepository.findAllWithDocumentCount(); } public Person getById(UUID id) { 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 44950607..bafb209f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -21,6 +21,8 @@ import java.util.Collections; import java.util.List; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -57,14 +59,27 @@ class PersonControllerTest { @Test @WithMockUser void getPersons_delegatesQueryParam_toService() throws Exception { - Person person = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); - when(personService.findAll("Hans")).thenReturn(List.of(person)); + PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller"); + when(personService.findAll("Hans")).thenReturn(List.of(dto)); mockMvc.perform(get("/api/persons").param("q", "Hans")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].firstName").value("Hans")); } + private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) { + return new PersonSummaryDTO() { + public java.util.UUID getId() { return UUID.randomUUID(); } + public String getFirstName() { return firstName; } + public String getLastName() { return lastName; } + public String getAlias() { return null; } + public Integer getBirthYear() { return null; } + public Integer getDeathYear() { return null; } + public String getNotes() { return null; } + public long getDocumentCount() { return 0; } + }; + } + // ─── GET /api/persons/{id} ──────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java index 69696fdb..20811c72 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java @@ -11,6 +11,8 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.context.annotation.Import; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; + import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.util.List; @@ -288,6 +290,76 @@ class PersonRepositoryTest { assertThat(targetCount).isEqualTo(1); // no duplicate } + // ─── Phase 3.2: findAllWithDocumentCount ────────────────────────────────── + + @Test + void findAllWithDocumentCount_includesDocumentCountAsSenderAndReceiver() { + Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build()); + + // Walter sends 2 docs to Anna (Anna receives 2) + documentRepository.save(Document.builder() + .title("Brief 1").originalFilename("b1.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(anna)).build()); + documentRepository.save(Document.builder() + .title("Brief 2").originalFilename("b2.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(anna)).build()); + // Anna also sends 1 doc to Walter + documentRepository.save(Document.builder() + .title("Brief 3").originalFilename("b3.pdf") + .status(DocumentStatus.UPLOADED) + .sender(anna).receivers(Set.of(walter)).build()); + + List result = personRepository.findAllWithDocumentCount(); + + PersonSummaryDTO walterSummary = result.stream() + .filter(p -> p.getId().equals(walter.getId())).findFirst().orElseThrow(); + PersonSummaryDTO annaSummary = result.stream() + .filter(p -> p.getId().equals(anna.getId())).findFirst().orElseThrow(); + + assertThat(walterSummary.getDocumentCount()).isEqualTo(3); // sent 2, received 1 + assertThat(annaSummary.getDocumentCount()).isEqualTo(3); // sent 1, received 2 + } + + @Test + void findAllWithDocumentCount_returnsZero_whenPersonHasNoDocuments() { + Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build()); + + List result = personRepository.findAllWithDocumentCount(); + + PersonSummaryDTO soloSummary = result.stream() + .filter(p -> p.getId().equals(solo.getId())).findFirst().orElseThrow(); + assertThat(soloSummary.getDocumentCount()).isEqualTo(0); + } + + @Test + void searchWithDocumentCount_filtersAndIncludesCount() { + Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build()); + + documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .sender(hans).receivers(Set.of(anna)).build()); + + List result = personRepository.searchWithDocumentCount("Hans"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getFirstName()).isEqualTo("Hans"); + assertThat(result.get(0).getDocumentCount()).isEqualTo(1); + } + + @Test + void searchWithDocumentCount_isCaseInsensitive() { + personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build()); + + List result = personRepository.searchWithDocumentCount("hans"); + + assertThat(result).hasSize(1); + } + // ─── deleteReceiverReferences ───────────────────────────────────────────── @Test 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 b9208d75..e0638a00 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -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.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Person; @@ -52,32 +53,32 @@ class PersonServiceTest { @Test void findAll_returnsAll_whenQueryIsNull() { - List expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build()); - when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected); + List expected = List.of(); + when(personRepository.findAllWithDocumentCount()).thenReturn(expected); assertThat(personService.findAll(null)).isEqualTo(expected); - verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc(); - verify(personRepository, never()).searchByName(any()); + verify(personRepository).findAllWithDocumentCount(); + verify(personRepository, never()).searchWithDocumentCount(any()); } @Test void findAll_returnsAll_whenQueryIsBlank() { - List expected = List.of(); - when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected); + List expected = List.of(); + when(personRepository.findAllWithDocumentCount()).thenReturn(expected); assertThat(personService.findAll(" ")).isEqualTo(expected); - verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc(); - verify(personRepository, never()).searchByName(any()); + verify(personRepository).findAllWithDocumentCount(); + verify(personRepository, never()).searchWithDocumentCount(any()); } @Test void findAll_searchesByName_whenQueryIsNonBlank() { - List expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Müller").build()); - when(personRepository.searchByName("Anna")).thenReturn(expected); + List expected = List.of(); + when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected); assertThat(personService.findAll("Anna")).isEqualTo(expected); - verify(personRepository).searchByName("Anna"); - verify(personRepository, never()).findAllByOrderByLastNameAscFirstNameAsc(); + verify(personRepository).searchWithDocumentCount("Anna"); + verify(personRepository, never()).findAllWithDocumentCount(); } // ─── createPerson ───────────────────────────────────────────────────────── -- 2.49.1 From 707a7610f8a8e0759d11b5198ad853ba3adf1503 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:45:25 +0200 Subject: [PATCH 07/18] feat(stats): add GET /api/stats endpoint returning totalPersons + totalDocuments New StatsController + StatsDTO; no WRITE_ALL required (read-only aggregates). Co-Authored-By: Claude Sonnet 4.6 --- .../controller/StatsController.java | 25 ++++++++ .../raddatz/familienarchiv/dto/StatsDTO.java | 7 +++ .../controller/StatsControllerTest.java | 61 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/controller/StatsController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/StatsDTO.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/StatsControllerTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/StatsController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/StatsController.java new file mode 100644 index 00000000..c735d74a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/StatsController.java @@ -0,0 +1,25 @@ +package org.raddatz.familienarchiv.controller; + +import org.raddatz.familienarchiv.dto.StatsDTO; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.PersonRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/stats") +@RequiredArgsConstructor +public class StatsController { + + private final PersonRepository personRepository; + private final DocumentRepository documentRepository; + + @GetMapping + public ResponseEntity getStats() { + return ResponseEntity.ok(new StatsDTO(personRepository.count(), documentRepository.count())); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/StatsDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/StatsDTO.java new file mode 100644 index 00000000..dcd26c41 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/StatsDTO.java @@ -0,0 +1,7 @@ +package org.raddatz.familienarchiv.dto; + +/** + * Aggregate counts for the dashboard/persons stats bar. + */ +public record StatsDTO(long totalPersons, long totalDocuments) { +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/StatsControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/StatsControllerTest.java new file mode 100644 index 00000000..44228a7b --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/StatsControllerTest.java @@ -0,0 +1,61 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.PersonRepository; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(StatsController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class StatsControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean PersonRepository personRepository; + @MockitoBean DocumentRepository documentRepository; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + @Test + void getStats_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/stats")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getStats_returns200_withCorrectCounts() throws Exception { + when(personRepository.count()).thenReturn(4L); + when(documentRepository.count()).thenReturn(12L); + + mockMvc.perform(get("/api/stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalPersons").value(4)) + .andExpect(jsonPath("$.totalDocuments").value(12)); + } + + @Test + @WithMockUser + void getStats_returns200_withZeroCounts() throws Exception { + when(personRepository.count()).thenReturn(0L); + when(documentRepository.count()).thenReturn(0L); + + mockMvc.perform(get("/api/stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalPersons").value(0)) + .andExpect(jsonPath("$.totalDocuments").value(0)); + } +} -- 2.49.1 From 7b03aada3b64c1d02cb74db764bc4dbe03e73604 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:47:13 +0200 Subject: [PATCH 08/18] chore(api): regenerate TypeScript API types from updated OpenAPI spec Includes PersonSummaryDTO with documentCount, StatsDTO, and new /api/stats endpoint. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 106 +++++++++++++++++------------- 1 file changed, 60 insertions(+), 46 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 696d48a2..3090e5aa 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -468,6 +468,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/persons/{id}/received-documents": { parameters: { query?: never; @@ -708,22 +724,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/auth/reset-token-for-test": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getResetTokenForTest"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/admin/import-status": { parameters: { query?: never; @@ -1003,12 +1003,34 @@ export interface components { createdAt: string; actorName?: string; }; + StatsDTO: { + /** Format: int64 */ + totalPersons?: number; + /** Format: int64 */ + totalDocuments?: number; + }; + PersonSummaryDTO: { + /** Format: uuid */ + id?: string; + firstName?: string; + lastName?: string; + /** Format: int32 */ + birthYear?: number; + /** Format: int32 */ + deathYear?: number; + alias?: string; + notes?: string; + /** Format: int64 */ + documentCount?: number; + }; PageNotificationDTO: { /** Format: int64 */ totalElements?: number; /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -1017,8 +1039,6 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; - first?: boolean; - last?: boolean; empty?: boolean; }; PageableObject: { @@ -1479,7 +1499,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Person"][]; + "*/*": components["schemas"]["PersonSummaryDTO"][]; }; }; }; @@ -1493,9 +1513,7 @@ export interface operations { }; requestBody: { content: { - "application/json": { - [key: string]: string; - }; + "application/json": components["schemas"]["PersonUpdateDTO"]; }; }; responses: { @@ -2111,6 +2129,26 @@ export interface operations { }; }; }; + getStats: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["StatsDTO"]; + }; + }; + }; + }; getPersonReceivedDocuments: { parameters: { query?: never; @@ -2184,9 +2222,7 @@ export interface operations { query?: { page?: number; size?: number; - /** @description Filter by notification type */ type?: "REPLY" | "MENTION"; - /** @description Filter by read status */ read?: boolean; }; header?: never; @@ -2459,28 +2495,6 @@ export interface operations { }; }; }; - getResetTokenForTest: { - parameters: { - query: { - email: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": string; - }; - }; - }; - }; importStatus: { parameters: { query?: never; -- 2.49.1 From 3abdf9bb6809f6e0e551e199d9517b04b4388d17 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:50:30 +0200 Subject: [PATCH 09/18] feat(persons): add formatLifeDateRange + formatDocumentStatus utility functions Unit tests for both; i18n keys for doc status and person stats bar; PERSON_NOT_FOUND added to frontend ErrorCode type. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 19 ++++++++++++- frontend/messages/en.json | 19 ++++++++++++- frontend/messages/es.json | 19 ++++++++++++- frontend/src/lib/errors.ts | 3 ++ .../src/lib/utils/documentStatusLabel.spec.ts | 28 +++++++++++++++++++ frontend/src/lib/utils/documentStatusLabel.ts | 22 +++++++++++++++ .../src/lib/utils/personLifeDates.spec.ts | 24 ++++++++++++++++ frontend/src/lib/utils/personLifeDates.ts | 20 +++++++++++++ 8 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/utils/documentStatusLabel.spec.ts create mode 100644 frontend/src/lib/utils/documentStatusLabel.ts create mode 100644 frontend/src/lib/utils/personLifeDates.spec.ts create mode 100644 frontend/src/lib/utils/personLifeDates.ts diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 9424d65f..3931b8a3 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -320,5 +320,22 @@ "dashboard_needs_metadata_show_all": "Alle anzeigen", "dashboard_recent_heading": "Zuletzt aktiv", "dashboard_resume_label": "Zuletzt geöffnet:", - "dashboard_resume_fallback": "Unbekanntes Dokument" + "dashboard_resume_fallback": "Unbekanntes Dokument", + "doc_status_placeholder": "Platzhalter", + "doc_status_uploaded": "Hochgeladen", + "doc_status_transcribed": "Transkribiert", + "doc_status_reviewed": "Geprüft", + "doc_status_archived": "Archiviert", + "doc_status_unknown": "Unbekannt", + "persons_stats_persons_one": "1 Person", + "persons_stats_persons_many": "{count} Personen", + "persons_stats_documents_one": "1 Dokument", + "persons_stats_documents_many": "{count} Dokumente", + "error_person_not_found": "Die Person wurde nicht gefunden.", + "person_btn_edit": "Bearbeiten", + "person_discard_changes": "Änderungen verwerfen", + "person_danger_zone_heading": "Gefahrenzone", + "persons_new_birth_year": "Geburtsjahr", + "persons_new_death_year": "Todesjahr", + "persons_new_notes": "Notizen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4e1e071d..07dd1e6c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -320,5 +320,22 @@ "dashboard_needs_metadata_show_all": "Show all", "dashboard_recent_heading": "Recent Activity", "dashboard_resume_label": "Last opened:", - "dashboard_resume_fallback": "Unknown document" + "dashboard_resume_fallback": "Unknown document", + "doc_status_placeholder": "Placeholder", + "doc_status_uploaded": "Uploaded", + "doc_status_transcribed": "Transcribed", + "doc_status_reviewed": "Reviewed", + "doc_status_archived": "Archived", + "doc_status_unknown": "Unknown", + "persons_stats_persons_one": "1 person", + "persons_stats_persons_many": "{count} persons", + "persons_stats_documents_one": "1 document", + "persons_stats_documents_many": "{count} documents", + "error_person_not_found": "Person not found.", + "person_btn_edit": "Edit", + "person_discard_changes": "Discard changes", + "person_danger_zone_heading": "Danger zone", + "persons_new_birth_year": "Birth year", + "persons_new_death_year": "Death year", + "persons_new_notes": "Notes" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 14c9f868..fdb95ee4 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -320,5 +320,22 @@ "dashboard_needs_metadata_show_all": "Ver todos", "dashboard_recent_heading": "Actividad reciente", "dashboard_resume_label": "Último abierto:", - "dashboard_resume_fallback": "Documento desconocido" + "dashboard_resume_fallback": "Documento desconocido", + "doc_status_placeholder": "Marcador", + "doc_status_uploaded": "Cargado", + "doc_status_transcribed": "Transcrito", + "doc_status_reviewed": "Revisado", + "doc_status_archived": "Archivado", + "doc_status_unknown": "Desconocido", + "persons_stats_persons_one": "1 persona", + "persons_stats_persons_many": "{count} personas", + "persons_stats_documents_one": "1 documento", + "persons_stats_documents_many": "{count} documentos", + "error_person_not_found": "Persona no encontrada.", + "person_btn_edit": "Editar", + "person_discard_changes": "Descartar cambios", + "person_danger_zone_heading": "Zona de peligro", + "persons_new_birth_year": "Año de nacimiento", + "persons_new_death_year": "Año de fallecimiento", + "persons_new_notes": "Notas" } diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 3abe2b0c..d5964198 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -5,6 +5,7 @@ import * as m from '$lib/paraglide/messages.js'; * Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java */ export type ErrorCode = + | 'PERSON_NOT_FOUND' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' | 'FILE_NOT_FOUND' @@ -47,6 +48,8 @@ export async function parseBackendError(res: Response): Promise { + it('maps PLACEHOLDER to correct label', () => { + expect(formatDocumentStatus('PLACEHOLDER')).toBe('Platzhalter'); + }); + + it('maps UPLOADED to correct label', () => { + expect(formatDocumentStatus('UPLOADED')).toBe('Hochgeladen'); + }); + + it('maps TRANSCRIBED to correct label', () => { + expect(formatDocumentStatus('TRANSCRIBED')).toBe('Transkribiert'); + }); + + it('maps REVIEWED to correct label', () => { + expect(formatDocumentStatus('REVIEWED')).toBe('Geprüft'); + }); + + it('maps ARCHIVED to correct label', () => { + expect(formatDocumentStatus('ARCHIVED')).toBe('Archiviert'); + }); + + it('returns fallback for unknown status', () => { + expect(formatDocumentStatus('SOMETHING_NEW')).toBe('Unbekannt'); + }); +}); diff --git a/frontend/src/lib/utils/documentStatusLabel.ts b/frontend/src/lib/utils/documentStatusLabel.ts new file mode 100644 index 00000000..715fae30 --- /dev/null +++ b/frontend/src/lib/utils/documentStatusLabel.ts @@ -0,0 +1,22 @@ +import { m } from '$lib/paraglide/messages.js'; + +/** + * Maps a document status string to a localised human-readable label. + * Falls back to "Unknown" for unrecognised values. + */ +export function formatDocumentStatus(status: string): string { + switch (status) { + case 'PLACEHOLDER': + return m.doc_status_placeholder(); + case 'UPLOADED': + return m.doc_status_uploaded(); + case 'TRANSCRIBED': + return m.doc_status_transcribed(); + case 'REVIEWED': + return m.doc_status_reviewed(); + case 'ARCHIVED': + return m.doc_status_archived(); + default: + return m.doc_status_unknown(); + } +} diff --git a/frontend/src/lib/utils/personLifeDates.spec.ts b/frontend/src/lib/utils/personLifeDates.spec.ts new file mode 100644 index 00000000..cbe23ab8 --- /dev/null +++ b/frontend/src/lib/utils/personLifeDates.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { formatLifeDateRange } from './personLifeDates'; + +describe('formatLifeDateRange', () => { + it('returns both dates when birth and death year are given', () => { + expect(formatLifeDateRange(1882, 1944)).toBe('* 1882 – † 1944'); + }); + + it('returns only birth year when only birthYear is given', () => { + expect(formatLifeDateRange(1882, undefined)).toBe('* 1882'); + }); + + it('returns only death year when only deathYear is given', () => { + expect(formatLifeDateRange(undefined, 1944)).toBe('† 1944'); + }); + + it('returns empty string when neither year is given', () => { + expect(formatLifeDateRange(undefined, undefined)).toBe(''); + }); + + it('returns empty string when both are null', () => { + expect(formatLifeDateRange(null, null)).toBe(''); + }); +}); diff --git a/frontend/src/lib/utils/personLifeDates.ts b/frontend/src/lib/utils/personLifeDates.ts new file mode 100644 index 00000000..4adbb2fa --- /dev/null +++ b/frontend/src/lib/utils/personLifeDates.ts @@ -0,0 +1,20 @@ +/** + * Formats the life date range for a person. + * Examples: + * * 1882 – † 1944 (both) + * * 1882 (birth only) + * † 1944 (death only) + * "" (neither) + */ +export function formatLifeDateRange(birthYear?: number | null, deathYear?: number | null): string { + if (birthYear && deathYear) { + return `* ${birthYear} – † ${deathYear}`; + } + if (birthYear) { + return `* ${birthYear}`; + } + if (deathYear) { + return `† ${deathYear}`; + } + return ''; +} -- 2.49.1 From f4c99cabd59fcd470181d1892014b866317d1e33 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:52:37 +0200 Subject: [PATCH 10/18] feat(persons): enrich /persons list with stats bar, life dates, doc count chip Load /api/stats in parallel; PersonsStatsBar shows totals; person cards show alias, life date range, and document count badge. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/persons/+page.server.ts | 27 +++++++++---- frontend/src/routes/persons/+page.svelte | 40 +++++++++++-------- .../routes/persons/PersonsEmptyState.svelte | 18 +++++++++ .../src/routes/persons/PersonsStatsBar.svelte | 26 ++++++++++++ .../src/routes/persons/page.svelte.spec.ts | 33 ++++++++++++++- 5 files changed, 119 insertions(+), 25 deletions(-) create mode 100644 frontend/src/routes/persons/PersonsEmptyState.svelte create mode 100644 frontend/src/routes/persons/PersonsStatsBar.svelte diff --git a/frontend/src/routes/persons/+page.server.ts b/frontend/src/routes/persons/+page.server.ts index 139ca9ec..79ad39bc 100644 --- a/frontend/src/routes/persons/+page.server.ts +++ b/frontend/src/routes/persons/+page.server.ts @@ -2,17 +2,30 @@ import { error } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; -export async function load({ url, fetch }) { +export async function load({ url, fetch, locals }) { const q = url.searchParams.get('q') || ''; const api = createApiClient(fetch); - const result = await api.GET('/api/persons', { - params: { query: { q: q || undefined } } - }); + const canWrite = + (locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) => + g.permissions.includes('WRITE_ALL') + ) ?? false; - if (!result.response.ok) { - throw error(result.response.status, getErrorMessage(undefined)); + const [personsResult, statsResult] = await Promise.all([ + api.GET('/api/persons', { params: { query: { q: q || undefined } } }), + api.GET('/api/stats', {}) + ]); + + if (!personsResult.response.ok) { + throw error(personsResult.response.status, getErrorMessage(undefined)); } - return { persons: result.data!, q }; + const stats = statsResult.response.ok + ? { + totalPersons: statsResult.data!.totalPersons ?? 0, + totalDocuments: statsResult.data!.totalDocuments ?? 0 + } + : { totalPersons: 0, totalDocuments: 0 }; + + return { persons: personsResult.data!, stats, q, canWrite }; } diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte index 3b3d6a42..2ee01017 100644 --- a/frontend/src/routes/persons/+page.svelte +++ b/frontend/src/routes/persons/+page.svelte @@ -2,6 +2,9 @@ import { goto } from '$app/navigation'; import { untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; +import { formatLifeDateRange } from '$lib/utils/personLifeDates'; +import PersonsStatsBar from './PersonsStatsBar.svelte'; +import PersonsEmptyState from './PersonsEmptyState.svelte'; let { data } = $props(); @@ -37,6 +40,12 @@ function handleSearch() {

{m.persons_subtitle()}

+
+ +
{#if data.canWrite} {#if data.persons.length === 0} -
-
- -
-

{m.persons_empty_heading()}

-

{m.persons_empty_text()}

-
+ {:else}
{#each data.persons as person (person.id)} @@ -113,7 +109,7 @@ function handleSearch() {
- {person.firstName[0]}{person.lastName[0]} + {person.firstName?.[0]}{person.lastName?.[0]}
@@ -124,7 +120,19 @@ function handleSearch() { {person.lastName}

{#if person.alias} -

"{person.alias}"

+

"{person.alias}"

+ {/if} + {#if person.birthYear || person.deathYear} +

+ {formatLifeDateRange(person.birthYear, person.deathYear)} +

+ {/if} + {#if (person.documentCount ?? 0) > 0} + + {person.documentCount} + {/if} diff --git a/frontend/src/routes/persons/PersonsEmptyState.svelte b/frontend/src/routes/persons/PersonsEmptyState.svelte new file mode 100644 index 00000000..9e9f005b --- /dev/null +++ b/frontend/src/routes/persons/PersonsEmptyState.svelte @@ -0,0 +1,18 @@ + + +
+
+ +
+

{m.persons_empty_heading()}

+

{m.persons_empty_text()}

+
diff --git a/frontend/src/routes/persons/PersonsStatsBar.svelte b/frontend/src/routes/persons/PersonsStatsBar.svelte new file mode 100644 index 00000000..67f1e4db --- /dev/null +++ b/frontend/src/routes/persons/PersonsStatsBar.svelte @@ -0,0 +1,26 @@ + + +

+ {personsLabel} · {documentsLabel} +

diff --git a/frontend/src/routes/persons/page.svelte.spec.ts b/frontend/src/routes/persons/page.svelte.spec.ts index 9dc5fc75..9fbb8f7f 100644 --- a/frontend/src/routes/persons/page.svelte.spec.ts +++ b/frontend/src/routes/persons/page.svelte.spec.ts @@ -11,11 +11,24 @@ const makePerson = (overrides = {}) => ({ id: '1', firstName: 'Max', lastName: 'Mustermann', + documentCount: 0, ...overrides }); -const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] }; -const dataWithPersons = { ...emptyData, persons: [makePerson()] }; +const defaultStats = { totalPersons: 0, totalDocuments: 0 }; +const emptyData = { + user: undefined, + canWrite: true, + canAnnotate: false, + q: '', + persons: [], + stats: defaultStats +}; +const dataWithPersons = { + ...emptyData, + persons: [makePerson()], + stats: { totalPersons: 1, totalDocuments: 3 } +}; afterEach(cleanup); @@ -48,6 +61,22 @@ describe('Persons page – rendering', () => { .element(page.getByRole('link', { name: /Max Mustermann/ })) .toHaveAttribute('href', '/persons/1'); }); + + it('shows alias in italic when provided', async () => { + render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } }); + await expect.element(page.getByText('"Maxi"')).toBeInTheDocument(); + }); + + it('shows life date range when birthYear is provided', async () => { + render(Page, { data: { ...emptyData, persons: [makePerson({ birthYear: 1900 })] } }); + await expect.element(page.getByText('* 1900')).toBeInTheDocument(); + }); + + it('shows stats bar with person and document counts', async () => { + render(Page, { data: dataWithPersons }); + await expect.element(page.getByText(/1 Person/)).toBeInTheDocument(); + await expect.element(page.getByText(/3 Dokumente/)).toBeInTheDocument(); + }); }); // ─── Keystroke preservation (issue #34) ────────────────────────────────────── -- 2.49.1 From 7141ae1e1fb550bbbea322605806b9adc9cb3867 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:53:30 +0200 Subject: [PATCH 11/18] feat(persons): add birthYear, deathYear, notes fields to /persons/new form Server action passes all 6 fields to POST /api/persons. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/persons/new/+page.server.ts | 15 ++++++- frontend/src/routes/persons/new/+page.svelte | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/persons/new/+page.server.ts b/frontend/src/routes/persons/new/+page.server.ts index d4f78137..eae1424c 100644 --- a/frontend/src/routes/persons/new/+page.server.ts +++ b/frontend/src/routes/persons/new/+page.server.ts @@ -15,14 +15,27 @@ export const actions = { const firstName = formData.get('firstName')?.toString().trim(); const lastName = formData.get('lastName')?.toString().trim(); const alias = formData.get('alias')?.toString().trim() || undefined; + const birthYearStr = formData.get('birthYear')?.toString().trim(); + 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.' }); } + const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined; + const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined; + const api = createApiClient(fetch); const result = await api.POST('/api/persons', { - body: { firstName, lastName, ...(alias ? { alias } : {}) } + body: { + firstName, + lastName, + ...(alias ? { alias } : {}), + ...(birthYear ? { birthYear } : {}), + ...(deathYear ? { deathYear } : {}), + ...(notes ? { notes } : {}) + } }); if (!result.response.ok) { diff --git a/frontend/src/routes/persons/new/+page.svelte b/frontend/src/routes/persons/new/+page.svelte index df4380dd..733a17b2 100644 --- a/frontend/src/routes/persons/new/+page.svelte +++ b/frontend/src/routes/persons/new/+page.svelte @@ -77,6 +77,49 @@ let { form } = $props(); class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink" /> + +
+ + +
+ +
+ + +
+ +
+ + +
-- 2.49.1 From 44e8891ca9cdf8f2a8673baafbbe1d5cb17c8d46 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:55:31 +0200 Subject: [PATCH 12/18] feat(persons): redesign /persons/[id] detail page (Concept A layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersonCard: remove edit toggle, add Edit→/edit link; 2-column layout on lg; CoCorrespondentsList: add chat icon + title tooltip; remove update/merge actions. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/persons/[id]/+page.server.ts | 70 +---- frontend/src/routes/persons/[id]/+page.svelte | 44 +-- .../persons/[id]/CoCorrespondentsList.svelte | 16 ++ .../src/routes/persons/[id]/PersonCard.svelte | 271 ++++-------------- .../routes/persons/[id]/page.server.spec.ts | 31 +- 5 files changed, 137 insertions(+), 295 deletions(-) diff --git a/frontend/src/routes/persons/[id]/+page.server.ts b/frontend/src/routes/persons/[id]/+page.server.ts index 0bb0ca2a..6b7e8f79 100644 --- a/frontend/src/routes/persons/[id]/+page.server.ts +++ b/frontend/src/routes/persons/[id]/+page.server.ts @@ -1,11 +1,16 @@ -import { error, fail, redirect } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; -export async function load({ params, fetch }) { +export async function load({ params, fetch, locals }) { const { id } = params; const api = createApiClient(fetch); + const canWrite = + (locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) => + g.permissions.includes('WRITE_ALL') + ) ?? false; + const [personResult, sentDocsResult, receivedDocsResult] = await Promise.all([ api.GET('/api/persons/{id}', { params: { path: { id } } }), api.GET('/api/persons/{id}/documents', { params: { path: { id } } }), @@ -20,64 +25,7 @@ export async function load({ params, fetch }) { return { person: personResult.data!, sentDocuments: sentDocsResult.data ?? [], - receivedDocuments: receivedDocsResult.data ?? [] + receivedDocuments: receivedDocsResult.data ?? [], + canWrite }; } - -export const actions = { - update: async ({ request, params, fetch }) => { - const formData = await request.formData(); - const firstName = formData.get('firstName')?.toString().trim(); - const lastName = formData.get('lastName')?.toString().trim(); - const alias = formData.get('alias')?.toString().trim() || undefined; - const notes = formData.get('notes')?.toString().trim() || undefined; - const birthYearStr = formData.get('birthYear')?.toString().trim(); - const deathYearStr = formData.get('deathYear')?.toString().trim(); - 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.' }); - } - - const api = createApiClient(fetch); - const { error: apiError } = await api.PUT('/api/persons/{id}', { - params: { path: { id: params.id } }, - body: { - firstName, - lastName, - ...(alias ? { alias } : {}), - ...(notes ? { notes } : {}), - ...(birthYear ? { birthYear } : {}), - ...(deathYear ? { deathYear } : {}) - } - }); - - if (apiError) { - return fail(400, { updateError: 'Speichern fehlgeschlagen.' }); - } - - return { updated: true }; - }, - - merge: async ({ request, params, fetch }) => { - const formData = await request.formData(); - const targetPersonId = formData.get('targetPersonId')?.toString(); - - if (!targetPersonId) { - return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' }); - } - - const api = createApiClient(fetch); - const { error: apiError } = await api.POST('/api/persons/{id}/merge', { - params: { path: { id: params.id } }, - body: { targetPersonId } - }); - - if (apiError) { - return fail(400, { mergeError: 'Zusammenführen fehlgeschlagen.' }); - } - - throw redirect(303, `/persons/${targetPersonId}`); - } -}; diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index a37e87da..2da3e2f2 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -2,11 +2,10 @@ import { m } from '$lib/paraglide/messages.js'; import { SvelteMap } from 'svelte/reactivity'; import PersonCard from './PersonCard.svelte'; -import PersonMergePanel from './PersonMergePanel.svelte'; import CoCorrespondentsList from './CoCorrespondentsList.svelte'; import PersonDocumentList from './PersonDocumentList.svelte'; -let { data, form } = $props(); +let { data } = $props(); const person = $derived(data.person); const sentDocuments = $derived(data.sentDocuments); @@ -47,7 +46,7 @@ const coCorrespondents = $derived.by(() => { }); -
+
- + +
+ +
+ +
- {#if data.canWrite} - {#key person.id} - - {/key} - {/if} + +
+ - + - - - + +
+
diff --git a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte index 5b0409eb..81855ed2 100644 --- a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte +++ b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte @@ -19,8 +19,24 @@ let { {#each coCorrespondents as c (c.id)} + + {c.name} ({c.count}) diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte b/frontend/src/routes/persons/[id]/PersonCard.svelte index 27844aad..e7f8341e 100644 --- a/frontend/src/routes/persons/[id]/PersonCard.svelte +++ b/frontend/src/routes/persons/[id]/PersonCard.svelte @@ -1,13 +1,13 @@
- {#if editMode && canWrite} - -
-
-

- {m.person_edit_heading()} -

- - {#if form?.updateError} -

- {form.updateError} -

- {/if} - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
+ +
+
+
+ {person.firstName[0]}{person.lastName[0]}
- - {:else} - -
-
-
- {person.firstName[0]}{person.lastName[0]} +
+ +
+
+

+ {person.firstName} + {person.lastName} +

+
+ {#if canWrite} + + + {m.btn_edit()} + + {/if}
-
-
-

- {person.firstName} - {person.lastName} -

-
- {#if canWrite} - - {/if} -
-
- -
+
+ {#if person.alias}
{m.person_label_full_name()} - {person.firstName} {person.lastName}{m.form_label_alias()} + "{person.alias}"
+ {/if} - {#if person.alias} -
- {m.form_label_alias()} - "{person.alias}" -
- {/if} + {#if person.birthYear || person.deathYear} +
+ + {#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if} + + + {formatLifeDateRange(person.birthYear, person.deathYear)} + +
+ {/if} - {#if person.birthYear || person.deathYear} -
- - {#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if} - - - {#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear} -  {/if}{#if person.deathYear}† {person.deathYear}{/if} - -
- {/if} - - {#if person.notes} -
- {m.person_label_notes()} -

- {person.notes} -

-
- {/if} -
+ {#if person.notes} +
+ {m.person_label_notes()} +

+ {person.notes} +

+
+ {/if}
- {/if} +
diff --git a/frontend/src/routes/persons/[id]/page.server.spec.ts b/frontend/src/routes/persons/[id]/page.server.spec.ts index e1e8f493..dcdb1c5f 100644 --- a/frontend/src/routes/persons/[id]/page.server.spec.ts +++ b/frontend/src/routes/persons/[id]/page.server.spec.ts @@ -6,6 +6,8 @@ vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); import { createApiClient } from '$lib/api.server'; const mockFetch = vi.fn() as unknown as typeof fetch; +const mockLocals = { user: { groups: [{ permissions: ['READ_ALL'] }] } }; +const mockLocalsWriter = { user: { groups: [{ permissions: ['WRITE_ALL'] }] } }; beforeEach(() => vi.clearAllMocks()); @@ -24,13 +26,30 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - const result = await load({ params: { id: 'p1' }, fetch: mockFetch }); + const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); expect(result.person.firstName).toBe('Hans'); expect(result.sentDocuments).toHaveLength(1); expect(result.receivedDocuments).toEqual([]); }); + it('returns canWrite=true when user has WRITE_ALL', async () => { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValueOnce({ + response: { ok: true, status: 200 }, + data: { id: 'p1', firstName: 'Anna', lastName: 'Schmidt' } + }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) + } as ReturnType); + + const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter }); + + expect(result.canWrite).toBe(true); + }); + it('returns empty arrays when sent/received document APIs fail', async () => { vi.mocked(createApiClient).mockReturnValue({ GET: vi @@ -43,7 +62,7 @@ describe('person detail load — happy path', () => { .mockResolvedValueOnce({ response: { ok: false }, data: null }) } as ReturnType); - const result = await load({ params: { id: 'p1' }, fetch: mockFetch }); + const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals }); expect(result.sentDocuments).toEqual([]); expect(result.receivedDocuments).toEqual([]); @@ -62,7 +81,9 @@ describe('person detail load — error paths', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - await expect(load({ params: { id: 'missing' }, fetch: mockFetch })).rejects.toMatchObject({ + await expect( + load({ params: { id: 'missing' }, fetch: mockFetch, locals: mockLocals }) + ).rejects.toMatchObject({ status: 404 }); }); @@ -76,7 +97,9 @@ describe('person detail load — error paths', () => { .mockResolvedValueOnce({ response: { ok: true }, data: [] }) } as ReturnType); - await expect(load({ params: { id: 'forbidden' }, fetch: mockFetch })).rejects.toMatchObject({ + await expect( + load({ params: { id: 'forbidden' }, fetch: mockFetch, locals: mockLocals }) + ).rejects.toMatchObject({ status: 403 }); }); -- 2.49.1 From 272073f186f928ce1d63729c65e2149401be338b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 19:57:32 +0200 Subject: [PATCH 13/18] feat(persons): add /persons/[id]/edit route with PersonEditForm, PersonDangerZone New edit route with WRITE_ALL guard; PersonEditForm (6 fields), sticky PersonEditSaveBar, collapsed PersonDangerZone with PersonMergePanel. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/persons/[id]/edit/+page.server.ts | 87 +++++++++++++++ .../src/routes/persons/[id]/edit/+page.svelte | 56 ++++++++++ .../persons/[id]/edit/PersonDangerZone.svelte | 44 ++++++++ .../persons/[id]/edit/PersonEditForm.svelte | 100 ++++++++++++++++++ .../[id]/edit/PersonEditSaveBar.svelte | 21 ++++ 5 files changed, 308 insertions(+) create mode 100644 frontend/src/routes/persons/[id]/edit/+page.server.ts create mode 100644 frontend/src/routes/persons/[id]/edit/+page.svelte create mode 100644 frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte create mode 100644 frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte create mode 100644 frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte diff --git a/frontend/src/routes/persons/[id]/edit/+page.server.ts b/frontend/src/routes/persons/[id]/edit/+page.server.ts new file mode 100644 index 00000000..6b5f9ebc --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/+page.server.ts @@ -0,0 +1,87 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; + +export async function load({ params, fetch, locals }) { + const canWrite = + (locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) => + g.permissions.includes('WRITE_ALL') + ) ?? false; + + if (!canWrite) throw error(403, 'Forbidden'); + + const { id } = params; + const api = createApiClient(fetch); + const result = await api.GET('/api/persons/{id}', { params: { path: { id } } }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); + } + + return { person: result.data! }; +} + +export const actions = { + update: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const firstName = formData.get('firstName')?.toString().trim(); + const lastName = formData.get('lastName')?.toString().trim(); + const alias = formData.get('alias')?.toString().trim() || undefined; + const notes = formData.get('notes')?.toString().trim() || undefined; + const birthYearStr = formData.get('birthYear')?.toString().trim(); + const deathYearStr = formData.get('deathYear')?.toString().trim(); + 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.' }); + } + + const api = createApiClient(fetch); + const result = await api.PUT('/api/persons/{id}', { + params: { path: { id: params.id } }, + body: { + firstName, + lastName, + ...(alias ? { alias } : {}), + ...(notes ? { notes } : {}), + ...(birthYear ? { birthYear } : {}), + ...(deathYear ? { deathYear } : {}) + } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { updateError: getErrorMessage(code) }); + } + + throw redirect(303, `/persons/${params.id}`); + }, + + discard: async ({ params }) => { + throw redirect(303, `/persons/${params.id}`); + }, + + merge: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const targetPersonId = formData.get('targetPersonId')?.toString(); + + if (!targetPersonId) { + return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' }); + } + + const api = createApiClient(fetch); + const result = await api.POST('/api/persons/{id}/merge', { + params: { path: { id: params.id } }, + body: { targetPersonId } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { mergeError: getErrorMessage(code) }); + } + + throw redirect(303, `/persons/${targetPersonId}`); + } +}; diff --git a/frontend/src/routes/persons/[id]/edit/+page.svelte b/frontend/src/routes/persons/[id]/edit/+page.svelte new file mode 100644 index 00000000..a3483e4b --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/+page.svelte @@ -0,0 +1,56 @@ + + +
+ + + + {#if form?.updateError} +
+ {form.updateError} +
+ {/if} + +
+
+

+ {m.persons_section_details()} +

+ +
+ + + + + +
diff --git a/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte b/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte new file mode 100644 index 00000000..b5fe3025 --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte @@ -0,0 +1,44 @@ + + +
+ + + {#if open} +
+ {#key person.id} + + {/key} +
+ {/if} +
diff --git a/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte new file mode 100644 index 00000000..de77c3e7 --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/PersonEditForm.svelte @@ -0,0 +1,100 @@ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte b/frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte new file mode 100644 index 00000000..ff6c3042 --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte @@ -0,0 +1,21 @@ + + + +
+ + {m.person_discard_changes()} + + +
-- 2.49.1 From 241e4874ad5c44d34e1cd7e1322d30cbe843f4d7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 20:12:45 +0200 Subject: [PATCH 14/18] fix: resolve lint and type-check issues introduced by persons redesign - Cast PersonSummaryDTO array to concrete type in +page.server.ts (all fields are optional in the generated type but always populated at runtime) - Cast mockLocals/mockLocalsWriter to `any` in persons detail spec to match the pre-existing test pattern used throughout the codebase - Add .svelte-kit-backup/ to .gitignore and .prettierignore to prevent lint failures from Docker-owned leftover .svelte-kit directory Co-Authored-By: Claude Sonnet 4.6 --- frontend/.gitignore | 1 + frontend/.prettierignore | 4 ++++ frontend/src/routes/+page.server.ts | 7 +++++-- frontend/src/routes/persons/[id]/page.server.spec.ts | 6 ++++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/.gitignore b/frontend/.gitignore index 79a69b7d..3f9bc1b0 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -6,6 +6,7 @@ node_modules .netlify .wrangler /.svelte-kit +/.svelte-kit-backup /build # OS diff --git a/frontend/.prettierignore b/frontend/.prettierignore index dc7a1d03..507fbd90 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -8,6 +8,10 @@ bun.lockb # Miscellaneous /static/ +# Build artifacts +/.svelte-kit/ +/.svelte-kit-backup/ + # Generated files /src/lib/generated/ /src/lib/paraglide/ diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 9c6c1c64..1b106903 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -45,8 +45,11 @@ export async function load({ url, fetch }) { } const documents: Document[] = docsResult?.data ?? []; - const allPersons: { id: string; firstName: string; lastName: string }[] = - personsResult.data ?? []; + const allPersons = (personsResult.data ?? []) as { + id: string; + firstName: string; + lastName: string; + }[]; const senderObj = allPersons.find((p) => p.id === senderId); const receiverObj = allPersons.find((p) => p.id === receiverId); diff --git a/frontend/src/routes/persons/[id]/page.server.spec.ts b/frontend/src/routes/persons/[id]/page.server.spec.ts index dcdb1c5f..3994fe40 100644 --- a/frontend/src/routes/persons/[id]/page.server.spec.ts +++ b/frontend/src/routes/persons/[id]/page.server.spec.ts @@ -6,8 +6,10 @@ vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); import { createApiClient } from '$lib/api.server'; const mockFetch = vi.fn() as unknown as typeof fetch; -const mockLocals = { user: { groups: [{ permissions: ['READ_ALL'] }] } }; -const mockLocalsWriter = { user: { groups: [{ permissions: ['WRITE_ALL'] }] } }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockLocals = { user: { groups: [{ permissions: ['READ_ALL'] }] } } as any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockLocalsWriter = { user: { groups: [{ permissions: ['WRITE_ALL'] }] } } as any; beforeEach(() => vi.clearAllMocks()); -- 2.49.1 From 27d7225330488e6c7cc37d97285ea347ee189931 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 20:27:22 +0200 Subject: [PATCH 15/18] =?UTF-8?q?fix(persons):=20align=20pages=20with=20Co?= =?UTF-8?q?ncept=20A=20spec=20=E2=80=94=20card=20layout,=20stats=20bar,=20?= =?UTF-8?q?status=20labels,=20save=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 3 +- frontend/messages/en.json | 3 +- frontend/messages/es.json | 3 +- frontend/src/routes/persons/+page.svelte | 119 ++++++++------- .../src/routes/persons/PersonsStatsBar.svelte | 31 ++-- .../src/routes/persons/[id]/PersonCard.svelte | 135 ++++++++---------- .../persons/[id]/PersonDocumentList.svelte | 3 +- .../[id]/edit/PersonEditSaveBar.svelte | 2 +- 8 files changed, 142 insertions(+), 157 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 3931b8a3..58b1f589 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -337,5 +337,6 @@ "person_danger_zone_heading": "Gefahrenzone", "persons_new_birth_year": "Geburtsjahr", "persons_new_death_year": "Todesjahr", - "persons_new_notes": "Notizen" + "persons_new_notes": "Notizen", + "person_save_changes": "Änderungen speichern" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 07dd1e6c..83401231 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -337,5 +337,6 @@ "person_danger_zone_heading": "Danger zone", "persons_new_birth_year": "Birth year", "persons_new_death_year": "Death year", - "persons_new_notes": "Notes" + "persons_new_notes": "Notes", + "person_save_changes": "Save changes" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index fdb95ee4..cb11ae92 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -337,5 +337,6 @@ "person_danger_zone_heading": "Zona de peligro", "persons_new_birth_year": "Año de nacimiento", "persons_new_death_year": "Año de fallecimiento", - "persons_new_notes": "Notas" + "persons_new_notes": "Notas", + "person_save_changes": "Guardar cambios" } diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte index 2ee01017..dbae1ea6 100644 --- a/frontend/src/routes/persons/+page.svelte +++ b/frontend/src/routes/persons/+page.svelte @@ -11,7 +11,6 @@ let { data } = $props(); let q = $state(untrack(() => data.q || '')); let qFocused = $state(false); -// Sync URL → local state after navigation, but not while the user is typing. $effect(() => { if (!qFocused) q = data.q || ''; }); @@ -31,41 +30,22 @@ function handleSearch() {
- -
+ +
-

{m.persons_heading()}

-

- {m.persons_subtitle()} -

-
+

{m.page_title_persons()}

+
- {#if data.canWrite} - - - {m.persons_btn_new()} - - {/if}
- -
- +
+
+ (qFocused = true)} onblur={() => (qFocused = false)} - class="block w-full rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none" + class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none" />
+ + + {#if data.canWrite} + + + {m.persons_btn_new()} + + {/if}
{#if data.persons.length === 0} {:else} -
+
{#each data.persons as person (person.id)} - +
- -
- -
-
- {person.firstName?.[0]}{person.lastName?.[0]} -
+
+ {person.firstName?.[0]}{person.lastName?.[0]}
- -
-

- {person.firstName} - {person.lastName} + +

+ {person.firstName} + {person.lastName} +

+ + + {#if person.alias} +

„{person.alias}"

+ {/if} + + + {#if person.birthYear || person.deathYear} +

+ {formatLifeDateRange(person.birthYear, person.deathYear)}

- {#if person.alias} -

"{person.alias}"

- {/if} - {#if person.birthYear || person.deathYear} -

- {formatLifeDateRange(person.birthYear, person.deathYear)} -

- {/if} - {#if (person.documentCount ?? 0) > 0} - - {person.documentCount} - - {/if} -
+ {/if} + + + {#if (person.documentCount ?? 0) > 0} + + {person.documentCount} docs + + {/if}
{/each} diff --git a/frontend/src/routes/persons/PersonsStatsBar.svelte b/frontend/src/routes/persons/PersonsStatsBar.svelte index 67f1e4db..9e733c91 100644 --- a/frontend/src/routes/persons/PersonsStatsBar.svelte +++ b/frontend/src/routes/persons/PersonsStatsBar.svelte @@ -1,6 +1,4 @@ -

- {personsLabel} · {documentsLabel} -

+
+
+ {totalPersons} + + Persons + +
+ · +
+ {totalDocuments} + + Documents + +
+
diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte b/frontend/src/routes/persons/[id]/PersonCard.svelte index e7f8341e..a14217bc 100644 --- a/frontend/src/routes/persons/[id]/PersonCard.svelte +++ b/frontend/src/routes/persons/[id]/PersonCard.svelte @@ -19,81 +19,70 @@ let { } = $props(); -
-
+
+ +
-
- -
-
-
- {person.firstName[0]}{person.lastName[0]} -
-
- -
-
-

- {person.firstName} - {person.lastName} -

-
- {#if canWrite} - - - {m.btn_edit()} - - {/if} -
-
- -
- {#if person.alias} -
- {m.form_label_alias()} - "{person.alias}" -
- {/if} - - {#if person.birthYear || person.deathYear} -
- - {#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if} - - - {formatLifeDateRange(person.birthYear, person.deathYear)} - -
- {/if} - - {#if person.notes} -
- {m.person_label_notes()} -

- {person.notes} -

-
- {/if} -
+
+ +
+
+ {person.firstName[0]}{person.lastName[0]}
+ + +

+ {person.firstName} + {person.lastName} +

+ + + {#if person.alias} +

„{person.alias}"

+ {/if} + + + {#if person.birthYear || person.deathYear} +

+ {formatLifeDateRange(person.birthYear, person.deathYear)} +

+ {:else} +
+ {/if} + + + {#if person.notes} +
+ + {m.person_label_notes()} + +

+ {person.notes} +

+
+ {/if} + + + {#if canWrite} + + + {m.btn_edit()} + + {/if}
diff --git a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte index 56905999..0fbe5d95 100644 --- a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte +++ b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte @@ -2,6 +2,7 @@ import { m } from '$lib/paraglide/messages.js'; import { formatDate } from '$lib/utils/date'; import { sortDocumentsByDate, type SortDir } from '$lib/utils/sort'; +import { formatDocumentStatus } from '$lib/utils/documentStatusLabel'; const DOCS_PREVIEW_LIMIT = 5; @@ -106,7 +107,7 @@ const visibleDocuments = $derived( ? 'border-accent/50 bg-accent/20 text-ink' : 'border-yellow-200 bg-yellow-50 text-yellow-800'}" > - {doc.status} + {formatDocumentStatus(doc.status)} - {m.btn_save()} + {m.person_save_changes()}
-- 2.49.1 From f5645d6c325084019da2c4856be04865162d4972 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 20:37:51 +0200 Subject: [PATCH 16/18] fix(persons): replace hardcoded 'docs'/'Persons'/'Documents' strings with i18n keys Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 6 ++++++ frontend/messages/en.json | 6 ++++++ frontend/messages/es.json | 6 ++++++ frontend/src/routes/persons/+page.svelte | 4 +++- .../src/routes/persons/PersonsStatsBar.svelte | 15 +++++++++++++-- 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 58b1f589..2ba463c6 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -331,6 +331,12 @@ "persons_stats_persons_many": "{count} Personen", "persons_stats_documents_one": "1 Dokument", "persons_stats_documents_many": "{count} Dokumente", + "persons_stats_label_persons_one": "Person", + "persons_stats_label_persons_many": "Personen", + "persons_stats_label_documents_one": "Dokument", + "persons_stats_label_documents_many": "Dokumente", + "person_card_doc_count_one": "1 Dok.", + "person_card_doc_count_many": "{count} Dok.", "error_person_not_found": "Die Person wurde nicht gefunden.", "person_btn_edit": "Bearbeiten", "person_discard_changes": "Änderungen verwerfen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 83401231..695c5980 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -331,6 +331,12 @@ "persons_stats_persons_many": "{count} persons", "persons_stats_documents_one": "1 document", "persons_stats_documents_many": "{count} documents", + "persons_stats_label_persons_one": "Person", + "persons_stats_label_persons_many": "Persons", + "persons_stats_label_documents_one": "Document", + "persons_stats_label_documents_many": "Documents", + "person_card_doc_count_one": "1 doc", + "person_card_doc_count_many": "{count} docs", "error_person_not_found": "Person not found.", "person_btn_edit": "Edit", "person_discard_changes": "Discard changes", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index cb11ae92..5e7a3f42 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -331,6 +331,12 @@ "persons_stats_persons_many": "{count} personas", "persons_stats_documents_one": "1 documento", "persons_stats_documents_many": "{count} documentos", + "persons_stats_label_persons_one": "Persona", + "persons_stats_label_persons_many": "Personas", + "persons_stats_label_documents_one": "Documento", + "persons_stats_label_documents_many": "Documentos", + "person_card_doc_count_one": "1 doc.", + "person_card_doc_count_many": "{count} docs.", "error_person_not_found": "Persona no encontrada.", "person_btn_edit": "Editar", "person_discard_changes": "Descartar cambios", diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte index dbae1ea6..56512485 100644 --- a/frontend/src/routes/persons/+page.svelte +++ b/frontend/src/routes/persons/+page.svelte @@ -125,7 +125,9 @@ function handleSearch() { - {person.documentCount} docs + {person.documentCount === 1 + ? m.person_card_doc_count_one() + : m.person_card_doc_count_many({ count: person.documentCount ?? 0 })} {/if}
diff --git a/frontend/src/routes/persons/PersonsStatsBar.svelte b/frontend/src/routes/persons/PersonsStatsBar.svelte index 9e733c91..8c79f1a2 100644 --- a/frontend/src/routes/persons/PersonsStatsBar.svelte +++ b/frontend/src/routes/persons/PersonsStatsBar.svelte @@ -1,4 +1,6 @@
{totalPersons} - Persons + {personsLabel}
·
{totalDocuments} - Documents + {documentsLabel}
-- 2.49.1 From fffecb5bf684db707815c77f20f1b757ecf4c458 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 29 Mar 2026 20:49:58 +0200 Subject: [PATCH 17/18] feat(persons): redesign detail page sections to match Concept A spec - CoCorrespondentsList: white card wrapper with navy initials circles in chips - PersonDocumentList: flat row-divider pattern with variant-tinted icons (sent=navy, received=teal) - Add variant prop (sent/received) to PersonDocumentList and wire up in page - Add person_correspondents_hint i18n key to all three message files Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/routes/persons/[id]/+page.svelte | 2 + .../persons/[id]/CoCorrespondentsList.svelte | 34 +++-- .../persons/[id]/PersonDocumentList.svelte | 121 ++++++++++-------- 6 files changed, 96 insertions(+), 64 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 2ba463c6..54b4af81 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -120,6 +120,7 @@ "person_role_sender": "Gesendet", "person_role_receiver": "Empfangen", "person_co_correspondents_heading": "Häufige Korrespondenten", + "person_correspondents_hint": "klicken für Konversation", "person_show_more": "+ {count} weitere anzeigen", "conv_heading": "Konversationen", "conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 695c5980..ab6e3bc0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -120,6 +120,7 @@ "person_role_sender": "Sent", "person_role_receiver": "Received", "person_co_correspondents_heading": "Frequent correspondents", + "person_correspondents_hint": "click to view conversation", "person_show_more": "+ {count} more", "conv_heading": "Conversations", "conv_subtitle": "Follow the correspondence between two persons chronologically.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 5e7a3f42..552b6849 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -120,6 +120,7 @@ "person_role_sender": "Enviado", "person_role_receiver": "Recibido", "person_co_correspondents_heading": "Corresponsales frecuentes", + "person_correspondents_hint": "clic para ver conversación", "person_show_more": "+ {count} más", "conv_heading": "Conversaciones", "conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.", diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 2da3e2f2..e14ea2a5 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -78,12 +78,14 @@ const coCorrespondents = $derived.by(() => { documents={sentDocuments} heading={m.person_docs_heading()} emptyMessage={m.person_no_docs()} + variant="sent" />
diff --git a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte index 81855ed2..fc0cc90b 100644 --- a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte +++ b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte @@ -8,23 +8,43 @@ let { coCorrespondents: { id: string; name: string; count: number }[]; personId: string; } = $props(); + +function initials(name: string): string { + return name + .split(' ') + .map((n) => n[0] ?? '') + .join('') + .slice(0, 2) + .toUpperCase(); +} {#if coCorrespondents.length > 0} -
-

- {m.person_co_correspondents_heading()} -

+
+
+

+ {m.person_co_correspondents_heading()} +

+ {m.person_correspondents_hint()} +
diff --git a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte index 0fbe5d95..cedefbf4 100644 --- a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte +++ b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte @@ -1,15 +1,16 @@ -
-
-

{heading}

- +
+ +
+

{heading}

+ {documents.length} {#if yearRange} - {yearRange} + {yearRange} {/if} {#if documents.length > 1} @@ -62,69 +72,68 @@ const visibleDocuments = $derived(
{#if documents.length === 0} -
-

{emptyMessage}

-
+

{emptyMessage}

{:else} -