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/ ├── persons/
│ ├── [id]/ Person detail │ ├── [id]/ Person detail
│ ├── [id]/edit/ Person edit form │ ├── [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) ├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
├── aktivitaeten/ Unified activity feed (Chronik) ├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new ├── 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 org.springframework.web.server.ResponseStatusException;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@RestController @RestController
@RequestMapping("/api/persons") @RequestMapping("/api/persons")
@RequiredArgsConstructor @RequiredArgsConstructor
@Validated
public class PersonController { public class PersonController {
private final PersonService personService; private final PersonService personService;
@@ -35,15 +38,37 @@ public class PersonController {
@GetMapping @GetMapping
@RequirePermission(Permission.READ_ALL) @RequirePermission(Permission.READ_ALL)
public ResponseEntity<List<PersonSummaryDTO>> getPersons( public ResponseEntity<PersonSearchResult> getPersons(
@RequestParam(required = false) String q, @RequestParam(required = false) String q,
@RequestParam(required = false, defaultValue = "0") int size, @RequestParam(required = false) PersonType type,
@RequestParam(required = false) String sort) { @RequestParam(required = false) Boolean familyOnly,
if ("documentCount".equals(sort) && size > 0 && q == null) { @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); 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}") @GetMapping("/{id}")
@@ -110,6 +135,21 @@ public class PersonController {
personService.mergePersons(id, UUID.fromString(targetIdStr)); 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 ──────────────────────────────────────────────────── // ─── Alias endpoints ────────────────────────────────────────────────────
@GetMapping("/{id}/aliases") @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) nativeQuery = true)
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit); 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 --- // --- Correspondent queries ---
@Query(value = """ @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) @Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
void reassignSender(@Param("source") UUID source, @Param("target") UUID target); 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 @Modifying
@Query(value = """ @Query(value = """
INSERT INTO document_receivers (document_id, person_id) 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 PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository; 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) { public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
return personRepository.findTopByDocumentCount(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) { public Person getById(UUID id) {
return personRepository.findById(id) return personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));

View File

@@ -65,44 +65,144 @@ class PersonControllerTest {
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void getPersons_returns200_withEmptyList() throws Exception { void getPersons_returns200_withEmptyPagedResult() throws Exception {
when(personService.findAll(null)).thenReturn(Collections.emptyList()); when(personService.search(any(), eq(0), eq(50), eq(null)))
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
mockMvc.perform(get("/api/persons")) mockMvc.perform(get("/api/persons"))
.andExpect(status().isOk()); .andExpect(status().isOk())
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.totalElements").value(0));
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void getPersons_delegatesQueryParam_toService() throws Exception { void getPersons_delegatesQueryParam_toService() throws Exception {
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller"); 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")) mockMvc.perform(get("/api/persons").param("q", "Hans"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Hans")); .andExpect(jsonPath("$.items[0].firstName").value("Hans"));
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @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"); PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz");
when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top)); when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top));
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4")) mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Käthe")); .andExpect(jsonPath("$.items[0].firstName").value("Käthe"));
} }
@Test @Test
@WithMockUser(authorities = "READ_ALL") @WithMockUser(authorities = "READ_ALL")
void getPersons_capsTopByDocumentCount_atFifty() throws Exception { void getPersons_topByDocumentCount_isNonPaged_totalElementsEqualsReturnedCount() throws Exception {
ArgumentCaptor<Integer> sizeCaptor = ArgumentCaptor.forClass(Integer.class); // The top-N dashboard path is deliberately NON-paged: it returns the complete result
when(personService.findTopByDocumentCount(sizeCaptor.capture())).thenReturn(Collections.emptyList()); // (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")) mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
.andExpect(status().isOk()); .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) { private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
@@ -398,6 +498,61 @@ class PersonControllerTest {
.andExpect(status().isNoContent()); .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 ──────────────────────── // ─── PUT /api/persons/{id} — lastName blank branch ────────────────────────
@Test @Test

View File

@@ -505,4 +505,171 @@ class PersonRepositoryTest {
.filter(p -> p.getId().equals(provisional.getId())).findFirst().orElseThrow(); .filter(p -> p.getId().equals(provisional.getId())).findFirst().orElseThrow();
assertThat(summary.isProvisional()).isTrue(); 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.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig; 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.Person;
import org.raddatz.familienarchiv.person.PersonType; import org.raddatz.familienarchiv.person.PersonType;
import org.raddatz.familienarchiv.person.PersonRepository; 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 org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client; 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; import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@@ -24,6 +32,9 @@ class PersonServiceIntegrationTest {
@MockitoBean S3Client s3Client; @MockitoBean S3Client s3Client;
@Autowired PersonService personService; @Autowired PersonService personService;
@Autowired PersonRepository personRepository; @Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@PersistenceContext EntityManager entityManager;
@Test @Test
void findOrCreateByAlias_skipReturnsNull_noRecordCreated() { void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
@@ -63,4 +74,97 @@ class PersonServiceIntegrationTest {
assertThat(result.getFirstName()).isEqualTo("Clara"); assertThat(result.getFirstName()).isEqualTo("Clara");
assertThat(result.getLastName()).isEqualTo("Cram"); 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); assertThat(personService.getById(id)).isEqualTo(person);
} }
// ─── findAll ───────────────────────────────────────────────────────────── // ─── #667: search (filter + pagination) ──────────────────────────────────
@Test @Test
void findAll_returnsAll_whenQueryIsNull() { void search_returnsPagedResult_withTotalsFromCountQuery() {
List<PersonSummaryDTO> expected = List.of(); PersonFilter filter = PersonFilter.cleanDefault();
when(personRepository.findAllWithDocumentCount()).thenReturn(expected); 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); PersonSearchResult result = personService.search(filter, 0, 50, null);
verify(personRepository).findAllWithDocumentCount();
verify(personRepository, never()).searchWithDocumentCount(any()); assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(0);
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
} }
@Test @Test
void findAll_returnsEmpty_whenQueryIsWhitespaceOnly() { void search_passesTypeAsEnumName_toRepository() {
assertThat(personService.findAll(" ")).isEmpty(); PersonFilter filter = PersonFilter.builder().type(PersonType.INSTITUTION).build();
verify(personRepository, never()).findAllWithDocumentCount(); when(personRepository.countByFilter("INSTITUTION", null, null, null, false, null)).thenReturn(0L);
verify(personRepository, never()).searchWithDocumentCount(any()); 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 @Test
void findAll_searchesByName_whenQueryIsNonBlank() { void search_computesOffset_fromPageAndSize() {
List<PersonSummaryDTO> expected = List.of(); PersonFilter filter = PersonFilter.showAll();
when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected); 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); personService.search(filter, 2, 20, null); // offset = page * size = 40
verify(personRepository).searchWithDocumentCount("Anna");
verify(personRepository, never()).findAllWithDocumentCount(); 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 ───────────────────────────────────────────────────────── // ─── createPerson ─────────────────────────────────────────────────────────

View File

@@ -7,12 +7,12 @@ Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL", "PostgreSQL 16") ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
System_Boundary(backend, "API Backend (Spring Boot)") { 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(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(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(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.") 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") Container(backend, "API Backend", "Spring Boot")
System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { 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(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(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(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.") 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(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(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(briefwechsel, backend, "GET /api/documents/conversation", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON") Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten", "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 │ ├── +layout.server.ts # Loads current user, injects auth cookie
│ ├── +page.svelte # Home / document search dashboard │ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload │ ├── 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 │ ├── briefwechsel/ # Bilateral conversation timeline
│ ├── aktivitaeten/ # Unified activity feed (Chronik) │ ├── aktivitaeten/ # Unified activity feed (Chronik)
│ ├── admin/ # User, group, tag, OCR, system management │ ├── admin/ # User, group, tag, OCR, system management

View File

@@ -130,6 +130,31 @@
"persons_search_placeholder": "Namen suchen...", "persons_search_placeholder": "Namen suchen...",
"persons_empty_heading": "Keine Personen gefunden.", "persons_empty_heading": "Keine Personen gefunden.",
"persons_empty_text": "Versuchen Sie einen anderen Suchbegriff.", "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_new_heading": "Neue Person",
"persons_section_details": "Angaben zur Person", "persons_section_details": "Angaben zur Person",
"person_edit_heading": "Person bearbeiten", "person_edit_heading": "Person bearbeiten",

View File

@@ -130,6 +130,31 @@
"persons_search_placeholder": "Search names...", "persons_search_placeholder": "Search names...",
"persons_empty_heading": "No persons found.", "persons_empty_heading": "No persons found.",
"persons_empty_text": "Try a different search term.", "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_new_heading": "New person",
"persons_section_details": "Person details", "persons_section_details": "Person details",
"person_edit_heading": "Edit person", "person_edit_heading": "Edit person",

View File

@@ -130,6 +130,31 @@
"persons_search_placeholder": "Buscar nombres...", "persons_search_placeholder": "Buscar nombres...",
"persons_empty_heading": "No se encontraron personas.", "persons_empty_heading": "No se encontraron personas.",
"persons_empty_text": "Pruebe con otro término de búsqueda.", "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_new_heading": "Nueva persona",
"persons_section_details": "Datos de la persona", "persons_section_details": "Datos de la persona",
"person_edit_heading": "Editar persona", "person_edit_heading": "Editar persona",

View File

@@ -78,12 +78,28 @@ export interface paths {
get: operations["getPerson"]; get: operations["getPerson"];
put: operations["updatePerson"]; put: operations["updatePerson"];
post?: never; post?: never;
delete?: never; delete: operations["deletePerson"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; patch?: never;
trace?: 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}": { "/api/documents/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2244,6 +2260,17 @@ export interface components {
familyMember?: boolean; familyMember?: boolean;
provisional?: 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: { InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"]; person: components["schemas"]["PersonNodeDTO"];
label: string; label: string;
@@ -3129,8 +3156,14 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
q?: string; q?: string;
size?: number; type?: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
familyOnly?: boolean;
hasDocuments?: boolean;
provisional?: boolean;
review?: boolean;
sort?: string; sort?: string;
page?: number;
size?: number;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3144,11 +3177,53 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { 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: { createPerson: {
parameters: { parameters: {
query?: never; 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; loading = true;
try { try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`); const res = await fetch(`/api/persons?review=true&q=${encodeURIComponent(searchTerm)}`);
if (res.ok) { 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)); results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
} }
} catch { } catch {

View File

@@ -34,11 +34,12 @@ const PERSONS = [
]; ];
function mockFetch(persons = PERSONS) { function mockFetch(persons = PERSONS) {
// /api/persons now returns a paged { items } envelope.
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
ok: true, 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()) : []; return res.ok ? filter(await res.json()) : [];
} }
if (term.length < 1) return []; if (term.length < 1) return [];
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`); // review=true so the typeahead searches the whole directory (incl. provisional /
return res.ok ? filter(await res.json()) : []; // 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 debounceMs: 300
}); });

View File

@@ -24,12 +24,18 @@ const PERSONS = [
]; ];
function mockFetchWithPersons(persons = 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( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockImplementation((url: string) =>
Promise.resolve({
ok: true, ok: true,
json: vi.fn().mockResolvedValue(persons) json: vi
.fn()
.mockResolvedValue(url.includes('/correspondents') ? persons : { items: persons })
}) })
)
); );
} }
@@ -266,7 +272,9 @@ describe('PersonTypeahead correspondent mode', () => {
await waitForDebounce(); await waitForDebounce();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>; 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 // Defensive client-side cap — server-side enforcement is tracked
// separately. Markus on PR #629. // separately. Markus on PR #629.
const res = await fetch( 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 (id !== requestId) return;
if (!res.ok) { if (!res.ok) {
dropdownState.items = []; dropdownState.items = [];
return; return;
} }
const data = (await res.json()) as Person[]; const body = (await res.json()) as { items?: Person[] };
if (id !== requestId) return; if (id !== requestId) return;
dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT); dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
} catch { } catch {
if (id !== requestId) return; if (id !== requestId) return;
dropdownState.items = []; dropdownState.items = [];

View File

@@ -53,14 +53,14 @@ const ANNA: Person = {
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }) vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: persons }) })
); );
} }
function mockFetchEmpty() { function mockFetchEmpty() {
vi.stubGlobal( vi.stubGlobal(
'fetch', '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 () => { it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug'); await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => { 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 () => { it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug'); await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => { 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 () => { it('editing the search input fires a debounced fetch with the new query', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
@@ -243,7 +245,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
// Sara on PR #629 round 3. // Sara on PR #629 round 3.
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); 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 () => { it('clearing the search input clears the list without firing a fetch', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
@@ -323,10 +325,12 @@ describe('PersonMentionEditor — whitespace-only query', () => {
describe('PersonMentionEditor — stale-response race', () => { describe('PersonMentionEditor — stale-response race', () => {
it('discards a stale response that resolves after the search has been cleared', async () => { it('discards a stale response that resolves after the search has been cleared', async () => {
let resolveFetch!: (v: { ok: boolean; json: () => Promise<Person[]> }) => void; let resolveFetch!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<Person[]> }>((r) => { const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>(
(r) => {
resolveFetch = r; resolveFetch = r;
}); }
);
const fetchMock = vi.fn().mockReturnValue(pendingResponse); const fetchMock = vi.fn().mockReturnValue(pendingResponse);
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
@@ -334,7 +338,9 @@ describe('PersonMentionEditor — stale-response race', () => {
// Open the dropdown and let the debounce fire so a fetch is in flight. // Open the dropdown and let the debounce fire so a fetch is in flight.
await userEvent.type(page.getByRole('textbox'), '@Aug'); await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => { 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. // Clear the search input *before* the fetch resolves.
@@ -342,7 +348,7 @@ describe('PersonMentionEditor — stale-response race', () => {
await expect.element(page.getByRole('searchbox')).toHaveValue(''); await expect.element(page.getByRole('searchbox')).toHaveValue('');
// The stale fetch now resolves with persons. The dropdown must stay empty. // 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 // Flush pending Svelte reactivity so any (non-)update from the stale
// fetch resolution has landed before we assert. expect.element already // fetch resolution has landed before we assert. expect.element already
// polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4. // 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); await Promise.allSettled(readerFetches);
const readerStats = settled<StatsDTO>(statsRes); 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 searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
const recentDocs = searchData?.items.map((i) => i.document) ?? []; const recentDocs = searchData?.items.map((i) => i.document) ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? []; 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 { env } from '$env/dynamic/private';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors'; import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
export async function load({ export async function load({
params, params,
@@ -15,30 +16,21 @@ export async function load({
depends: (dep: string) => void; depends: (dep: string) => void;
}) { }) {
depends('app:document'); depends('app:document');
const canWrite = if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw error(403, 'Forbidden');
const { id } = params; const { id } = params;
const api = createApiClient(fetch); const api = createApiClient(fetch);
const [docResult, personsResult] = await Promise.all([ const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/persons')
]);
if (!docResult.response.ok) { if (!docResult.response.ok) {
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error))); throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
} }
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
}
return { return {
document: docResult.data!, 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 { env } from '$env/dynamic/private';
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors'; import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import { hasWriteAll } from '$lib/shared/server/permissions';
export async function load({ export async function load({
fetch, fetch,
@@ -12,11 +13,7 @@ export async function load({
locals: App.Locals; locals: App.Locals;
url: URL; url: URL;
}) { }) {
const canWrite = if (!hasWriteAll(locals)) throw redirect(303, '/');
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
if (!canWrite) throw redirect(303, '/');
const senderId = url.searchParams.get('senderId') || ''; const senderId = url.searchParams.get('senderId') || '';
const receiverId = url.searchParams.get('receiverId') || ''; 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 { 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, initialSenderId: senderId,
initialSenderName, initialSenderName,
initialReceivers initialReceivers

View File

@@ -1,25 +1,55 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; 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 }) { export async function load({ url, fetch, locals }) {
const q = url.searchParams.get('q') || ''; 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 api = createApiClient(fetch);
const canWrite = const canWrite = hasWriteAll(locals);
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
g.permissions.includes('WRITE_ALL')
) ?? false;
const [personsResult, statsResult] = await Promise.all([ const filters = {
api.GET('/api/persons', { params: { query: { q: q || undefined } } }), q: q || undefined,
api.GET('/api/stats', {}) 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) { if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage(undefined)); throw error(personsResult.response.status, getErrorMessage(undefined));
} }
const result = personsResult.data!;
const stats = statsResult.response.ok const stats = statsResult.response.ok
? { ? {
totalPersons: statsResult.data!.totalPersons ?? 0, totalPersons: statsResult.data!.totalPersons ?? 0,
@@ -27,5 +57,21 @@ export async function load({ url, fetch, locals }) {
} }
: { totalPersons: 0, totalDocuments: 0 }; : { 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"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/person/personLifeDates'; import PersonCard from '$lib/person/PersonCard.svelte';
import PersonTypeBadge from '$lib/person/PersonTypeBadge.svelte'; import PersonFilterBar from '$lib/person/PersonFilterBar.svelte';
import Pagination from '$lib/shared/primitives/Pagination.svelte';
import PersonsStatsBar from './PersonsStatsBar.svelte'; import PersonsStatsBar from './PersonsStatsBar.svelte';
import PersonsEmptyState from './PersonsEmptyState.svelte'; import PersonsEmptyState from './PersonsEmptyState.svelte';
@@ -18,12 +21,31 @@ $effect(() => {
let searchTimeout: ReturnType<typeof setTimeout>; 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() { function handleSearch() {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { 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); }, 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> </script>
<svelte:head> <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"> <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 --> <!-- 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> <div>
<h1 class="font-serif text-3xl font-medium text-ink">{m.page_title_persons()}</h1> <h1 class="font-serif text-3xl font-medium text-ink">{m.page_title_persons()}</h1>
<div class="mt-2"> <div class="mt-2">
@@ -46,7 +68,7 @@ function handleSearch() {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Search --> <!-- Search -->
<div class="relative"> <div class="relative">
<label for="search" class="sr-only">Suche</label> <label for="search" class="sr-only">{m.persons_search_placeholder()}</label>
<input <input
id="search" id="search"
type="text" type="text"
@@ -69,11 +91,21 @@ function handleSearch() {
</div> </div>
</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 --> <!-- New person CTA -->
{#if data.canWrite} {#if data.canWrite}
<a <a
href="/persons/new" 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 <img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg" src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
@@ -87,86 +119,35 @@ function handleSearch() {
</div> </div>
</div> </div>
{#if data.persons.length === 0} <!-- 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 /> <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} {:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each data.persons as person (person.id)} {#each data.persons as person (person.id)}
<a href="/persons/{person.id}" class="group block"> <PersonCard person={person} />
<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>
{/each} {/each}
</div> </div>
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
{/if} {/if}
</div> </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)); const tick = () => new Promise((r) => setTimeout(r, 0));
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/persons') } }));
const makePerson = (overrides = {}) => ({ const makePerson = (overrides = {}) => ({
id: '1', id: '1',
@@ -13,6 +14,8 @@ const makePerson = (overrides = {}) => ({
lastName: 'Mustermann', lastName: 'Mustermann',
displayName: 'Max Mustermann', displayName: 'Max Mustermann',
documentCount: 0, documentCount: 0,
provisional: false,
personType: 'PERSON',
...overrides ...overrides
}); });
@@ -24,7 +27,13 @@ const emptyData = {
canBlogWrite: false, canBlogWrite: false,
q: '', q: '',
persons: [], 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 = { const dataWithPersons = {
...emptyData, ...emptyData,

View File

@@ -16,6 +16,8 @@ vi.mock('$app/navigation', () => ({
onNavigate: () => () => {} onNavigate: () => () => {}
})); }));
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/persons') } }));
const { default: PersonsListPage } = await import('./+page.svelte'); const { default: PersonsListPage } = await import('./+page.svelte');
afterEach(cleanup); afterEach(cleanup);
@@ -31,8 +33,15 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
birthYear?: number; birthYear?: number;
deathYear?: number; deathYear?: number;
documentCount?: number; documentCount?: number;
provisional?: boolean;
}>, }>,
stats: { totalPersons: 0, totalDocuments: 0 }, 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, canWrite: false,
q: '', q: '',
...overrides ...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>