feat(fase-11): item_tags + shopping_item_tags schema (11.1)
Migration 022 adds two new tables for collective-scoped item tags plus the many-to-many bridge to shopping_items. Tags carry a colour from an 8-preset palette enforced by CHECK. RLS uses is_member for SELECT (guests can see) and is_active_member for writes (guests are read-only). A new STABLE helper item_collective_id(uuid) resolves the parent collective in a single function call so the shopping_item_tags policies do not chain two STABLE lookups. Both tables join the supabase_realtime publication with REPLICA IDENTITY FULL, matching the shape of 007 for shopping_items / shopping_lists. pgTAP 014 covers schema invariants (publication, replica identity, unique + CHECK constraints, cascade behaviour for both tag delete and collective delete). Per-role RLS semantics will be covered by the Vitest integration suite in the next commit — pgTAP runs as postgres which bypasses RLS. Types: ItemTag, ItemWithTags + ItemTagColor in domain.ts; item_tags, shopping_item_tags, item_collective_id added by hand to the curated database.ts (the supabase CLI is not installed locally; just db-types is gated and skips with a notice). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
101
supabase/migrations/022_item_tags.sql
Normal file
101
supabase/migrations/022_item_tags.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- Migration 022: item_tags + shopping_item_tags (Fase 11)
|
||||
--
|
||||
-- Tags are scoped to a collective (a string in two collectives = two distinct
|
||||
-- rows). No hierarchy. Color from an 8-preset palette so the UI can render
|
||||
-- without a custom picker. The shopping_item_tags many-to-many is enforced by
|
||||
-- RLS via the parent shopping_items row (which itself uses list_collective_id).
|
||||
--
|
||||
-- Reads (select): is_member — guests included, so they see tags too.
|
||||
-- Writes (insert/update/delete): is_active_member — guests are read-only,
|
||||
-- matching the policy shape used by shopping_items / shopping_lists.
|
||||
|
||||
-- ── item_tags ────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE public.item_tags (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
color text NOT NULL DEFAULT 'slate'
|
||||
CHECK (color IN ('slate','red','amber','green','sky','indigo','pink','stone')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (collective_id, name)
|
||||
);
|
||||
|
||||
ALTER TABLE public.item_tags ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE INDEX item_tags_collective_idx ON public.item_tags (collective_id);
|
||||
|
||||
CREATE POLICY item_tags_select
|
||||
ON public.item_tags FOR SELECT
|
||||
USING (public.is_member(collective_id));
|
||||
|
||||
CREATE POLICY item_tags_insert
|
||||
ON public.item_tags FOR INSERT
|
||||
WITH CHECK (public.is_active_member(collective_id));
|
||||
|
||||
CREATE POLICY item_tags_update
|
||||
ON public.item_tags FOR UPDATE
|
||||
USING (public.is_active_member(collective_id))
|
||||
WITH CHECK (public.is_active_member(collective_id));
|
||||
|
||||
CREATE POLICY item_tags_delete
|
||||
ON public.item_tags FOR DELETE
|
||||
USING (public.is_active_member(collective_id));
|
||||
|
||||
-- ── shopping_item_tags ───────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE public.shopping_item_tags (
|
||||
item_id uuid NOT NULL REFERENCES public.shopping_items(id) ON DELETE CASCADE,
|
||||
tag_id uuid NOT NULL REFERENCES public.item_tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (item_id, tag_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.shopping_item_tags ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE INDEX shopping_item_tags_tag_idx ON public.shopping_item_tags (tag_id);
|
||||
|
||||
-- Helper: resolve the collective for a shopping_items row.
|
||||
-- STABLE + SECURITY DEFINER mirrors public.list_collective_id (Migration 005).
|
||||
-- We inline it here rather than chain two calls inside policies so the planner
|
||||
-- gets a single STABLE function call per row.
|
||||
CREATE OR REPLACE FUNCTION public.item_collective_id(p_item_id uuid)
|
||||
RETURNS uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT sl.collective_id
|
||||
FROM public.shopping_items si
|
||||
JOIN public.shopping_lists sl ON sl.id = si.list_id
|
||||
WHERE si.id = p_item_id;
|
||||
$$;
|
||||
|
||||
CREATE POLICY shopping_item_tags_select
|
||||
ON public.shopping_item_tags FOR SELECT
|
||||
USING (public.is_member(public.item_collective_id(item_id)));
|
||||
|
||||
CREATE POLICY shopping_item_tags_insert
|
||||
ON public.shopping_item_tags FOR INSERT
|
||||
WITH CHECK (
|
||||
public.is_active_member(public.item_collective_id(item_id))
|
||||
-- Prevent attaching a tag from a different collective than the item.
|
||||
AND public.item_collective_id(item_id) = (
|
||||
SELECT collective_id FROM public.item_tags WHERE id = tag_id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY shopping_item_tags_delete
|
||||
ON public.shopping_item_tags FOR DELETE
|
||||
USING (public.is_active_member(public.item_collective_id(item_id)));
|
||||
|
||||
-- ── Realtime publication ─────────────────────────────────────────────────────
|
||||
-- Mirror the shape used by migration 007: add both tables and set REPLICA
|
||||
-- IDENTITY FULL so UPDATE/DELETE events carry the full row (needed for the
|
||||
-- item-id-based client filtering used by the lists detail view).
|
||||
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.item_tags;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.shopping_item_tags;
|
||||
|
||||
ALTER TABLE public.item_tags REPLICA IDENTITY FULL;
|
||||
ALTER TABLE public.shopping_item_tags REPLICA IDENTITY FULL;
|
||||
Reference in New Issue
Block a user