diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java index e189d771..0b86bbde 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -34,6 +34,13 @@ public class PersonController { return personService.getById(id); } + @GetMapping("/{id}/correspondents") + public ResponseEntity> getCorrespondents( + @PathVariable UUID id, + @RequestParam(required = false) String q) { + return ResponseEntity.ok(personService.findCorrespondents(id, q)); + } + @GetMapping("/{id}/documents") public List getPersonDocuments(@PathVariable UUID id) { return documentService.getDocumentsBySender(id); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index b57af49c..2fef46d3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -28,6 +28,51 @@ public interface PersonRepository extends JpaRepository { // Lookup by full alias string, used during ODS mass import Optional findByAliasIgnoreCase(String alias); + // --- Correspondent queries --- + + @Query(value = """ + SELECT p.* FROM persons p + INNER JOIN ( + SELECT dr.person_id AS other_id, d.id AS doc_id + FROM documents d + JOIN document_receivers dr ON dr.document_id = d.id + WHERE d.sender_id = :personId + UNION ALL + SELECT d.sender_id AS other_id, d.id AS doc_id + FROM documents d + JOIN document_receivers dr ON dr.document_id = d.id + WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL + ) shared ON shared.other_id = p.id + WHERE p.id != :personId + GROUP BY p.id + ORDER BY COUNT(DISTINCT shared.doc_id) DESC + LIMIT 10 + """, nativeQuery = true) + List findCorrespondents(@Param("personId") UUID personId); + + @Query(value = """ + SELECT p.* FROM persons p + INNER JOIN ( + SELECT dr.person_id AS other_id, d.id AS doc_id + FROM documents d + JOIN document_receivers dr ON dr.document_id = d.id + WHERE d.sender_id = :personId + UNION ALL + SELECT d.sender_id AS other_id, d.id AS doc_id + FROM documents d + JOIN document_receivers dr ON dr.document_id = d.id + WHERE dr.person_id = :personId AND d.sender_id IS NOT NULL + ) shared ON shared.other_id = p.id + WHERE p.id != :personId + AND (LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:q,'%')) + OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:q,'%')) + OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:q,'%'))) + GROUP BY p.id + ORDER BY COUNT(DISTINCT shared.doc_id) DESC + LIMIT 10 + """, nativeQuery = true) + List findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q); + // --- Merge helpers (native SQL to bypass JPA entity layer) --- @Modifying diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index 02a872ff..a3b56da1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -31,6 +31,13 @@ public class PersonService { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden")); } + public List findCorrespondents(UUID personId, String q) { + if (q != null && !q.isBlank()) { + return personRepository.findCorrespondentsWithFilter(personId, q); + } + return personRepository.findCorrespondents(personId); + } + public List getAllById(List ids) { return personRepository.findAllById(ids); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index df569543..0d02fc1e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.repository.PersonRepository; import org.springframework.web.server.ResponseStatusException; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -158,6 +159,41 @@ class PersonServiceTest { assertThat(result.getDeathYear()).isEqualTo(1900); } + // ─── findCorrespondents ────────────────────────────────────────────────── + + @Test + void findCorrespondents_delegatesToRepository_withoutFilter() { + UUID personId = UUID.randomUUID(); + List expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build()); + when(personRepository.findCorrespondents(personId)).thenReturn(expected); + + assertThat(personService.findCorrespondents(personId, null)).isEqualTo(expected); + verify(personRepository).findCorrespondents(personId); + verify(personRepository, never()).findCorrespondentsWithFilter(any(), any()); + } + + @Test + void findCorrespondents_delegatesToRepository_withFilter() { + UUID personId = UUID.randomUUID(); + List expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Muster").build()); + when(personRepository.findCorrespondentsWithFilter(personId, "Anna")).thenReturn(expected); + + assertThat(personService.findCorrespondents(personId, "Anna")).isEqualTo(expected); + verify(personRepository).findCorrespondentsWithFilter(personId, "Anna"); + verify(personRepository, never()).findCorrespondents(any()); + } + + @Test + void findCorrespondents_delegatesToRepository_withBlankFilter() { + UUID personId = UUID.randomUUID(); + when(personRepository.findCorrespondents(personId)).thenReturn(List.of()); + + personService.findCorrespondents(personId, " "); + + verify(personRepository).findCorrespondents(personId); + verify(personRepository, never()).findCorrespondentsWithFilter(any(), any()); + } + // ─── mergePersons ───────────────────────────────────────────────────────── @Test diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index b498e820..fb9a54dc 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -8,10 +8,18 @@ interface Props { label: string; value?: string; initialName?: string; + restrictToCorrespondentsOf?: string; onchange?: (value: string) => void; } -let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props(); +let { + name, + label, + value = $bindable(''), + initialName = '', + restrictToCorrespondentsOf, + onchange +}: Props = $props(); let searchTerm = $derived(initialName); @@ -30,13 +38,24 @@ function handleInput() { clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { - if (searchTerm.length < 1) { - results = []; - return; - } loading = true; try { - const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`); + let url: string; + if (restrictToCorrespondentsOf) { + if (searchTerm.length >= 1) { + url = `/api/persons/${restrictToCorrespondentsOf}/correspondents?q=${encodeURIComponent(searchTerm)}`; + } else { + url = `/api/persons/${restrictToCorrespondentsOf}/correspondents`; + } + } else { + if (searchTerm.length < 1) { + results = []; + loading = false; + return; + } + url = `/api/persons?q=${encodeURIComponent(searchTerm)}`; + } + const res = await fetch(url); results = res.ok ? await res.json() : []; } catch (e) { console.error('Suche fehlgeschlagen', e); @@ -47,6 +66,23 @@ function handleInput() { }, 300); } +async function handleFocus() { + updateDropdownPosition(); + showDropdown = true; + if (restrictToCorrespondentsOf) { + loading = true; + try { + const res = await fetch(`/api/persons/${restrictToCorrespondentsOf}/correspondents`); + results = res.ok ? await res.json() : []; + } catch (e) { + console.error('Suche fehlgeschlagen', e); + results = []; + } finally { + loading = false; + } + } +} + function selectPerson(person: Person) { value = person.id!; searchTerm = `${person.firstName} ${person.lastName}`; @@ -92,7 +128,7 @@ function clickOutside(node: HTMLElement) { autocomplete="off" bind:value={searchTerm} oninput={handleInput} - onfocus={() => { updateDropdownPosition(); showDropdown = true; }} + onfocus={handleFocus} placeholder={m.comp_typeahead_placeholder()} class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500" /> diff --git a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts index 6a1ba331..b481e16e 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts +++ b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts @@ -179,6 +179,69 @@ describe('PersonTypeahead – clearing a selection', () => { }); }); +// ─── Correspondent mode ─────────────────────────────────────────────────────── + +describe('PersonTypeahead – correspondent mode', () => { + it('fetches correspondents immediately on focus when restrictToCorrespondentsOf is set', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { + name: 'receiverId', + label: 'Empfänger', + restrictToCorrespondentsOf: 'person-a-id' + }); + + await page.getByPlaceholder('Namen tippen...').click(); + await waitForDebounce(); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/persons/person-a-id/correspondents') + ); + }); + + it('shows correspondent results immediately on focus without typing', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { + name: 'receiverId', + label: 'Empfänger', + restrictToCorrespondentsOf: 'person-a-id' + }); + + await page.getByPlaceholder('Namen tippen...').click(); + await waitForDebounce(); + + await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument(); + }); + + it('uses correspondents endpoint with q param when typing', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { + name: 'receiverId', + label: 'Empfänger', + restrictToCorrespondentsOf: 'person-a-id' + }); + + await page.getByPlaceholder('Namen tippen...').fill('Anna'); + await waitForDebounce(); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/persons/person-a-id/correspondents?q=Anna') + ); + }); + + it('uses normal persons endpoint when restrictToCorrespondentsOf is not set', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + + await page.getByPlaceholder('Namen tippen...').fill('Anna'); + await waitForDebounce(); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna')); + }); +}); + // ─── Click outside ──────────────────────────────────────────────────────────── describe('PersonTypeahead – click outside', () => { diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 9e7abbfa..854b45c4 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -60,6 +60,7 @@ function toggleSort() { label={m.conv_label_person_a()} bind:value={senderId} initialName={data.initialValues.senderName} + restrictToCorrespondentsOf={receiverId || undefined} onchange={() => applyFilters()} /> @@ -73,6 +74,7 @@ function toggleSort() { label={m.conv_label_person_b()} bind:value={receiverId} initialName={data.initialValues.receiverName} + restrictToCorrespondentsOf={senderId || undefined} onchange={() => applyFilters()} />