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..59921087 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -4,16 +4,23 @@ import java.util.List; 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; +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; 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 @@ -25,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)); } @@ -52,17 +59,20 @@ public class PersonController { } @PostMapping - 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()) { + @RequirePermission(Permission.WRITE_ALL) + public ResponseEntity createPerson(@Valid @RequestBody PersonUpdateDTO dto) { + if (dto.getFirstName() == null || dto.getFirstName().isBlank() + || dto.getLastName() == null || dto.getLastName().isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder"); } - 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}") - public ResponseEntity updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) { + @RequirePermission(Permission.WRITE_ALL) + public ResponseEntity updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) { if (dto.getFirstName() == null || dto.getFirstName().isBlank() || dto.getLastName() == null || dto.getLastName().isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder"); @@ -74,6 +84,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/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/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/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/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/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/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 8ac9a3bd..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,7 +4,10 @@ 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; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.http.HttpStatus; @@ -20,16 +23,16 @@ 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) { 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) { @@ -71,12 +74,36 @@ public class PersonService { } @Transactional - public Person updatePerson(UUID id, PersonUpdateDTO dto) { - if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) { + 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"); + } + 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")); + .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()); @@ -92,9 +119,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/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index a56df834..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 @@ -162,7 +177,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 +186,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 +195,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 +204,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,10 +213,10 @@ 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); + when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved); mockMvc.perform(post("/api/persons") .contentType(MediaType.APPLICATION_JSON) @@ -221,7 +236,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 +245,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 +254,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 +278,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 +287,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 +296,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 +319,78 @@ class PersonControllerTest { .content("{\"firstName\":\"Hans\",\"lastName\":\" \"}")) .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 + @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 + @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()); + } } 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)); + } +} 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 f2eb3628..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,7 +5,9 @@ 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; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.web.server.ResponseStatusException; @@ -33,8 +35,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); } @@ -51,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 ───────────────────────────────────────────────────────── @@ -109,6 +111,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 @@ -267,6 +300,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 @@ -321,8 +404,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); } @@ -335,8 +418,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); } diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 30f0c510..852473db 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -8,6 +8,10 @@ bun.lockb # Miscellaneous /static/ +# Build artifacts +/.svelte-kit/ +/.svelte-kit-backup/ + # Generated files /.svelte-kit-backup/ /src/lib/generated/ diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 9b133bc7..e8cf82d4 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.", @@ -321,6 +322,30 @@ "dashboard_recent_heading": "Zuletzt aktiv", "dashboard_resume_label": "Zuletzt geöffnet:", "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", + "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", + "person_danger_zone_heading": "Gefahrenzone", + "persons_new_birth_year": "Geburtsjahr", + "persons_new_death_year": "Todesjahr", + "persons_new_notes": "Notizen", + "person_save_changes": "Änderungen speichern", "notification_view_all": "Alle anzeigen →", "notification_history_heading": "Benachrichtigungen", "notification_history_view_link": "Benachrichtigungsverlauf ansehen →", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a452d12d..527a4f48 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.", @@ -321,6 +322,30 @@ "dashboard_recent_heading": "Recent Activity", "dashboard_resume_label": "Last opened:", "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", + "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", + "person_danger_zone_heading": "Danger zone", + "persons_new_birth_year": "Birth year", + "persons_new_death_year": "Death year", + "persons_new_notes": "Notes", + "person_save_changes": "Save changes", "notification_view_all": "View all →", "notification_history_heading": "Notifications", "notification_history_view_link": "View notification history →", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index ab9c31e0..3d72309e 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.", @@ -321,6 +322,30 @@ "dashboard_recent_heading": "Actividad reciente", "dashboard_resume_label": "Último abierto:", "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", + "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", + "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", + "person_save_changes": "Guardar cambios", "notification_view_all": "Ver todas →", "notification_history_heading": "Notificaciones", "notification_history_view_link": "Ver historial de notificaciones →", 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 ''; +} 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/+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..f8e024f0 100644 --- a/frontend/src/routes/persons/+page.svelte +++ b/frontend/src/routes/persons/+page.svelte @@ -2,13 +2,15 @@ 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(); 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 || ''; }); @@ -28,35 +30,22 @@ function handleSearch() {
- -
+ +
-

{m.persons_heading()}

-

- {m.persons_subtitle()} -

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

{m.page_title_persons()}

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

{m.persons_empty_heading()}

-

{m.persons_empty_text()}

-
+ {: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} + + + {#if (person.documentCount ?? 0) > 0} + + {person.documentCount === 1 + ? m.person_card_doc_count_one() + : m.person_card_doc_count_many({ count: person.documentCount ?? 0 })} + + {/if}
{/each} 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..8c79f1a2 --- /dev/null +++ b/frontend/src/routes/persons/PersonsStatsBar.svelte @@ -0,0 +1,36 @@ + + +
+
+ {totalPersons} + + {personsLabel} + +
+ · +
+ {totalDocuments} + + {documentsLabel} + +
+
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..e14ea2a5 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..fc0cc90b 100644 --- a/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte +++ b/frontend/src/routes/persons/[id]/CoCorrespondentsList.svelte @@ -8,21 +8,55 @@ 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]/PersonCard.svelte b/frontend/src/routes/persons/[id]/PersonCard.svelte index 27844aad..a14217bc 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} - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- {:else} - -
-
-
- {person.firstName[0]}{person.lastName[0]} -
-
- -
-
-

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

-
- {#if canWrite} - - {/if} -
-
- -
-
- {m.person_label_full_name()} - {person.firstName} {person.lastName} -
- - {#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} - - - {#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} -
-
+
+ +
+
+ {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..cedefbf4 100644 --- a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte +++ b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte @@ -1,6 +1,7 @@ -
-
-

{heading}

- +
+ +
+

{heading}

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

{emptyMessage}

-
+

{emptyMessage}

{:else} -
    + + {#if documents.length > DOCS_PREVIEW_LIMIT && !showAll} 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..318b1e4f --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/PersonEditSaveBar.svelte @@ -0,0 +1,21 @@ + + + +
    + + {m.person_discard_changes()} + + +
    diff --git a/frontend/src/routes/persons/[id]/page.server.spec.ts b/frontend/src/routes/persons/[id]/page.server.spec.ts index e1e8f493..3994fe40 100644 --- a/frontend/src/routes/persons/[id]/page.server.spec.ts +++ b/frontend/src/routes/persons/[id]/page.server.spec.ts @@ -6,6 +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; +// 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()); @@ -24,13 +28,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 +64,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 +83,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 +99,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 }); }); 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" />
+ +
+ + +
+ +
+ + +
+ +
+ + +
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) ──────────────────────────────────────