-- Migration 010: full-text search — tsvector columns + GIN indexes + search function -- -- Search uses `simple` text-search config (no stemming) — the app is bilingual -- (en + es) and a stemmer tuned to one language degrades the other. Clients can -- still match partial words by appending `:*` to the query. -- -- Each domain table (`shopping_items`, `tasks`, `notes`) gets a STORED GENERATED -- `search` column backed by a GIN index. Generated-always means Postgres keeps -- the vector in sync with every INSERT/UPDATE — no trigger needed. -- -- `search_in_collective()` runs SECURITY INVOKER so RLS still applies. Trash is -- excluded per RN-08 (notes via `deleted_at IS NULL`; items via the parent list's -- `deleted_at`; tasks have no trash). -- ── Generated tsvector columns ─────────────────────────────────────────────── ALTER TABLE public.shopping_items ADD COLUMN search tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED; CREATE INDEX shopping_items_search_idx ON public.shopping_items USING GIN (search); ALTER TABLE public.tasks ADD COLUMN search tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title, ''))) STORED; CREATE INDEX tasks_search_idx ON public.tasks USING GIN (search); ALTER TABLE public.notes ADD COLUMN search tsvector GENERATED ALWAYS AS ( to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(content, '')) ) STORED; CREATE INDEX notes_search_idx ON public.notes USING GIN (search); -- ── Search function ────────────────────────────────────────────────────────── CREATE TYPE public.search_result_type AS ENUM ('shopping_item', 'task', 'note'); DROP FUNCTION IF EXISTS public.search_in_collective(uuid, text, public.search_result_type[], uuid, timestamptz, timestamptz); CREATE OR REPLACE FUNCTION public.search_in_collective( p_collective_id uuid, p_query text, p_types public.search_result_type[] DEFAULT NULL, p_creator uuid DEFAULT NULL, p_from timestamptz DEFAULT NULL, p_to timestamptz DEFAULT NULL ) RETURNS TABLE ( id uuid, result_type public.search_result_type, title text, snippet text, collective_id uuid, list_id uuid, created_by uuid, created_at timestamptz, rank real ) LANGUAGE sql STABLE SECURITY INVOKER SET search_path = public AS $$ WITH q AS ( SELECT websearch_to_tsquery('simple', coalesce(p_query, '')) AS query ) -- shopping_items SELECT si.id, 'shopping_item'::public.search_result_type, si.name AS title, si.name AS snippet, sl.collective_id, sl.id AS list_id, si.created_by, si.created_at, ts_rank(si.search, q.query)::real AS rank FROM public.shopping_items si JOIN public.shopping_lists sl ON sl.id = si.list_id CROSS JOIN q WHERE sl.collective_id = p_collective_id AND sl.deleted_at IS NULL AND (p_types IS NULL OR 'shopping_item' = ANY (p_types)) AND (p_creator IS NULL OR si.created_by = p_creator) AND (p_from IS NULL OR si.created_at >= p_from) AND (p_to IS NULL OR si.created_at <= p_to) AND si.search @@ q.query UNION ALL -- tasks SELECT t.id, 'task'::public.search_result_type, t.title AS title, t.title AS snippet, tl.collective_id, tl.id AS list_id, t.created_by, t.created_at, ts_rank(t.search, q.query)::real AS rank FROM public.tasks t JOIN public.task_lists tl ON tl.id = t.list_id CROSS JOIN q WHERE tl.collective_id = p_collective_id AND (p_types IS NULL OR 'task' = ANY (p_types)) AND (p_creator IS NULL OR t.created_by = p_creator) AND (p_from IS NULL OR t.created_at >= p_from) AND (p_to IS NULL OR t.created_at <= p_to) AND t.search @@ q.query UNION ALL -- notes (trash and permanently deleted excluded by deleted_at filter; per -- analysis RN-08 search does not return trashed content) SELECT n.id, 'note'::public.search_result_type, n.title AS title, left(coalesce(n.content, ''), 160) AS snippet, n.collective_id, NULL::uuid AS list_id, n.created_by, n.created_at, ts_rank(n.search, q.query)::real AS rank FROM public.notes n CROSS JOIN q WHERE n.collective_id = p_collective_id AND n.deleted_at IS NULL AND (p_types IS NULL OR 'note' = ANY (p_types)) AND (p_creator IS NULL OR n.created_by = p_creator) AND (p_from IS NULL OR n.created_at >= p_from) AND (p_to IS NULL OR n.created_at <= p_to) AND n.search @@ q.query ORDER BY rank DESC, created_at DESC LIMIT 200; $$; -- Allow the anon + authenticated roles to call the function (RLS still filters rows). GRANT EXECUTE ON FUNCTION public.search_in_collective(uuid, text, public.search_result_type[], uuid, timestamptz, timestamptz) TO anon, authenticated;