-- 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'; $$ );