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>
This commit is contained in:
154
supabase/migrations/005_shopping.sql
Normal file
154
supabase/migrations/005_shopping.sql
Normal file
@@ -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';
|
||||
$$
|
||||
);
|
||||
70
supabase/migrations/006_item_frequency.sql
Normal file
70
supabase/migrations/006_item_frequency.sql
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user