feat(fase-10): trash drawer — restore + hard-delete RPCs (10.4)
Migration 020 (019 reserved per plan) introduces two SECURITY DEFINER RPCs that back the trash drawer's per-row actions: - restore_list(uuid): clears deleted_at; requires caller to be admin or member of the owning collective; rejects if the list is not currently in trash (defensive) - hard_delete_list(uuid): permanently deletes a soft-deleted list; same role guard; rejects if the list is still active Both close the existing UI gap where guest could in principle issue the equivalent UPDATE (the FROM-UPDATE policy is is_active_member, which excludes guest, but the surface area is broader than these two intents — the RPCs make the contract explicit). The lists drawer already exposed the Restore + Delete-for-ever buttons; only the store calls swap from chained PostgREST writes to .rpc() — the UI itself is unchanged. pgTAP 012 (8 assertions) covers existence, restore happy path, non-member rejection, hard-delete happy path, active-list guard. Integration T-10..T-14 (5 vitest) verifies the wire format the store relies on (rpc errors, row state, active-listing visibility). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
104
supabase/migrations/020_restore_list_rpc.sql
Normal file
104
supabase/migrations/020_restore_list_rpc.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
-- Migration 020: restore_list() + hard_delete_list() RPCs — Fase 10.4.
|
||||
-- (Migration number 019 intentionally reserved; see plan/fase-10-spec-completion.md.)
|
||||
--
|
||||
-- Both functions are SECURITY DEFINER and guard explicitly on caller
|
||||
-- membership (admin or member; guests cannot mutate). They give the
|
||||
-- trash drawer a single round-trip that does what it says, instead of
|
||||
-- chained PostgREST UPDATEs (which evaluate SELECT RLS on the returned
|
||||
-- row — see CLAUDE.md "Backend / RLS / Kong" gotcha).
|
||||
|
||||
-- ── restore_list ─────────────────────────────────────────────────────────────
|
||||
-- Clear deleted_at on a soft-deleted list. Rejects if the list is not
|
||||
-- currently soft-deleted (defensive — restore on an active list is a no-op
|
||||
-- but tests can rely on the throw to detect logic errors).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.restore_list(p_list_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, auth
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user uuid := auth.uid();
|
||||
v_collective uuid;
|
||||
v_role public.member_role;
|
||||
v_deleted timestamptz;
|
||||
BEGIN
|
||||
IF v_user IS NULL THEN
|
||||
RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
SELECT collective_id, deleted_at INTO v_collective, v_deleted
|
||||
FROM public.shopping_lists
|
||||
WHERE id = p_list_id;
|
||||
|
||||
IF v_collective IS NULL THEN
|
||||
RAISE EXCEPTION 'list not found' USING ERRCODE = 'P0404';
|
||||
END IF;
|
||||
|
||||
SELECT role INTO v_role
|
||||
FROM public.collective_members
|
||||
WHERE collective_id = v_collective AND user_id = v_user;
|
||||
|
||||
IF v_role IS NULL OR v_role = 'guest' THEN
|
||||
RAISE EXCEPTION 'not allowed' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF v_deleted IS NULL THEN
|
||||
RAISE EXCEPTION 'list is not in trash' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
UPDATE public.shopping_lists SET deleted_at = NULL WHERE id = p_list_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.restore_list(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.restore_list(uuid) TO authenticated;
|
||||
|
||||
-- ── hard_delete_list ─────────────────────────────────────────────────────────
|
||||
-- Permanently delete a list that is already in the trash. Rejects if the
|
||||
-- list is still active (i.e. not soft-deleted yet) — the user must trash
|
||||
-- it first. shopping_items cascade on the FK.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.hard_delete_list(p_list_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, auth
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user uuid := auth.uid();
|
||||
v_collective uuid;
|
||||
v_role public.member_role;
|
||||
v_deleted timestamptz;
|
||||
BEGIN
|
||||
IF v_user IS NULL THEN
|
||||
RAISE EXCEPTION 'not authenticated' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
SELECT collective_id, deleted_at INTO v_collective, v_deleted
|
||||
FROM public.shopping_lists
|
||||
WHERE id = p_list_id;
|
||||
|
||||
IF v_collective IS NULL THEN
|
||||
RAISE EXCEPTION 'list not found' USING ERRCODE = 'P0404';
|
||||
END IF;
|
||||
|
||||
SELECT role INTO v_role
|
||||
FROM public.collective_members
|
||||
WHERE collective_id = v_collective AND user_id = v_user;
|
||||
|
||||
IF v_role IS NULL OR v_role = 'guest' THEN
|
||||
RAISE EXCEPTION 'not allowed' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF v_deleted IS NULL THEN
|
||||
RAISE EXCEPTION 'list is not in trash' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.shopping_lists WHERE id = p_list_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.hard_delete_list(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.hard_delete_list(uuid) TO authenticated;
|
||||
Reference in New Issue
Block a user