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:
2026-05-18 03:01:52 +02:00
parent a868b5a314
commit 4c3552fb3c
4 changed files with 310 additions and 2 deletions

View File

@@ -10,6 +10,15 @@ export type MemberRoleEnum = 'admin' | 'member' | 'guest';
export type ListStatusEnum = 'active' | 'completed' | 'archived'; export type ListStatusEnum = 'active' | 'completed' | 'archived';
export type NoteColor = 'yellow' | 'green' | 'blue' | 'pink' | 'purple' | 'orange' | 'gray' | 'red'; export type NoteColor = 'yellow' | 'green' | 'blue' | 'pink' | 'purple' | 'orange' | 'gray' | 'red';
export type ThemeMode = 'light' | 'dark' | 'system'; export type ThemeMode = 'light' | 'dark' | 'system';
export type ItemTagColor =
| 'slate'
| 'red'
| 'amber'
| 'green'
| 'sky'
| 'indigo'
| 'pink'
| 'stone';
export interface Database { export interface Database {
public: { public: {
@@ -414,6 +423,45 @@ export interface Database {
}; };
Relationships: []; 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>; Views: Record<string, never>;
Functions: { Functions: {
@@ -433,6 +481,10 @@ export interface Database {
Args: { p_collective_id: string }; Args: { p_collective_id: string };
Returns: boolean; Returns: boolean;
}; };
item_collective_id: {
Args: { p_item_id: string };
Returns: string;
};
}; };
Enums: { Enums: {
language_code: LanguageCode; language_code: LanguageCode;

View File

@@ -6,8 +6,8 @@ export type AvatarType = 'initials' | 'emoji' | 'upload';
export type MemberRole = 'admin' | 'member' | 'guest'; export type MemberRole = 'admin' | 'member' | 'guest';
export type ListStatus = 'active' | 'completed' | 'archived'; export type ListStatus = 'active' | 'completed' | 'archived';
// NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts // NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts
import type { NoteColor } from './database.js'; import type { NoteColor, ItemTagColor } from './database.js';
export type { NoteColor }; export type { NoteColor, ItemTagColor };
export type Language = 'en' | 'es'; export type Language = 'en' | 'es';
export interface User { export interface User {
@@ -79,6 +79,19 @@ export interface ItemFrequency {
last_used_at: string; 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 { export interface TaskList {
id: string; id: string;
collective_id: string; collective_id: string;

View 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;

View 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;