Files
collective-lists/supabase/migrations/005_shopping.sql
Oier Bravo Urtasun 3af1276c15 feat: Fase 2a — shopping lists CRUD
DB: shopping_lists + shopping_items tables with full RLS, pg_cron jobs
(archive completed lists after 7 days, purge trash after 7 days).
item_frequency table with SECURITY DEFINER trigger that normalises and
upserts on every shopping_items INSERT.

Frontend: /lists overview (featured card + 2-col grid + trash toggle),
/lists/[id] detail (qty stepper, inline edit, swipe-to-reveal-delete,
drag & drop reorder via svelte-dnd-action, ItemSuggestions chips).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 22:25:31 +02:00

155 lines
6.6 KiB
PL/PgSQL

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