From 4c3552fb3c5a1d480dbc1dc480fe1f84fe780864 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 03:01:52 +0200 Subject: [PATCH] feat(fase-11): item_tags + shopping_item_tags schema (11.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/types/src/database.ts | 52 ++++++++++ packages/types/src/domain.ts | 17 ++- supabase/migrations/022_item_tags.sql | 101 ++++++++++++++++++ supabase/tests/014_item_tags.sql | 142 ++++++++++++++++++++++++++ 4 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/022_item_tags.sql create mode 100644 supabase/tests/014_item_tags.sql diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index 0685c59..5a0ff80 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -10,6 +10,15 @@ export type MemberRoleEnum = 'admin' | 'member' | 'guest'; export type ListStatusEnum = 'active' | 'completed' | 'archived'; export type NoteColor = 'yellow' | 'green' | 'blue' | 'pink' | 'purple' | 'orange' | 'gray' | 'red'; export type ThemeMode = 'light' | 'dark' | 'system'; +export type ItemTagColor = + | 'slate' + | 'red' + | 'amber' + | 'green' + | 'sky' + | 'indigo' + | 'pink' + | 'stone'; export interface Database { public: { @@ -414,6 +423,45 @@ export interface Database { }; Relationships: []; }; + item_tags: { + Row: { + id: string; + collective_id: string; + name: string; + color: ItemTagColor; + created_at: string; + }; + Insert: { + id?: string; + collective_id: string; + name: string; + color?: ItemTagColor; + created_at?: string; + }; + Update: { + id?: string; + collective_id?: string; + name?: string; + color?: ItemTagColor; + created_at?: string; + }; + Relationships: []; + }; + shopping_item_tags: { + Row: { + item_id: string; + tag_id: string; + }; + Insert: { + item_id: string; + tag_id: string; + }; + Update: { + item_id?: string; + tag_id?: string; + }; + Relationships: []; + }; }; Views: Record; Functions: { @@ -433,6 +481,10 @@ export interface Database { Args: { p_collective_id: string }; Returns: boolean; }; + item_collective_id: { + Args: { p_item_id: string }; + Returns: string; + }; }; Enums: { language_code: LanguageCode; diff --git a/packages/types/src/domain.ts b/packages/types/src/domain.ts index d3080b3..50d1959 100644 --- a/packages/types/src/domain.ts +++ b/packages/types/src/domain.ts @@ -6,8 +6,8 @@ export type AvatarType = 'initials' | 'emoji' | 'upload'; export type MemberRole = 'admin' | 'member' | 'guest'; export type ListStatus = 'active' | 'completed' | 'archived'; // NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts -import type { NoteColor } from './database.js'; -export type { NoteColor }; +import type { NoteColor, ItemTagColor } from './database.js'; +export type { NoteColor, ItemTagColor }; export type Language = 'en' | 'es'; export interface User { @@ -79,6 +79,19 @@ export interface ItemFrequency { last_used_at: string; } +export interface ItemTag { + id: string; + collective_id: string; + name: string; + color: ItemTagColor; + created_at: string; +} + +/** Shopping item with the tag rows already joined in. */ +export interface ItemWithTags extends ShoppingItem { + tags: ItemTag[]; +} + export interface TaskList { id: string; collective_id: string; diff --git a/supabase/migrations/022_item_tags.sql b/supabase/migrations/022_item_tags.sql new file mode 100644 index 0000000..68a97fb --- /dev/null +++ b/supabase/migrations/022_item_tags.sql @@ -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; diff --git a/supabase/tests/014_item_tags.sql b/supabase/tests/014_item_tags.sql new file mode 100644 index 0000000..633538d --- /dev/null +++ b/supabase/tests/014_item_tags.sql @@ -0,0 +1,142 @@ +-- pgTAP: item_tags + shopping_item_tags structural invariants (Fase 11) +-- Run with: psql -U postgres -d postgres -f supabase/tests/014_item_tags.sql +-- +-- These pgTAP assertions verify schema-level invariants only (publication +-- membership, REPLICA IDENTITY, unique constraint, cascade behaviour). The +-- per-role RLS semantics (member can insert, non-member denied, cross- +-- collective attach rejected) are covered by the Vitest integration suite +-- (packages/test-utils/tests/rls-item-tags.test.ts) which connects as an +-- authenticated role — pgTAP runs as postgres which bypasses RLS. + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(8); + +-- T-01: item_tags is in the supabase_realtime publication +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'item_tags' + ), + 'T-01: item_tags is part of the supabase_realtime publication' +); + +-- T-02: shopping_item_tags is in the supabase_realtime publication +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'shopping_item_tags' + ), + 'T-02: shopping_item_tags is part of the supabase_realtime publication' +); + +-- T-03: REPLICA IDENTITY FULL on item_tags +SELECT results_eq( + $$ SELECT relreplident::text FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = 'item_tags' $$, + $$ VALUES ('f') $$, + 'T-03: item_tags has REPLICA IDENTITY FULL' +); + +-- T-04: REPLICA IDENTITY FULL on shopping_item_tags +SELECT results_eq( + $$ SELECT relreplident::text FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = 'shopping_item_tags' $$, + $$ VALUES ('f') $$, + 'T-04: shopping_item_tags has REPLICA IDENTITY FULL' +); + +-- T-05: UNIQUE (collective_id, name) enforced +INSERT INTO public.item_tags (collective_id, name, color) +VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pgtap-vegano', 'green'); + +SELECT throws_ok( + $$ INSERT INTO public.item_tags (collective_id, name, color) + VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pgtap-vegano', 'red') $$, + '23505', + NULL, + 'T-05: (collective_id, name) unique constraint enforced' +); + +-- T-06: color CHECK constraint rejects unknown palette entries +SELECT throws_ok( + $$ INSERT INTO public.item_tags (collective_id, name, color) + VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pgtap-bad-color', 'rainbow') $$, + '23514', + NULL, + 'T-06: color CHECK constraint blocks values outside the 8-preset palette' +); + +-- T-07: shopping_item_tags cascades on item_tags DELETE +INSERT INTO public.item_tags (id, collective_id, name, color) +VALUES ( + 'eeeeeeee-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'pgtap-cascade-tag', + 'sky' +); + +INSERT INTO public.shopping_item_tags (item_id, tag_id) +SELECT id, 'eeeeeeee-1111-1111-1111-111111111111' +FROM public.shopping_items +WHERE list_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' +ORDER BY sort_order +LIMIT 1; + +DELETE FROM public.item_tags WHERE id = 'eeeeeeee-1111-1111-1111-111111111111'; + +SELECT is( + (SELECT count(*)::int FROM public.shopping_item_tags + WHERE tag_id = 'eeeeeeee-1111-1111-1111-111111111111'), + 0, + 'T-07: deleting a tag cascades shopping_item_tags rows' +); + +-- T-08: deleting the collective cascades item_tags + shopping_item_tags +-- Build an isolated mini-tree: collective → list → item, plus a tag attached. +INSERT INTO public.collectives (id, name, emoji, created_by) +VALUES ('aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'pgtap-cascade-collective', '🧪', '11111111-1111-1111-1111-111111111111'); + +INSERT INTO public.collective_members (collective_id, user_id, role) +VALUES ('aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'admin'); + +INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by) +VALUES ( + 'bbbbbbbb-1111-1111-1111-111111111111', + 'aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'cascade list', 'active', '11111111-1111-1111-1111-111111111111' +); + +INSERT INTO public.shopping_items (id, list_id, name, sort_order, created_by) +VALUES ( + 'cccccccc-1111-1111-1111-111111111111', + 'bbbbbbbb-1111-1111-1111-111111111111', + 'cascade item', 0, '11111111-1111-1111-1111-111111111111' +); + +INSERT INTO public.item_tags (id, collective_id, name, color) +VALUES ('eeeeeeee-2222-2222-2222-222222222222', + 'aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'cascade tag', 'pink'); + +INSERT INTO public.shopping_item_tags (item_id, tag_id) +VALUES ('cccccccc-1111-1111-1111-111111111111', + 'eeeeeeee-2222-2222-2222-222222222222'); + +DELETE FROM public.collectives WHERE id = 'aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +SELECT ok( + NOT EXISTS (SELECT 1 FROM public.item_tags WHERE id = 'eeeeeeee-2222-2222-2222-222222222222') + AND NOT EXISTS (SELECT 1 FROM public.shopping_item_tags WHERE tag_id = 'eeeeeeee-2222-2222-2222-222222222222'), + 'T-08: deleting a collective cascades item_tags + shopping_item_tags' +); + +SELECT * FROM finish(); +ROLLBACK;