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

111 lines
4 KiB
PL/PgSQL

-- 회의 생성 시 문서 템플릿은 선택 사항이다. 템플릿 없이도 회의를 시작할 수
-- 있어야 하며(데스크톱 동작과 동일), 문서 생성 시점에 사용자 템플릿 라이브러리
-- 에서 템플릿이 해결된다. meetings.template_id 컬럼과 소유권/생성 무결성
-- 트리거(enforce_meeting_template_owner_v1, enforce_meeting_creation_identity_v1)
-- 는 이미 NULL을 허용하므로 생성 RPC의 인자 검증만 완화한다.
--
-- 주의: idempotency 요청 해시에 template_id가 포함되므로, 같은 idempotency
-- 키를 NULL/NOT NULL 템플릿으로 재시도하면 creation_request_hash 불일치로
-- meeting_creation_idempotency_conflict가 발생한다 — 의도된 방어 동작이다.
CREATE OR REPLACE FUNCTION public.mobile_create_meeting_workspace_v2(
p_title text,
p_attendees jsonb,
p_language text,
p_template_id uuid,
p_idempotency_key uuid
)
RETURNS public.meetings
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, auth, extensions
AS $$
DECLARE
actor_id uuid := auth.uid();
safe_title text := regexp_replace(trim(p_title), '[[:space:]]+', ' ', 'g');
safe_language text := lower(trim(p_language));
safe_attendees jsonb;
request_digest text;
created public.meetings;
BEGIN
IF actor_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF p_idempotency_key IS NULL THEN
RAISE EXCEPTION 'meeting_creation_identifiers_required' USING ERRCODE = '22023';
END IF;
IF p_title IS NULL OR char_length(safe_title) NOT BETWEEN 1 AND 160 THEN
RAISE EXCEPTION 'invalid_meeting_title' USING ERRCODE = '22023';
END IF;
IF p_language IS NULL OR safe_language NOT IN ('ko', 'en', 'ja', 'zh-cn') THEN
RAISE EXCEPTION 'invalid_meeting_language' USING ERRCODE = '22023';
END IF;
IF NOT public.is_valid_meeting_attendees_v1(p_attendees) THEN
RAISE EXCEPTION 'invalid_meeting_attendees' USING ERRCODE = '22023';
END IF;
IF p_template_id IS NOT NULL AND NOT EXISTS (
SELECT 1
FROM public.user_templates
WHERE id = p_template_id
AND user_id = actor_id
AND template_kind = 'meeting_document'
) THEN
RAISE EXCEPTION 'meeting_template_not_found' USING ERRCODE = 'P0002';
END IF;
PERFORM set_config('d3ro.meeting_creation_actor', actor_id::text, true);
SELECT coalesce(
jsonb_agg(
to_jsonb(regexp_replace(trim(item.value #>> '{}'), '[[:space:]]+', ' ', 'g'))
ORDER BY item.ordinality
),
'[]'::jsonb
)
INTO safe_attendees
FROM jsonb_array_elements(p_attendees) WITH ORDINALITY AS item(value, ordinality);
request_digest := encode(extensions.digest(
jsonb_build_object(
'title', safe_title,
'attendees', safe_attendees,
'language', safe_language,
'template_id', p_template_id
)::text,
'sha256'
), 'hex');
INSERT INTO public.meetings(
user_id, title, status, language, attendees, template_id,
creation_idempotency_key, creation_request_hash
)
VALUES (
actor_id, safe_title, 'recording', safe_language, safe_attendees,
p_template_id, p_idempotency_key, request_digest
)
ON CONFLICT (user_id, creation_idempotency_key)
WHERE creation_idempotency_key IS NOT NULL
DO NOTHING
RETURNING * INTO created;
IF created.id IS NULL THEN
SELECT * INTO created
FROM public.meetings
WHERE user_id = actor_id
AND creation_idempotency_key = p_idempotency_key;
IF created.id IS NULL THEN
RAISE EXCEPTION 'meeting_creation_retry_state_missing' USING ERRCODE = 'PT409';
END IF;
IF created.creation_request_hash <> request_digest THEN
RAISE EXCEPTION 'meeting_creation_idempotency_conflict' USING ERRCODE = 'PT409';
END IF;
END IF;
RETURN created;
END;
$$;
REVOKE ALL ON FUNCTION public.mobile_create_meeting_workspace_v2(text, jsonb, text, uuid, uuid)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.mobile_create_meeting_workspace_v2(text, jsonb, text, uuid, uuid)
TO authenticated;