feat(persons): clean filterable directory + triage UI (Phase 5, #667) #679
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 > 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 > 0, no other constraints. */
|
||||||
|
public static PersonFilter cleanDefault() {
|
||||||
|
return PersonFilter.builder().readerDefault(true).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
146
frontend/src/lib/person/PersonCard.svelte
Normal file
146
frontend/src/lib/person/PersonCard.svelte
Normal 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>
|
||||||
87
frontend/src/lib/person/PersonCard.svelte.test.ts
Normal file
87
frontend/src/lib/person/PersonCard.svelte.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
162
frontend/src/lib/person/PersonFilterBar.svelte
Normal file
162
frontend/src/lib/person/PersonFilterBar.svelte
Normal 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>
|
||||||
90
frontend/src/lib/person/PersonFilterBar.svelte.test.ts
Normal file
90
frontend/src/lib/person/PersonFilterBar.svelte.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
158
frontend/src/lib/person/PersonReviewRow.svelte
Normal file
158
frontend/src/lib/person/PersonReviewRow.svelte
Normal 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>
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
30
frontend/src/lib/shared/server/permissions.spec.ts
Normal file
30
frontend/src/lib/shared/server/permissions.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
frontend/src/lib/shared/server/permissions.ts
Normal file
14
frontend/src/lib/shared/server/permissions.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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) ?? [];
|
||||||
|
|||||||
@@ -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[]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
137
frontend/src/routes/persons/page.server.spec.ts
Normal file
137
frontend/src/routes/persons/page.server.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
107
frontend/src/routes/persons/review/+page.server.ts
Normal file
107
frontend/src/routes/persons/review/+page.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
56
frontend/src/routes/persons/review/+page.svelte
Normal file
56
frontend/src/routes/persons/review/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user