-- 회의 생성 시 문서 템플릿은 선택 사항이다. 템플릿 없이도 회의를 시작할 수 -- 있어야 하며(데스크톱 동작과 동일), 문서 생성 시점에 사용자 템플릿 라이브러리 -- 에서 템플릿이 해결된다. 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;