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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
138
supabase/migrations/027_list_title_catalog.sql
Normal file
138
supabase/migrations/027_list_title_catalog.sql
Normal 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;
|
||||
123
supabase/tests/019_list_title_catalog.sql
Normal file
123
supabase/tests/019_list_title_catalog.sql
Normal 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;
|
||||
Reference in New Issue
Block a user