Files
collective-lists/apps/web/src/lib/stores/listTitles.ts
Oier Bravo Urtasun 3c4551acd9 feat(fase-18): listTitles store + numbering utils + unit + integration
`$lib/utils/list-title.ts` is the pure module — `parseTitle` extracts
{prefix, number} from a typed value, `nextNumberFromNumbers` is the
trivial `max + 1`, and `computeNextNumberFromNames` walks a row list,
counts a bare-prefix match as N=0, and falls back to 1 when nothing
matches. The regex requires whitespace between prefix and `#` so
"Compra#5" stays literal (the chip only fires for catalog-curated
prefixes). 19 unit tests cover LT-U-01..05 plus seven boundary cases
(multi-word prefixes, case-insensitive matching, substring near-miss,
trimming, empty input, bare-hash rejection).

`$lib/stores/listTitles.ts` wraps the two SECURITY DEFINER RPCs
(add/remove) and exposes `loadTitleCatalog`, `setDefaultListTitle`
(plain UPDATE on `collectives` — RLS already gates UPDATE to admins),
`fetchTitleSuggestions` (catalog ∪ last 10 distinct shopping_lists.name,
dedup case-insensitive, cap 15) and `computeNextNumber` (driver for the
SQL `name.ilike.<prefix>,name.ilike.<prefix> #%` filter that feeds the
pure helper). Race-condition note in the docstring points back to the
migration header.

Integration test `list-title-flow.test.ts` (6 cases): admin add round-
trips into the catalog (LT-INT-01), Borja's member-attempt yields P0002
(LT-INT-02), the computeNextNumber math is end-to-end exercised against
the dev DB with 3 seeded "Compra #1/#2/#5" rows → 6 (LT-INT-03), the
no-matches path returns 1 (LT-INT-04), admin remove deletes the row
(LT-INT-05), and an empty-title insert is rejected with P0001 (LT-INT-06).
The pgTAP suite can't drive these because postgres bypasses
auth.uid — same caveat documented in Fase 15's common-items.test.ts.

Tests: 75 unit (was 56; +19), 185 integration (was 179; +6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:11:34 +02:00

225 lines
7.3 KiB
TypeScript

/**
* Fase 18: per-collective list-title catalog store + helpers.
*
* Two surfaces:
*
* • The /collective/manage UI loads the full catalog and lets admins
* add/remove entries via the two SECURITY DEFINER RPCs from migration
* 027. Direct writes are all-deny RLS — the RPCs are the only entry
* points. P0002 on non-admin matches the Fase 15 common-items flow.
*
* • The create-list modal queries `fetchTitleSuggestions(collectiveId,
* prefix)` for the autocomplete dropdown — union of the catalog +
* the last 10 distinct `shopping_lists.name` values, dedup'd case-
* insensitively, capped at 15. And `computeNextNumber(collectiveId,
* prefix)` to decide whether to auto-suffix "#N" on submit.
*
* The pure numbering rules live in `$lib/utils/list-title` so they're
* unit-testable without a DB.
*/
import { writable } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
import { computeNextNumberFromNames } from '$lib/utils/list-title';
export interface CollectiveListTitle {
collective_id: string;
title: string;
created_at: string;
}
export const listTitleCatalog = writable<CollectiveListTitle[]>([]);
export const listTitleCatalogLoading = writable(false);
// ── Catalog (admin curation) ────────────────────────────────────────────────
/**
* Load every curated title for the active collective. Members + guests
* read (the `select_member` policy on `collective_list_titles` includes
* everyone in the collective); only admins write via the RPCs below.
*/
export async function loadTitleCatalog(collectiveId: string): Promise<void> {
listTitleCatalogLoading.set(true);
try {
const { data, error } = await getSupabase()
.from('collective_list_titles')
.select('*')
.eq('collective_id', collectiveId)
.order('title', { ascending: true });
if (!error && data) {
listTitleCatalog.set(data as CollectiveListTitle[]);
}
} finally {
listTitleCatalogLoading.set(false);
}
}
/**
* Admin-only: add a title to the catalog. The RPC trims + rejects empty +
* is idempotent via ON CONFLICT DO NOTHING. Returns the error (or null)
* so the caller can surface it as a toast / inline message.
*/
export async function addTitle(
collectiveId: string,
title: string
): Promise<Error | null> {
const trimmed = title.trim();
if (trimmed.length === 0) return new Error('title must not be empty');
const { error } = await getSupabase().rpc('add_list_title', {
p_collective_id: collectiveId,
p_title: trimmed
});
if (error) return new Error(error.message);
// Optimistic local upsert — keep the store sorted by title to match the
// load order. The realtime subscription will overwrite this when the
// INSERT event comes back, but the modal close shouldn't have to wait.
listTitleCatalog.update((arr) => {
if (arr.some((r) => r.title === trimmed)) return arr;
const row: CollectiveListTitle = {
collective_id: collectiveId,
title: trimmed,
created_at: new Date().toISOString()
};
return [...arr, row].sort((a, b) => a.title.localeCompare(b.title));
});
return null;
}
/**
* Admin-only: remove a title from the catalog. Idempotent — removing a
* row that was never there is a no-op (the DELETE just matches zero rows).
*/
export async function removeTitle(
collectiveId: string,
title: string
): Promise<Error | null> {
const trimmed = title.trim();
if (trimmed.length === 0) return new Error('title must not be empty');
const { error } = await getSupabase().rpc('remove_list_title', {
p_collective_id: collectiveId,
p_title: trimmed
});
if (error) return new Error(error.message);
listTitleCatalog.update((arr) => arr.filter((r) => r.title !== trimmed));
return null;
}
/**
* Admin-only: persist the new default_list_title on the collective itself.
* Empty string / null clears the default. RLS on `collectives` already
* gates UPDATE to admins — no separate RPC needed.
*
* Caller is responsible for re-fetching the active collective row (or
* letting the features-store realtime subscription do it).
*/
export async function setDefaultListTitle(
collectiveId: string,
value: string | null
): Promise<Error | null> {
const normalized = value === null ? null : value.trim();
const persisted = normalized && normalized.length > 0 ? normalized : null;
const { error } = await getSupabase()
.from('collectives')
.update({ default_list_title: persisted })
.eq('id', collectiveId);
if (error) return new Error(error.message);
return null;
}
// ── Modal helpers (autocomplete + numbering) ────────────────────────────────
const SUGGESTION_LIMIT = 15;
const RECENT_LISTS_SCAN = 10;
/**
* Return up to 15 suggestion strings for the create-list autocomplete.
* Union of (a) the curated catalog and (b) the most recent
* RECENT_LISTS_SCAN distinct `shopping_lists.name` values for the active
* collective. Dedup case-insensitively (first occurrence wins).
*
* If `prefix` is non-empty, both sources are filtered case-insensitively
* by prefix; otherwise the union is returned unfiltered.
*/
export async function fetchTitleSuggestions(
collectiveId: string,
prefix: string
): Promise<string[]> {
const normPrefix = prefix.trim().toLowerCase();
const sb = getSupabase();
const catalogQuery = sb
.from('collective_list_titles')
.select('title')
.eq('collective_id', collectiveId)
.order('title', { ascending: true });
if (normPrefix.length > 0) {
catalogQuery.ilike('title', `${normPrefix}%`);
}
const recentQuery = sb
.from('shopping_lists')
.select('name')
.eq('collective_id', collectiveId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(RECENT_LISTS_SCAN);
if (normPrefix.length > 0) {
recentQuery.ilike('name', `${normPrefix}%`);
}
const [catalogRes, recentRes] = await Promise.all([catalogQuery, recentQuery]);
const catalog = (catalogRes.data ?? []).map((r) => r.title);
const recent = (recentRes.data ?? []).map((r) => r.name);
const seen = new Set<string>();
const out: string[] = [];
for (const candidate of [...catalog, ...recent]) {
const key = candidate.trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(candidate);
if (out.length >= SUGGESTION_LIMIT) break;
}
return out;
}
/**
* Look at every active `shopping_lists.name` in the collective whose
* prefix matches `basePrefix` (case-insensitive), and return the next
* `#N` (max(N) + 1). Returns 1 when no rows match.
*
* Race-condition note: two clients computing this concurrently may both
* resolve to the same N. Accepted (see migration 027 header). The DB has
* no unique constraint on (collective_id, name) so the duplicate
* "Compra #6" lands without error.
*/
export async function computeNextNumber(
collectiveId: string,
basePrefix: string
): Promise<number> {
const trimmed = basePrefix.trim();
if (trimmed.length === 0) return 1;
const sb = getSupabase();
const orFilter = `name.ilike.${trimmed},name.ilike.${trimmed} #%`;
const { data, error } = await sb
.from('shopping_lists')
.select('name')
.eq('collective_id', collectiveId)
.is('deleted_at', null)
.or(orFilter);
if (error || !data) return 1;
return computeNextNumberFromNames(
trimmed,
data.map((r) => r.name)
);
}