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:
@@ -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<string, never>;
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
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;
|
||||
142
supabase/tests/014_item_tags.sql
Normal file
142
supabase/tests/014_item_tags.sql
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user