From 5563afd04c8c20abb3b42742bcededb26773ca55 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 11:15:20 +0200 Subject: [PATCH] feat(fase-15): item_frequency.weight + admin RPCs (15.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-collective curation knob on top of the existing use_count ordering. The UI exposes a 3-way Hide/Normal/Boost control that writes weight=-50/0/50 via the new SECURITY DEFINER RPC; the column itself is unconstrained so a future granularity tier doesn't need a schema diff. Both new RPCs (set_item_frequency_weight, purge_item_frequency) gate on collective_members.role='admin'. The all-deny RLS from migration 006 is preserved — the RPCs are the only write path. The trigger that fires on shopping_items INSERT keeps backfilling weight=0 via the column default. Re-create the suggestion index to lead with weight DESC so the dropdown query can scan in order without a sort step. pgTAP test 018 covers the column/index/RPC schema invariants + the default-from-trigger behaviour (11 assertions). Role-gate denial-for-non- admin is covered by the Vitest integration suite (next commit) — pgTAP runs as postgres so the auth.uid() guard can't be exercised here. Co-Authored-By: Claude Opus 4.7 --- packages/types/src/database.ts | 11 ++ packages/types/src/domain.ts | 1 + .../migrations/026_item_frequency_weight.sql | 104 +++++++++++++++++ supabase/tests/018_item_frequency_weight.sql | 108 ++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 supabase/migrations/026_item_frequency_weight.sql create mode 100644 supabase/tests/018_item_frequency_weight.sql diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index fc32a2e..f710fb5 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -416,18 +416,21 @@ export interface Database { collective_id: string; name: string; use_count: number; + weight: number; last_used_at: string; }; Insert: { collective_id: string; name: string; use_count?: number; + weight?: number; last_used_at?: string; }; Update: { collective_id?: string; name?: string; use_count?: number; + weight?: number; last_used_at?: string; }; Relationships: []; @@ -618,6 +621,14 @@ export interface Database { Args: { p_section: string }; Returns: void; }; + set_item_frequency_weight: { + Args: { p_collective_id: string; p_name: string; p_weight: number }; + Returns: void; + }; + purge_item_frequency: { + Args: { p_collective_id: string; p_name: string }; + Returns: void; + }; }; Enums: { language_code: LanguageCode; diff --git a/packages/types/src/domain.ts b/packages/types/src/domain.ts index e72144d..09e0c16 100644 --- a/packages/types/src/domain.ts +++ b/packages/types/src/domain.ts @@ -92,6 +92,7 @@ export interface ItemFrequency { collective_id: string; name: string; // normalized: lower(trim()) use_count: number; + weight: number; // Fase 15: admin curation knob (-50/0/50 via the UI, any int in DB) last_used_at: string; } diff --git a/supabase/migrations/026_item_frequency_weight.sql b/supabase/migrations/026_item_frequency_weight.sql new file mode 100644 index 0000000..342a71f --- /dev/null +++ b/supabase/migrations/026_item_frequency_weight.sql @@ -0,0 +1,104 @@ +-- Migration 026: item_frequency.weight column + admin-only RPCs (Fase 15.1) +-- +-- Adds a curation knob on top of the existing `use_count` suggestion ordering. +-- Admins can promote (positive weight) or hide (negative weight) specific +-- items in the per-collective suggestion dropdown. The client UI exposes a +-- 3-way Hide / Normal / Boost control mapped to -50 / 0 / 50, but the column +-- accepts any integer so future granularity (e.g. "strong boost") does not +-- require a schema migration. +-- +-- Write path: same all-deny RLS as migration 006 — clients never UPDATE the +-- column directly. The two SECURITY DEFINER RPCs below are the only entry +-- points. Both gate on `collective_members.role = 'admin'`. + +ALTER TABLE public.item_frequency + ADD COLUMN weight integer NOT NULL DEFAULT 0; + +-- Re-create the suggestion index to include weight first. The old index was a +-- prefix of this one — drop it so we don't carry two overlapping indexes. +DROP INDEX IF EXISTS public.item_frequency_collective_count_idx; + +CREATE INDEX item_frequency_collective_order_idx + ON public.item_frequency (collective_id, weight DESC, use_count DESC, last_used_at DESC); + +-- ── RPC: admin upsert of weight ───────────────────────────────────────────── +-- Upsert semantics: if the (collective_id, name) row doesn't exist yet, +-- insert it with use_count=0 and the requested weight. Useful for seeding +-- the catalog before any list actually mentions the item. + +CREATE OR REPLACE FUNCTION public.set_item_frequency_weight( + p_collective_id uuid, + p_name text, + p_weight integer +) RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + v_role text; + v_norm text := lower(trim(p_name)); +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'not authenticated' USING ERRCODE = 'P0001'; + END IF; + IF v_norm = '' THEN + RAISE EXCEPTION 'name must not be empty' USING ERRCODE = 'P0001'; + END IF; + + SELECT role INTO v_role + FROM public.collective_members + WHERE collective_id = p_collective_id + AND user_id = auth.uid(); + + IF v_role IS DISTINCT FROM 'admin' THEN + RAISE EXCEPTION 'only admins can manage common items' USING ERRCODE = 'P0002'; + END IF; + + INSERT INTO public.item_frequency (collective_id, name, use_count, weight, last_used_at) + VALUES (p_collective_id, v_norm, 0, p_weight, now()) + ON CONFLICT (collective_id, name) DO UPDATE SET + weight = EXCLUDED.weight; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.set_item_frequency_weight(uuid, text, integer) + TO authenticated; + +-- ── RPC: admin purge ──────────────────────────────────────────────────────── +-- Removes the row from item_frequency only. Does NOT touch shopping_items +-- (the existing items in lists stay; future inserts will recreate this row +-- with weight=0 via the existing trigger). + +CREATE OR REPLACE FUNCTION public.purge_item_frequency( + p_collective_id uuid, + p_name text +) RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + v_role text; + v_norm text := lower(trim(p_name)); +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'not authenticated' USING ERRCODE = 'P0001'; + END IF; + + SELECT role INTO v_role + FROM public.collective_members + WHERE collective_id = p_collective_id + AND user_id = auth.uid(); + + IF v_role IS DISTINCT FROM 'admin' THEN + RAISE EXCEPTION 'only admins can purge common items' USING ERRCODE = 'P0002'; + END IF; + + DELETE FROM public.item_frequency + WHERE collective_id = p_collective_id + AND name = v_norm; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.purge_item_frequency(uuid, text) TO authenticated; diff --git a/supabase/tests/018_item_frequency_weight.sql b/supabase/tests/018_item_frequency_weight.sql new file mode 100644 index 0000000..39d2ba2 --- /dev/null +++ b/supabase/tests/018_item_frequency_weight.sql @@ -0,0 +1,108 @@ +-- pgTAP: item_frequency.weight + set_item_frequency_weight + purge_item_frequency +-- (Fase 15.1) +-- Run with: psql -U postgres -d postgres -f supabase/tests/018_item_frequency_weight.sql +-- +-- Schema invariants for the new weight column and the two SECURITY DEFINER +-- RPCs that gate writes (admin-only). Behavioural denial-for-non-admin runs +-- via the postgres superuser here, so we cannot exercise the role gate from +-- inside pgTAP (postgres bypasses the function's `auth.uid()` check by +-- returning NULL); the role-gate behaviour for member / guest is covered by +-- the Vitest integration suite (common-items.test.ts). + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(11); + +-- ── Schema invariants ─────────────────────────────────────────────────────── + +SELECT has_column( + 'public', 'item_frequency', 'weight', + 'IFW-T01: item_frequency.weight column exists' +); + +SELECT col_type_is( + 'public', 'item_frequency', 'weight', 'integer', + 'IFW-T02: item_frequency.weight is integer' +); + +SELECT col_not_null( + 'public', 'item_frequency', 'weight', + 'IFW-T03: item_frequency.weight is NOT NULL' +); + +SELECT col_default_is( + 'public', 'item_frequency', 'weight', '0', + 'IFW-T04: item_frequency.weight defaults to 0' +); + +-- IFW-T05: the suggestion ordering index includes weight first. +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'item_frequency' + AND indexname = 'item_frequency_collective_order_idx' + ), + 'IFW-T05: item_frequency_collective_order_idx exists' +); + +-- IFW-T06: the old index name is gone (replaced by the order-idx above). +SELECT ok( + NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'item_frequency' + AND indexname = 'item_frequency_collective_count_idx' + ), + 'IFW-T06: old item_frequency_collective_count_idx was dropped' +); + +-- ── RPC existence + SECURITY DEFINER posture ─────────────────────────────── + +SELECT has_function( + 'public', 'set_item_frequency_weight', ARRAY['uuid', 'text', 'integer'], + 'IFW-T07: set_item_frequency_weight(uuid, text, integer) exists' +); + +SELECT has_function( + 'public', 'purge_item_frequency', ARRAY['uuid', 'text'], + 'IFW-T08: purge_item_frequency(uuid, text) exists' +); + +SELECT ok( + (SELECT prosecdef FROM pg_proc + WHERE proname = 'set_item_frequency_weight' + AND pronamespace = 'public'::regnamespace), + 'IFW-T09: set_item_frequency_weight is SECURITY DEFINER' +); + +SELECT ok( + (SELECT prosecdef FROM pg_proc + WHERE proname = 'purge_item_frequency' + AND pronamespace = 'public'::regnamespace), + 'IFW-T10: purge_item_frequency is SECURITY DEFINER' +); + +-- ── Behaviour: trigger-inserted row gets weight=0 default ─────────────────── +-- The existing trigger only writes (collective_id, name, use_count, last_used_at) +-- so the new weight column must take the column default. + +INSERT INTO public.shopping_items (list_id, name, sort_order, created_by) +VALUES ( + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'IFW-trigger-default-item', + 9001, + '11111111-1111-1111-1111-111111111111' +); + +SELECT is( + (SELECT weight FROM public.item_frequency + WHERE collective_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + AND name = 'ifw-trigger-default-item'), + 0, + 'IFW-T11: trigger-inserted item_frequency row gets weight=0 default' +); + +SELECT * FROM finish(); +ROLLBACK;