feat(fase-15): item_frequency.weight + admin RPCs (15.1)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -416,18 +416,21 @@ export interface Database {
|
|||||||
collective_id: string;
|
collective_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
use_count: number;
|
use_count: number;
|
||||||
|
weight: number;
|
||||||
last_used_at: string;
|
last_used_at: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
collective_id: string;
|
collective_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
use_count?: number;
|
use_count?: number;
|
||||||
|
weight?: number;
|
||||||
last_used_at?: string;
|
last_used_at?: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
collective_id?: string;
|
collective_id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
use_count?: number;
|
use_count?: number;
|
||||||
|
weight?: number;
|
||||||
last_used_at?: string;
|
last_used_at?: string;
|
||||||
};
|
};
|
||||||
Relationships: [];
|
Relationships: [];
|
||||||
@@ -618,6 +621,14 @@ export interface Database {
|
|||||||
Args: { p_section: string };
|
Args: { p_section: string };
|
||||||
Returns: void;
|
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: {
|
Enums: {
|
||||||
language_code: LanguageCode;
|
language_code: LanguageCode;
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export interface ItemFrequency {
|
|||||||
collective_id: string;
|
collective_id: string;
|
||||||
name: string; // normalized: lower(trim())
|
name: string; // normalized: lower(trim())
|
||||||
use_count: number;
|
use_count: number;
|
||||||
|
weight: number; // Fase 15: admin curation knob (-50/0/50 via the UI, any int in DB)
|
||||||
last_used_at: string;
|
last_used_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
104
supabase/migrations/026_item_frequency_weight.sql
Normal file
104
supabase/migrations/026_item_frequency_weight.sql
Normal file
@@ -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;
|
||||||
108
supabase/tests/018_item_frequency_weight.sql
Normal file
108
supabase/tests/018_item_frequency_weight.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user