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 = {
|
const created = {
|
||||||
...(data as { id: string; name: string; emoji: string; created_at: string }),
|
...(data as { id: string; name: string; emoji: string; created_at: string }),
|
||||||
// New collective starts with no flags — every section ON by default.
|
// 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]);
|
userCollectives.update((list) => [...list, created]);
|
||||||
currentCollective.set(created);
|
currentCollective.set(created);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export interface Collective {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
feature_flags: FeatureFlags;
|
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 {
|
export interface CollectiveMember {
|
||||||
|
|||||||
@@ -240,7 +240,14 @@ export async function subscribeCollectiveFeatures(collectiveId: string): Promise
|
|||||||
filter: `id=eq.${collectiveId}`
|
filter: `id=eq.${collectiveId}`
|
||||||
} as never,
|
} as never,
|
||||||
((payload: {
|
((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 incoming = payload.new;
|
||||||
const flags = (incoming.feature_flags ?? {}) as FeatureFlags;
|
const flags = (incoming.feature_flags ?? {}) as FeatureFlags;
|
||||||
@@ -251,7 +258,8 @@ export async function subscribeCollectiveFeatures(collectiveId: string): Promise
|
|||||||
...active,
|
...active,
|
||||||
name: incoming.name,
|
name: incoming.name,
|
||||||
emoji: incoming.emoji,
|
emoji: incoming.emoji,
|
||||||
feature_flags: flags
|
feature_flags: flags,
|
||||||
|
default_list_title: incoming.default_list_title ?? null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}) as never
|
}) as never
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
.from('collectives')
|
.from('collectives')
|
||||||
.update({ name: trimmed })
|
.update({ name: trimmed })
|
||||||
.eq('id', $currentCollective.id)
|
.eq('id', $currentCollective.id)
|
||||||
.select('id, name, emoji, created_at, feature_flags')
|
.select('id, name, emoji, created_at, feature_flags, default_list_title')
|
||||||
.single();
|
.single();
|
||||||
nameSaving = false;
|
nameSaving = false;
|
||||||
if (updErr || !data) {
|
if (updErr || !data) {
|
||||||
@@ -200,6 +200,7 @@
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
feature_flags: import('@colectivo/types').FeatureFlags;
|
feature_flags: import('@colectivo/types').FeatureFlags;
|
||||||
|
default_list_title: string | null;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
nameSaved = true;
|
nameSaved = true;
|
||||||
@@ -223,7 +224,7 @@
|
|||||||
.from('collectives')
|
.from('collectives')
|
||||||
.update({ emoji })
|
.update({ emoji })
|
||||||
.eq('id', $currentCollective.id)
|
.eq('id', $currentCollective.id)
|
||||||
.select('id, name, emoji, created_at, feature_flags')
|
.select('id, name, emoji, created_at, feature_flags, default_list_title')
|
||||||
.single();
|
.single();
|
||||||
if (updErr || !data) {
|
if (updErr || !data) {
|
||||||
error = updErr?.message ?? 'Failed to save emoji.';
|
error = updErr?.message ?? 'Failed to save emoji.';
|
||||||
@@ -236,6 +237,7 @@
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
feature_flags: import('@colectivo/types').FeatureFlags;
|
feature_flags: import('@colectivo/types').FeatureFlags;
|
||||||
|
default_list_title: string | null;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -246,6 +248,7 @@
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
feature_flags: import('@colectivo/types').FeatureFlags;
|
feature_flags: import('@colectivo/types').FeatureFlags;
|
||||||
|
default_list_title: string | null;
|
||||||
}) {
|
}) {
|
||||||
currentCollective.set(c);
|
currentCollective.set(c);
|
||||||
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));
|
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));
|
||||||
|
|||||||
@@ -237,7 +237,7 @@
|
|||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('collective_members')
|
.from('collective_members')
|
||||||
.select(
|
.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);
|
.eq('user_id', userId);
|
||||||
|
|
||||||
@@ -249,11 +249,19 @@
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
feature_flags: FeatureFlags;
|
feature_flags: FeatureFlags;
|
||||||
|
default_list_title: string | null;
|
||||||
};
|
};
|
||||||
const collectives = data
|
const collectives = data
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const c = row.collectives as
|
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;
|
| null;
|
||||||
return c
|
return c
|
||||||
? {
|
? {
|
||||||
@@ -261,7 +269,8 @@
|
|||||||
name: c.name,
|
name: c.name,
|
||||||
emoji: c.emoji,
|
emoji: c.emoji,
|
||||||
created_at: c.created_at,
|
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;
|
: null;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
const collective = {
|
const collective = {
|
||||||
...(data as { id: string; name: string; emoji: string; created_at: string }),
|
...(data as { id: string; name: string; emoji: string; created_at: string }),
|
||||||
// New collective starts with no flags — every section ON by default.
|
// 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]);
|
userCollectives.update((list) => [...list, collective]);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface Database {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
feature_flags: Json;
|
feature_flags: Json;
|
||||||
|
default_list_title: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
};
|
};
|
||||||
@@ -81,6 +82,7 @@ export interface Database {
|
|||||||
emoji?: string;
|
emoji?: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
feature_flags?: Json;
|
feature_flags?: Json;
|
||||||
|
default_list_title?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
};
|
};
|
||||||
@@ -90,6 +92,7 @@ export interface Database {
|
|||||||
emoji?: string;
|
emoji?: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
feature_flags?: Json;
|
feature_flags?: Json;
|
||||||
|
default_list_title?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
deleted_at?: string | null;
|
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: {
|
collective_invitations: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -629,6 +657,14 @@ export interface Database {
|
|||||||
Args: { p_collective_id: string; p_name: string };
|
Args: { p_collective_id: string; p_name: string };
|
||||||
Returns: void;
|
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: {
|
Enums: {
|
||||||
language_code: LanguageCode;
|
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