feat(conversations): filter person typeahead to correspondents of selected person
All checks were successful
CI / E2E Tests (push) Successful in 18m17s
CI / Unit & Component Tests (push) Successful in 3m37s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / Unit & Component Tests (pull_request) Successful in 2m12s
CI / Backend Unit Tests (pull_request) Successful in 2m1s
CI / E2E Tests (pull_request) Successful in 15m17s

Closes #29

Backend:
- Add PersonRepository.findCorrespondents / findCorrespondentsWithFilter
  (native SQL, orders by shared document count DESC, limit 10)
- Add PersonService.findCorrespondents(personId, q) delegating to the
  correct repository method based on whether a query string is present
- Expose GET /api/persons/{id}/correspondents?q= in PersonController

Frontend:
- Add optional restrictToCorrespondentsOf prop to PersonTypeahead
- On focus with the prop set, fetch correspondents immediately (no typing
  required) — opens the dropdown showing top correspondents
- On input with the prop set, hit the correspondents endpoint with q= param
- Without the prop, keep existing /api/persons?q= behaviour unchanged
- Wire the prop bidirectionally in /conversations: sender restricts receiver
  and vice versa

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #42.
This commit is contained in:
Marcel
2026-03-20 21:23:11 +01:00
parent acf6fc05ad
commit 0525e66d55
7 changed files with 203 additions and 7 deletions

View File

@@ -34,6 +34,13 @@ public class PersonController {
return personService.getById(id);
}
@GetMapping("/{id}/correspondents")
public ResponseEntity<List<Person>> getCorrespondents(
@PathVariable UUID id,
@RequestParam(required = false) String q) {
return ResponseEntity.ok(personService.findCorrespondents(id, q));
}
@GetMapping("/{id}/documents")
public List<Document> getPersonDocuments(@PathVariable UUID id) {
return documentService.getDocumentsBySender(id);

View File

@@ -28,6 +28,51 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Lookup by full alias string, used during ODS mass import
Optional<Person> 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<Person> 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<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
@Modifying

View File

@@ -31,6 +31,13 @@ public class PersonService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
}
public List<Person> findCorrespondents(UUID personId, String q) {
if (q != null && !q.isBlank()) {
return personRepository.findCorrespondentsWithFilter(personId, q);
}
return personRepository.findCorrespondents(personId);
}
public List<Person> getAllById(List<UUID> ids) {
return personRepository.findAllById(ids);
}

View File

@@ -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<Person> 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<Person> 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

View File

@@ -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"
/>

View File

@@ -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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna'));
});
});
// ─── Click outside ────────────────────────────────────────────────────────────
describe('PersonTypeahead click outside', () => {

View File

@@ -60,6 +60,7 @@ function toggleSort() {
label={m.conv_label_person_a()}
bind:value={senderId}
initialName={data.initialValues.senderName}
restrictToCorrespondentsOf={receiverId || undefined}
onchange={() => applyFilters()}
/>
</div>
@@ -73,6 +74,7 @@ function toggleSort() {
label={m.conv_label_person_b()}
bind:value={receiverId}
initialName={data.initialValues.receiverName}
restrictToCorrespondentsOf={senderId || undefined}
onchange={() => applyFilters()}
/>
</div>