-- Mobile template, memo-tag, and generated meeting-document parity. -- All user mutations are owner-scoped. Builtins are copied per account so the -- cloud row is the SSOT while their stable desktop keys and content remain intact. CREATE OR REPLACE FUNCTION public.is_valid_dictation_template_fields_v1(p_fields jsonb) RETURNS boolean LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog AS $$ DECLARE field_value jsonb; BEGIN IF p_fields IS NULL OR jsonb_typeof(p_fields) <> 'array' OR jsonb_array_length(p_fields) NOT BETWEEN 1 AND 50 THEN RETURN false; END IF; FOR field_value IN SELECT value FROM jsonb_array_elements(p_fields) LOOP IF jsonb_typeof(field_value) <> 'object' OR jsonb_typeof(field_value -> 'id') <> 'string' OR char_length(field_value ->> 'id') NOT BETWEEN 1 AND 80 OR (field_value ->> 'id') !~ '^[A-Za-z][A-Za-z0-9_-]*$' OR jsonb_typeof(field_value -> 'name') <> 'string' OR char_length(field_value ->> 'name') NOT BETWEEN 1 AND 80 OR jsonb_typeof(field_value -> 'label') <> 'string' OR char_length(field_value ->> 'label') NOT BETWEEN 1 AND 120 OR jsonb_typeof(field_value -> 'promptText') <> 'string' OR char_length(field_value ->> 'promptText') NOT BETWEEN 1 AND 500 OR jsonb_typeof(field_value -> 'required') <> 'boolean' OR jsonb_typeof(field_value -> 'maxDurationSec') <> 'number' OR ((field_value ->> 'maxDurationSec')::numeric % 1) <> 0 OR (field_value ->> 'maxDurationSec')::integer NOT BETWEEN 1 AND 1800 OR field_value - ARRAY['id', 'name', 'label', 'promptText', 'required', 'maxDurationSec']::text[] <> '{}'::jsonb THEN RETURN false; END IF; END LOOP; IF ( SELECT count(*) = count(DISTINCT lower(value ->> 'id')) FROM jsonb_array_elements(p_fields) ) IS NOT TRUE THEN RETURN false; END IF; IF ( SELECT count(*) = count(DISTINCT lower(value ->> 'name')) FROM jsonb_array_elements(p_fields) ) IS NOT TRUE THEN RETURN false; END IF; RETURN true; EXCEPTION WHEN invalid_text_representation OR numeric_value_out_of_range THEN RETURN false; END; $$; REVOKE ALL ON FUNCTION public.is_valid_dictation_template_fields_v1(jsonb) FROM PUBLIC, anon, authenticated; CREATE TABLE public.user_templates ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, template_kind text NOT NULL CHECK (template_kind IN ('dictation', 'meeting_document')), builtin_key text, name text NOT NULL CHECK (char_length(trim(name)) BETWEEN 1 AND 120), description text CHECK (description IS NULL OR char_length(description) <= 1000), fields jsonb NOT NULL DEFAULT '[]'::jsonb, output_format text, template_type text CHECK (template_type IS NULL OR template_type IN ('minutes', 'report', 'idea-note', 'custom', 'mindmap')), system_prompt text, is_builtin boolean NOT NULL DEFAULT false, revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (id, user_id), CHECK ( (is_builtin AND builtin_key IS NOT NULL AND builtin_key ~ '^builtin-[a-z0-9-]+$') OR (NOT is_builtin AND builtin_key IS NULL) ), CHECK ( ( template_kind = 'dictation' AND public.is_valid_dictation_template_fields_v1(fields) AND output_format IS NOT NULL AND char_length(output_format) BETWEEN 1 AND 20000 AND template_type IS NULL AND system_prompt IS NULL ) OR ( template_kind = 'meeting_document' AND fields = '[]'::jsonb AND output_format IS NULL AND template_type IS NOT NULL AND system_prompt IS NOT NULL AND char_length(system_prompt) BETWEEN 1 AND 12000 AND (is_builtin OR template_type = 'custom') ) ) ); CREATE UNIQUE INDEX user_templates_builtin_key_unique ON public.user_templates(user_id, template_kind, builtin_key) WHERE builtin_key IS NOT NULL; CREATE INDEX user_templates_owner_kind_updated ON public.user_templates(user_id, template_kind, updated_at DESC, id DESC); CREATE INDEX user_templates_owner_name_search ON public.user_templates(user_id, template_kind, lower(name)); CREATE TABLE public.user_template_selections ( user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, template_kind text NOT NULL CHECK (template_kind IN ('dictation', 'meeting_document')), template_id uuid NOT NULL, revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (user_id, template_kind), FOREIGN KEY (template_id, user_id) REFERENCES public.user_templates(id, user_id) ON DELETE RESTRICT ); ALTER TABLE public.user_templates ENABLE ROW LEVEL SECURITY; ALTER TABLE public.user_template_selections ENABLE ROW LEVEL SECURITY; CREATE POLICY user_templates_read_own ON public.user_templates FOR SELECT TO authenticated USING (user_id = auth.uid()); CREATE POLICY user_template_selections_read_own ON public.user_template_selections FOR SELECT TO authenticated USING (user_id = auth.uid()); REVOKE ALL ON TABLE public.user_templates FROM PUBLIC, anon, authenticated; REVOKE ALL ON TABLE public.user_template_selections FROM PUBLIC, anon, authenticated; GRANT SELECT ON TABLE public.user_templates TO authenticated; GRANT SELECT ON TABLE public.user_template_selections TO authenticated; CREATE OR REPLACE FUNCTION public.bootstrap_user_templates_v1() RETURNS SETOF public.user_templates LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; INSERT INTO public.user_templates( user_id, template_kind, builtin_key, name, description, fields, output_format, template_type, system_prompt, is_builtin ) VALUES ( actor_id, 'dictation', 'builtin-email', 'Email', 'Email template with recipient, subject, and body', '[{"id":"recipient","name":"recipient","label":"Recipient","promptText":"Who is this email for?","required":true,"maxDurationSec":15},{"id":"subject","name":"subject","label":"Subject","promptText":"What is the subject?","required":true,"maxDurationSec":15},{"id":"body","name":"body","label":"Body","promptText":"Please dictate the email body.","required":true,"maxDurationSec":120}]'::jsonb, E'To: {{recipient}}\nSubject: {{subject}}\n\n{{body}}', NULL, NULL, true ), ( actor_id, 'dictation', 'builtin-meeting-notes', 'Meeting Notes', 'Meeting notes template', '[{"id":"title","name":"title","label":"Title","promptText":"What is the meeting title?","required":true,"maxDurationSec":15},{"id":"attendees","name":"attendees","label":"Attendees","promptText":"Who attended?","required":false,"maxDurationSec":30},{"id":"agenda","name":"agenda","label":"Agenda","promptText":"What was discussed?","required":true,"maxDurationSec":120},{"id":"decisions","name":"decisions","label":"Decisions","promptText":"What decisions were made?","required":false,"maxDurationSec":60}]'::jsonb, E'# {{title}}\n\nAttendees: {{attendees}}\n\n## Agenda\n{{agenda}}\n\n## Decisions\n{{decisions}}', NULL, NULL, true ), ( actor_id, 'dictation', 'builtin-report', 'Report', 'Simple report template', '[{"id":"title","name":"title","label":"Title","promptText":"Report title?","required":true,"maxDurationSec":15},{"id":"summary","name":"summary","label":"Summary","promptText":"Summarize the key points.","required":true,"maxDurationSec":60},{"id":"details","name":"details","label":"Details","promptText":"Provide the details.","required":true,"maxDurationSec":180}]'::jsonb, E'# {{title}}\n\n## Summary\n{{summary}}\n\n## Details\n{{details}}', NULL, NULL, true ), ( actor_id, 'meeting_document', 'builtin-minutes', '회의록', '결정사항, 할 일, 타임라인 구조의 표준 회의록', '[]'::jsonb, NULL, 'minutes', $minutes$당신은 전문 회의록 작성 비서입니다. 전사록을 분석하여 구조화된 회의록을 작성합니다. 규칙: - 전사록에 없는 내용을 추가하지 마세요 - 시간 순서를 유지하세요 - 한국어로 작성하세요 (원문이 영어면 원어 유지) 출력 형식: ## 요약 (3-5줄 핵심 요약) ## 핵심 결정사항 - (결정 1) - (결정 2) ## 할 일 목록 - [ ] (담당자가 있으면 포함) (할 일) ## 타임라인 | 시간 | 내용 | |------|------| | MM:SS | 주요 발언/이벤트 |$minutes$, true ), ( actor_id, 'meeting_document', 'builtin-report', '보고서', '개요, 핵심 내용, 결론, 제안 구조의 보고서', '[]'::jsonb, NULL, 'report', $report$당신은 전문 비즈니스 보고서 작성 비서입니다. 회의 전사록을 분석하여 체계적인 보고서를 작성합니다. 규칙: - 전사록에 없는 내용을 추가하지 마세요 - 사실 기반으로 작성하세요 - 한국어로 작성하세요 출력 형식: ## 개요 (회의 목적과 배경 2-3줄) ## 핵심 내용 - (주요 논의 사항 1) - (주요 논의 사항 2) - (주요 논의 사항 3) ## 결론 (합의된 결론 및 방향 2-3줄) ## 제안사항 - (제안 1) - (제안 2)$report$, true ), ( actor_id, 'meeting_document', 'builtin-idea-note', '아이디어 노트', '핵심 아이디어, 장단점, 우선순위, 다음 단계 구조', '[]'::jsonb, NULL, 'idea-note', $idea$당신은 창의적 아이디어 정리 전문가입니다. 회의 전사록에서 아이디어와 제안을 추출하여 정리합니다. 규칙: - 전사록에 등장한 아이디어만 정리하세요 - 실행 가능성 중심으로 평가하세요 - 한국어로 작성하세요 출력 형식: ## 핵심 아이디어 - (아이디어 1) - (아이디어 2) ## 장점 - (장점 1) - (장점 2) ## 단점 / 리스크 - (단점 1) - (단점 2) ## 우선순위 1. (최우선 항목) 2. (차순위 항목) ## 다음 단계 - [ ] (액션 아이템 1) - [ ] (액션 아이템 2)$idea$, true ), ( actor_id, 'meeting_document', 'builtin-mindmap', '마인드맵', '전사 내용의 핵심 구조를 시각적 마인드맵으로 정리', '[]'::jsonb, NULL, 'mindmap', $mindmap$전사록을 분석하여 마인드맵을 마크다운 계층 구조로 작성하세요. # 중심 주제 (회의의 핵심 주제) ## 주요 주제 1 - 세부 항목 A - 세부 항목 B - 하위 항목 ## 주요 주제 2 - 세부 항목 C ## 결론 및 다음 단계 - 액션 아이템 규칙: - 계층은 최대 4단계까지 - 각 항목은 간결하게 (한 줄) - 전사록에 없는 내용 추가 금지 - 한국어로 작성$mindmap$, true ) ON CONFLICT (user_id, template_kind, builtin_key) WHERE builtin_key IS NOT NULL DO NOTHING; INSERT INTO public.user_template_selections(user_id, template_kind, template_id) SELECT actor_id, seed.template_kind, template.id FROM (VALUES ('dictation'::text, 'builtin-email'::text), ('meeting_document'::text, 'builtin-minutes'::text) ) AS seed(template_kind, builtin_key) JOIN public.user_templates AS template ON template.user_id = actor_id AND template.template_kind = seed.template_kind AND template.builtin_key = seed.builtin_key ON CONFLICT (user_id, template_kind) DO NOTHING; RETURN QUERY SELECT template.* FROM public.user_templates AS template WHERE template.user_id = actor_id ORDER BY template.template_kind, template.is_builtin DESC, template.created_at, template.id; END; $$; REVOKE ALL ON FUNCTION public.bootstrap_user_templates_v1() FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.bootstrap_user_templates_v1() TO authenticated; CREATE OR REPLACE FUNCTION public.create_user_template_v1( p_template_kind text, p_name text, p_description text DEFAULT NULL, p_fields jsonb DEFAULT '[]'::jsonb, p_output_format text DEFAULT NULL, p_system_prompt text DEFAULT NULL ) RETURNS public.user_templates LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); created public.user_templates; BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF p_template_kind NOT IN ('dictation', 'meeting_document') THEN RAISE EXCEPTION 'invalid_template_kind' USING ERRCODE = '22023'; END IF; INSERT INTO public.user_templates( user_id, template_kind, name, description, fields, output_format, template_type, system_prompt, is_builtin ) VALUES ( actor_id, p_template_kind, trim(p_name), nullif(trim(p_description), ''), CASE WHEN p_template_kind = 'dictation' THEN p_fields ELSE '[]'::jsonb END, CASE WHEN p_template_kind = 'dictation' THEN p_output_format ELSE NULL END, CASE WHEN p_template_kind = 'meeting_document' THEN 'custom' ELSE NULL END, CASE WHEN p_template_kind = 'meeting_document' THEN p_system_prompt ELSE NULL END, false ) RETURNING * INTO created; RETURN created; END; $$; REVOKE ALL ON FUNCTION public.create_user_template_v1(text, text, text, jsonb, text, text) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.create_user_template_v1(text, text, text, jsonb, text, text) TO authenticated; CREATE OR REPLACE FUNCTION public.update_user_template_v1( p_template_id uuid, p_expected_revision bigint, p_name text, p_description text DEFAULT NULL, p_fields jsonb DEFAULT '[]'::jsonb, p_output_format text DEFAULT NULL, p_system_prompt text DEFAULT NULL ) RETURNS public.user_templates LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); current_template public.user_templates; updated public.user_templates; BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; SELECT * INTO current_template FROM public.user_templates WHERE id = p_template_id AND user_id = actor_id FOR UPDATE; IF current_template.id IS NULL THEN RAISE EXCEPTION 'template_not_found' USING ERRCODE = 'P0002'; END IF; IF current_template.is_builtin THEN RAISE EXCEPTION 'builtin_template_immutable' USING ERRCODE = '42501'; END IF; IF current_template.revision <> p_expected_revision THEN RAISE EXCEPTION 'template_revision_conflict' USING ERRCODE = 'PT409'; END IF; UPDATE public.user_templates SET name = trim(p_name), description = nullif(trim(p_description), ''), fields = CASE WHEN template_kind = 'dictation' THEN p_fields ELSE '[]'::jsonb END, output_format = CASE WHEN template_kind = 'dictation' THEN p_output_format ELSE NULL END, system_prompt = CASE WHEN template_kind = 'meeting_document' THEN p_system_prompt ELSE NULL END, revision = revision + 1, updated_at = now() WHERE id = current_template.id RETURNING * INTO updated; RETURN updated; END; $$; REVOKE ALL ON FUNCTION public.update_user_template_v1(uuid, bigint, text, text, jsonb, text, text) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.update_user_template_v1(uuid, bigint, text, text, jsonb, text, text) TO authenticated; CREATE OR REPLACE FUNCTION public.delete_user_template_v1( p_template_id uuid, p_expected_revision bigint ) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); current_template public.user_templates; fallback_id uuid; BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; SELECT * INTO current_template FROM public.user_templates WHERE id = p_template_id AND user_id = actor_id FOR UPDATE; IF current_template.id IS NULL THEN RAISE EXCEPTION 'template_not_found' USING ERRCODE = 'P0002'; END IF; IF current_template.is_builtin THEN RAISE EXCEPTION 'builtin_template_immutable' USING ERRCODE = '42501'; END IF; IF current_template.revision <> p_expected_revision THEN RAISE EXCEPTION 'template_revision_conflict' USING ERRCODE = 'PT409'; END IF; SELECT id INTO fallback_id FROM public.user_templates WHERE user_id = actor_id AND template_kind = current_template.template_kind AND builtin_key = CASE current_template.template_kind WHEN 'dictation' THEN 'builtin-email' ELSE 'builtin-minutes' END; IF fallback_id IS NULL THEN DELETE FROM public.user_template_selections WHERE user_id = actor_id AND template_kind = current_template.template_kind AND template_id = current_template.id; ELSE UPDATE public.user_template_selections SET template_id = fallback_id, revision = revision + 1, updated_at = now() WHERE user_id = actor_id AND template_kind = current_template.template_kind AND template_id = current_template.id; END IF; DELETE FROM public.user_templates WHERE id = current_template.id; RETURN true; END; $$; REVOKE ALL ON FUNCTION public.delete_user_template_v1(uuid, bigint) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.delete_user_template_v1(uuid, bigint) TO authenticated; CREATE OR REPLACE FUNCTION public.select_user_template_v1( p_template_id uuid, p_expected_revision bigint DEFAULT NULL ) RETURNS public.user_template_selections LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); selected_template public.user_templates; current_selection public.user_template_selections; saved public.user_template_selections; BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; SELECT * INTO selected_template FROM public.user_templates WHERE id = p_template_id AND user_id = actor_id; IF selected_template.id IS NULL THEN RAISE EXCEPTION 'template_not_found' USING ERRCODE = 'P0002'; END IF; SELECT * INTO current_selection FROM public.user_template_selections WHERE user_id = actor_id AND template_kind = selected_template.template_kind FOR UPDATE; IF current_selection.user_id IS NULL THEN IF p_expected_revision IS NOT NULL THEN RAISE EXCEPTION 'template_selection_revision_conflict' USING ERRCODE = 'PT409'; END IF; INSERT INTO public.user_template_selections(user_id, template_kind, template_id) VALUES (actor_id, selected_template.template_kind, selected_template.id) RETURNING * INTO saved; ELSE IF p_expected_revision IS NULL OR current_selection.revision <> p_expected_revision THEN RAISE EXCEPTION 'template_selection_revision_conflict' USING ERRCODE = 'PT409'; END IF; UPDATE public.user_template_selections SET template_id = selected_template.id, revision = revision + 1, updated_at = now() WHERE user_id = actor_id AND template_kind = selected_template.template_kind RETURNING * INTO saved; END IF; RETURN saved; END; $$; REVOKE ALL ON FUNCTION public.select_user_template_v1(uuid, bigint) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.select_user_template_v1(uuid, bigint) TO authenticated; -- Harden the existing memo_tags table. Tags are case-insensitive per history row -- and their owner is always copied from the referenced history row. ALTER TABLE public.memo_tags ADD COLUMN normalized_tag text; UPDATE public.memo_tags SET tag = regexp_replace(trim(tag), '[[:space:]]+', ' ', 'g'), normalized_tag = lower(regexp_replace(trim(tag), '[[:space:]]+', ' ', 'g')); UPDATE public.memo_tags AS memo_tag SET user_id = history_item.user_id FROM public.history AS history_item WHERE history_item.id = memo_tag.history_id AND memo_tag.user_id <> history_item.user_id; DELETE FROM public.memo_tags WHERE normalized_tag = '' OR char_length(tag) > 80; WITH ranked AS ( SELECT id, row_number() OVER ( PARTITION BY history_id, normalized_tag ORDER BY created_at, id ) AS duplicate_rank FROM public.memo_tags ) DELETE FROM public.memo_tags AS tag USING ranked WHERE tag.id = ranked.id AND ranked.duplicate_rank > 1; ALTER TABLE public.memo_tags ALTER COLUMN normalized_tag SET NOT NULL, ADD CONSTRAINT memo_tags_tag_length_v1 CHECK (char_length(tag) BETWEEN 1 AND 80), ADD CONSTRAINT memo_tags_normalized_tag_v1 CHECK ( normalized_tag = lower(regexp_replace(trim(tag), '[[:space:]]+', ' ', 'g')) ); ALTER TABLE public.memo_tags DROP CONSTRAINT IF EXISTS memo_tags_history_id_tag_key; DROP INDEX IF EXISTS public.memo_tags_history_id_tag_key; CREATE UNIQUE INDEX memo_tags_history_normalized_unique_v1 ON public.memo_tags(history_id, normalized_tag); CREATE OR REPLACE FUNCTION public.enforce_memo_tag_owner_v1() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ DECLARE history_owner uuid; BEGIN NEW.tag := regexp_replace(trim(NEW.tag), '[[:space:]]+', ' ', 'g'); NEW.normalized_tag := lower(NEW.tag); IF char_length(NEW.tag) NOT BETWEEN 1 AND 80 THEN RAISE EXCEPTION 'invalid_memo_tag' USING ERRCODE = '22023'; END IF; SELECT user_id INTO history_owner FROM public.history WHERE id = NEW.history_id; IF history_owner IS NULL THEN RAISE EXCEPTION 'history_not_found' USING ERRCODE = 'P0002'; END IF; NEW.user_id := history_owner; RETURN NEW; END; $$; REVOKE ALL ON FUNCTION public.enforce_memo_tag_owner_v1() FROM PUBLIC, anon, authenticated; CREATE TRIGGER memo_tags_owner_and_normalization_v1 BEFORE INSERT OR UPDATE OF user_id, history_id, tag, normalized_tag ON public.memo_tags FOR EACH ROW EXECUTE FUNCTION public.enforce_memo_tag_owner_v1(); DROP POLICY IF EXISTS memo_tags_own ON public.memo_tags; DROP POLICY IF EXISTS "memo_tags_own" ON public.memo_tags; CREATE POLICY memo_tags_read_own_v1 ON public.memo_tags FOR SELECT TO authenticated USING ( user_id = auth.uid() AND EXISTS ( SELECT 1 FROM public.history WHERE history.id = memo_tags.history_id AND history.user_id = auth.uid() ) ); REVOKE ALL ON TABLE public.memo_tags FROM PUBLIC, anon, authenticated; GRANT SELECT ON TABLE public.memo_tags TO authenticated; CREATE OR REPLACE FUNCTION public.mobile_add_memo_tag_v1( p_history_id uuid, p_tag text ) RETURNS public.memo_tags LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); canonical_tag text := regexp_replace(trim(p_tag), '[[:space:]]+', ' ', 'g'); normalized text; saved public.memo_tags; BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF char_length(canonical_tag) NOT BETWEEN 1 AND 80 THEN RAISE EXCEPTION 'invalid_memo_tag' USING ERRCODE = '22023'; END IF; IF NOT EXISTS ( SELECT 1 FROM public.history WHERE id = p_history_id AND user_id = actor_id ) THEN RAISE EXCEPTION 'history_not_found' USING ERRCODE = 'P0002'; END IF; normalized := lower(canonical_tag); INSERT INTO public.memo_tags(user_id, history_id, tag, normalized_tag) VALUES (actor_id, p_history_id, canonical_tag, normalized) ON CONFLICT (history_id, normalized_tag) DO NOTHING RETURNING * INTO saved; IF saved.id IS NULL THEN SELECT * INTO saved FROM public.memo_tags WHERE history_id = p_history_id AND user_id = actor_id AND normalized_tag = normalized; END IF; RETURN saved; END; $$; REVOKE ALL ON FUNCTION public.mobile_add_memo_tag_v1(uuid, text) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.mobile_add_memo_tag_v1(uuid, text) TO authenticated; CREATE OR REPLACE FUNCTION public.mobile_remove_memo_tag_v1( p_history_id uuid, p_tag text ) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); deleted_count integer; BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; DELETE FROM public.memo_tags WHERE history_id = p_history_id AND user_id = actor_id AND normalized_tag = lower(regexp_replace(trim(p_tag), '[[:space:]]+', ' ', 'g')); GET DIAGNOSTICS deleted_count = ROW_COUNT; RETURN deleted_count > 0; END; $$; REVOKE ALL ON FUNCTION public.mobile_remove_memo_tag_v1(uuid, text) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.mobile_remove_memo_tag_v1(uuid, text) TO authenticated; CREATE OR REPLACE FUNCTION public.mobile_rename_memo_tag_v1( p_old_tag text, p_new_tag text ) RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); old_normalized text := lower(regexp_replace(trim(p_old_tag), '[[:space:]]+', ' ', 'g')); new_canonical text := regexp_replace(trim(p_new_tag), '[[:space:]]+', ' ', 'g'); changed integer; BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF old_normalized = '' OR char_length(new_canonical) NOT BETWEEN 1 AND 80 THEN RAISE EXCEPTION 'invalid_memo_tag' USING ERRCODE = '22023'; END IF; IF old_normalized = lower(new_canonical) THEN UPDATE public.memo_tags SET tag = new_canonical WHERE user_id = actor_id AND normalized_tag = old_normalized; GET DIAGNOSTICS changed = ROW_COUNT; RETURN changed; END IF; WITH source_tags AS ( SELECT history_id FROM public.memo_tags WHERE user_id = actor_id AND normalized_tag = old_normalized ), inserted AS ( INSERT INTO public.memo_tags(user_id, history_id, tag, normalized_tag) SELECT actor_id, history_id, new_canonical, lower(new_canonical) FROM source_tags ON CONFLICT (history_id, normalized_tag) DO NOTHING RETURNING 1 ) SELECT count(*)::integer INTO changed FROM source_tags; DELETE FROM public.memo_tags WHERE user_id = actor_id AND normalized_tag = old_normalized; RETURN changed; END; $$; REVOKE ALL ON FUNCTION public.mobile_rename_memo_tag_v1(text, text) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.mobile_rename_memo_tag_v1(text, text) TO authenticated; CREATE OR REPLACE FUNCTION public.mobile_list_memo_tags_v1() RETURNS TABLE(tag text, normalized_tag text, history_count bigint) LANGUAGE sql SECURITY DEFINER STABLE SET search_path = pg_catalog, public, auth AS $$ SELECT min(memo_tags.tag) AS tag, memo_tags.normalized_tag, count(DISTINCT memo_tags.history_id) AS history_count FROM public.memo_tags WHERE memo_tags.user_id = auth.uid() GROUP BY memo_tags.normalized_tag ORDER BY count(DISTINCT memo_tags.history_id) DESC, memo_tags.normalized_tag; $$; REVOKE ALL ON FUNCTION public.mobile_list_memo_tags_v1() FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.mobile_list_memo_tags_v1() TO authenticated; CREATE OR REPLACE FUNCTION public.mobile_search_memos_v1( p_query text DEFAULT '', p_tag text DEFAULT NULL, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0 ) RETURNS TABLE(history_row jsonb, tags jsonb) LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); query_value text := trim(coalesce(p_query, '')); tag_value text := nullif(lower(regexp_replace(trim(coalesce(p_tag, '')), '[[:space:]]+', ' ', 'g')), ''); BEGIN IF actor_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF char_length(query_value) > 100 OR p_limit NOT BETWEEN 1 AND 100 OR p_offset NOT BETWEEN 0 AND 100000 THEN RAISE EXCEPTION 'invalid_memo_search' USING ERRCODE = '22023'; END IF; RETURN QUERY SELECT to_jsonb(history_item) AS history_row, coalesce( ( SELECT jsonb_agg(tag_item.tag ORDER BY tag_item.normalized_tag) FROM public.memo_tags AS tag_item WHERE tag_item.history_id = history_item.id AND tag_item.user_id = actor_id ), '[]'::jsonb ) AS tags FROM public.history AS history_item WHERE history_item.user_id = actor_id AND ( query_value = '' OR coalesce(history_item.title, '') ILIKE '%' || query_value || '%' OR history_item.original_text ILIKE '%' || query_value || '%' OR coalesce(history_item.polished_text, '') ILIKE '%' || query_value || '%' OR coalesce(history_item.summary_text, '') ILIKE '%' || query_value || '%' OR EXISTS ( SELECT 1 FROM public.memo_tags AS query_tag WHERE query_tag.history_id = history_item.id AND query_tag.user_id = actor_id AND query_tag.tag ILIKE '%' || query_value || '%' ) ) AND ( tag_value IS NULL OR EXISTS ( SELECT 1 FROM public.memo_tags AS filter_tag WHERE filter_tag.history_id = history_item.id AND filter_tag.user_id = actor_id AND filter_tag.normalized_tag = tag_value ) ) ORDER BY history_item.created_at DESC, history_item.id DESC LIMIT p_limit OFFSET p_offset; END; $$; REVOKE ALL ON FUNCTION public.mobile_search_memos_v1(text, text, integer, integer) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.mobile_search_memos_v1(text, text, integer, integer) TO authenticated; -- Document generation requests are claimed before provider I/O and committed by -- one atomic RPC only after a validated provider response exists. CREATE TABLE public.meeting_document_generation_requests ( user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, idempotency_key uuid NOT NULL, meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, template_id uuid NOT NULL, request_hash text NOT NULL CHECK (request_hash ~ '^[0-9a-f]{64}$'), document_title text NOT NULL CHECK (char_length(document_title) BETWEEN 1 AND 160), model text NOT NULL, quota_feature text NOT NULL CHECK (quota_feature IN ('llm_haiku', 'llm_sonnet', 'llm_opus')), quota_limit integer NOT NULL CHECK (quota_limit >= -1), quota_period text NOT NULL CHECK (quota_period IN ('daily', 'weekly')), template_revision bigint NOT NULL, template_type text NOT NULL CHECK (template_type IN ('minutes', 'report', 'idea-note', 'custom', 'mindmap')), transcript_hash text NOT NULL CHECK (transcript_hash ~ '^[0-9a-f]{64}$'), status text NOT NULL CHECK (status IN ('processing', 'succeeded', 'failed')), document_id uuid REFERENCES public.meeting_documents(id) ON DELETE SET NULL, error_code text, created_at timestamptz NOT NULL DEFAULT now(), completed_at timestamptz, PRIMARY KEY (user_id, idempotency_key) ); CREATE INDEX meeting_document_generation_meeting_idx ON public.meeting_document_generation_requests(meeting_id, created_at DESC); CREATE TABLE public.meeting_document_generation_audit ( id bigserial PRIMARY KEY, user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, meeting_id uuid NOT NULL REFERENCES public.meetings(id) ON DELETE CASCADE, template_id uuid REFERENCES public.user_templates(id) ON DELETE SET NULL, document_id uuid NOT NULL REFERENCES public.meeting_documents(id) ON DELETE CASCADE, idempotency_key uuid NOT NULL, model text NOT NULL, template_revision bigint NOT NULL, transcript_hash text NOT NULL, input_tokens integer CHECK (input_tokens IS NULL OR input_tokens >= 0), output_tokens integer CHECK (output_tokens IS NULL OR output_tokens >= 0), latency_ms integer NOT NULL CHECK (latency_ms >= 0), created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (user_id, idempotency_key) ); ALTER TABLE public.meeting_documents ADD COLUMN template_id uuid REFERENCES public.user_templates(id) ON DELETE SET NULL, ADD COLUMN generation_idempotency_key uuid; CREATE UNIQUE INDEX meeting_documents_generation_idempotency_unique ON public.meeting_documents(user_id, generation_idempotency_key) WHERE generation_idempotency_key IS NOT NULL; ALTER TABLE public.meeting_document_generation_requests ENABLE ROW LEVEL SECURITY; ALTER TABLE public.meeting_document_generation_audit ENABLE ROW LEVEL SECURITY; CREATE POLICY meeting_document_generation_requests_read_own ON public.meeting_document_generation_requests FOR SELECT TO authenticated USING (user_id = auth.uid()); REVOKE ALL ON TABLE public.meeting_document_generation_requests FROM PUBLIC, anon, authenticated; REVOKE ALL ON TABLE public.meeting_document_generation_audit FROM PUBLIC, anon, authenticated; GRANT SELECT ON TABLE public.meeting_document_generation_requests TO authenticated; CREATE OR REPLACE FUNCTION public.claim_meeting_document_generation_v1( p_actor_id uuid, p_idempotency_key uuid, p_meeting_id uuid, p_template_id uuid, p_title text, p_model text ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth, extensions AS $$ DECLARE meeting_row public.meetings; template_row public.user_templates; transcript_value text; transcript_digest text; request_digest text; request_row public.meeting_document_generation_requests; inserted boolean := false; tier_value text := 'free'; overage_value integer := 0; quota_feature_value text; quota_limit_value integer; quota_period_value text; current_usage bigint := 0; safe_title text := trim(p_title); BEGIN IF p_actor_id IS NULL OR p_idempotency_key IS NULL OR p_meeting_id IS NULL OR p_template_id IS NULL THEN RAISE EXCEPTION 'generation_identifiers_required' USING ERRCODE = '22023'; END IF; IF char_length(safe_title) NOT BETWEEN 1 AND 160 THEN RAISE EXCEPTION 'invalid_document_title' USING ERRCODE = '22023'; END IF; IF p_model NOT IN ('claude-haiku-4-5-20251001', 'claude-sonnet-4-6', 'claude-opus-4-6') THEN RAISE EXCEPTION 'invalid_generation_model' USING ERRCODE = '22023'; END IF; SELECT * INTO meeting_row FROM public.meetings WHERE id = p_meeting_id; IF meeting_row.id IS NULL THEN RAISE EXCEPTION 'meeting_not_found' USING ERRCODE = 'P0002'; END IF; IF meeting_row.user_id <> p_actor_id AND NOT ( meeting_row.team_id IS NOT NULL AND ( EXISTS ( SELECT 1 FROM public.teams WHERE id = meeting_row.team_id AND owner_id = p_actor_id ) OR EXISTS ( SELECT 1 FROM public.team_members WHERE team_id = meeting_row.team_id AND user_id = p_actor_id AND role IN ('owner', 'admin') ) ) ) THEN RAISE EXCEPTION 'meeting_generation_forbidden' USING ERRCODE = '42501'; END IF; SELECT * INTO template_row FROM public.user_templates WHERE id = p_template_id AND user_id = p_actor_id AND template_kind = 'meeting_document'; IF template_row.id IS NULL THEN RAISE EXCEPTION 'meeting_template_not_found' USING ERRCODE = 'P0002'; END IF; SELECT nullif(string_agg( CASE WHEN nullif(trim(transcript.speaker), '') IS NULL THEN transcript.text ELSE trim(transcript.speaker) || ': ' || transcript.text END, E'\n' ORDER BY transcript.segment_index ), '') INTO transcript_value FROM public.transcripts AS transcript WHERE transcript.meeting_id = meeting_row.id; transcript_value := coalesce( transcript_value, nullif(trim(meeting_row.edited_transcript), ''), nullif(trim(meeting_row.raw_transcript), '') ); IF transcript_value IS NULL THEN RAISE EXCEPTION 'meeting_transcript_required' USING ERRCODE = '22023'; END IF; IF char_length(transcript_value) > 48000 THEN RAISE EXCEPTION 'meeting_transcript_too_large' USING ERRCODE = '22023'; END IF; SELECT coalesce(subscription.tier, 'free'), coalesce(subscription.overage_credits, 0) INTO tier_value, overage_value FROM public.subscriptions AS subscription WHERE subscription.user_id = p_actor_id; tier_value := coalesce(tier_value, 'free'); overage_value := coalesce(overage_value, 0); IF tier_value = 'free' AND p_model <> 'claude-haiku-4-5-20251001' THEN RAISE EXCEPTION 'generation_model_not_allowed' USING ERRCODE = '42501'; END IF; quota_feature_value := CASE WHEN p_model LIKE '%sonnet%' THEN 'llm_sonnet' WHEN p_model LIKE '%opus%' THEN 'llm_opus' ELSE 'llm_haiku' END; quota_limit_value := CASE WHEN tier_value = 'free' AND quota_feature_value = 'llm_haiku' THEN 250 WHEN tier_value = 'free' THEN 0 WHEN tier_value = 'pro' AND quota_feature_value = 'llm_haiku' THEN 1500 WHEN tier_value = 'pro' AND quota_feature_value = 'llm_sonnet' THEN 300 WHEN tier_value = 'pro' AND quota_feature_value = 'llm_opus' THEN 50 WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_haiku' THEN -1 WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_sonnet' THEN 1500 WHEN tier_value = 'pro_plus' AND quota_feature_value = 'llm_opus' THEN 300 WHEN tier_value = 'team' AND quota_feature_value = 'llm_haiku' THEN -1 WHEN tier_value = 'team' AND quota_feature_value = 'llm_sonnet' THEN 3000 WHEN tier_value = 'team' AND quota_feature_value = 'llm_opus' THEN 600 WHEN tier_value = 'enterprise' THEN -1 ELSE 0 END; quota_period_value := CASE WHEN tier_value = 'free' THEN 'weekly' ELSE 'daily' END; IF quota_limit_value = 0 THEN RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001'; END IF; IF quota_limit_value > 0 THEN SELECT coalesce(sum(usage.count), 0) INTO current_usage FROM public.daily_usage AS usage WHERE usage.user_id = p_actor_id AND usage.feature = quota_feature_value AND usage.date >= CASE quota_period_value WHEN 'weekly' THEN current_date - 6 ELSE current_date END; IF current_usage >= quota_limit_value AND overage_value <= 0 THEN RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001'; END IF; END IF; transcript_digest := encode(extensions.digest(transcript_value, 'sha256'), 'hex'); request_digest := encode(extensions.digest( jsonb_build_object( 'meeting_id', meeting_row.id, 'template_id', template_row.id, 'template_revision', template_row.revision, 'transcript_hash', transcript_digest, 'title', safe_title, 'model', p_model )::text, 'sha256' ), 'hex'); INSERT INTO public.meeting_document_generation_requests( user_id, idempotency_key, meeting_id, template_id, request_hash, document_title, model, quota_feature, quota_limit, quota_period, template_revision, template_type, transcript_hash, status ) VALUES ( p_actor_id, p_idempotency_key, meeting_row.id, template_row.id, request_digest, safe_title, p_model, quota_feature_value, quota_limit_value, quota_period_value, template_row.revision, template_row.template_type, transcript_digest, 'processing' ) ON CONFLICT DO NOTHING RETURNING true INTO inserted; SELECT * INTO request_row FROM public.meeting_document_generation_requests WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key; IF NOT coalesce(inserted, false) THEN IF request_row.request_hash <> request_digest THEN RAISE EXCEPTION 'generation_idempotency_conflict' USING ERRCODE = '22023'; END IF; END IF; RETURN jsonb_build_object( 'claimed', inserted, 'status', request_row.status, 'documentId', request_row.document_id, 'meetingTitle', coalesce(meeting_row.title, 'Meeting'), 'documentTitle', request_row.document_title, 'templateType', request_row.template_type, 'systemPrompt', CASE WHEN inserted THEN template_row.system_prompt ELSE NULL END, 'transcript', CASE WHEN inserted THEN transcript_value ELSE NULL END, 'model', request_row.model ); END; $$; REVOKE ALL ON FUNCTION public.claim_meeting_document_generation_v1(uuid, uuid, uuid, uuid, text, text) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.claim_meeting_document_generation_v1(uuid, uuid, uuid, uuid, text, text) TO service_role; CREATE OR REPLACE FUNCTION public.fail_meeting_document_generation_v1( p_actor_id uuid, p_idempotency_key uuid, p_error_code text ) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ DECLARE changed integer; BEGIN IF p_error_code NOT IN ( 'provider_unavailable', 'provider_timeout', 'provider_request_failed', 'provider_invalid_response', 'quota_exceeded', 'commit_failed' ) THEN RAISE EXCEPTION 'invalid_generation_error_code' USING ERRCODE = '22023'; END IF; UPDATE public.meeting_document_generation_requests SET status = 'failed', error_code = p_error_code, completed_at = now() WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key AND status = 'processing'; GET DIAGNOSTICS changed = ROW_COUNT; RETURN changed > 0; END; $$; REVOKE ALL ON FUNCTION public.fail_meeting_document_generation_v1(uuid, uuid, text) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.fail_meeting_document_generation_v1(uuid, uuid, text) TO service_role; CREATE OR REPLACE FUNCTION public.commit_meeting_document_generation_v1( p_actor_id uuid, p_idempotency_key uuid, p_content text, p_latency_ms integer, p_input_tokens integer DEFAULT NULL, p_output_tokens integer DEFAULT NULL ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ DECLARE request_row public.meeting_document_generation_requests; document_row public.meeting_documents; current_usage bigint := 0; overage_value integer := 0; consumed_from text; BEGIN IF char_length(trim(p_content)) NOT BETWEEN 1 AND 100000 OR p_latency_ms NOT BETWEEN 0 AND 600000 OR (p_input_tokens IS NOT NULL AND p_input_tokens < 0) OR (p_output_tokens IS NOT NULL AND p_output_tokens < 0) THEN RAISE EXCEPTION 'invalid_generation_result' USING ERRCODE = '22023'; END IF; SELECT * INTO request_row FROM public.meeting_document_generation_requests WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key FOR UPDATE; IF request_row.user_id IS NULL THEN RAISE EXCEPTION 'generation_request_not_found' USING ERRCODE = 'P0002'; END IF; IF request_row.status = 'succeeded' THEN SELECT * INTO document_row FROM public.meeting_documents WHERE id = request_row.document_id; RETURN jsonb_build_object('idempotent', true, 'document', to_jsonb(document_row)); END IF; IF request_row.status <> 'processing' THEN RAISE EXCEPTION 'generation_request_not_committable' USING ERRCODE = '55000'; END IF; PERFORM pg_advisory_xact_lock(hashtextextended(p_actor_id::text || ':' || request_row.quota_feature, 0)); SELECT coalesce(overage_credits, 0) INTO overage_value FROM public.subscriptions WHERE user_id = p_actor_id FOR UPDATE; overage_value := coalesce(overage_value, 0); SELECT coalesce(sum(usage.count), 0) INTO current_usage FROM public.daily_usage AS usage WHERE usage.user_id = p_actor_id AND usage.feature = request_row.quota_feature AND usage.date >= CASE request_row.quota_period WHEN 'weekly' THEN current_date - 6 ELSE current_date END; IF request_row.quota_limit = -1 THEN consumed_from := 'unlimited'; ELSIF current_usage < request_row.quota_limit THEN consumed_from := 'base'; ELSIF overage_value > 0 THEN UPDATE public.subscriptions SET overage_credits = overage_credits - 1, updated_at = now() WHERE user_id = p_actor_id; consumed_from := 'overage'; ELSE RAISE EXCEPTION 'generation_quota_exceeded' USING ERRCODE = 'P0001'; END IF; INSERT INTO public.daily_usage(user_id, date, feature, count) VALUES (p_actor_id, current_date, request_row.quota_feature, 1) ON CONFLICT (user_id, date, feature) DO UPDATE SET count = public.daily_usage.count + 1; INSERT INTO public.meeting_documents( meeting_id, user_id, template_type, title, content, prompt_used, llm_model, llm_latency_ms, template_id, generation_idempotency_key ) VALUES ( request_row.meeting_id, p_actor_id, request_row.template_type, request_row.document_title, trim(p_content), 'template:' || request_row.template_id::text || '@' || request_row.template_revision::text, request_row.model, p_latency_ms, request_row.template_id, p_idempotency_key ) RETURNING * INTO document_row; INSERT INTO public.meeting_document_generation_audit( user_id, meeting_id, template_id, document_id, idempotency_key, model, template_revision, transcript_hash, input_tokens, output_tokens, latency_ms ) VALUES ( p_actor_id, request_row.meeting_id, request_row.template_id, document_row.id, p_idempotency_key, request_row.model, request_row.template_revision, request_row.transcript_hash, p_input_tokens, p_output_tokens, p_latency_ms ); UPDATE public.meeting_document_generation_requests SET status = 'succeeded', document_id = document_row.id, error_code = NULL, completed_at = now() WHERE user_id = p_actor_id AND idempotency_key = p_idempotency_key; RETURN jsonb_build_object( 'idempotent', false, 'consumedFrom', consumed_from, 'document', to_jsonb(document_row) ); END; $$; REVOKE ALL ON FUNCTION public.commit_meeting_document_generation_v1(uuid, uuid, text, integer, integer, integer) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.commit_meeting_document_generation_v1(uuid, uuid, text, integer, integer, integer) TO service_role; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'user_templates' ) THEN ALTER PUBLICATION supabase_realtime ADD TABLE public.user_templates; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'user_template_selections' ) THEN ALTER PUBLICATION supabase_realtime ADD TABLE public.user_template_selections; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_publication_tables WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'memo_tags' ) THEN ALTER PUBLICATION supabase_realtime ADD TABLE public.memo_tags; END IF; END; $$;