refactor(persons): update all callers for the paged /api/persons response

GET /api/persons now returns PersonSearchResult { items, … } instead of a bare
list. Update every caller: the dashboard top-persons path reads .items; the
unused full-list fetches in documents/new and documents/[id]/edit are dropped
(both pages use the self-fetching PersonTypeahead); the raw-fetch consumers
(PersonTypeahead, PersonMultiSelect, PersonMentionEditor) read body.items and
pass review=true so search still spans the whole directory. Specs updated to
the new envelope shape.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-27 13:56:00 +02:00
parent 9d859dcb05
commit 6c3552dc6a
9 changed files with 56 additions and 39 deletions

View File

@@ -34,9 +34,10 @@ function handleInput() {
} }
loading = true; loading = true;
try { try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`); const res = await fetch(`/api/persons?review=true&q=${encodeURIComponent(searchTerm)}`);
if (res.ok) { if (res.ok) {
const all: Person[] = await res.json(); const body = await res.json();
const all: Person[] = body.items ?? [];
results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id)); results = all.filter((p) => !selectedPersons.some((s) => s.id === p.id));
} }
} catch { } catch {

View File

@@ -34,11 +34,12 @@ const PERSONS = [
]; ];
function mockFetch(persons = PERSONS) { function mockFetch(persons = PERSONS) {
// /api/persons now returns a paged { items } envelope.
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
ok: true, ok: true,
json: vi.fn().mockResolvedValue(persons) json: vi.fn().mockResolvedValue({ items: persons })
}) })
); );
} }

View File

@@ -79,8 +79,12 @@ const typeahead = createTypeahead<Person>({
return res.ok ? filter(await res.json()) : []; return res.ok ? filter(await res.json()) : [];
} }
if (term.length < 1) return []; if (term.length < 1) return [];
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`); // review=true so the typeahead searches the whole directory (incl. provisional /
return res.ok ? filter(await res.json()) : []; // zero-document persons), not just the clean reader subset.
const res = await fetch(`/api/persons?review=true&q=${encodeURIComponent(term)}`);
if (!res.ok) return [];
const body = await res.json();
return filter(body.items ?? []);
}, },
debounceMs: 300 debounceMs: 300
}); });

View File

@@ -24,12 +24,18 @@ const PERSONS = [
]; ];
function mockFetchWithPersons(persons = PERSONS) { function mockFetchWithPersons(persons = PERSONS) {
// The directory endpoint (/api/persons?…) now returns a paged { items } envelope; the
// correspondents endpoint still returns a bare array. Branch the mock on the URL.
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockImplementation((url: string) =>
Promise.resolve({
ok: true, ok: true,
json: vi.fn().mockResolvedValue(persons) json: vi
.fn()
.mockResolvedValue(url.includes('/correspondents') ? persons : { items: persons })
}) })
)
); );
} }
@@ -266,7 +272,9 @@ describe('PersonTypeahead correspondent mode', () => {
await waitForDebounce(); await waitForDebounce();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>; const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna')); expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/persons?review=true&q=Anna')
);
}); });
}); });

View File

@@ -197,16 +197,16 @@ onMount(() => {
// Defensive client-side cap — server-side enforcement is tracked // Defensive client-side cap — server-side enforcement is tracked
// separately. Markus on PR #629. // separately. Markus on PR #629.
const res = await fetch( const res = await fetch(
`/api/persons?q=${encodeURIComponent(query)}&limit=${SEARCH_RESULT_LIMIT}` `/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
); );
if (id !== requestId) return; if (id !== requestId) return;
if (!res.ok) { if (!res.ok) {
dropdownState.items = []; dropdownState.items = [];
return; return;
} }
const data = (await res.json()) as Person[]; const body = (await res.json()) as { items?: Person[] };
if (id !== requestId) return; if (id !== requestId) return;
dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT); dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
} catch { } catch {
if (id !== requestId) return; if (id !== requestId) return;
dropdownState.items = []; dropdownState.items = [];

View File

@@ -53,14 +53,14 @@ const ANNA: Person = {
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }) vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: persons }) })
); );
} }
function mockFetchEmpty() { function mockFetchEmpty() {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
); );
} }
@@ -132,28 +132,30 @@ describe('PersonMentionEditor — typeahead', () => {
it('hits /api/persons?q= with the typed query', async () => { it('hits /api/persons?q= with the typed query', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug'); await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => { await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/persons?review=true&q=Aug')
);
}); });
}); });
it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => { it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug'); await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => { await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('limit=5')); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('size=5'));
}); });
}); });
@@ -206,7 +208,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
it('editing the search input fires a debounced fetch with the new query', async () => { it('editing the search input fires a debounced fetch with the new query', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
@@ -243,7 +245,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
// Sara on PR #629 round 3. // Sara on PR #629 round 3.
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
@@ -270,7 +272,7 @@ describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
it('clearing the search input clears the list without firing a fetch', async () => { it('clearing the search input clears the list without firing a fetch', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [AUGUSTE] }) });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
@@ -323,10 +325,12 @@ describe('PersonMentionEditor — whitespace-only query', () => {
describe('PersonMentionEditor — stale-response race', () => { describe('PersonMentionEditor — stale-response race', () => {
it('discards a stale response that resolves after the search has been cleared', async () => { it('discards a stale response that resolves after the search has been cleared', async () => {
let resolveFetch!: (v: { ok: boolean; json: () => Promise<Person[]> }) => void; let resolveFetch!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<Person[]> }>((r) => { const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>(
(r) => {
resolveFetch = r; resolveFetch = r;
}); }
);
const fetchMock = vi.fn().mockReturnValue(pendingResponse); const fetchMock = vi.fn().mockReturnValue(pendingResponse);
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
renderHost(); renderHost();
@@ -334,7 +338,9 @@ describe('PersonMentionEditor — stale-response race', () => {
// Open the dropdown and let the debounce fire so a fetch is in flight. // Open the dropdown and let the debounce fire so a fetch is in flight.
await userEvent.type(page.getByRole('textbox'), '@Aug'); await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => { await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/persons?review=true&q=Aug')
);
}); });
// Clear the search input *before* the fetch resolves. // Clear the search input *before* the fetch resolves.
@@ -342,7 +348,7 @@ describe('PersonMentionEditor — stale-response race', () => {
await expect.element(page.getByRole('searchbox')).toHaveValue(''); await expect.element(page.getByRole('searchbox')).toHaveValue('');
// The stale fetch now resolves with persons. The dropdown must stay empty. // The stale fetch now resolves with persons. The dropdown must stay empty.
resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) }); resolveFetch({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
// Flush pending Svelte reactivity so any (non-)update from the stale // Flush pending Svelte reactivity so any (non-)update from the stale
// fetch resolution has landed before we assert. expect.element already // fetch resolution has landed before we assert. expect.element already
// polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4. // polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4.

View File

@@ -52,7 +52,7 @@ export async function load({ fetch, parent }) {
await Promise.allSettled(readerFetches); await Promise.allSettled(readerFetches);
const readerStats = settled<StatsDTO>(statsRes); const readerStats = settled<StatsDTO>(statsRes);
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? []; const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? [];
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
const recentDocs = searchData?.items.map((i) => i.document) ?? []; const recentDocs = searchData?.items.map((i) => i.document) ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? []; const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];

View File

@@ -24,21 +24,16 @@ export async function load({
const { id } = params; const { id } = params;
const api = createApiClient(fetch); const api = createApiClient(fetch);
const [docResult, personsResult] = await Promise.all([ const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
api.GET('/api/documents/{id}', { params: { path: { id } } }),
api.GET('/api/persons')
]);
if (!docResult.response.ok) { if (!docResult.response.ok) {
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error))); throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
} }
if (!personsResult.response.ok) {
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
}
return { return {
document: docResult.data!, document: docResult.data!,
persons: personsResult.data // Sender/receiver editing uses PersonTypeahead (self-fetching); no full list is consumed.
persons: [] as never[]
}; };
} }

View File

@@ -57,10 +57,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