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()}
-
+
+
-
-
+
+
+
+ {#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}
+
+
+
+
+
+
+ {#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)}
+
+
+
+
+
+
+
+
+
+ {/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();