feat(fase-18): migration 027 — list title catalog + per-collective default

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 02:03:41 +02:00
parent 4832981e25
commit bd9a0a6d3f
9 changed files with 336 additions and 9 deletions

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)));

View File

@@ -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;
})

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;