`$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>
225 lines
7.3 KiB
TypeScript
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)
|
|
);
|
|
}
|