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:
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;
|
||||
Reference in New Issue
Block a user