d3ro-voice/server/supabase/migrations/20260821000024_mobile_templates_memos_documents.sql
2026-08-29 18:33:45 +09:00

1333 lines
46 KiB
PL/PgSQL

-- 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;
$$;