diff --git a/CLAUDE.md b/CLAUDE.md index 47c2db6..ec46874 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -**Fase 0 complete. Fase 1 complete. ✅ Acceptance test passed (all 5 users authenticate; `auth.uid()` resolves correctly in RLS).** +**Fase 0 complete. Fase 1 complete. Fase 2a complete. ✅** - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) @@ -34,11 +34,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `apps/web/src/routes/(app)/collective/manage/+page.svelte` — members, roles, invite link generator - `apps/web/src/routes/invitation/[token]/+page.svelte` — accept invitation (auth-aware) - `apps/web/src/routes/(app)/settings/+page.svelte` — display name, avatar, language, sign out -- `apps/web/messages/en.json` + `es.json` — all Fase 1 strings +- `apps/web/messages/en.json` + `es.json` — all Fase 1 + 2a strings - `packages/types/src/database.ts` — manually seeded from migrations (update with `just db-types` once CLI is wired) - `setup.sh` — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide) - `infra/kong-start.sh` — Kong container entrypoint; substitutes `${VAR}` placeholders in `kong.yml` before Kong starts (Kong 2.8 does not do this natively) +### Fase 2a — what has been built + +- `supabase/migrations/005_shopping.sql` — `shopping_lists`, `shopping_items`, RLS, `list_collective_id()` helper, pg_cron jobs (archive completed > 7d, purge deleted > 7d) +- `supabase/migrations/006_item_frequency.sql` — `item_frequency` table, `fn_record_item_frequency` trigger (SECURITY DEFINER, fires on every `shopping_items` INSERT) +- `apps/web/src/lib/stores/lists.ts` — shopping lists store (optimistic CRUD) + item operations + suggestion fetching +- `apps/web/src/lib/components/ItemSuggestions.svelte` — frequency chips with active-prefix highlighting +- `apps/web/src/routes/(app)/lists/+page.svelte` — overview: featured card + 2-col grid + completed section + trash view + sticky create input +- `apps/web/src/routes/(app)/lists/[id]/+page.svelte` — list detail: items with checkbox, qty stepper (−/N/+), inline edit, swipe-to-reveal-delete (touch) + hover delete (desktop), drag & drop reorder via `svelte-dnd-action`, sticky add form with `ItemSuggestions` + ## Local Dev: Required /etc/hosts Entry GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine: diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 16850a0..e38f3cd 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -70,6 +70,23 @@ "invite_email_label": "Email address", "invite_send": "Send invitation", "lists_title": "Shopping Lists", + "lists_new_list": "Create", + "lists_no_lists": "No lists yet", + "lists_create_first": "Create your first shopping list", + "lists_trash": "Trash", + "list_status_active": "Active List", + "list_status_completed": "Completed", + "list_status_archived": "Archived", + "list_name_placeholder": "e.g. Weekly shop", + "list_items_empty": "List is empty", + "list_items_empty_hint": "Add your first item below", + "list_reset": "Reset list", + "list_archive": "Archive", + "list_delete": "Move to trash", + "list_actions": "List actions", + "list_item_placeholder": "Add item (e.g. Yogurt 500g)", + "list_qty_label": "Qty", + "list_unit_label": "Unit", "list_add_item": "Add item…", "list_to_buy": "To buy", "list_checked": "Checked", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index b26684a..9788d94 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -70,6 +70,23 @@ "invite_email_label": "Correo electrónico", "invite_send": "Enviar invitación", "lists_title": "Listas de la compra", + "lists_new_list": "Crear", + "lists_no_lists": "Sin listas", + "lists_create_first": "Crea tu primera lista de la compra", + "lists_trash": "Papelera", + "list_status_active": "Lista activa", + "list_status_completed": "Completada", + "list_status_archived": "Archivada", + "list_name_placeholder": "p. ej. Compra semanal", + "list_items_empty": "Lista vacía", + "list_items_empty_hint": "Añade tu primer producto abajo", + "list_reset": "Reiniciar lista", + "list_archive": "Archivar", + "list_delete": "Mover a papelera", + "list_actions": "Acciones de lista", + "list_item_placeholder": "Añadir producto…", + "list_qty_label": "Cant.", + "list_unit_label": "Unidad", "list_add_item": "Añadir producto…", "list_to_buy": "Por comprar", "list_checked": "Marcado", diff --git a/apps/web/src/lib/components/ItemSuggestions.svelte b/apps/web/src/lib/components/ItemSuggestions.svelte new file mode 100644 index 0000000..3d5d9d6 --- /dev/null +++ b/apps/web/src/lib/components/ItemSuggestions.svelte @@ -0,0 +1,28 @@ + + +{#if suggestions.length > 0} +
+ {#each suggestions as item (item.name)} + + {/each} +
+{/if} diff --git a/apps/web/src/lib/stores/lists.ts b/apps/web/src/lib/stores/lists.ts new file mode 100644 index 0000000..a87d01d --- /dev/null +++ b/apps/web/src/lib/stores/lists.ts @@ -0,0 +1,215 @@ +import { writable } from 'svelte/store'; +import { getSupabase } from '$lib/supabase'; +import type { ShoppingList, ShoppingItem, ItemFrequency } from '@colectivo/types'; + +// ── Global list store (overview page) ───────────────────────────────────────── + +export const lists = writable([]); +export const listsLoading = writable(false); + +export async function loadLists(collectiveId: string) { + listsLoading.set(true); + const { data, error } = await getSupabase() + .from('shopping_lists') + .select('*') + .eq('collective_id', collectiveId) + .is('deleted_at', null) + .order('created_at', { ascending: false }); + + if (!error && data) { + lists.set(data as ShoppingList[]); + } + listsLoading.set(false); +} + +export async function loadTrashedLists(collectiveId: string): Promise { + const { data, error } = await getSupabase() + .from('shopping_lists') + .select('*') + .eq('collective_id', collectiveId) + .not('deleted_at', 'is', null) + .order('deleted_at', { ascending: false }); + + if (error || !data) return []; + return data as ShoppingList[]; +} + +// ── List mutations (optimistic) ─────────────────────────────────────────────── + +export async function createList( + collectiveId: string, + name: string, + userId: string +): Promise { + const tempId = crypto.randomUUID(); + const optimistic: ShoppingList = { + id: tempId, + collective_id: collectiveId, + name, + status: 'active', + created_by: userId, + created_at: new Date().toISOString(), + completed_at: null, + deleted_at: null + }; + lists.update((ls) => [optimistic, ...ls]); + + const { data, error } = await getSupabase() + .from('shopping_lists') + .insert({ collective_id: collectiveId, name, created_by: userId }) + .select() + .single(); + + if (error || !data) { + lists.update((ls) => ls.filter((l) => l.id !== tempId)); + return null; + } + + const real = data as ShoppingList; + lists.update((ls) => ls.map((l) => (l.id === tempId ? real : l))); + return real; +} + +export async function renameList(id: string, name: string) { + lists.update((ls) => ls.map((l) => (l.id === id ? { ...l, name } : l))); + await getSupabase().from('shopping_lists').update({ name }).eq('id', id); +} + +export async function softDeleteList(id: string) { + lists.update((ls) => ls.filter((l) => l.id !== id)); + await getSupabase() + .from('shopping_lists') + .update({ deleted_at: new Date().toISOString() }) + .eq('id', id); +} + +export async function restoreList(id: string, collectiveId: string) { + await getSupabase().from('shopping_lists').update({ deleted_at: null }).eq('id', id); + await loadLists(collectiveId); +} + +export async function permanentDeleteList(id: string) { + await getSupabase().from('shopping_lists').delete().eq('id', id); +} + +export async function archiveList(id: string) { + lists.update((ls) => ls.filter((l) => l.id !== id)); + await getSupabase().from('shopping_lists').update({ status: 'archived' }).eq('id', id); +} + +export async function completeList(id: string) { + const completed_at = new Date().toISOString(); + lists.update((ls) => + ls.map((l) => (l.id === id ? { ...l, status: 'completed' as const, completed_at } : l)) + ); + await getSupabase() + .from('shopping_lists') + .update({ status: 'completed', completed_at }) + .eq('id', id); +} + +export async function resetList(id: string) { + lists.update((ls) => + ls.map((l) => + l.id === id ? { ...l, status: 'active' as const, completed_at: null } : l + ) + ); + await Promise.all([ + getSupabase() + .from('shopping_lists') + .update({ status: 'active', completed_at: null }) + .eq('id', id), + getSupabase() + .from('shopping_items') + .update({ is_checked: false, checked_by: null, checked_at: null }) + .eq('list_id', id) + ]); +} + +// ── Item operations (used by [id] page) ─────────────────────────────────────── + +export async function loadItems(listId: string): Promise { + const { data, error } = await getSupabase() + .from('shopping_items') + .select('*') + .eq('list_id', listId) + .order('sort_order', { ascending: true }); + + if (error || !data) return []; + return data as ShoppingItem[]; +} + +export async function addItem( + listId: string, + name: string, + quantity: number | null, + unit: string | null, + userId: string, + sortOrder: number +): Promise { + const { data, error } = await getSupabase() + .from('shopping_items') + .insert({ list_id: listId, name, quantity, unit, sort_order: sortOrder, created_by: userId }) + .select() + .single(); + + if (error || !data) return null; + return data as ShoppingItem; +} + +export async function updateItem( + id: string, + patch: Partial< + Pick< + ShoppingItem, + 'name' | 'quantity' | 'unit' | 'is_checked' | 'checked_by' | 'checked_at' | 'sort_order' + > + > +) { + await getSupabase().from('shopping_items').update(patch).eq('id', id); +} + +export async function checkItem(id: string, userId: string, checked: boolean) { + const patch = checked + ? { is_checked: true, checked_by: userId, checked_at: new Date().toISOString() } + : { is_checked: false, checked_by: null, checked_at: null }; + await getSupabase().from('shopping_items').update(patch).eq('id', id); +} + +export async function deleteItem(id: string) { + await getSupabase().from('shopping_items').delete().eq('id', id); +} + +export async function reorderItems(items: Pick[]) { + await Promise.all( + items.map((item, index) => + getSupabase().from('shopping_items').update({ sort_order: index }).eq('id', item.id) + ) + ); +} + +// ── Suggestions (item_frequency) ────────────────────────────────────────────── + +export async function fetchSuggestions( + collectiveId: string, + prefix: string +): Promise { + if (!prefix.trim()) { + const { data } = await getSupabase() + .from('item_frequency') + .select('*') + .eq('collective_id', collectiveId) + .order('use_count', { ascending: false }) + .limit(5); + return (data as ItemFrequency[]) ?? []; + } + + const { data } = await getSupabase() + .from('item_frequency') + .select('*') + .eq('collective_id', collectiveId) + .ilike('name', `${prefix.toLowerCase().trim()}%`) + .order('use_count', { ascending: false }) + .limit(10); + return (data as ItemFrequency[]) ?? []; +} diff --git a/apps/web/src/routes/(app)/lists/+page.svelte b/apps/web/src/routes/(app)/lists/+page.svelte index 37c4ace..c1edea9 100644 --- a/apps/web/src/routes/(app)/lists/+page.svelte +++ b/apps/web/src/routes/(app)/lists/+page.svelte @@ -1,17 +1,426 @@ + +
-
-

- {m.nav_lists()} -

-

- {m.lists_title()} -

+ +
+
+

+ {$currentCollective?.name ?? m.app_name()} +

+

+ {m.lists_title()} +

+
+
-
- + + +
+ {#if showTrash} + +
+

+ {m.lists_trash()} +

+ {#if trashLoading} +

{m.loading()}

+ {:else if trashedLists.length === 0} +

{m.trash_empty()}

+ {:else} +
+ {#each trashedLists as list (list.id)} +
+ + {list.name} + + {m.trash_expires_in({ days: trashDaysLeft(list.deleted_at!) })} + + + +
+ {/each} +
+ {/if} +
+ {:else if $listsLoading} + +
+ {#each [1, 2, 3] as i} + + {/each} +
+ {:else if activeLists.length === 0 && completedLists.length === 0} + +
+
+ +
+

{m.lists_no_lists()}

+

{m.lists_create_first()}

+
+ {:else} + + {#if featuredList} +
+ +
+
+ + + {m.list_status_active()} + +
+
+

+ {featuredList.name} +

+
+ +
+ + {#if openMenuId === featuredList.id} +
+ + + +
+ {/if} +
+
+ {/if} + + + {#if gridLists.length > 0} +
+ {#each gridLists as list (list.id)} +
+ +
+ +
+

+ {list.name} +

+
+
+ + {#if openMenuId === list.id} +
+ + + +
+ {/if} +
+
+ {/each} +
+ {/if} + + + {#if completedLists.length > 0} +
+

+ {m.list_status_completed()} +

+
+ {#each completedLists as list (list.id)} +
+ + + {list.name} + + + {#if openMenuId === list.id} +
+ + + +
+ {/if} +
+ {/each} +
+
+ {/if} + {/if} +
+ + +
+
+ + +
+
diff --git a/apps/web/src/routes/(app)/lists/[id]/+page.svelte b/apps/web/src/routes/(app)/lists/[id]/+page.svelte new file mode 100644 index 0000000..f022a8f --- /dev/null +++ b/apps/web/src/routes/(app)/lists/[id]/+page.svelte @@ -0,0 +1,581 @@ + + +{#if loading} +
+ {m.loading()} +
+{:else} +
+ +
+ + + +
+

+ {m.list_status_active()} +

+

+ {list?.name} +

+
+ +
+ + {#if showMenu} +
+ +
+ {/if} +
+
+ + +
+ {#if uncheckedItems.length === 0 && checkedItems.length === 0} +
+

{m.list_items_empty()}

+

{m.list_items_empty_hint()}

+
+ {:else} + + {#if uncheckedItems.length > 0} +
+ {#each uncheckedItems as item (item.id)} +
+ +
+ +
+ + +
onSwipeStart(e, item.id)} + onpointermove={(e) => onSwipeMove(e, item.id)} + onpointerup={(e) => onSwipeEnd(e, item.id)} + > + + + + + + + {#if editingId === item.id} + +
+ commitEdit(item)} + onkeydown={(e) => handleEditKeydown(e, item)} + class="flex-1 min-w-0 bg-transparent text-sm text-text-primary outline-none + border-b border-slate-300 dark:border-slate-600 pb-0.5" + /> + commitEdit(item)} + min="0" + placeholder={m.list_qty_label()} + class="w-14 bg-transparent text-sm text-text-secondary outline-none + border-b border-slate-300 dark:border-slate-600 pb-0.5 text-right" + /> + commitEdit(item)} + onkeydown={(e) => handleEditKeydown(e, item)} + placeholder={m.list_unit_label()} + class="w-14 bg-transparent text-sm text-text-secondary outline-none + border-b border-slate-300 dark:border-slate-600 pb-0.5" + /> +
+ {:else} + + + + +
+ + + {item.quantity ?? 1} + + +
+ + + + {/if} +
+
+ {/each} +
+ {/if} + + + {#if checkedItems.length > 0} +
+

+ {m.list_checked()} ({checkedItems.length}) +

+ {#each checkedItems as item (item.id)} +
+ + + + + + + + {item.name} + + + +
+ {/each} +
+ {/if} + {/if} +
+ + +
+ + + + +
+
+ + + +
+ +
+
+
+{/if} diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index 1d38607..d26f21e 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -7,6 +7,7 @@ export type Json = string | number | boolean | null | { [key: string]: Json | un export type LanguageCode = 'en' | 'es'; export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload'; export type MemberRoleEnum = 'admin' | 'member' | 'guest'; +export type ListStatusEnum = 'active' | 'completed' | 'archived'; export interface Database { public: { @@ -161,6 +162,102 @@ export interface Database { } ]; }; + shopping_lists: { + Row: { + id: string; + collective_id: string; + name: string; + status: ListStatusEnum; + created_by: string; + created_at: string; + completed_at: string | null; + deleted_at: string | null; + }; + Insert: { + id?: string; + collective_id: string; + name: string; + status?: ListStatusEnum; + created_by: string; + created_at?: string; + completed_at?: string | null; + deleted_at?: string | null; + }; + Update: { + id?: string; + collective_id?: string; + name?: string; + status?: ListStatusEnum; + created_by?: string; + created_at?: string; + completed_at?: string | null; + deleted_at?: string | null; + }; + Relationships: []; + }; + shopping_items: { + Row: { + id: string; + list_id: string; + name: string; + quantity: number | null; + unit: string | null; + is_checked: boolean; + checked_by: string | null; + checked_at: string | null; + sort_order: number; + created_by: string; + created_at: string; + }; + Insert: { + id?: string; + list_id: string; + name: string; + quantity?: number | null; + unit?: string | null; + is_checked?: boolean; + checked_by?: string | null; + checked_at?: string | null; + sort_order?: number; + created_by: string; + created_at?: string; + }; + Update: { + id?: string; + list_id?: string; + name?: string; + quantity?: number | null; + unit?: string | null; + is_checked?: boolean; + checked_by?: string | null; + checked_at?: string | null; + sort_order?: number; + created_by?: string; + created_at?: string; + }; + Relationships: []; + }; + item_frequency: { + Row: { + collective_id: string; + name: string; + use_count: number; + last_used_at: string; + }; + Insert: { + collective_id: string; + name: string; + use_count?: number; + last_used_at?: string; + }; + Update: { + collective_id?: string; + name?: string; + use_count?: number; + last_used_at?: string; + }; + Relationships: []; + }; }; Views: Record; Functions: { @@ -185,6 +282,7 @@ export interface Database { language_code: LanguageCode; avatar_type: AvatarTypeEnum; member_role: MemberRoleEnum; + list_status: ListStatusEnum; }; }; } diff --git a/packages/types/src/domain.ts b/packages/types/src/domain.ts index e1a784a..7e68f70 100644 --- a/packages/types/src/domain.ts +++ b/packages/types/src/domain.ts @@ -78,7 +78,6 @@ export interface ShoppingItem { sort_order: number; created_by: string; created_at: string; - deleted_at: string | null; } export interface ItemFrequency { diff --git a/supabase/migrations/005_shopping.sql b/supabase/migrations/005_shopping.sql new file mode 100644 index 0000000..375e160 --- /dev/null +++ b/supabase/migrations/005_shopping.sql @@ -0,0 +1,154 @@ +-- Migration 005: shopping_lists and shopping_items +-- +-- Trash model: shopping_lists has deleted_at for soft-delete (7-day recovery window). +-- shopping_items are hard-deleted — no trash, no recovery needed. + +-- ── Enum ────────────────────────────────────────────────────────────────────── + +CREATE TYPE public.list_status AS ENUM ('active', 'completed', 'archived'); + +-- ── shopping_lists ──────────────────────────────────────────────────────────── + +CREATE TABLE public.shopping_lists ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE, + name text NOT NULL, + status public.list_status NOT NULL DEFAULT 'active', + created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT, + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz NULL, + deleted_at timestamptz NULL -- soft delete; pg_cron purges after 7 days +); + +ALTER TABLE public.shopping_lists ENABLE ROW LEVEL SECURITY; + +-- Partial index: main query always filters by collective + status and excludes deleted rows +CREATE INDEX shopping_lists_collective_status_idx + ON public.shopping_lists (collective_id, status) + WHERE deleted_at IS NULL; + +-- ── shopping_items ──────────────────────────────────────────────────────────── + +CREATE TABLE public.shopping_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + list_id uuid NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE, + name text NOT NULL, + quantity numeric NULL, + unit text NULL, + is_checked boolean NOT NULL DEFAULT false, + checked_by uuid NULL REFERENCES public.users(id) ON DELETE SET NULL, + checked_at timestamptz NULL, + sort_order integer NOT NULL DEFAULT 0, + created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.shopping_items ENABLE ROW LEVEL SECURITY; + +-- Composite index for the main list view: all items for a list in display order +CREATE INDEX shopping_items_list_order_idx + ON public.shopping_items (list_id, sort_order); + +-- ── Helper: resolve collective for an item (used in RLS policies) ───────────── +-- STABLE lets PostgreSQL cache the result within a single query. + +CREATE OR REPLACE FUNCTION public.list_collective_id(p_list_id uuid) +RETURNS uuid +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT collective_id FROM public.shopping_lists WHERE id = p_list_id; +$$; + +-- ── RLS: shopping_lists ─────────────────────────────────────────────────────── + +-- All members (including guests) can see non-deleted lists, plus soft-deleted +-- lists within the 7-day recovery window (trash view). +CREATE POLICY shopping_lists_select + ON public.shopping_lists FOR SELECT + USING ( + public.is_member(collective_id) + AND ( + deleted_at IS NULL + OR deleted_at > now() - INTERVAL '7 days' + ) + ); + +-- Active members (admin + member) can create lists. +CREATE POLICY shopping_lists_insert + ON public.shopping_lists FOR INSERT + WITH CHECK ( + public.is_active_member(collective_id) + AND created_by = auth.uid() + ); + +-- Active members can update lists (rename, change status, set completed_at / deleted_at). +CREATE POLICY shopping_lists_update + ON public.shopping_lists FOR UPDATE + USING (public.is_active_member(collective_id)) + WITH CHECK (public.is_active_member(collective_id)); + +-- Active members can permanently hard-delete a list only once it has been soft-deleted. +-- pg_cron also purges old trash as superuser, bypassing RLS — intentional. +CREATE POLICY shopping_lists_delete + ON public.shopping_lists FOR DELETE + USING ( + public.is_active_member(collective_id) + AND deleted_at IS NOT NULL + ); + +-- ── RLS: shopping_items ─────────────────────────────────────────────────────── + +-- All members (including guests) can read items. +CREATE POLICY shopping_items_select + ON public.shopping_items FOR SELECT + USING (public.is_member(public.list_collective_id(list_id))); + +-- Active members can add items. +CREATE POLICY shopping_items_insert + ON public.shopping_items FOR INSERT + WITH CHECK ( + public.is_active_member(public.list_collective_id(list_id)) + AND created_by = auth.uid() + ); + +-- Active members can edit items (name, qty, unit, is_checked, sort_order, etc.). +CREATE POLICY shopping_items_update + ON public.shopping_items FOR UPDATE + USING (public.is_active_member(public.list_collective_id(list_id))) + WITH CHECK (public.is_active_member(public.list_collective_id(list_id))); + +-- Active members can hard-delete items (no trash for items). +CREATE POLICY shopping_items_delete + ON public.shopping_items FOR DELETE + USING (public.is_active_member(public.list_collective_id(list_id))); + +-- ── pg_cron jobs ────────────────────────────────────────────────────────────── +-- Supabase self-hosted ships with pg_cron enabled. +-- These jobs run as the database superuser and bypass RLS — intentional for maintenance. + +-- Job 1: Nightly — move 'completed' lists to 'archived' after they have been +-- completed for more than 7 days. +SELECT cron.schedule( + 'archive-completed-lists', + '0 3 * * *', + $$ + UPDATE public.shopping_lists + SET status = 'archived' + WHERE status = 'completed' + AND completed_at < now() - INTERVAL '7 days' + AND deleted_at IS NULL; + $$ +); + +-- Job 2: Nightly — permanently delete lists that have been in the trash for > 7 days. +SELECT cron.schedule( + 'purge-deleted-lists', + '5 3 * * *', + $$ + DELETE FROM public.shopping_lists + WHERE deleted_at < now() - INTERVAL '7 days'; + $$ +); diff --git a/supabase/migrations/006_item_frequency.sql b/supabase/migrations/006_item_frequency.sql new file mode 100644 index 0000000..d9d258b --- /dev/null +++ b/supabase/migrations/006_item_frequency.sql @@ -0,0 +1,70 @@ +-- Migration 006: item_frequency — per-collective item usage tracking. +-- +-- Write path: SECURITY DEFINER trigger only. The app role has SELECT access only. +-- Names are normalized (lower + trim) before upsert so "Milk" and "milk" are the same entry. + +-- ── item_frequency ──────────────────────────────────────────────────────────── + +CREATE TABLE public.item_frequency ( + collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE, + name text NOT NULL, -- normalized: lower(trim(original name)) + use_count integer NOT NULL DEFAULT 1, + last_used_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (collective_id, name) +); + +ALTER TABLE public.item_frequency ENABLE ROW LEVEL SECURITY; + +-- Index for the suggestion query: ordered by use_count descending per collective +CREATE INDEX item_frequency_collective_count_idx + ON public.item_frequency (collective_id, use_count DESC); + +-- ── RLS ─────────────────────────────────────────────────────────────────────── + +-- Members (any role) can read frequency data for collectives they belong to. +CREATE POLICY item_frequency_select + ON public.item_frequency FOR SELECT + USING (public.is_member(collective_id)); + +-- All direct writes are blocked; the SECURITY DEFINER trigger below is the only +-- write path. This prevents clients from inflating suggestion scores artificially. +CREATE POLICY item_frequency_insert_deny + ON public.item_frequency FOR INSERT + WITH CHECK (false); + +CREATE POLICY item_frequency_update_deny + ON public.item_frequency FOR UPDATE + USING (false); + +CREATE POLICY item_frequency_delete_deny + ON public.item_frequency FOR DELETE + USING (false); + +-- ── Trigger: upsert frequency on shopping_items insert ─────────────────────── + +CREATE OR REPLACE FUNCTION public.fn_record_item_frequency() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_collective_id uuid; +BEGIN + SELECT collective_id INTO v_collective_id + FROM public.shopping_lists + WHERE id = NEW.list_id; + + INSERT INTO public.item_frequency (collective_id, name, use_count, last_used_at) + VALUES (v_collective_id, lower(trim(NEW.name)), 1, now()) + ON CONFLICT (collective_id, name) DO UPDATE SET + use_count = item_frequency.use_count + 1, + last_used_at = now(); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_item_frequency + AFTER INSERT ON public.shopping_items + FOR EACH ROW EXECUTE FUNCTION public.fn_record_item_frequency();