From c1152fac90c49fdc218f808b558c38d48b7d0a1a Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 03:04:25 +0200 Subject: [PATCH] feat(fase-11): tags store + lateral-join item loader (11.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web/src/lib/stores/tags.ts — collective-scoped store for item_tags with realtime subscription per active collective (resub on switch), plus mutation helpers: createTag, renameTag, recolorTag, deleteTag, and the many-to-many writers attachTag / detachTag for shopping_item_tags. apps/web/src/lib/stores/lists.ts — new loadListItems(listId) issues a single PostgREST embedding (`*, shopping_item_tags(item_tags(*))`) and flattens the rows to ItemWithTags. Cast goes through `unknown` because the curated database.ts doesn't declare the shopping_items ↔ shopping_item_tags relationship — runtime resolves it from the FK. Tests: packages/test-utils/tests/rls-item-tags.test.ts adds 6 integration assertions covering the policies an authenticated role sees: member create (IT-01), guest reject (IT-02), non-member empty read (IT-03), cross-collective attach reject (IT-04), unique violation (IT-05), and cascade on item delete (IT-06). Co-Authored-By: Claude Opus 4.7 --- apps/web/src/lib/stores/lists.ts | 41 +++- apps/web/src/lib/stores/tags.ts | 182 ++++++++++++++++ .../test-utils/tests/rls-item-tags.test.ts | 200 ++++++++++++++++++ 3 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/stores/tags.ts create mode 100644 packages/test-utils/tests/rls-item-tags.test.ts diff --git a/apps/web/src/lib/stores/lists.ts b/apps/web/src/lib/stores/lists.ts index 996a63f..4c0c435 100644 --- a/apps/web/src/lib/stores/lists.ts +++ b/apps/web/src/lib/stores/lists.ts @@ -1,7 +1,13 @@ import { writable } from 'svelte/store'; import { getSupabase } from '$lib/supabase'; import { generateId } from '$lib/utils/id'; -import type { ShoppingList, ShoppingItem, ItemFrequency } from '@colectivo/types'; +import type { + ShoppingList, + ShoppingItem, + ItemFrequency, + ItemTag, + ItemWithTags +} from '@colectivo/types'; // ── Global list store (overview page) ───────────────────────────────────────── @@ -147,6 +153,39 @@ export async function loadItems(listId: string): Promise { return data as ShoppingItem[]; } +/** + * Load items with their tag rows embedded — one PostgREST call instead of + * N+1 lookups. The join goes via shopping_item_tags; PostgREST returns + * `{ ...item, shopping_item_tags: [{ item_tags: {...tag} }, ...] }` so we + * flatten the shape before returning so callers see `tags: ItemTag[]`. + * + * Note: the realtime channel for shopping_item_tags is the source of truth + * for live updates — the embedded view here is the cold-load snapshot only. + */ +export async function loadListItems(listId: string): Promise { + const { data, error } = await getSupabase() + .from('shopping_items') + .select('*, shopping_item_tags(item_tags(*))') + .eq('list_id', listId) + .order('sort_order', { ascending: true }); + + if (error || !data) return []; + + type Embedded = ShoppingItem & { + shopping_item_tags: Array<{ item_tags: ItemTag | null }> | null; + }; + // Cast via unknown: the curated database.ts does not declare the + // shopping_items ↔ shopping_item_tags relationship to PostgREST, so the + // generated query type sees `SelectQueryError`. The runtime shape is + // correct (PostgREST resolves the embedding from the FK). + return (data as unknown as Embedded[]).map(({ shopping_item_tags, ...rest }) => ({ + ...rest, + tags: (shopping_item_tags ?? []) + .map((row) => row.item_tags) + .filter((t): t is ItemTag => t !== null) + })); +} + /** * Insert a new item. Accepts an optional pre-generated `id` so callers can * use the same UUID as their local optimistic row — this makes the write diff --git a/apps/web/src/lib/stores/tags.ts b/apps/web/src/lib/stores/tags.ts new file mode 100644 index 0000000..f490828 --- /dev/null +++ b/apps/web/src/lib/stores/tags.ts @@ -0,0 +1,182 @@ +/** + * Tag store — collective-scoped item tags (Fase 11). + * + * Loads + caches the tags for the active collective and subscribes to + * `item_tags` postgres-changes so concurrent rename / create / delete from + * another tab or device propagates without a reload. + * + * Per CLAUDE.md "Supabase JS v2 + SvelteKit deadlock" gotcha: this store is + * created from `onMount`-style component lifecycles, never from inside + * `onAuthStateChange`. The Realtime subscription itself does not call + * `getAccessToken()`, so the lock-defer pattern is not needed here. + */ +import { writable, get } from 'svelte/store'; +import { getSupabase } from '$lib/supabase'; +import type { ItemTag, ItemTagColor } from '@colectivo/types'; +import type { RealtimeChannel } from '@supabase/supabase-js'; + +export const tags = writable([]); +export const tagsLoading = writable(false); + +let activeChannel: RealtimeChannel | null = null; +let activeCollectiveId: string | null = null; + +export async function loadTags(collectiveId: string): Promise { + tagsLoading.set(true); + try { + const { data, error } = await getSupabase() + .from('item_tags') + .select('*') + .eq('collective_id', collectiveId) + .order('name', { ascending: true }); + + if (!error && data) { + tags.set(data as ItemTag[]); + } + } finally { + tagsLoading.set(false); + } + + // Re-subscribe whenever the active collective changes. Cheap: one channel + // per collective + tab. + if (activeCollectiveId !== collectiveId) { + await unsubscribe(); + activeCollectiveId = collectiveId; + await subscribe(collectiveId); + } +} + +async function subscribe(collectiveId: string): Promise { + const channel = getSupabase().channel(`tags:${collectiveId}`); + + channel.on( + // @ts-expect-error — postgres_changes is a runtime string event + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'item_tags', + filter: `collective_id=eq.${collectiveId}` + }, + (payload: { + eventType: 'INSERT' | 'UPDATE' | 'DELETE'; + new: ItemTag; + old: ItemTag | { id: string }; + }) => { + if (payload.eventType === 'INSERT') { + tags.update((arr) => + arr.some((t) => t.id === payload.new.id) ? arr : sortByName([...arr, payload.new]) + ); + } else if (payload.eventType === 'UPDATE') { + tags.update((arr) => + sortByName(arr.map((t) => (t.id === payload.new.id ? payload.new : t))) + ); + } else { + const id = (payload.old as { id: string }).id; + tags.update((arr) => arr.filter((t) => t.id !== id)); + } + } + ); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('tags channel subscribe timeout')), 10_000); + channel.subscribe((status) => { + if (status === 'SUBSCRIBED') { + clearTimeout(timer); + resolve(); + } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') { + clearTimeout(timer); + reject(new Error(`tags channel subscribe failed: ${status}`)); + } + }); + }); + + activeChannel = channel; +} + +async function unsubscribe(): Promise { + if (!activeChannel) return; + const supabase = getSupabase(); + try { + await activeChannel.unsubscribe(); + await supabase.removeChannel(activeChannel); + } catch { + // idempotent cleanup + } + activeChannel = null; +} + +export async function teardownTags(): Promise { + await unsubscribe(); + activeCollectiveId = null; + tags.set([]); +} + +function sortByName(arr: ItemTag[]): ItemTag[] { + return [...arr].sort((a, b) => a.name.localeCompare(b.name)); +} + +// ── Mutations ───────────────────────────────────────────────────────────────── + +export async function createTag( + collectiveId: string, + name: string, + color: ItemTagColor = 'slate' +): Promise { + const trimmed = name.trim(); + if (!trimmed) return null; + + const { data, error } = await getSupabase() + .from('item_tags') + .insert({ collective_id: collectiveId, name: trimmed, color }) + .select() + .single(); + + if (error || !data) return null; + const row = data as ItemTag; + // Optimistic local insert — the Realtime echo is deduped by id. + tags.update((arr) => (arr.some((t) => t.id === row.id) ? arr : sortByName([...arr, row]))); + return row; +} + +export async function renameTag(id: string, name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) return; + tags.update((arr) => sortByName(arr.map((t) => (t.id === id ? { ...t, name: trimmed } : t)))); + await getSupabase().from('item_tags').update({ name: trimmed }).eq('id', id); +} + +export async function recolorTag(id: string, color: ItemTagColor): Promise { + tags.update((arr) => arr.map((t) => (t.id === id ? { ...t, color } : t))); + await getSupabase().from('item_tags').update({ color }).eq('id', id); +} + +export async function deleteTag(id: string): Promise { + tags.update((arr) => arr.filter((t) => t.id !== id)); + await getSupabase().from('item_tags').delete().eq('id', id); +} + +// ── Attach / detach on a shopping_item ─────────────────────────────────────── + +export async function attachTag(itemId: string, tagId: string): Promise { + const { error } = await getSupabase() + .from('shopping_item_tags') + .insert({ item_id: itemId, tag_id: tagId }); + return !error; +} + +export async function detachTag(itemId: string, tagId: string): Promise { + const { error } = await getSupabase() + .from('shopping_item_tags') + .delete() + .eq('item_id', itemId) + .eq('tag_id', tagId); + return !error; +} + +// ── Local-only helpers ─────────────────────────────────────────────────────── + +export function findTagByName(name: string): ItemTag | undefined { + const lookup = name.trim().toLowerCase(); + return get(tags).find((t) => t.name.toLowerCase() === lookup); +} diff --git a/packages/test-utils/tests/rls-item-tags.test.ts b/packages/test-utils/tests/rls-item-tags.test.ts new file mode 100644 index 0000000..55bc1e8 --- /dev/null +++ b/packages/test-utils/tests/rls-item-tags.test.ts @@ -0,0 +1,200 @@ +/** + * IT-series: item_tags + shopping_item_tags RLS + * + * Schema invariants (publication, replica identity, cascades, unique/check + * constraints) are covered by pgTAP in supabase/tests/014_item_tags.sql. + * Here we verify the policies as observed by an authenticated role: + * + * IT-01 member can create a tag in their collective + * IT-02 guest cannot create a tag (read-only role) + * IT-03 non-member cannot read tags from another collective + * IT-04 attaching a tag from a different collective to an item is rejected + * IT-05 unique(collective_id, name) — duplicate insert errors out + * IT-06 deleting an item cascades shopping_item_tags + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; +import { + ANA_ID, + BORJA_ID, + DAVID_ID, + EVA_ID, + COLLECTIVE_ID, + SEED_LIST_ID +} from '../src/seed-constants.js'; + +const admin = createAdminClient(); +const createdTagIds: string[] = []; +const createdItemIds: string[] = []; +const createdCollectiveIds: string[] = []; +const createdListIds: string[] = []; + +afterAll(async () => { + if (createdTagIds.length) { + await admin.from('item_tags').delete().in('id', createdTagIds); + } + if (createdItemIds.length) { + await admin.from('shopping_items').delete().in('id', createdItemIds); + } + if (createdListIds.length) { + await admin.from('shopping_lists').delete().in('id', createdListIds); + } + if (createdCollectiveIds.length) { + await admin.from('collectives').delete().in('id', createdCollectiveIds); + } +}); + +describe('item_tags RLS', () => { + it('IT-01: member (Borja) can create a tag in their collective', async () => { + const borja = await createClientAs(BORJA_ID); + const { data, error } = await borja + .from('item_tags') + .insert({ collective_id: COLLECTIVE_ID, name: `IT-01-${Date.now()}`, color: 'green' }) + .select('id, name, color') + .single(); + expect(error).toBeNull(); + expect(data?.color).toBe('green'); + if (data?.id) createdTagIds.push(data.id); + }); + + it('IT-02: guest (David) cannot create a tag', async () => { + const david = await createClientAs(DAVID_ID); + const { data, error } = await david + .from('item_tags') + .insert({ collective_id: COLLECTIVE_ID, name: `IT-02-${Date.now()}`, color: 'red' }) + .select('id') + .single(); + expect(data).toBeNull(); + expect(error).not.toBeNull(); + }); + + it('IT-03: non-member (Eva) sees no tags in another collective', async () => { + // Seed a tag as Ana so there's something to (not) see. + const ana = await createClientAs(ANA_ID); + const { data: tag } = await ana + .from('item_tags') + .insert({ collective_id: COLLECTIVE_ID, name: `IT-03-${Date.now()}`, color: 'sky' }) + .select('id') + .single(); + if (tag?.id) createdTagIds.push(tag.id); + + const eva = await createClientAs(EVA_ID); + const { data } = await eva.from('item_tags').select('id').eq('collective_id', COLLECTIVE_ID); + expect(data ?? []).toHaveLength(0); + }); +}); + +describe('shopping_item_tags cross-collective protection', () => { + let foreignCollectiveId: string; + let foreignListId: string; + let foreignTagId: string; + let seedItemId: string; + + beforeAll(async () => { + // Build a separate collective (no Borja membership) with one tag. + const { data: coll } = await admin + .from('collectives') + .insert({ name: `IT foreign ${Date.now()}`, emoji: '🛡️', created_by: EVA_ID }) + .select('id') + .single(); + foreignCollectiveId = coll!.id; + createdCollectiveIds.push(foreignCollectiveId); + + await admin + .from('collective_members') + .insert({ collective_id: foreignCollectiveId, user_id: EVA_ID, role: 'admin' }); + + const { data: list } = await admin + .from('shopping_lists') + .insert({ collective_id: foreignCollectiveId, name: 'foreign list', created_by: EVA_ID }) + .select('id') + .single(); + foreignListId = list!.id; + createdListIds.push(foreignListId); + + const { data: tag } = await admin + .from('item_tags') + .insert({ collective_id: foreignCollectiveId, name: 'foreign', color: 'pink' }) + .select('id') + .single(); + foreignTagId = tag!.id; + createdTagIds.push(foreignTagId); + + // And one item in the home collective that Borja could try to tag. + const { data: item } = await admin + .from('shopping_items') + .insert({ + list_id: SEED_LIST_ID, + name: `IT cross item ${Date.now()}`, + sort_order: 9999, + created_by: BORJA_ID + }) + .select('id') + .single(); + seedItemId = item!.id; + createdItemIds.push(seedItemId); + }); + + it('IT-04: attaching a foreign-collective tag to a home item is rejected', async () => { + const borja = await createClientAs(BORJA_ID); + const { error } = await borja + .from('shopping_item_tags') + .insert({ item_id: seedItemId, tag_id: foreignTagId }); + expect(error).not.toBeNull(); + }); +}); + +describe('item_tags constraints + cascade', () => { + it('IT-05: duplicate (collective_id, name) errors out (unique constraint)', async () => { + const ana = await createClientAs(ANA_ID); + const name = `IT-05-dup-${Date.now()}`; + const first = await ana + .from('item_tags') + .insert({ collective_id: COLLECTIVE_ID, name, color: 'amber' }) + .select('id') + .single(); + expect(first.error).toBeNull(); + if (first.data?.id) createdTagIds.push(first.data.id); + + const second = await ana + .from('item_tags') + .insert({ collective_id: COLLECTIVE_ID, name, color: 'indigo' }) + .select('id') + .single(); + expect(second.data).toBeNull(); + expect(second.error).not.toBeNull(); + }); + + it('IT-06: deleting an item cascades shopping_item_tags', async () => { + const ana = await createClientAs(ANA_ID); + const { data: tag } = await ana + .from('item_tags') + .insert({ collective_id: COLLECTIVE_ID, name: `IT-06-${Date.now()}`, color: 'stone' }) + .select('id') + .single(); + if (tag?.id) createdTagIds.push(tag.id); + + const { data: item } = await ana + .from('shopping_items') + .insert({ + list_id: SEED_LIST_ID, + name: `IT-06 item ${Date.now()}`, + sort_order: 8888, + created_by: ANA_ID + }) + .select('id') + .single(); + expect(item?.id).toBeTruthy(); + + await ana.from('shopping_item_tags').insert({ item_id: item!.id, tag_id: tag!.id }); + + // Delete the item; the join row should be gone. + await ana.from('shopping_items').delete().eq('id', item!.id); + + const { data: links } = await admin + .from('shopping_item_tags') + .select('item_id') + .eq('tag_id', tag!.id); + expect(links ?? []).toHaveLength(0); + }); +});