feat(persons): clean filterable directory + triage UI (Phase 5, #667) #679

Merged
marcel merged 15 commits from feature/667-persons-directory into docs/import-migration 2026-05-27 18:25:22 +02:00
40 changed files with 2116 additions and 196 deletions

View File

@@ -192,7 +192,8 @@ frontend/src/routes/
├── persons/
│ ├── [id]/ Person detail
│ ├── [id]/edit/ Person edit form
── new/ Create person form
── new/ Create person form
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new

View File

@@ -22,12 +22,15 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/persons")
@RequiredArgsConstructor
@Validated
public class PersonController {
private final PersonService personService;
@@ -35,15 +38,37 @@ public class PersonController {
@GetMapping
@RequirePermission(Permission.READ_ALL)
public ResponseEntity<List<PersonSummaryDTO>> getPersons(
public ResponseEntity<PersonSearchResult> getPersons(
@RequestParam(required = false) String q,
@RequestParam(required = false, defaultValue = "0") int size,
@RequestParam(required = false) String sort) {
if ("documentCount".equals(sort) && size > 0 && q == null) {
@RequestParam(required = false) PersonType type,
@RequestParam(required = false) Boolean familyOnly,
@RequestParam(required = false) Boolean hasDocuments,
@RequestParam(required = false) Boolean provisional,
// review=true reveals the import noise (transcriber view); absent/false keeps the
// clean reader default (familyMember OR documentCount > 0). The explicit filters AND
// within whichever base the review flag selects.
@RequestParam(required = false, defaultValue = "false") boolean review,
@RequestParam(required = false) String sort,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
// Legacy top-N-by-document-count path (reader dashboard): preserved, wrapped in the
// same envelope so /api/persons always returns one shape. It is explicitly NON-paged —
// the top-N query returns the complete result, so PersonSearchResult.topN reports an
// honest totalElements (= returned count) instead of pretending to be a page slice.
if ("documentCount".equals(sort) && q == null) {
int safeSize = Math.min(size, 50);
return ResponseEntity.ok(personService.findTopByDocumentCount(safeSize));
List<PersonSummaryDTO> top = personService.findTopByDocumentCount(safeSize);
return ResponseEntity.ok(PersonSearchResult.topN(top));
}
return ResponseEntity.ok(personService.findAll(q));
PersonFilter filter = PersonFilter.builder()
.type(type)
.familyOnly(familyOnly)
.hasDocuments(hasDocuments)
.provisional(provisional)
.readerDefault(!review)
.build();
return ResponseEntity.ok(personService.search(filter, page, size, q));
}
@GetMapping("/{id}")
@@ -110,6 +135,21 @@ public class PersonController {
personService.mergePersons(id, UUID.fromString(targetIdStr));
}
// Dedicated state transition that clears the provisional flag. A separate verb (not a
// mass-assignable DTO field) so provisional can never be smuggled in via create/update.
@PatchMapping("/{id}/confirm")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> confirmPerson(@PathVariable UUID id) {
return ResponseEntity.ok(personService.confirmPerson(id));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void deletePerson(@PathVariable UUID id) {
personService.deletePerson(id);
}
// ─── Alias endpoints ────────────────────────────────────────────────────
@GetMapping("/{id}/aliases")

View File

@@ -0,0 +1,36 @@
package org.raddatz.familienarchiv.person;
import lombok.Builder;
/**
* The reader/triage filter set for the persons directory, threaded as one value through
* {@code PersonController -> PersonService -> PersonRepository}. Each field is nullable:
* null means "do not constrain on this dimension".
*
* <ul>
* <li>{@code type} — restrict to a single {@link PersonType}.</li>
* <li>{@code familyOnly} — when true, only {@code familyMember} persons.</li>
* <li>{@code hasDocuments} — when true, only persons with documentCount &gt; 0.</li>
* <li>{@code provisional} — match the {@code Person.provisional} flag exactly.</li>
* <li>{@code readerDefault} — when true, restrict to {@code familyMember OR documentCount > 0}
* (the clean reader view). The explicit filters above AND with this restriction.</li>
* </ul>
*/
@Builder
public record PersonFilter(
PersonType type,
Boolean familyOnly,
Boolean hasDocuments,
Boolean provisional,
boolean readerDefault
) {
/** The unconstrained "show all" filter (transcriber view, no reader restriction). */
public static PersonFilter showAll() {
return PersonFilter.builder().readerDefault(false).build();
}
/** The clean reader default: familyMember OR documentCount &gt; 0, no other constraints. */
public static PersonFilter cleanDefault() {
return PersonFilter.builder().readerDefault(true).build();
}
}

View File

@@ -88,6 +88,61 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
nativeQuery = true)
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
// --- #667: filter-aware paged directory ---
//
// The slice query and the count query below MUST keep an IDENTICAL WHERE clause so the
// rendered page and totalElements can never drift. Every filter is nullable: a null param
// disables that predicate via the `:param IS NULL OR …` idiom. `readerDefault` (a plain
// boolean) restricts to "familyMember OR has documents"; the explicit filters AND on top.
// documentCount is recomputed inline (not via the SELECT alias) because WHERE cannot
// reference a computed alias. All params are named — no string concatenation, no injection.
String FILTER_WHERE = """
WHERE (CAST(:type AS text) IS NULL OR p.person_type = CAST(:type AS text))
AND (:familyOnly = FALSE OR :familyOnly IS NULL OR p.family_member = TRUE)
AND (:hasDocuments = FALSE OR :hasDocuments IS NULL OR (
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)) > 0)
AND (:provisional IS NULL OR p.provisional = :provisional)
AND (:readerDefault = FALSE OR (
p.family_member = TRUE OR (
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)) > 0))
AND (CAST(:query AS text) IS NULL OR
LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%'))
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%')))
""";
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(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
""" + FILTER_WHERE + """
ORDER BY p.last_name ASC, p.first_name ASC
LIMIT :limit OFFSET :offset
""",
nativeQuery = true)
List<PersonSummaryDTO> findByFilter(@Param("type") String type,
@Param("familyOnly") Boolean familyOnly,
@Param("hasDocuments") Boolean hasDocuments,
@Param("provisional") Boolean provisional,
@Param("readerDefault") boolean readerDefault,
@Param("query") String query,
@Param("limit") int limit,
@Param("offset") int offset);
@Query(value = "SELECT COUNT(*) FROM persons p " + FILTER_WHERE, nativeQuery = true)
long countByFilter(@Param("type") String type,
@Param("familyOnly") Boolean familyOnly,
@Param("hasDocuments") Boolean hasDocuments,
@Param("provisional") Boolean provisional,
@Param("readerDefault") boolean readerDefault,
@Param("query") String query);
// --- Correspondent queries ---
@Query(value = """
@@ -139,6 +194,12 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
// delete cannot orphan a documents.sender_id FK (the column is nullable).
@Modifying
@Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true)
void reassignSenderToNull(@Param("source") UUID source);
@Modifying
@Query(value = """
INSERT INTO document_receivers (document_id, person_id)

View File

@@ -0,0 +1,50 @@
package org.raddatz.familienarchiv.person;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/**
* Paged result for the /api/persons list endpoint.
*
* <p>Hand-written to mirror {@code document/DocumentSearchResult} field-for-field so the
* frontend sees one paged shape across the app. Deliberately NOT Spring {@code Page<T>}
* (unstable serialized shape across Spring versions, noisy in OpenAPI) and deliberately
* NOT a reuse of the document DTO (would couple two feature modules — duplication beats
* coupling here).
*/
public record PersonSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<PersonSummaryDTO> items,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long totalElements,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageNumber,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageSize,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int totalPages
) {
/**
* Paged factory: derives {@code totalPages} from the full match count and the page size.
* A zero count yields zero pages so the frontend hides the pagination control.
*/
public static PersonSearchResult paged(List<PersonSummaryDTO> slice, int pageNumber, int pageSize, long totalElements) {
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new PersonSearchResult(slice, totalElements, pageNumber, pageSize, totalPages);
}
/**
* Non-paged factory for the legacy {@code sort=documentCount} top-N dashboard path.
* That query returns the <em>complete</em> result in one shot — there is no further page
* to fetch — so the envelope reports reality rather than pretending to be a slice of a
* larger set: {@code totalElements} equals the number of rows actually returned,
* {@code pageSize} equals that same count, and {@code totalPages} is 1 (or 0 when empty).
* This avoids the earlier ambiguity where {@code totalElements} looked like a paged total.
*/
public static PersonSearchResult topN(List<PersonSummaryDTO> all) {
int count = all.size();
int totalPages = count == 0 ? 0 : 1;
return new PersonSearchResult(all, count, 0, count, totalPages);
}
}

View File

@@ -31,20 +31,55 @@ public class PersonService {
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
public List<PersonSummaryDTO> findAll(String q) {
if (q == null) {
return personRepository.findAllWithDocumentCount();
}
if (q.isBlank()) {
return List.of();
}
return personRepository.searchWithDocumentCount(q.trim());
}
public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
return personRepository.findTopByDocumentCount(limit);
}
/**
* Filtered, paginated directory query. The slice and the total are derived from one
* shared WHERE clause (see {@link PersonRepository#FILTER_WHERE}) so totalElements can
* never drift from the rendered page. {@code type} is passed as the enum name because the
* native query compares against the string column.
*/
public PersonSearchResult search(PersonFilter filter, int page, int size, String q) {
String type = filter.type() == null ? null : filter.type().name();
String query = (q == null || q.isBlank()) ? null : q.trim();
int offset = page * size;
List<PersonSummaryDTO> items = personRepository.findByFilter(
type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(),
filter.readerDefault(), query, size, offset);
long total = personRepository.countByFilter(
type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(),
filter.readerDefault(), query);
return PersonSearchResult.paged(items, page, size, total);
}
/**
* Clears the {@code provisional} flag — a deliberate state transition exposed as
* {@code PATCH /api/persons/{id}/confirm}, never as a mass-assignable DTO field (CWE-915).
*/
@Transactional
public Person confirmPerson(UUID id) {
Person person = getById(id);
person.setProvisional(false);
return personRepository.save(person);
}
/**
* Hard-deletes a person used by triage. Detaches the person from any documents they
* sent (nulls sender_id) and from any received-document references first, so the delete
* cannot orphan an FK and fail with a 500.
*/
@Transactional
public void deletePerson(UUID id) {
getById(id);
personRepository.reassignSenderToNull(id);
personRepository.deleteReceiverReferences(id);
personRepository.deleteById(id);
}
public Person getById(UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));

View File

@@ -65,44 +65,144 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_returns200_withEmptyList() throws Exception {
when(personService.findAll(null)).thenReturn(Collections.emptyList());
void getPersons_returns200_withEmptyPagedResult() throws Exception {
when(personService.search(any(), eq(0), eq(50), eq(null)))
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
mockMvc.perform(get("/api/persons"))
.andExpect(status().isOk());
.andExpect(status().isOk())
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.totalElements").value(0));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_delegatesQueryParam_toService() throws Exception {
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
when(personService.findAll("Hans")).thenReturn(List.of(dto));
when(personService.search(any(), eq(0), eq(50), eq("Hans")))
.thenReturn(PersonSearchResult.paged(List.of(dto), 0, 50, 1));
mockMvc.perform(get("/api/persons").param("q", "Hans"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Hans"));
.andExpect(jsonPath("$.items[0].firstName").value("Hans"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_delegatesTopByDocumentCount_whenSortAndSizeGiven() throws Exception {
void getPersons_passesFilterParams_toService() throws Exception {
ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
mockMvc.perform(get("/api/persons")
.param("type", "INSTITUTION")
.param("familyOnly", "true")
.param("hasDocuments", "true")
.param("provisional", "false"))
.andExpect(status().isOk());
PersonFilter captured = filterCaptor.getValue();
assertThat(captured.type()).isEqualTo(PersonType.INSTITUTION);
assertThat(captured.familyOnly()).isTrue();
assertThat(captured.hasDocuments()).isTrue();
assertThat(captured.provisional()).isFalse();
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_defaultsToReaderDefault_whenNoReviewFlag() throws Exception {
ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
mockMvc.perform(get("/api/persons")).andExpect(status().isOk());
assertThat(filterCaptor.getValue().readerDefault()).isTrue();
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_dropsReaderDefault_whenReviewFlagSet() throws Exception {
ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
mockMvc.perform(get("/api/persons").param("review", "true")).andExpect(status().isOk());
assertThat(filterCaptor.getValue().readerDefault()).isFalse();
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_passesPageAndSize_toService() throws Exception {
when(personService.search(any(), eq(2), eq(25), eq(null)))
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 2, 25, 0));
mockMvc.perform(get("/api/persons").param("page", "2").param("size", "25"))
.andExpect(status().isOk());
verify(personService).search(any(), eq(2), eq(25), eq(null));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_returns400_whenSizeIsZero() throws Exception {
mockMvc.perform(get("/api/persons").param("size", "0"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_returns400_whenSizeExceeds100() throws Exception {
mockMvc.perform(get("/api/persons").param("size", "101"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_returns400_whenPageIsNegative() throws Exception {
mockMvc.perform(get("/api/persons").param("page", "-1"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_delegatesTopByDocumentCount_whenSortGiven() throws Exception {
PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz");
when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top));
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Käthe"));
.andExpect(jsonPath("$.items[0].firstName").value("Käthe"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_capsTopByDocumentCount_atFifty() throws Exception {
ArgumentCaptor<Integer> sizeCaptor = ArgumentCaptor.forClass(Integer.class);
when(personService.findTopByDocumentCount(sizeCaptor.capture())).thenReturn(Collections.emptyList());
void getPersons_topByDocumentCount_isNonPaged_totalElementsEqualsReturnedCount() throws Exception {
// The top-N dashboard path is deliberately NON-paged: it returns the complete result
// (no further page exists), so totalElements equals the number of rows returned and
// totalPages is 1. Pinned so nobody "fixes" it into a misleading paged total.
when(personService.findTopByDocumentCount(50))
.thenReturn(List.of(mockPersonSummary("Käthe", "Raddatz"),
mockPersonSummary("Hans", "Müller")));
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "999"))
.andExpect(status().isOk());
mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items.length()").value(2))
.andExpect(jsonPath("$.totalElements").value(2))
.andExpect(jsonPath("$.pageNumber").value(0))
.andExpect(jsonPath("$.pageSize").value(2))
.andExpect(jsonPath("$.totalPages").value(1));
}
assertThat(sizeCaptor.getValue()).isEqualTo(50);
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_topByDocumentCount_emptyResult_reportsZeroPages() throws Exception {
when(personService.findTopByDocumentCount(50)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(0))
.andExpect(jsonPath("$.totalPages").value(0));
}
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
@@ -398,6 +498,61 @@ class PersonControllerTest {
.andExpect(status().isNoContent());
}
// ─── PATCH /api/persons/{id}/confirm ──────────────────────────────────────
@Test
void confirmPerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/persons/{id}/confirm", UUID.randomUUID()).with(csrf()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void confirmPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(patch("/api/persons/{id}/confirm", UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void confirmPerson_returns200_andClearsProvisional() throws Exception {
UUID id = UUID.randomUUID();
Person confirmed = Person.builder().id(id).firstName("Bald").lastName("Bestaetigt").provisional(false).build();
when(personService.confirmPerson(id)).thenReturn(confirmed);
mockMvc.perform(patch("/api/persons/{id}/confirm", id).with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.provisional").value(false));
verify(personService).confirmPerson(id);
}
// ─── DELETE /api/persons/{id} ──────────────────────────────────────────────
@Test
void deletePerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/persons/{id}", UUID.randomUUID()).with(csrf()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deletePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(delete("/api/persons/{id}", UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deletePerson_returns204_whenValid() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(delete("/api/persons/{id}", id).with(csrf()))
.andExpect(status().isNoContent());
verify(personService).deletePerson(id);
}
// ─── PUT /api/persons/{id} — lastName blank branch ────────────────────────
@Test

View File

@@ -505,4 +505,171 @@ class PersonRepositoryTest {
.filter(p -> p.getId().equals(provisional.getId())).findFirst().orElseThrow();
assertThat(summary.isProvisional()).isTrue();
}
// ─── #667: filter-aware paged slice + paired COUNT (Postgres-only) ────────
// The slice query (findByFilter) and the count query (countByFilter) MUST share one
// WHERE clause so totalElements can never drift from the rendered page. These tests run
// against real Postgres because the slice ORDER BY uses a computed alias that fails on H2.
private void seedDirectoryFixture() {
// Register family member, no documents — visible by reader default (familyMember)
personRepository.save(Person.builder().firstName("Karl").lastName("Register").familyMember(true).build());
// Person with one document — visible by reader default (documentCount > 0)
Person hasDoc = personRepository.save(Person.builder().firstName("Doku").lastName("Person").build());
documentRepository.save(Document.builder().title("B").originalFilename("b.pdf")
.status(DocumentStatus.UPLOADED).sender(hasDoc).build());
// Provisional, zero-document, non-family — hidden by reader default
personRepository.save(Person.builder().firstName("Unbe").lastName("Staetigt").provisional(true).build());
// An institution with no documents, non-family, non-provisional
personRepository.save(Person.builder().lastName("Verlag GmbH").personType(PersonType.INSTITUTION).build());
}
@Test
void findByFilter_readerDefault_returnsOnlyFamilyOrWithDocuments() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, true, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName)
.containsExactlyInAnyOrder("Register", "Person");
}
@Test
void countByFilter_readerDefault_matchesSliceSize() {
seedDirectoryFixture();
long count = personRepository.countByFilter(null, null, null, null, true, null);
assertThat(count).isEqualTo(2);
}
@Test
void findByFilter_showAll_returnsEveryone() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, null, 50, 0);
assertThat(slice).hasSize(4);
}
@Test
void findByFilter_typeInstitution_returnsOnlyInstitutions() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
"INSTITUTION", null, null, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
}
@Test
void findByFilter_familyOnly_returnsOnlyFamilyMembers() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, true, null, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Register");
}
@Test
void findByFilter_hasDocuments_returnsOnlyPersonsWithDocuments() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, true, null, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Person");
}
@Test
void findByFilter_provisionalTrue_returnsOnlyProvisional() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, true, false, null, 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Staetigt");
}
@Test
void findByFilter_combinedFilters_andTogether() {
seedDirectoryFixture();
// family + has-documents → intersection is empty (Register has no docs, Doku is not family)
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, true, true, null, false, null, 50, 0);
assertThat(slice).isEmpty();
}
@Test
void findByFilter_query_combinesWithFilters() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, "Verlag", 50, 0);
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
}
@Test
void findByFilter_pageBeyondRange_returnsEmptySlice() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, null, 50, 999 * 50);
assertThat(slice).isEmpty();
}
@Test
void findByFilter_respectsPageSize() {
seedDirectoryFixture();
List<PersonSummaryDTO> firstPage = personRepository.findByFilter(
null, null, null, null, false, null, 2, 0);
List<PersonSummaryDTO> secondPage = personRepository.findByFilter(
null, null, null, null, false, null, 2, 2);
assertThat(firstPage).hasSize(2);
assertThat(secondPage).hasSize(2);
assertThat(firstPage).extracting(PersonSummaryDTO::getId)
.doesNotContainAnyElementsOf(secondPage.stream().map(PersonSummaryDTO::getId).toList());
}
@Test
void countByFilter_typeInstitution_matchesSlice() {
seedDirectoryFixture();
long count = personRepository.countByFilter("INSTITUTION", null, null, null, false, null);
assertThat(count).isEqualTo(1);
}
@Test
void countByFilter_query_matchesSliceSize() {
// The whole point of the shared FILTER_WHERE is that the slice and the count can never
// drift. Pin the query (LIKE) path explicitly: countByFilter must equal the slice size
// so a future edit to one query's LIKE clause is caught.
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, null, null, false, "Verlag", 50, 0);
long count = personRepository.countByFilter(null, null, null, null, false, "Verlag");
assertThat(count).isEqualTo(slice.size());
assertThat(count).isEqualTo(1);
}
@Test
void findByFilter_projectsDocumentCount() {
seedDirectoryFixture();
List<PersonSummaryDTO> slice = personRepository.findByFilter(
null, null, true, null, false, null, 50, 0);
assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
}
}

View File

@@ -2,6 +2,9 @@ package org.raddatz.familienarchiv.person;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonRepository;
@@ -13,6 +16,11 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@@ -24,6 +32,9 @@ class PersonServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired PersonService personService;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@PersistenceContext EntityManager entityManager;
@Test
void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
@@ -63,4 +74,97 @@ class PersonServiceIntegrationTest {
assertThat(result.getFirstName()).isEqualTo("Clara");
assertThat(result.getLastName()).isEqualTo("Cram");
}
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
@Test
void search_readerDefault_hidesProvisionalZeroDocumentPerson() {
personRepository.save(Person.builder()
.firstName("Unbe").lastName("Staetigt").provisional(true).build());
PersonSearchResult result = personService.search(PersonFilter.cleanDefault(), 0, 50, null);
assertThat(result.items()).noneMatch(p -> p.getLastName().equals("Staetigt"));
assertThat(result.totalElements()).isEqualTo(result.items().size());
}
@Test
void search_showAll_includesProvisionalZeroDocumentPerson() {
personRepository.save(Person.builder()
.firstName("Unbe").lastName("Staetigt").provisional(true).build());
PersonSearchResult result = personService.search(PersonFilter.showAll(), 0, 50, null);
assertThat(result.items()).anyMatch(p -> p.getLastName().equals("Staetigt"));
}
@Test
void confirmPerson_clearsProvisional_andShowAllTreatsItAsConfirmed() {
Person provisional = personRepository.save(Person.builder()
.firstName("Bald").lastName("Bestaetigt").provisional(true).build());
personService.confirmPerson(provisional.getId());
Person reloaded = personRepository.findById(provisional.getId()).orElseThrow();
assertThat(reloaded.isProvisional()).isFalse();
PersonSearchResult showAll = personService.search(PersonFilter.showAll(), 0, 50, null);
assertThat(showAll.items())
.filteredOn(p -> p.getId().equals(provisional.getId()))
.allMatch(p -> !p.isProvisional());
}
@Test
void deletePerson_removesPerson() {
Person target = personRepository.save(Person.builder()
.firstName("Weg").lastName("Person").provisional(true).build());
personService.deletePerson(target.getId());
assertThat(personRepository.findById(target.getId())).isEmpty();
}
@Test
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
// A person referenced as BOTH a document sender and a document receiver must delete
// cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
// the documents themselves survive.
Person target = personRepository.save(Person.builder()
.firstName("Weg").lastName("Person").provisional(true).build());
Person bystander = personRepository.save(Person.builder()
.firstName("Bleibt").lastName("Hier").build());
Document sent = documentRepository.save(Document.builder()
.title("Sent letter").originalFilename("sent.pdf")
.status(DocumentStatus.UPLOADED).sender(target).build());
Document received = documentRepository.save(Document.builder()
.title("Received letter").originalFilename("received.pdf")
.status(DocumentStatus.UPLOADED).sender(bystander)
.receivers(new java.util.HashSet<>(Set.of(target))).build());
// Persist the fixture and detach everything so the native @Modifying deletes operate on
// the database directly without the persistence context holding stale references that
// would re-flush a now-deleted person as a transient association.
entityManager.flush();
entityManager.clear();
personService.deletePerson(target.getId());
// Native @Modifying queries bypass the persistence context — clear it so the asserting
// reads observe the post-delete database state, not stale managed entities.
entityManager.flush();
entityManager.clear();
assertThat(personRepository.findById(target.getId())).isEmpty();
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
assertThat(reloadedSent.getSender()).isNull();
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
assertThat(reloadedReceived.getReceivers())
.noneMatch(p -> p.getId().equals(target.getId()));
// The other person and the documents themselves survive the delete.
assertThat(personRepository.findById(bystander.getId())).isPresent();
}
}

View File

@@ -58,33 +58,109 @@ class PersonServiceTest {
assertThat(personService.getById(id)).isEqualTo(person);
}
// ─── findAll ─────────────────────────────────────────────────────────────
// ─── #667: search (filter + pagination) ──────────────────────────────────
@Test
void findAll_returnsAll_whenQueryIsNull() {
List<PersonSummaryDTO> expected = List.of();
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
void search_returnsPagedResult_withTotalsFromCountQuery() {
PersonFilter filter = PersonFilter.cleanDefault();
when(personRepository.countByFilter(null, null, null, null, true, null)).thenReturn(120L);
when(personRepository.findByFilter(null, null, null, null, true, null, 50, 0))
.thenReturn(List.of());
assertThat(personService.findAll(null)).isEqualTo(expected);
verify(personRepository).findAllWithDocumentCount();
verify(personRepository, never()).searchWithDocumentCount(any());
PersonSearchResult result = personService.search(filter, 0, 50, null);
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(0);
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
}
@Test
void findAll_returnsEmpty_whenQueryIsWhitespaceOnly() {
assertThat(personService.findAll(" ")).isEmpty();
verify(personRepository, never()).findAllWithDocumentCount();
verify(personRepository, never()).searchWithDocumentCount(any());
void search_passesTypeAsEnumName_toRepository() {
PersonFilter filter = PersonFilter.builder().type(PersonType.INSTITUTION).build();
when(personRepository.countByFilter("INSTITUTION", null, null, null, false, null)).thenReturn(0L);
when(personRepository.findByFilter("INSTITUTION", null, null, null, false, null, 50, 0))
.thenReturn(List.of());
personService.search(filter, 0, 50, null);
verify(personRepository).findByFilter("INSTITUTION", null, null, null, false, null, 50, 0);
}
@Test
void findAll_searchesByName_whenQueryIsNonBlank() {
List<PersonSummaryDTO> expected = List.of();
when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected);
void search_computesOffset_fromPageAndSize() {
PersonFilter filter = PersonFilter.showAll();
when(personRepository.countByFilter(null, null, null, null, false, null)).thenReturn(0L);
when(personRepository.findByFilter(null, null, null, null, false, null, 20, 40))
.thenReturn(List.of());
assertThat(personService.findAll("Anna")).isEqualTo(expected);
verify(personRepository).searchWithDocumentCount("Anna");
verify(personRepository, never()).findAllWithDocumentCount();
personService.search(filter, 2, 20, null); // offset = page * size = 40
verify(personRepository).findByFilter(null, null, null, null, false, null, 20, 40);
}
@Test
void search_trimsBlankQueryToNull() {
PersonFilter filter = PersonFilter.showAll();
when(personRepository.countByFilter(null, null, null, null, false, null)).thenReturn(0L);
when(personRepository.findByFilter(null, null, null, null, false, null, 50, 0))
.thenReturn(List.of());
personService.search(filter, 0, 50, " ");
verify(personRepository).findByFilter(null, null, null, null, false, null, 50, 0);
}
// ─── #667: confirmPerson ──────────────────────────────────────────────────
@Test
void confirmPerson_clearsProvisionalFlag() {
UUID id = UUID.randomUUID();
Person provisional = Person.builder().id(id).firstName("Inferred").lastName("Person").provisional(true).build();
when(personRepository.findById(id)).thenReturn(Optional.of(provisional));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.confirmPerson(id);
assertThat(result.isProvisional()).isFalse();
verify(personRepository).save(argThat(p -> !p.isProvisional()));
}
@Test
void confirmPerson_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(personRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.confirmPerson(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(404);
}
// ─── #667: deletePerson ───────────────────────────────────────────────────
@Test
void deletePerson_deletes_whenPersonExists() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Weg").lastName("Person").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
personService.deletePerson(id);
verify(personRepository).reassignSenderToNull(id);
verify(personRepository).deleteReceiverReferences(id);
verify(personRepository).deleteById(id);
}
@Test
void deletePerson_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(personRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.deletePerson(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(404);
}
// ─── createPerson ─────────────────────────────────────────────────────────

View File

@@ -7,12 +7,12 @@ Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Lists and searches family members. Returns documents sent by or received by a person, correspondent suggestions, and person summary with document counts.")
Component(personCtrl, "PersonController", "Spring MVC — /api/persons", "Filtered, paginated directory (type/familyOnly/hasDocuments/provisional + page/size -> PersonSearchResult). Returns documents sent/received, correspondent suggestions, person summaries with counts. PATCH /{id}/confirm clears provisional; DELETE /{id} removes a person (both WRITE_ALL).")
Component(relCtrl, "RelationshipController", "Spring MVC — /api/network, /api/persons/{id}/relationships", "CRUD for explicit person relationships and the full family network graph (nodes + edges) used by the Stammbaum view.")
Component(personSvc, "PersonService", "Spring Service", "Person CRUD, alias management, and merge operations (reassigns all document sender/receiver references before deleting duplicate persons).")
Component(personSvc, "PersonService", "Spring Service", "Person CRUD, alias management, filtered paged search (PersonFilter -> paired slice/count), confirm (clears provisional), delete (detaches document refs first), and merge operations (reassigns all document sender/receiver references before deleting duplicate persons).")
Component(relSvc, "RelationshipService", "Spring Service", "Manages explicit directional family relationships (PARENT_OF, SPOUSE_OF, SIBLING_OF, etc.) with optional date ranges and notes.")
Component(relInference, "RelationshipInferenceService", "Spring Service", "Computes transitive family relationships from explicit edges to infer grandparent/grandchild, aunt/uncle, and other extended-family links for the network graph.")
Component(personRepo, "PersonRepository", "Spring Data JPA", "Queries persons with name search (including aliases), correspondent discovery, person summaries with document counts, and merge/reassignment helpers.")
Component(personRepo, "PersonRepository", "Spring Data JPA", "Queries persons with name search (including aliases), correspondent discovery, person summaries with document counts, paired filter-aware slice + COUNT queries (one shared WHERE clause), and merge/reassignment helpers.")
Component(relRepo, "PersonRelationshipRepository", "Spring Data JPA", "Reads and writes PersonRelationship records. Supports lookup by person ID, by relation type, and existence checks for deduplication.")
}

View File

@@ -7,8 +7,9 @@ Person(user, "User")
Container(backend, "API Backend", "Spring Boot")
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory and detail. Detail: metadata, document list sent/received, correspondents, explicit and inferred family relationships.")
Component(personsPage, "/persons and /persons/[id]", "SvelteKit Routes", "Person directory (server-side filtered + paginated) and detail. Directory: type/family/has-documents chips, reader default (familyMember OR documentCount > 0), writer-only show-all toggle. Detail: metadata, document list sent/received, correspondents, family relationships.")
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
Component(briefwechsel, "/briefwechsel", "SvelteKit Route", "Bilateral conversation timeline. Selects two persons via PersonTypeahead, fetches GET /api/documents/conversation, displays chronological exchange.")
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
@@ -19,8 +20,9 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
}
Rel(user, personsPage, "Browses family members", "HTTPS / Browser")
Rel(personsPage, backend, "GET /api/persons, GET /api/persons/{id}", "HTTP / JSON")
Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearchResult), GET /api/persons/{id}", "HTTP / JSON")
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
Rel(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")

View File

@@ -28,7 +28,7 @@ src/
│ ├── +layout.server.ts # Loads current user, injects auth cookie
│ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload
│ ├── persons/ # Person directory, detail, edit, merge
│ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage)
│ ├── briefwechsel/ # Bilateral conversation timeline
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
│ ├── admin/ # User, group, tag, OCR, system management

View File

@@ -130,6 +130,31 @@
"persons_search_placeholder": "Namen suchen...",
"persons_empty_heading": "Keine Personen gefunden.",
"persons_empty_text": "Versuchen Sie einen anderen Suchbegriff.",
"persons_empty_filtered": "Keine Personen für diese Filter.",
"persons_filter_group_label": "Filter",
"persons_filter_type_person": "Person",
"persons_filter_type_group": "Gruppe",
"persons_filter_type_institution": "Institution",
"persons_filter_family_only": "Nur Familie",
"persons_filter_has_documents": "Mit Dokumenten",
"persons_toggle_show_all": "Alle anzeigen",
"persons_toggle_needs_review": "Zu prüfen ({count})",
"person_badge_unconfirmed": "unbestätigt",
"persons_review_heading": "Personen prüfen",
"persons_review_intro": "Vom Import erzeugte, noch nicht bestätigte Personen. Zusammenführen, umbenennen, bestätigen oder löschen.",
"persons_review_action_merge": "Zusammenführen",
"persons_review_action_rename": "Umbenennen",
"persons_review_action_confirm": "Bestätigen",
"persons_review_action_delete": "Löschen",
"persons_review_action_cancel": "Abbrechen",
"persons_review_action_save": "Speichern",
"persons_review_empty": "Keine Personen zu prüfen.",
"persons_review_delete_confirm_title": "Person löschen",
"persons_review_delete_confirm_text": "Diese Person wird endgültig gelöscht. Dokumentverweise bleiben erhalten, verlieren aber diese Person.",
"persons_review_delete_confirm_button": "Person löschen",
"persons_review_merge_label": "Mit welcher Person zusammenführen?",
"persons_field_first_name": "Vorname",
"persons_field_last_name": "Nachname",
"persons_new_heading": "Neue Person",
"persons_section_details": "Angaben zur Person",
"person_edit_heading": "Person bearbeiten",

View File

@@ -130,6 +130,31 @@
"persons_search_placeholder": "Search names...",
"persons_empty_heading": "No persons found.",
"persons_empty_text": "Try a different search term.",
"persons_empty_filtered": "No persons match these filters.",
"persons_filter_group_label": "Filter",
"persons_filter_type_person": "Person",
"persons_filter_type_group": "Group",
"persons_filter_type_institution": "Institution",
"persons_filter_family_only": "Family only",
"persons_filter_has_documents": "With documents",
"persons_toggle_show_all": "Show all",
"persons_toggle_needs_review": "Needs review ({count})",
"person_badge_unconfirmed": "unconfirmed",
"persons_review_heading": "Review persons",
"persons_review_intro": "Import-generated persons not yet confirmed. Merge, rename, confirm or delete.",
"persons_review_action_merge": "Merge",
"persons_review_action_rename": "Rename",
"persons_review_action_confirm": "Confirm",
"persons_review_action_delete": "Delete",
"persons_review_action_cancel": "Cancel",
"persons_review_action_save": "Save",
"persons_review_empty": "No persons to review.",
"persons_review_delete_confirm_title": "Delete person",
"persons_review_delete_confirm_text": "This person will be permanently deleted. Document references are kept but lose this person.",
"persons_review_delete_confirm_button": "Delete person",
"persons_review_merge_label": "Merge into which person?",
"persons_field_first_name": "First name",
"persons_field_last_name": "Last name",
"persons_new_heading": "New person",
"persons_section_details": "Person details",
"person_edit_heading": "Edit person",

View File

@@ -130,6 +130,31 @@
"persons_search_placeholder": "Buscar nombres...",
"persons_empty_heading": "No se encontraron personas.",
"persons_empty_text": "Pruebe con otro término de búsqueda.",
"persons_empty_filtered": "Ninguna persona coincide con estos filtros.",
"persons_filter_group_label": "Filtro",
"persons_filter_type_person": "Persona",
"persons_filter_type_group": "Grupo",
"persons_filter_type_institution": "Institución",
"persons_filter_family_only": "Solo familia",
"persons_filter_has_documents": "Con documentos",
"persons_toggle_show_all": "Mostrar todo",
"persons_toggle_needs_review": "Por revisar ({count})",
"person_badge_unconfirmed": "sin confirmar",
"persons_review_heading": "Revisar personas",
"persons_review_intro": "Personas generadas por la importación aún sin confirmar. Fusionar, renombrar, confirmar o eliminar.",
"persons_review_action_merge": "Fusionar",
"persons_review_action_rename": "Renombrar",
"persons_review_action_confirm": "Confirmar",
"persons_review_action_delete": "Eliminar",
"persons_review_action_cancel": "Cancelar",
"persons_review_action_save": "Guardar",
"persons_review_empty": "No hay personas por revisar.",
"persons_review_delete_confirm_title": "Eliminar persona",
"persons_review_delete_confirm_text": "Esta persona se eliminará de forma permanente. Las referencias de documentos se conservan pero pierden a esta persona.",
"persons_review_delete_confirm_button": "Eliminar persona",
"persons_review_merge_label": "¿Fusionar con qué persona?",
"persons_field_first_name": "Nombre",
"persons_field_last_name": "Apellido",
"persons_new_heading": "Nueva persona",
"persons_section_details": "Datos de la persona",
"person_edit_heading": "Editar persona",

View File

@@ -78,12 +78,28 @@ export interface paths {
get: operations["getPerson"];
put: operations["updatePerson"];
post?: never;
delete?: never;
delete: operations["deletePerson"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/persons/{id}/confirm": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["confirmPerson"];
trace?: never;
};
"/api/documents/{id}": {
parameters: {
query?: never;
@@ -2244,6 +2260,17 @@ export interface components {
familyMember?: boolean;
provisional?: boolean;
};
PersonSearchResult: {
items: components["schemas"]["PersonSummaryDTO"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */
pageNumber: number;
/** Format: int32 */
pageSize: number;
/** Format: int32 */
totalPages: number;
};
InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"];
label: string;
@@ -3129,8 +3156,14 @@ export interface operations {
parameters: {
query?: {
q?: string;
size?: number;
type?: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
familyOnly?: boolean;
hasDocuments?: boolean;
provisional?: boolean;
review?: boolean;
sort?: string;
page?: number;
size?: number;
};
header?: never;
path?: never;
@@ -3144,11 +3177,53 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["PersonSummaryDTO"][];
"*/*": components["schemas"]["PersonSearchResult"];
};
};
};
};
confirmPerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Person"];
};
};
};
};
deletePerson: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
createPerson: {
parameters: {
query?: never;

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import PersonTypeBadge from '$lib/person/PersonTypeBadge.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['PersonSummaryDTO'];
let { person }: { person: Person } = $props();
// "Unconfirmed" is exactly the `provisional` flag — the authoritative signal the importer
// sets and the triage flow clears. The badge, the "Zu prüfen (N)" count and the
// /persons/review list all key off this same flag, so badge ⇔ count ⇔ triage can never drift.
const isUnconfirmed = $derived(person.provisional === true);
// An empty / "?" last name is a separate, purely defensive concern: it must not crash the
// initials branch (reading lastName[0] on null throws) and must never render a "?" initial.
// It implies the placeholder glyph but — on its own — no "unbestätigt" badge.
const hasNoName = $derived(
person.lastName == null || person.lastName.trim() === '' || person.lastName === '?'
);
// A non-PERSON type (institution/group) gets a typed glyph; a confirmed, named person gets
// initials. Provisional entries and nameless entries fall back to the neutral placeholder glyph.
const showGlyph = $derived(
isUnconfirmed || hasNoName || (person.personType != null && person.personType !== 'PERSON')
);
const initials = $derived.by(() => {
const first = person.firstName?.[0] ?? '';
const last = person.lastName?.[0] ?? '';
return first ? first + last : last;
});
const documentCount = $derived(person.documentCount ?? 0);
</script>
<a href="/persons/{person.id}" class="group block">
<div
class="flex h-full flex-col items-center gap-2 rounded border border-line bg-surface px-4 py-6 text-center shadow-sm transition-all duration-200 hover:border-l-4 hover:border-accent hover:shadow-md"
>
<!-- Avatar: confirmed persons get a primary-coloured initials disc; institutions/groups
and unconfirmed entries get a neutral, muted glyph so "unverified" is pre-attentive. -->
<div
class={[
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full font-serif text-base font-bold transition-colors',
isUnconfirmed || hasNoName ? 'bg-muted text-ink-2' : 'bg-primary text-primary-fg'
]}
>
{#if showGlyph}
{#if person.personType === 'INSTITUTION'}
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
/>
</svg>
{:else if person.personType === 'GROUP'}
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
{:else}
<!-- Neutral person glyph for unconfirmed / UNKNOWN entries (never a "?" initial). -->
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6 opacity-70"
/>
{/if}
{:else}
{initials}
{/if}
</div>
<!-- Name -->
<p class="font-serif text-sm font-bold text-ink group-hover:underline">
{person.displayName}
</p>
{#if isUnconfirmed}
<!-- State conveyed by text + the muted placeholder shape, never colour alone (WCAG 1.4.1). -->
<span
class="inline-flex items-center gap-1 rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-xs font-semibold text-ink-2"
>
<svg
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m0 3.75h.008M10.34 3.94l-7.5 12.99A1.5 1.5 0 004.14 19.5h15.72a1.5 1.5 0 001.3-2.57l-7.5-12.99a1.5 1.5 0 00-2.62 0z"
/>
</svg>
{m.person_badge_unconfirmed()}
</span>
{:else if person.personType && person.personType !== 'PERSON'}
<PersonTypeBadge personType={person.personType} />
{/if}
<!-- Alias -->
{#if person.alias}
<p class="font-sans text-sm text-ink-2 italic">{person.alias}"</p>
{/if}
<!-- Life dates -->
{#if person.birthYear || person.deathYear}
<p class="font-sans text-sm text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</p>
{/if}
<!-- Doc count chip -->
{#if documentCount > 0}
<span
class="mt-1 inline-flex items-center rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-sm font-semibold text-ink-2"
>
{documentCount === 1
? m.person_card_doc_count_one()
: m.person_card_doc_count_many({ count: documentCount })}
</span>
{/if}
</div>
</a>

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonCard from './PersonCard.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['PersonSummaryDTO'];
const makePerson = (overrides: Partial<Person> = {}): Person => ({
id: 'p-1',
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON',
familyMember: false,
provisional: false,
documentCount: 0,
...overrides
});
afterEach(cleanup);
describe('PersonCard — confirmed person', () => {
it('renders the display name', async () => {
render(PersonCard, { props: { person: makePerson() } });
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
});
it('does not show an unconfirmed badge for a confirmed person', async () => {
render(PersonCard, { props: { person: makePerson() } });
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
});
describe('PersonCard — unconfirmed badge keys off provisional only (badge ⇔ count ⇔ triage parity)', () => {
it('renders without throwing when lastName is null', async () => {
// Before the fix, `lastName[0]` threw at render for a null lastName. Empty-name
// crash-safety is a SEPARATE concern from the badge: the placeholder glyph renders
// regardless, but the "unbestätigt" badge only fires when provisional is true.
const person = makePerson({
lastName: null as unknown as string,
displayName: '?',
provisional: true
});
render(PersonCard, { props: { person } });
// No throw + provisional → the badge is shown.
await expect.element(page.getByText('unbestätigt')).toBeVisible();
});
it('shows an unbestätigt badge for a provisional person', async () => {
render(PersonCard, { props: { person: makePerson({ provisional: true }) } });
await expect.element(page.getByText('unbestätigt')).toBeVisible();
});
it('does NOT show the badge for a "?" name when not provisional', async () => {
// Empty/"?" name alone is no longer treated as unconfirmed — only `provisional` is.
// This keeps the badge in lockstep with needsReviewCount and the /persons/review list.
render(PersonCard, {
props: {
person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' })
}
});
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
it('does NOT show the badge for an UNKNOWN type when not provisional', async () => {
render(PersonCard, {
props: { person: makePerson({ personType: 'UNKNOWN', displayName: 'Unklar' }) }
});
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
it('renders the placeholder glyph (never a "?" initial) for an empty name even without provisional', async () => {
// Crash-safety branch: a null/empty lastName must not throw and must not show "?".
render(PersonCard, {
props: {
person: makePerson({
firstName: undefined,
lastName: null as unknown as string,
displayName: 'Unbekannt'
})
}
});
await expect.element(page.getByText('Unbekannt')).toBeVisible();
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP';
interface Props {
type?: PersonType;
familyOnly: boolean;
hasDocuments: boolean;
review: boolean;
needsReviewCount: number;
canWrite: boolean;
}
let { type, familyOnly, hasDocuments, review, needsReviewCount, canWrite }: Props = $props();
const typeChips: { value: PersonType; label: () => string }[] = [
{ value: 'PERSON', label: m.persons_filter_type_person },
{ value: 'GROUP', label: m.persons_filter_type_group },
{ value: 'INSTITUTION', label: m.persons_filter_type_institution }
];
// Compose the next URL from the live params so a chip toggle never wipes q / page / other
// active chips. Any filter change resets to page 0.
function navigate(mutate: (params: SvelteURLSearchParams) => void) {
const params = new SvelteURLSearchParams(page.url.searchParams);
params.delete('page');
mutate(params);
const qs = params.toString();
goto(qs ? `/persons?${qs}` : '/persons', { keepFocus: true, noScroll: true });
}
function toggleType(value: PersonType) {
navigate((params) => {
if (type === value) params.delete('type');
else params.set('type', value);
});
}
function toggleFlag(key: 'familyOnly' | 'hasDocuments', active: boolean) {
navigate((params) => {
if (active) params.delete(key);
else params.set(key, 'true');
});
}
function setReview(next: boolean) {
navigate((params) => {
if (next) params.set('review', 'true');
else params.delete('review');
});
}
const chipBase =
'inline-flex min-h-[44px] min-w-[44px] items-center gap-1.5 rounded-sm border px-4 py-2 font-sans text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none';
const chipActive = 'border-brand-navy bg-brand-navy text-white';
const chipInactive = 'border-line bg-surface text-ink hover:bg-muted';
</script>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<!-- Type + boolean filter chips -->
<div role="group" aria-label={m.persons_filter_group_label()} class="flex flex-wrap gap-2">
{#each typeChips as chip (chip.value)}
{@const active = type === chip.value}
<button
type="button"
aria-pressed={active}
class={[chipBase, active ? chipActive : chipInactive]}
onclick={() => toggleType(chip.value)}
>
{#if active}
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{/if}
{chip.label()}
</button>
{/each}
<button
type="button"
aria-pressed={familyOnly}
class={[chipBase, familyOnly ? chipActive : chipInactive]}
onclick={() => toggleFlag('familyOnly', familyOnly)}
>
{#if familyOnly}
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{/if}
{m.persons_filter_family_only()}
</button>
<button
type="button"
aria-pressed={hasDocuments}
class={[chipBase, hasDocuments ? chipActive : chipInactive]}
onclick={() => toggleFlag('hasDocuments', hasDocuments)}
>
{#if hasDocuments}
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{/if}
{m.persons_filter_has_documents()}
</button>
</div>
<!-- Show-all / Zu prüfen toggle: transcriber-only, reveals the import noise. -->
{#if canWrite}
<!-- No aria-label: the visible text IS the accessible name (WCAG 2.5.3 Label in Name).
It flips between "Zu prüfen (N)" (off) and "Alle anzeigen" (on) and aria-checked
carries the toggle state, so the announced name always matches what the user reads. -->
<button
type="button"
role="switch"
aria-checked={review}
class={[chipBase, 'sm:ml-auto', review ? chipActive : chipInactive]}
onclick={() => setReview(!review)}
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
{review ? m.persons_toggle_show_all() : m.persons_toggle_needs_review({ count: needsReviewCount })}
</button>
{/if}
</div>

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page as browserPage } from 'vitest/browser';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import PersonFilterBar from './PersonFilterBar.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/persons') } }));
const gotoMock = vi.mocked(goto);
function setUrl(search: string) {
(page as unknown as { url: URL }).url = new URL(`http://localhost/persons${search}`);
}
beforeEach(() => {
gotoMock.mockClear();
setUrl('');
});
afterEach(cleanup);
const baseProps = {
type: undefined,
familyOnly: false,
hasDocuments: false,
review: false,
needsReviewCount: 12,
canWrite: true
};
describe('PersonFilterBar — chip activation', () => {
it('activating a type chip sets type and resets page', async () => {
setUrl('?page=3&q=Anna');
render(PersonFilterBar, { props: baseProps });
await browserPage.getByRole('button', { name: 'Institution' }).click();
expect(gotoMock).toHaveBeenCalled();
const target = gotoMock.mock.calls.at(-1)![0] as string;
const url = new URL(target, 'http://localhost');
expect(url.searchParams.get('type')).toBe('INSTITUTION');
expect(url.searchParams.get('q')).toBe('Anna'); // preserved
expect(url.searchParams.get('page')).toBeNull(); // reset
});
it('activating "Nur Familie" sets familyOnly=true', async () => {
render(PersonFilterBar, { props: baseProps });
await browserPage.getByRole('button', { name: 'Nur Familie' }).click();
const target = gotoMock.mock.calls.at(-1)![0] as string;
const url = new URL(target, 'http://localhost');
expect(url.searchParams.get('familyOnly')).toBe('true');
});
it('deactivating an already-active chip removes the param', async () => {
render(PersonFilterBar, { props: { ...baseProps, hasDocuments: true } });
await browserPage.getByRole('button', { name: 'Mit Dokumenten' }).click();
const target = gotoMock.mock.calls.at(-1)![0] as string;
const url = new URL(target, 'http://localhost');
expect(url.searchParams.get('hasDocuments')).toBeNull();
});
});
describe('PersonFilterBar — review toggle', () => {
it('renders a switch with the needs-review count in its accessible name', async () => {
render(PersonFilterBar, { props: baseProps });
await expect
.element(browserPage.getByRole('switch', { name: /Zu prüfen \(12\)/ }))
.toBeVisible();
});
it('is hidden for users without write permission', async () => {
render(PersonFilterBar, { props: { ...baseProps, canWrite: false } });
await expect.element(browserPage.getByRole('switch')).not.toBeInTheDocument();
});
it('toggling on sets review=true', async () => {
render(PersonFilterBar, { props: baseProps });
await browserPage.getByRole('switch').click();
const target = gotoMock.mock.calls.at(-1)![0] as string;
const url = new URL(target, 'http://localhost');
expect(url.searchParams.get('review')).toBe('true');
});
});

View File

@@ -34,9 +34,10 @@ function handleInput() {
}
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
const res = await fetch(`/api/persons?review=true&q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
const all: Person[] = await res.json();
const body = await res.json();
const all: Person[] = body.items ?? [];
results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
}
} catch {

View File

@@ -34,11 +34,12 @@ const PERSONS = [
];
function mockFetch(persons = PERSONS) {
// /api/persons now returns a paged { items } envelope.
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(persons)
json: vi.fn().mockResolvedValue({ items: persons })
})
);
}

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['PersonSummaryDTO'];
let { person }: { person: Person } = $props();
const { confirm } = getConfirmService();
let mode = $state<'idle' | 'rename' | 'merge'>('idle');
let renameFirstName = $state(person.firstName ?? '');
let renameLastName = $state(person.lastName ?? '');
let mergeTargetId = $state('');
const documentCount = $derived(person.documentCount ?? 0);
const actionBtn =
'inline-flex min-h-[44px] items-center justify-center rounded-sm border border-line bg-surface px-3 py-2 font-sans text-sm font-semibold text-ink transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none';
const deleteBtn =
'inline-flex min-h-[44px] items-center justify-center rounded-sm border border-danger px-3 py-2 font-sans text-sm font-semibold text-danger transition-colors hover:bg-danger/10 focus-visible:ring-2 focus-visible:ring-danger focus-visible:ring-offset-2 focus-visible:outline-none';
</script>
<li class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<!-- Neutral placeholder avatar — these rows are unconfirmed by definition. -->
<div
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-muted text-ink-2"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 opacity-70"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate font-serif text-base font-bold text-ink">{person.displayName}</p>
<p class="font-sans text-sm text-ink-2">
{documentCount === 1
? m.person_card_doc_count_one()
: m.person_card_doc_count_many({ count: documentCount })}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class={actionBtn}
onclick={() => (mode = mode === 'merge' ? 'idle' : 'merge')}
>
{m.persons_review_action_merge()}
</button>
<button
type="button"
class={actionBtn}
onclick={() => (mode = mode === 'rename' ? 'idle' : 'rename')}
>
{m.persons_review_action_rename()}
</button>
<form method="POST" action="?/confirm" use:enhance>
<input type="hidden" name="id" value={person.id} />
<button type="submit" class={actionBtn}>{m.persons_review_action_confirm()}</button>
</form>
<form
method="POST"
action="?/delete"
use:enhance={async ({ cancel }) => {
const ok = await confirm({
title: m.persons_review_delete_confirm_title(),
body: m.persons_review_delete_confirm_text(),
confirmLabel: m.persons_review_delete_confirm_button(),
destructive: true
});
if (!ok) cancel();
}}
>
<input type="hidden" name="id" value={person.id} />
<button type="submit" class={deleteBtn}>{m.persons_review_action_delete()}</button>
</form>
</div>
</div>
{#if mode === 'rename'}
<form
method="POST"
action="?/rename"
use:enhance={() => {
return () => {
mode = 'idle';
};
}}
class="flex flex-wrap items-end gap-2"
>
<input type="hidden" name="id" value={person.id} />
<input type="hidden" name="personType" value={person.personType ?? 'PERSON'} />
<label class="flex flex-col gap-1 text-sm">
<span class="font-sans text-ink-2">{m.persons_field_first_name()}</span>
<input
name="firstName"
bind:value={renameFirstName}
class="rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
/>
</label>
<label class="flex flex-1 flex-col gap-1 text-sm">
<span class="font-sans text-ink-2">{m.persons_field_last_name()}</span>
<input
name="lastName"
required
bind:value={renameLastName}
class="rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
/>
</label>
<button type="submit" class={actionBtn}>{m.persons_review_action_save()}</button>
<button type="button" class={actionBtn} onclick={() => (mode = 'idle')}>
{m.persons_review_action_cancel()}
</button>
</form>
{/if}
{#if mode === 'merge'}
<form
method="POST"
action="?/merge"
use:enhance={() => {
return () => {
mode = 'idle';
};
}}
class="flex flex-wrap items-end gap-2"
>
<input type="hidden" name="id" value={person.id} />
<input type="hidden" name="targetPersonId" bind:value={mergeTargetId} />
<div class="flex-1">
<PersonTypeahead
name="_mergeTargetDisplay"
label={m.persons_review_merge_label()}
value={mergeTargetId}
onchange={(value) => (mergeTargetId = value)}
/>
</div>
<button
type="submit"
disabled={!mergeTargetId}
class="{actionBtn} disabled:cursor-not-allowed disabled:opacity-40"
>
{m.persons_review_action_merge()}
</button>
<button type="button" class={actionBtn} onclick={() => (mode = 'idle')}>
{m.persons_review_action_cancel()}
</button>
</form>
{/if}
</li>

View File

@@ -79,8 +79,12 @@ const typeahead = createTypeahead<Person>({
return res.ok ? filter(await res.json()) : [];
}
if (term.length < 1) return [];
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
return res.ok ? filter(await res.json()) : [];
// review=true so the typeahead searches the whole directory (incl. provisional /
// zero-document persons), not just the clean reader subset.
const res = await fetch(`/api/persons?review=true&q=${encodeURIComponent(term)}`);
if (!res.ok) return [];
const body = await res.json();
return filter(body.items ?? []);
},
debounceMs: 300
});

View File

@@ -24,12 +24,18 @@ const PERSONS = [
];
function mockFetchWithPersons(persons = PERSONS) {
// The directory endpoint (/api/persons?…) now returns a paged { items } envelope; the
// correspondents endpoint still returns a bare array. Branch the mock on the URL.
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(persons)
})
vi.fn().mockImplementation((url: string) =>
Promise.resolve({
ok: true,
json: vi
.fn()
.mockResolvedValue(url.includes('/correspondents') ? persons : { items: persons })
})
)
);
}
@@ -266,7 +272,9 @@ describe('PersonTypeahead correspondent mode', () => {
await waitForDebounce();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna'));
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/persons?review=true&q=Anna')
);
});
});

View File

@@ -197,16 +197,16 @@ onMount(() => {
// Defensive client-side cap — server-side enforcement is tracked
// separately. Markus on PR #629.
const res = await fetch(
`/api/persons?q=${encodeURIComponent(query)}&limit=${SEARCH_RESULT_LIMIT}`
`/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
);
if (id !== requestId) return;
if (!res.ok) {
dropdownState.items = [];
return;
}
const data = (await res.json()) as Person[];
const body = (await res.json()) as { items?: Person[] };
if (id !== requestId) return;
dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT);
dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
} catch {
if (id !== requestId) return;
dropdownState.items = [];

View File

@@ -53,14 +53,14 @@ const ANNA: Person = {
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: persons }) })
);
}
function mockFetchEmpty() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
);
}
@@ -132,28 +132,30 @@ describe('PersonMentionEditor — typeahead', () => {
it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/persons?review=true&q=Aug')
);
});
});
it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('size=5'));
});
});
@@ -206,7 +208,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
it('editing the search input fires a debounced fetch with the new query', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
@@ -243,7 +245,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
// Sara on PR #629 round 3.
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
@@ -270,7 +272,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
it('clearing the search input clears the list without firing a fetch', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
@@ -323,10 +325,12 @@ describe('PersonMentionEditor — whitespace-only query', () => {
describe('PersonMentionEditor — stale-response race', () => {
it('discards a stale response that resolves after the search has been cleared', async () => {
let resolveFetch!: (v: { ok: boolean; json: () => Promise<Person[]> }) => void;
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<Person[]> }>((r) => {
resolveFetch = r;
});
let resolveFetch!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>(
(r) => {
resolveFetch = r;
}
);
const fetchMock = vi.fn().mockReturnValue(pendingResponse);
vi.stubGlobal('fetch', fetchMock);
renderHost();
@@ -334,7 +338,9 @@ describe('PersonMentionEditor — stale-response race', () => {
// Open the dropdown and let the debounce fire so a fetch is in flight.
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/persons?review=true&q=Aug')
);
});
// Clear the search input *before* the fetch resolves.
@@ -342,7 +348,7 @@ describe('PersonMentionEditor — stale-response race', () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('');
// The stale fetch now resolves with persons. The dropdown must stay empty.
resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) });
resolveFetch({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
// Flush pending Svelte reactivity so any (non-)update from the stale
// fetch resolution has landed before we assert. expect.element already
// polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4.

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { hasWriteAll } from './permissions';
type Locals = { user?: { groups?: { permissions: string[] }[] } };
const localsWith = (permissions: string[][]): Locals => ({
user: { groups: permissions.map((p) => ({ permissions: p })) }
});
describe('hasWriteAll', () => {
it('returns true when a group grants WRITE_ALL', () => {
expect(hasWriteAll(localsWith([['READ_ALL', 'WRITE_ALL']]))).toBe(true);
});
it('returns true when WRITE_ALL is in any of several groups', () => {
expect(hasWriteAll(localsWith([['READ_ALL'], ['WRITE_ALL']]))).toBe(true);
});
it('returns false when no group grants WRITE_ALL', () => {
expect(hasWriteAll(localsWith([['READ_ALL'], ['ANNOTATE_ALL']]))).toBe(false);
});
it('returns false for an anonymous user (no locals.user)', () => {
expect(hasWriteAll({})).toBe(false);
});
it('returns false when the user has no groups', () => {
expect(hasWriteAll({ user: {} })).toBe(false);
});
});

View File

@@ -0,0 +1,14 @@
/**
* Server-side permission predicates derived from the authenticated user in `locals`.
*
* The user shape is intentionally narrowed to the only field these checks read
* (`groups[].permissions`) so the helper works against `App.Locals` without importing it.
*/
type PermissionLocals = {
user?: { groups?: { permissions: string[] }[] } | null;
};
/** True when any of the user's groups grants WRITE_ALL. False for anonymous users. */
export function hasWriteAll(locals: PermissionLocals): boolean {
return locals.user?.groups?.some((group) => group.permissions.includes('WRITE_ALL')) ?? false;
}

View File

@@ -52,7 +52,7 @@ export async function load({ fetch, parent }) {
await Promise.allSettled(readerFetches);
const readerStats = settled<StatsDTO>(statsRes);
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? [];
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
const recentDocs = searchData?.items.map((i) => i.document) ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];

View File

@@ -2,6 +2,7 @@ import { error, fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
export async function load({
params,
@@ -15,30 +16,21 @@ export async function load({
depends: (dep: string) => void;
}) {
depends('app:document');
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
const { id } = params;
const api = createApiClient(fetch);
const [docResult, personsResult] = await Promise.all([
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/persons')
]);
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
if (!docResult.response.ok) {
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
}
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
}
return {
document: docResult.data!,
persons: personsResult.data
// Sender/receiver editing uses PersonTypeahead (self-fetching); no full list is consumed.
persons: [] as never[]
};
}

View File

@@ -2,6 +2,7 @@ import { fail, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/shared/api.server';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
export async function load({
fetch,
@@ -12,11 +13,7 @@ export async function load({
locals: App.Locals;
url: URL;
}) {
const canWrite =
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw redirect(303, '/');
if (!hasWriteAll(locals)) throw redirect(303, '/');
const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || '';
@@ -57,10 +54,12 @@ export async function load({
);
}
const [personsResult] = await Promise.all([api.GET('/api/persons'), ...requests]);
await Promise.all(requests);
return {
persons: personsResult.response.ok ? personsResult.data : [],
// Sender/receiver selection uses PersonTypeahead, which fetches its own results on
// demand — the page never consumes a pre-loaded full person list, so none is fetched.
persons: [] as never[],
initialSenderId: senderId,
initialSenderName,
initialReceivers

View File

@@ -1,25 +1,55 @@
import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
const PAGE_SIZE = 50;
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP';
function parseType(raw: string | null): PersonType | undefined {
return raw === 'PERSON' || raw === 'INSTITUTION' || raw === 'GROUP' ? raw : undefined;
}
export async function load({ url, fetch, locals }) {
const q = url.searchParams.get('q') || '';
const page = Math.max(0, Number.parseInt(url.searchParams.get('page') ?? '0', 10) || 0);
const review =
url.searchParams.get('review') === '1' || url.searchParams.get('review') === 'true';
const type = parseType(url.searchParams.get('type'));
const familyOnly = url.searchParams.get('familyOnly') === 'true';
const hasDocuments = url.searchParams.get('hasDocuments') === 'true';
const api = createApiClient(fetch);
const canWrite =
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
const canWrite = hasWriteAll(locals);
const [personsResult, statsResult] = await Promise.all([
api.GET('/api/persons', { params: { query: { q: q || undefined } } }),
api.GET('/api/stats', {})
const filters = {
q: q || undefined,
type,
familyOnly: familyOnly || undefined,
hasDocuments: hasDocuments || undefined,
review: review || undefined,
page,
size: PAGE_SIZE
};
// The "Zu prüfen (N)" link count is the totalElements of a provisional-only query. A size=1
// page keeps the extra request cheap — we only need the count, not the rows.
const [personsResult, statsResult, reviewCountResult] = await Promise.all([
api.GET('/api/persons', { params: { query: filters } }),
api.GET('/api/stats', {}),
canWrite
? api.GET('/api/persons', { params: { query: { provisional: true, review: true, size: 1 } } })
: Promise.resolve(null)
]);
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage(undefined));
}
const result = personsResult.data!;
const stats = statsResult.response.ok
? {
totalPersons: statsResult.data!.totalPersons ?? 0,
@@ -27,5 +57,21 @@ export async function load({ url, fetch, locals }) {
}
: { totalPersons: 0, totalDocuments: 0 };
return { persons: personsResult.data!, stats, q, canWrite };
const needsReviewCount =
reviewCountResult && reviewCountResult.response.ok
? (reviewCountResult.data!.totalElements ?? 0)
: 0;
return {
persons: result.items,
totalElements: result.totalElements,
totalPages: result.totalPages,
pageNumber: result.pageNumber,
pageSize: result.pageSize,
filters: { type, familyOnly, hasDocuments, review },
needsReviewCount,
stats,
q,
canWrite
};
}

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import PersonTypeBadge from '$lib/person/PersonTypeBadge.svelte';
import PersonCard from '$lib/person/PersonCard.svelte';
import PersonFilterBar from '$lib/person/PersonFilterBar.svelte';
import Pagination from '$lib/shared/primitives/Pagination.svelte';
import PersonsStatsBar from './PersonsStatsBar.svelte';
import PersonsEmptyState from './PersonsEmptyState.svelte';
@@ -18,12 +21,31 @@ $effect(() => {
let searchTimeout: ReturnType<typeof setTimeout>;
// Debounced search must preserve the active filters/review flag — compose over the live URL,
// never a bare `?q=` that would wipe page/type/review.
function handleSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
goto(`/persons?q=${q}`, { keepFocus: true });
const params = new SvelteURLSearchParams(page.url.searchParams);
params.delete('page');
if (q) params.set('q', q);
else params.delete('q');
const qs = params.toString();
goto(qs ? `/persons?${qs}` : '/persons', { keepFocus: true });
}, 300);
}
// Pagination links preserve every active param and only change the page index.
function buildPageHref(targetPage: number): string {
const params = new SvelteURLSearchParams(page.url.searchParams);
params.set('page', String(targetPage));
return `/persons?${params.toString()}`;
}
const hasResults = $derived(data.persons.length > 0);
const noFiltersActive = $derived(
!data.q && !data.filters.type && !data.filters.familyOnly && !data.filters.hasDocuments
);
</script>
<svelte:head>
@@ -32,7 +54,7 @@ function handleSearch() {
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<!-- Header: title+stats on left, search+CTA on right -->
<div class="mb-10 flex flex-wrap items-end justify-between gap-4 border-b border-ink/10 pb-6">
<div class="mb-6 flex flex-wrap items-end justify-between gap-4 border-b border-ink/10 pb-6">
<div>
<h1 class="font-serif text-3xl font-medium text-ink">{m.page_title_persons()}</h1>
<div class="mt-2">
@@ -46,7 +68,7 @@ function handleSearch() {
<div class="flex items-center gap-3">
<!-- Search -->
<div class="relative">
<label for="search" class="sr-only">Suche</label>
<label for="search" class="sr-only">{m.persons_search_placeholder()}</label>
<input
id="search"
type="text"
@@ -69,11 +91,21 @@ function handleSearch() {
</div>
</div>
<!-- Triage link (transcriber only) -->
{#if data.canWrite}
<a
href="/persons/review"
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm border border-line bg-surface px-4 py-2 font-sans text-sm font-semibold text-ink transition-colors hover:bg-muted"
>
{m.persons_toggle_needs_review({ count: data.needsReviewCount })}
</a>
{/if}
<!-- New person CTA -->
{#if data.canWrite}
<a
href="/persons/new"
class="inline-flex items-center gap-1.5 rounded-sm bg-primary px-4 py-2.5 font-sans text-sm font-bold tracking-wide text-primary-fg transition-colors hover:bg-primary/80"
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm bg-primary px-4 py-2.5 font-sans text-sm font-bold tracking-wide text-primary-fg transition-colors hover:bg-primary/80"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
@@ -87,86 +119,35 @@ function handleSearch() {
</div>
</div>
{#if data.persons.length === 0}
<PersonsEmptyState />
<!-- Filter chips + show-all toggle -->
<div class="mb-8">
<PersonFilterBar
type={data.filters.type}
familyOnly={data.filters.familyOnly}
hasDocuments={data.filters.hasDocuments}
review={data.filters.review}
needsReviewCount={data.needsReviewCount}
canWrite={data.canWrite}
/>
</div>
{#if !hasResults}
{#if noFiltersActive}
<PersonsEmptyState />
{:else}
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
>
<p class="font-serif text-lg text-ink">{m.persons_empty_filtered()}</p>
</div>
{/if}
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each data.persons as person (person.id)}
<a href="/persons/{person.id}" class="group block">
<div
class="flex h-full flex-col items-center gap-2 rounded border border-line bg-surface px-4 py-6 text-center shadow-sm transition-all duration-200 hover:border-l-4 hover:border-accent hover:shadow-md"
>
<!-- Avatar -->
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-primary font-serif text-base font-bold text-primary-fg transition-colors"
>
{#if person.personType && person.personType !== 'PERSON'}
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
{#if person.personType === 'INSTITUTION'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
/>
{:else if person.personType === 'GROUP'}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
/>
{/if}
</svg>
{:else}
{person.firstName ? person.firstName[0] : person.lastName[0]}{person.lastName[0]}
{/if}
</div>
<!-- Name -->
<p class="font-serif text-sm font-bold text-ink group-hover:underline">
{person.displayName}
</p>
{#if person.personType && person.personType !== 'PERSON'}
<PersonTypeBadge personType={person.personType} />
{/if}
<!-- Alias -->
{#if person.alias}
<p class="font-sans text-xs text-ink-2 italic">{person.alias}"</p>
{/if}
<!-- Life dates -->
{#if person.birthYear || person.deathYear}
<p class="font-sans text-[11px] text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</p>
{/if}
<!-- Doc count chip -->
{#if (person.documentCount ?? 0) > 0}
<span
class="mt-1 inline-flex items-center rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-[11px] font-semibold text-ink-2"
>
{person.documentCount === 1
? m.person_card_doc_count_one()
: m.person_card_doc_count_many({ count: person.documentCount ?? 0 })}
</span>
{/if}
</div>
</a>
<PersonCard person={person} />
{/each}
</div>
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
{/if}
</div>

View File

@@ -0,0 +1,137 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { load } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';
beforeEach(() => vi.clearAllMocks());
function makeUrl(params: Record<string, string> = {}) {
const url = new URL('http://localhost/persons');
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
return url;
}
/** Invokes the loader with a minimal event; the partial event is cast to satisfy the type. */
function runLoad(url: URL, user: unknown) {
return load({
url,
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/persons'),
locals: { user } as App.Locals
} as unknown as Parameters<typeof load>[0]);
}
/** Mock the typed client. /api/persons returns a paged envelope; /api/stats returns counts. */
function mockApi() {
const personsResult = {
response: { ok: true, status: 200 },
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
};
// Loose `...args` signature (matching the documents loader spec) so call tuples aren't
// narrowed to length 1 — the test inspects calls[i][1].params.query.
const get = vi.fn((...args: unknown[]) => {
if (args[0] === '/api/stats') {
return Promise.resolve({
response: { ok: true, status: 200 },
data: { totalPersons: 7, totalDocuments: 3 }
});
}
return Promise.resolve(personsResult);
});
vi.mocked(createApiClient).mockReturnValue({ GET: get } as unknown as ReturnType<
typeof createApiClient
>);
return get;
}
const writer = { groups: [{ permissions: ['READ_ALL', 'WRITE_ALL'] }] };
const reader = { groups: [{ permissions: ['READ_ALL'] }] };
type GetCall = [string, { params: { query: Record<string, unknown> } }];
/** Find the GET call to a path, optionally narrowing by a query predicate. */
function findCall(
get: ReturnType<typeof vi.fn>,
path: string,
matchQuery?: (q: Record<string, unknown>) => boolean
): GetCall | undefined {
return (get.mock.calls as unknown as GetCall[]).find(
(c) => c[0] === path && (!matchQuery || matchQuery(c[1].params.query))
);
}
describe('persons page load — reader default', () => {
it('does NOT pass review when no review param is present (clean reader default)', async () => {
const get = mockApi();
await runLoad(makeUrl(), reader);
const personsCall = findCall(get, '/api/persons');
expect(personsCall?.[1].params.query.review).toBeUndefined();
});
it('passes review=true when review=1 is in the URL', async () => {
const get = mockApi();
await runLoad(makeUrl({ review: '1' }), reader);
const personsCall = findCall(get, '/api/persons');
expect(personsCall?.[1].params.query.review).toBe(true);
});
});
describe('persons page load — filter forwarding', () => {
it('forwards type, familyOnly, hasDocuments and page to the API', async () => {
const get = mockApi();
await runLoad(
makeUrl({ type: 'INSTITUTION', familyOnly: 'true', hasDocuments: 'true', page: '2' }),
reader
);
const personsCall = findCall(get, '/api/persons');
expect(personsCall?.[1].params.query).toMatchObject({
type: 'INSTITUTION',
familyOnly: true,
hasDocuments: true,
page: 2,
size: 50
});
});
it('clamps a negative page to 0', async () => {
const get = mockApi();
await runLoad(makeUrl({ page: '-5' }), reader);
const personsCall = findCall(get, '/api/persons');
expect(personsCall?.[1].params.query.page).toBe(0);
});
});
describe('persons page load — needsReviewCount', () => {
it('fires a provisional count request for writers', async () => {
const get = mockApi();
await runLoad(makeUrl(), writer);
const provisionalCall = findCall(get, '/api/persons', (query) => query.provisional === true);
expect(provisionalCall).toBeDefined();
});
it('does not fire a provisional count request for read-only users', async () => {
const get = mockApi();
const result = await runLoad(makeUrl(), reader);
const provisionalCall = findCall(get, '/api/persons', (query) => query.provisional === true);
expect(provisionalCall).toBeUndefined();
expect(result.needsReviewCount).toBe(0);
expect(result.canWrite).toBe(false);
});
});

View File

@@ -6,6 +6,7 @@ import Page from './+page.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/persons') } }));
const makePerson = (overrides = {}) => ({
id: '1',
@@ -13,6 +14,8 @@ const makePerson = (overrides = {}) => ({
lastName: 'Mustermann',
displayName: 'Max Mustermann',
documentCount: 0,
provisional: false,
personType: 'PERSON',
...overrides
});
@@ -24,7 +27,13 @@ const emptyData = {
canBlogWrite: false,
q: '',
persons: [],
stats: defaultStats
stats: defaultStats,
totalElements: 0,
totalPages: 0,
pageNumber: 0,
pageSize: 50,
filters: { type: undefined, familyOnly: false, hasDocuments: false, review: false },
needsReviewCount: 0
};
const dataWithPersons = {
...emptyData,

View File

@@ -16,6 +16,8 @@ vi.mock('$app/navigation', () => ({
onNavigate: () => () => {}
}));
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/persons') } }));
const { default: PersonsListPage } = await import('./+page.svelte');
afterEach(cleanup);
@@ -31,8 +33,15 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
birthYear?: number;
deathYear?: number;
documentCount?: number;
provisional?: boolean;
}>,
stats: { totalPersons: 0, totalDocuments: 0 },
totalElements: 0,
totalPages: 0,
pageNumber: 0,
pageSize: 50,
filters: { type: undefined, familyOnly: false, hasDocuments: false, review: false },
needsReviewCount: 0,
canWrite: false,
q: '',
...overrides

View File

@@ -0,0 +1,107 @@
import { error, fail } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
const PAGE_SIZE = 50;
export async function load({ url, fetch, locals }) {
const canWrite = hasWriteAll(locals);
const page = Math.max(0, Number.parseInt(url.searchParams.get('page') ?? '0', 10) || 0);
const api = createApiClient(fetch);
const result = await api.GET('/api/persons', {
params: { query: { provisional: true, review: true, page, size: PAGE_SIZE } }
});
if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(undefined));
}
const data = result.data!;
return {
persons: data.items,
totalElements: data.totalElements,
totalPages: data.totalPages,
pageNumber: data.pageNumber,
canWrite
};
}
export const actions = {
confirm: async ({ request, fetch }) => {
const id = (await request.formData()).get('id') as string;
const api = createApiClient(fetch);
const result = await api.PATCH('/api/persons/{id}/confirm', {
params: { path: { id } }
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
}
return { success: true };
},
delete: async ({ request, fetch }) => {
const id = (await request.formData()).get('id') as string;
const api = createApiClient(fetch);
const result = await api.DELETE('/api/persons/{id}', {
params: { path: { id } }
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
}
return { success: true };
},
merge: async ({ request, fetch }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
const targetPersonId = formData.get('targetPersonId') as string;
if (!targetPersonId) {
return fail(400, { error: getErrorMessage('INVALID_INPUT') });
}
const api = createApiClient(fetch);
const result = await api.POST('/api/persons/{id}/merge', {
params: { path: { id } },
body: { targetPersonId }
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
}
return { success: true };
},
rename: async ({ request, fetch }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
const firstName = (formData.get('firstName') as string)?.trim() || undefined;
const lastName = (formData.get('lastName') as string)?.trim();
const personType = (formData.get('personType') as string) || 'PERSON';
if (!lastName) {
return fail(400, { error: getErrorMessage('INVALID_INPUT') });
}
const api = createApiClient(fetch);
const result = await api.PUT('/api/persons/{id}', {
params: { path: { id } },
body: {
firstName,
lastName,
personType: personType as 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN'
}
});
if (!result.response.ok) {
return fail(result.response.status, {
error: getErrorMessage(extractErrorCode(result.error))
});
}
return { success: true };
}
};

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { page } from '$app/state';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import Pagination from '$lib/shared/primitives/Pagination.svelte';
import PersonReviewRow from '$lib/person/PersonReviewRow.svelte';
let { data, form } = $props();
function buildPageHref(targetPage: number): string {
const params = new SvelteURLSearchParams(page.url.searchParams);
params.set('page', String(targetPage));
return `/persons/review?${params.toString()}`;
}
const hasResults = $derived(data.persons.length > 0);
</script>
<svelte:head>
<title>{m.persons_review_heading()}</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<BackButton />
<div class="mt-4 mb-8 border-b border-ink/10 pb-6">
<h1 class="font-serif text-3xl font-medium text-ink">{m.persons_review_heading()}</h1>
<p class="mt-2 font-sans text-sm text-ink-2">{m.persons_review_intro()}</p>
</div>
{#if form?.error}
<p
class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
role="alert"
>
{form.error}
</p>
{/if}
{#if !hasResults}
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
>
<p class="font-serif text-lg text-ink">{m.persons_review_empty()}</p>
</div>
{:else}
<ul class="flex flex-col gap-3">
{#each data.persons as person (person.id)}
<PersonReviewRow person={person} />
{/each}
</ul>
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
{/if}
</div>