/** * 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([]); 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 { 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 { 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 { 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 { 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 { 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(); 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 { 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) ); }