feat(fase-11): tags store + lateral-join item loader (11.2)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ShoppingItem[]> {
|
||||
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<ItemWithTags[]> {
|
||||
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
|
||||
|
||||
182
apps/web/src/lib/stores/tags.ts
Normal file
182
apps/web/src/lib/stores/tags.ts
Normal file
@@ -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<ItemTag[]>([]);
|
||||
export const tagsLoading = writable(false);
|
||||
|
||||
let activeChannel: RealtimeChannel | null = null;
|
||||
let activeCollectiveId: string | null = null;
|
||||
|
||||
export async function loadTags(collectiveId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void>((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<void> {
|
||||
if (!activeChannel) return;
|
||||
const supabase = getSupabase();
|
||||
try {
|
||||
await activeChannel.unsubscribe();
|
||||
await supabase.removeChannel(activeChannel);
|
||||
} catch {
|
||||
// idempotent cleanup
|
||||
}
|
||||
activeChannel = null;
|
||||
}
|
||||
|
||||
export async function teardownTags(): Promise<void> {
|
||||
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<ItemTag | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
200
packages/test-utils/tests/rls-item-tags.test.ts
Normal file
200
packages/test-utils/tests/rls-item-tags.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user