From 450233de89ba50356a2eacc44f667518a49cc0a8 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 22 Jun 2026 15:50:40 +0300 Subject: [PATCH] feat(contacts): add filter_contacts_by_tags RPC migration Server-side tag filter used by the Contacts page tag filter: join + DISTINCT (OR across tags) + windowed total count + LIMIT/OFFSET in one query, so a tag covering many contacts can't silently truncate results or skew the count. SECURITY INVOKER, so existing account RLS applies. Co-Authored-By: Claude Opus 4.8 --- .../025_filter_contacts_by_tags.sql | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 supabase/migrations/025_filter_contacts_by_tags.sql diff --git a/supabase/migrations/025_filter_contacts_by_tags.sql b/supabase/migrations/025_filter_contacts_by_tags.sql new file mode 100644 index 0000000..0ec0208 --- /dev/null +++ b/supabase/migrations/025_filter_contacts_by_tags.sql @@ -0,0 +1,75 @@ +-- ============================================================ +-- 025_filter_contacts_by_tags.sql — server-side tag filter +-- +-- Why an RPC +-- +-- The Contacts page filters by tag by resolving the selected +-- tags to contact ids and paging the result. Doing that on the +-- client (SELECT contact_id FROM contact_tags WHERE tag_id IN …, +-- then .in('id', ids) on contacts) hits two PostgREST limits for +-- accounts where a tag covers many contacts: +-- - the unbounded contact_tags select is silently capped +-- (~1000 rows), dropping contacts from the filter, and +-- - the follow-up .in('id', ids) pushes every matching id into +-- one IN-clause (the ~1000-value cap the broadcast sender +-- already pages around) and bloats the request URL. +-- +-- Both break the total count and pagination. This function does +-- the join, de-duplication (OR across tags), ordering, windowed +-- total count, and LIMIT/OFFSET in one query so the result is +-- always complete and correctly counted. +-- +-- Security +-- +-- SECURITY INVOKER (the default): the function runs as the +-- caller, so the existing RLS on `contacts` and `contact_tags` +-- (account membership, migration 017) scopes the result to the +-- caller's account. No privilege bypass — unlike the SECURITY +-- DEFINER member RPCs in 018/019. +-- +-- Idempotent — safe to run multiple times. +-- ============================================================ + +CREATE OR REPLACE FUNCTION public.filter_contacts_by_tags( + p_tag_ids UUID[], + p_search TEXT DEFAULT NULL, + p_limit INT DEFAULT 25, + p_offset INT DEFAULT 0 +) +RETURNS TABLE (contact contacts, total_count BIGINT) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = public +AS $$ + WITH matched AS ( + -- Distinct contacts having ANY of the selected tags (OR), + -- narrowed by the same name/phone/email search as the list. + SELECT DISTINCT c.id, c.created_at + FROM contacts c + JOIN contact_tags ct ON ct.contact_id = c.id + WHERE ct.tag_id = ANY(p_tag_ids) + AND ( + p_search IS NULL + OR c.name ILIKE '%' || p_search || '%' + OR c.phone ILIKE '%' || p_search || '%' + OR c.email ILIKE '%' || p_search || '%' + ) + ), + page AS ( + -- count(*) OVER() is evaluated before LIMIT, so it is the full + -- match total regardless of the page being returned. + SELECT id, count(*) OVER() AS total_count + FROM matched + ORDER BY created_at DESC, id + LIMIT p_limit OFFSET p_offset + ) + SELECT c AS contact, page.total_count + FROM page + JOIN contacts c ON c.id = page.id + ORDER BY c.created_at DESC, c.id; +$$; + +ALTER FUNCTION public.filter_contacts_by_tags(UUID[], TEXT, INT, INT) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.filter_contacts_by_tags(UUID[], TEXT, INT, INT) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.filter_contacts_by_tags(UUID[], TEXT, INT, INT) TO authenticated;