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}
-
-