feat(conversations): filter person typeahead to correspondents of selected person #42
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user