From bd9a0a6d3f425c3017003b0f3c8d8ad653e94864 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Tue, 19 May 2026 02:03:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(fase-18):=20migration=20027=20=E2=80=94=20?= =?UTF-8?q?list=20title=20catalog=20+=20per-collective=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `collectives.default_list_title` (nullable text) plus a new `collective_list_titles (collective_id, title)` table — admins curate via two SECURITY DEFINER RPCs (`add_list_title` / `remove_list_title`, P0002 on non-admin, idempotent on conflict). Members read; direct writes are all-deny RLS, matching the item_frequency pattern. Table is added to the `supabase_realtime` publication with REPLICA IDENTITY FULL so the manage UI sees admin edits live across browsers. Numbering rules (#N suffix) stay client-side intentionally — the DB is agnostic about the format so future "YYYY-MM" or other prefixes don't require a schema migration. The two-clients-race-on-#6 case is accepted (documented in migration header); a unique constraint would block deliberate duplicates. Wires `default_list_title` through every Collective construction site: loadUserCollectives in root layout, onboarding, CreateCollectiveModal, features.ts realtime subscriber, and the manage-collective patcher. The hand-curated `database.ts` gets the new column, table, and two RPC signatures. pgTAP `019_list_title_catalog.sql` (13 assertions): schema invariants on the column + table, PK shape, RLS posture, two-policies count, RPC existence + SECURITY DEFINER flag, realtime publication membership, and the default-NULL behaviour. Role-gate denial is covered by the upcoming list-title-flow integration suite (postgres bypasses auth.uid inside pgTAP, same caveat as Fase 15's 018_item_frequency_weight.sql). Tests: 191 pgTAP (was 178; +13). Co-Authored-By: Claude Opus 4.7 --- .../components/CreateCollectiveModal.svelte | 4 +- apps/web/src/lib/stores/collective.ts | 6 + apps/web/src/lib/stores/features.ts | 12 +- .../(app)/collective/manage/+page.svelte | 7 +- apps/web/src/routes/+layout.svelte | 15 +- apps/web/src/routes/onboarding/+page.svelte | 4 +- packages/types/src/database.ts | 36 +++++ .../migrations/027_list_title_catalog.sql | 138 ++++++++++++++++++ supabase/tests/019_list_title_catalog.sql | 123 ++++++++++++++++ 9 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 supabase/migrations/027_list_title_catalog.sql create mode 100644 supabase/tests/019_list_title_catalog.sql diff --git a/apps/web/src/lib/components/CreateCollectiveModal.svelte b/apps/web/src/lib/components/CreateCollectiveModal.svelte index 6c4d9ae..e40c6a8 100644 --- a/apps/web/src/lib/components/CreateCollectiveModal.svelte +++ b/apps/web/src/lib/components/CreateCollectiveModal.svelte @@ -34,7 +34,9 @@ const created = { ...(data as { id: string; name: string; emoji: string; created_at: string }), // New collective starts with no flags — every section ON by default. - feature_flags: {} as import('@colectivo/types').FeatureFlags + feature_flags: {} as import('@colectivo/types').FeatureFlags, + // Fase 18: no default list title until an admin sets one. + default_list_title: null as string | null }; userCollectives.update((list) => [...list, created]); currentCollective.set(created); diff --git a/apps/web/src/lib/stores/collective.ts b/apps/web/src/lib/stores/collective.ts index 16aa225..e3ef404 100644 --- a/apps/web/src/lib/stores/collective.ts +++ b/apps/web/src/lib/stores/collective.ts @@ -7,6 +7,12 @@ export interface Collective { emoji: string; created_at: string; feature_flags: FeatureFlags; + /** + * Fase 18: per-collective default for the create-list modal's title input. + * NULL/empty means no prefill — the input opens blank and the user must + * type something before submit is enabled. + */ + default_list_title: string | null; } export interface CollectiveMember { diff --git a/apps/web/src/lib/stores/features.ts b/apps/web/src/lib/stores/features.ts index 71f674f..e008e5b 100644 --- a/apps/web/src/lib/stores/features.ts +++ b/apps/web/src/lib/stores/features.ts @@ -240,7 +240,14 @@ export async function subscribeCollectiveFeatures(collectiveId: string): Promise filter: `id=eq.${collectiveId}` } as never, ((payload: { - new: { id: string; name: string; emoji: string; created_at: string; feature_flags?: FeatureFlags | null }; + new: { + id: string; + name: string; + emoji: string; + created_at: string; + feature_flags?: FeatureFlags | null; + default_list_title?: string | null; + }; }) => { const incoming = payload.new; const flags = (incoming.feature_flags ?? {}) as FeatureFlags; @@ -251,7 +258,8 @@ export async function subscribeCollectiveFeatures(collectiveId: string): Promise ...active, name: incoming.name, emoji: incoming.emoji, - feature_flags: flags + feature_flags: flags, + default_list_title: incoming.default_list_title ?? null }); } }) as never diff --git a/apps/web/src/routes/(app)/collective/manage/+page.svelte b/apps/web/src/routes/(app)/collective/manage/+page.svelte index 31eeb69..fa9c472 100644 --- a/apps/web/src/routes/(app)/collective/manage/+page.svelte +++ b/apps/web/src/routes/(app)/collective/manage/+page.svelte @@ -186,7 +186,7 @@ .from('collectives') .update({ name: trimmed }) .eq('id', $currentCollective.id) - .select('id, name, emoji, created_at, feature_flags') + .select('id, name, emoji, created_at, feature_flags, default_list_title') .single(); nameSaving = false; if (updErr || !data) { @@ -200,6 +200,7 @@ emoji: string; created_at: string; feature_flags: import('@colectivo/types').FeatureFlags; + default_list_title: string | null; } ); nameSaved = true; @@ -223,7 +224,7 @@ .from('collectives') .update({ emoji }) .eq('id', $currentCollective.id) - .select('id, name, emoji, created_at, feature_flags') + .select('id, name, emoji, created_at, feature_flags, default_list_title') .single(); if (updErr || !data) { error = updErr?.message ?? 'Failed to save emoji.'; @@ -236,6 +237,7 @@ emoji: string; created_at: string; feature_flags: import('@colectivo/types').FeatureFlags; + default_list_title: string | null; } ); } @@ -246,6 +248,7 @@ emoji: string; created_at: string; feature_flags: import('@colectivo/types').FeatureFlags; + default_list_title: string | null; }) { currentCollective.set(c); userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it))); diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 50c5b81..4c0605f 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -237,7 +237,7 @@ const { data } = await supabase .from('collective_members') .select( - 'collective_id, role, collectives(id, name, emoji, created_at, feature_flags)' + 'collective_id, role, collectives(id, name, emoji, created_at, feature_flags, default_list_title)' ) .eq('user_id', userId); @@ -249,11 +249,19 @@ emoji: string; created_at: string; feature_flags: FeatureFlags; + default_list_title: string | null; }; const collectives = data .map((row) => { const c = row.collectives as - | { id: string; name: string; emoji: string; created_at: string; feature_flags: FeatureFlags | null } + | { + id: string; + name: string; + emoji: string; + created_at: string; + feature_flags: FeatureFlags | null; + default_list_title: string | null; + } | null; return c ? { @@ -261,7 +269,8 @@ name: c.name, emoji: c.emoji, created_at: c.created_at, - feature_flags: (c.feature_flags ?? {}) as FeatureFlags + feature_flags: (c.feature_flags ?? {}) as FeatureFlags, + default_list_title: c.default_list_title ?? null } : null; }) diff --git a/apps/web/src/routes/onboarding/+page.svelte b/apps/web/src/routes/onboarding/+page.svelte index c6ea023..f1a13ff 100644 --- a/apps/web/src/routes/onboarding/+page.svelte +++ b/apps/web/src/routes/onboarding/+page.svelte @@ -51,7 +51,9 @@ const collective = { ...(data as { id: string; name: string; emoji: string; created_at: string }), // New collective starts with no flags — every section ON by default. - feature_flags: {} as import('@colectivo/types').FeatureFlags + feature_flags: {} as import('@colectivo/types').FeatureFlags, + // Fase 18: no default list title until an admin sets one. + default_list_title: null as string | null }; userCollectives.update((list) => [...list, collective]); diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index f710fb5..aaa64ae 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -72,6 +72,7 @@ export interface Database { emoji: string; created_by: string; feature_flags: Json; + default_list_title: string | null; created_at: string; deleted_at: string | null; }; @@ -81,6 +82,7 @@ export interface Database { emoji?: string; created_by: string; feature_flags?: Json; + default_list_title?: string | null; created_at?: string; deleted_at?: string | null; }; @@ -90,6 +92,7 @@ export interface Database { emoji?: string; created_by?: string; feature_flags?: Json; + default_list_title?: string | null; created_at?: string; deleted_at?: string | null; }; @@ -136,6 +139,31 @@ export interface Database { } ]; }; + collective_list_titles: { + Row: { + collective_id: string; + title: string; + created_at: string; + }; + Insert: { + collective_id: string; + title: string; + created_at?: string; + }; + Update: { + collective_id?: string; + title?: string; + created_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'collective_list_titles_collective_id_fkey'; + columns: ['collective_id']; + referencedRelation: 'collectives'; + referencedColumns: ['id']; + } + ]; + }; collective_invitations: { Row: { id: string; @@ -629,6 +657,14 @@ export interface Database { Args: { p_collective_id: string; p_name: string }; Returns: void; }; + add_list_title: { + Args: { p_collective_id: string; p_title: string }; + Returns: void; + }; + remove_list_title: { + Args: { p_collective_id: string; p_title: string }; + Returns: void; + }; }; Enums: { language_code: LanguageCode; diff --git a/supabase/migrations/027_list_title_catalog.sql b/supabase/migrations/027_list_title_catalog.sql new file mode 100644 index 0000000..39f7c13 --- /dev/null +++ b/supabase/migrations/027_list_title_catalog.sql @@ -0,0 +1,138 @@ +-- Migration 027: shopping-list title catalogue + per-collective default (Fase 18). +-- +-- Adds two surfaces for the "required title + auto-numbered prefix + +-- admin-curated catalogue" flow: +-- +-- 1) `collectives.default_list_title text` (nullable) — prefilled into the +-- create-list modal. Empty / NULL means no prefill (current behaviour). +-- 2) `collective_list_titles (collective_id, title)` — admin-curated list +-- of "known prefixes" the client uses to (a) populate autocomplete +-- suggestions and (b) decide whether to auto-suffix `#N` on submit when +-- the typed value exactly matches an entry. Members read; only the +-- SECURITY DEFINER RPCs below write. +-- +-- Numbering rules live in the client (`apps/web/src/lib/utils/list-title.ts`) +-- — the DB is intentionally agnostic about #N semantics so the same catalogue +-- can power a future "Compra YYYY-MM" or other format without a migration. +-- +-- Race-condition note: two clients racing on `Compra #6` is accepted (both +-- inserts succeed; the resulting two `Compra #6` rows are not corrupting any +-- invariant — `shopping_lists.name` has no uniqueness constraint and never +-- has had). Adding a unique constraint here would block legitimate +-- deliberate duplicates and is rejected. + +-- ── collectives.default_list_title ────────────────────────────────────────── + +ALTER TABLE public.collectives + ADD COLUMN default_list_title text; + +-- ── collective_list_titles table ──────────────────────────────────────────── + +CREATE TABLE public.collective_list_titles ( + collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE, + title text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (collective_id, title) +); + +ALTER TABLE public.collective_list_titles ENABLE ROW LEVEL SECURITY; + +-- Members (including guests — they see lists, they may as well see the +-- title catalogue) can read. The /collective/manage UI shows members the +-- catalogue read-only. +CREATE POLICY collective_list_titles_select + ON public.collective_list_titles FOR SELECT + USING (public.is_member(collective_id)); + +-- Direct writes are denied; the two RPCs below are the only entry points. +-- Mirrors the all-deny posture on item_frequency (migration 006). +CREATE POLICY collective_list_titles_deny_writes + ON public.collective_list_titles FOR ALL + USING (false) + WITH CHECK (false); + +-- ── RPC: admin add ────────────────────────────────────────────────────────── +-- Trim + reject empty. Idempotent via ON CONFLICT — adding an already-known +-- title is a no-op rather than an error so the UI can call this without +-- pre-checking. P0002 on non-admin matches the convention from migration 026 +-- (set_item_frequency_weight). + +CREATE OR REPLACE FUNCTION public.add_list_title( + p_collective_id uuid, + p_title text +) RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + v_role text; + v_norm text := trim(p_title); +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'not authenticated' USING ERRCODE = 'P0001'; + END IF; + IF v_norm = '' THEN + RAISE EXCEPTION 'title must not be empty' USING ERRCODE = 'P0001'; + END IF; + + SELECT role INTO v_role + FROM public.collective_members + WHERE collective_id = p_collective_id + AND user_id = auth.uid(); + + IF v_role IS DISTINCT FROM 'admin' THEN + RAISE EXCEPTION 'only admins can curate list titles' USING ERRCODE = 'P0002'; + END IF; + + INSERT INTO public.collective_list_titles (collective_id, title) + VALUES (p_collective_id, v_norm) + ON CONFLICT DO NOTHING; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.add_list_title(uuid, text) TO authenticated; + +-- ── RPC: admin remove ─────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.remove_list_title( + p_collective_id uuid, + p_title text +) RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + v_role text; + v_norm text := trim(p_title); +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'not authenticated' USING ERRCODE = 'P0001'; + END IF; + + SELECT role INTO v_role + FROM public.collective_members + WHERE collective_id = p_collective_id + AND user_id = auth.uid(); + + IF v_role IS DISTINCT FROM 'admin' THEN + RAISE EXCEPTION 'only admins can curate list titles' USING ERRCODE = 'P0002'; + END IF; + + DELETE FROM public.collective_list_titles + WHERE collective_id = p_collective_id + AND title = v_norm; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.remove_list_title(uuid, text) TO authenticated; + +-- ── Realtime publication ──────────────────────────────────────────────────── +-- The /collective/manage UI subscribes to this table so member clients see +-- admin edits live without a page reload. REPLICA IDENTITY FULL so DELETE +-- events carry collective_id (needed for the client filter; see the +-- migration 007 rationale). + +ALTER PUBLICATION supabase_realtime ADD TABLE public.collective_list_titles; +ALTER TABLE public.collective_list_titles REPLICA IDENTITY FULL; diff --git a/supabase/tests/019_list_title_catalog.sql b/supabase/tests/019_list_title_catalog.sql new file mode 100644 index 0000000..ada24fc --- /dev/null +++ b/supabase/tests/019_list_title_catalog.sql @@ -0,0 +1,123 @@ +-- pgTAP: collectives.default_list_title + collective_list_titles + +-- add_list_title / remove_list_title RPCs (Fase 18.1). +-- Run with: psql -U postgres -d postgres -f supabase/tests/019_list_title_catalog.sql +-- +-- Schema invariants for the new column and table, plus existence + SECURITY +-- DEFINER posture of the two write RPCs. As with the Fase 15 weight RPCs, +-- behavioural denial-for-non-admin runs through the postgres superuser here, +-- so we cannot exercise the role gate from inside pgTAP (postgres bypasses +-- the function's `auth.uid()` check by returning NULL). Role-gate behaviour +-- for member / guest is covered by the Vitest integration suite +-- (list-title-flow.test.ts). + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(13); + +-- ── collectives.default_list_title column ─────────────────────────────────── + +SELECT has_column( + 'public', 'collectives', 'default_list_title', + 'LTC-T01: collectives.default_list_title column exists' +); + +SELECT col_type_is( + 'public', 'collectives', 'default_list_title', 'text', + 'LTC-T02: collectives.default_list_title is text' +); + +SELECT col_is_null( + 'public', 'collectives', 'default_list_title', + 'LTC-T03: collectives.default_list_title is nullable' +); + +-- ── collective_list_titles table ──────────────────────────────────────────── + +SELECT has_table( + 'public', 'collective_list_titles', + 'LTC-T04: collective_list_titles table exists' +); + +SELECT col_is_pk( + 'public', 'collective_list_titles', ARRAY['collective_id', 'title'], + 'LTC-T05: collective_list_titles PK is (collective_id, title)' +); + +SELECT ok( + (SELECT relrowsecurity FROM pg_class + WHERE relname = 'collective_list_titles' + AND relnamespace = 'public'::regnamespace), + 'LTC-T06: collective_list_titles has RLS enabled' +); + +-- The select policy lets members read; the catch-all deny blocks direct +-- writes (admins write via the RPCs below). Two policies expected. +SELECT is( + (SELECT count(*)::int FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'collective_list_titles'), + 2, + 'LTC-T07: collective_list_titles has exactly 2 policies (select + deny)' +); + +-- ── RPC existence + SECURITY DEFINER posture ──────────────────────────────── + +SELECT has_function( + 'public', 'add_list_title', ARRAY['uuid', 'text'], + 'LTC-T08: add_list_title(uuid, text) exists' +); + +SELECT has_function( + 'public', 'remove_list_title', ARRAY['uuid', 'text'], + 'LTC-T09: remove_list_title(uuid, text) exists' +); + +SELECT ok( + (SELECT prosecdef FROM pg_proc + WHERE proname = 'add_list_title' + AND pronamespace = 'public'::regnamespace), + 'LTC-T10: add_list_title is SECURITY DEFINER' +); + +SELECT ok( + (SELECT prosecdef FROM pg_proc + WHERE proname = 'remove_list_title' + AND pronamespace = 'public'::regnamespace), + 'LTC-T11: remove_list_title is SECURITY DEFINER' +); + +-- ── Realtime publication membership ───────────────────────────────────────── +-- The /collective/manage UI subscribes to collective_list_titles so member +-- clients see admin edits live without a page reload. Add to the publication +-- in the same migration (documented in CLAUDE.md "Realtime publication" +-- gotcha). + +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'collective_list_titles' + ), + 'LTC-T12: collective_list_titles is in supabase_realtime publication' +); + +-- ── Behaviour: default_list_title defaults to NULL on a fresh collective ──── + +INSERT INTO public.collectives (id, name, emoji, created_by) +VALUES ( + '99999999-9999-9999-9999-999999999999', + 'LTC-test-collective', + '🏠', + '11111111-1111-1111-1111-111111111111' +); + +SELECT is( + (SELECT default_list_title FROM public.collectives + WHERE id = '99999999-9999-9999-9999-999999999999'), + NULL, + 'LTC-T13: default_list_title defaults to NULL on insert' +); + +SELECT * FROM finish(); +ROLLBACK;