1333 lines
46 KiB
PL/PgSQL
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;
|
|
$$;
|