-- H-006: atomic meeting creation with explicit attendees, language, and a -- user-owned meeting-document template. Legacy meetings keep unknown language -- as NULL; successful recording completion writes the authoritative STT language. CREATE OR REPLACE FUNCTION public.is_valid_meeting_attendees_v1(p_attendees jsonb) RETURNS boolean LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog AS $$ DECLARE attendee jsonb; BEGIN IF p_attendees IS NULL OR jsonb_typeof(p_attendees) <> 'array' OR jsonb_array_length(p_attendees) > 50 THEN RETURN false; END IF; FOR attendee IN SELECT value FROM jsonb_array_elements(p_attendees) LOOP IF jsonb_typeof(attendee) <> 'string' OR char_length(regexp_replace(trim(attendee #>> '{}'), '[[:space:]]+', ' ', 'g')) NOT BETWEEN 1 AND 120 OR (attendee #>> '{}') ~ '[[:cntrl:]]' THEN RETURN false; END IF; END LOOP; RETURN ( SELECT count(*) = count(DISTINCT lower(regexp_replace(trim(value #>> '{}'), '[[:space:]]+', ' ', 'g'))) FROM jsonb_array_elements(p_attendees) ); END; $$; REVOKE ALL ON FUNCTION public.is_valid_meeting_attendees_v1(jsonb) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.is_valid_meeting_attendees_v1(jsonb) TO authenticated, service_role; ALTER TABLE public.meetings ADD COLUMN language text, ADD COLUMN attendees jsonb NOT NULL DEFAULT '[]'::jsonb, ADD COLUMN template_id uuid REFERENCES public.user_templates(id) ON DELETE RESTRICT, ADD COLUMN creation_idempotency_key uuid, ADD COLUMN creation_request_hash text, ADD CONSTRAINT meetings_language_v1 CHECK ( language IS NULL OR ( char_length(language) BETWEEN 2 AND 35 AND language ~ '^[a-z]{2,3}(-[a-z0-9]{2,8})*$' ) ), ADD CONSTRAINT meetings_attendees_v1 CHECK ( public.is_valid_meeting_attendees_v1(attendees) ), ADD CONSTRAINT meetings_creation_hash_v1 CHECK ( (creation_idempotency_key IS NULL AND creation_request_hash IS NULL) OR ( creation_idempotency_key IS NOT NULL AND creation_request_hash ~ '^[0-9a-f]{64}$' ) ); CREATE UNIQUE INDEX meetings_creation_idempotency_unique_v1 ON public.meetings(user_id, creation_idempotency_key) WHERE creation_idempotency_key IS NOT NULL; CREATE INDEX meetings_owner_template_v1 ON public.meetings(user_id, template_id) WHERE template_id IS NOT NULL; CREATE OR REPLACE FUNCTION public.enforce_meeting_creation_identity_v1() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, auth AS $$ DECLARE actor_id uuid := auth.uid(); rpc_actor text := current_setting('d3ro.meeting_creation_actor', true); BEGIN IF TG_OP = 'INSERT' THEN IF NEW.creation_idempotency_key IS NOT NULL THEN IF actor_id IS NULL OR rpc_actor IS DISTINCT FROM actor_id::text THEN RAISE EXCEPTION 'meeting_creation_rpc_required' USING ERRCODE = '42501'; END IF; ELSIF NEW.template_id IS NOT NULL OR NEW.language IS NOT NULL OR NEW.attendees <> '[]'::jsonb THEN RAISE EXCEPTION 'meeting_creation_metadata_requires_rpc' USING ERRCODE = '42501'; END IF; RETURN NEW; END IF; IF NEW.creation_idempotency_key IS DISTINCT FROM OLD.creation_idempotency_key OR NEW.creation_request_hash IS DISTINCT FROM OLD.creation_request_hash THEN RAISE EXCEPTION 'meeting_creation_identity_immutable' USING ERRCODE = '42501'; END IF; IF OLD.creation_idempotency_key IS NOT NULL AND NEW.template_id IS DISTINCT FROM OLD.template_id THEN RAISE EXCEPTION 'meeting_creation_template_immutable' USING ERRCODE = '42501'; END IF; RETURN NEW; END; $$; REVOKE ALL ON FUNCTION public.enforce_meeting_creation_identity_v1() FROM PUBLIC, anon, authenticated; CREATE TRIGGER meetings_creation_identity_v1 BEFORE INSERT OR UPDATE OF creation_idempotency_key, creation_request_hash, template_id ON public.meetings FOR EACH ROW EXECUTE FUNCTION public.enforce_meeting_creation_identity_v1(); CREATE OR REPLACE FUNCTION public.enforce_meeting_template_owner_v1() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ BEGIN IF NEW.template_id IS NULL THEN RETURN NEW; END IF; IF NOT EXISTS ( SELECT 1 FROM public.user_templates WHERE id = NEW.template_id AND user_id = NEW.user_id AND template_kind = 'meeting_document' ) THEN RAISE EXCEPTION 'meeting_template_owner_mismatch' USING ERRCODE = '42501'; END IF; RETURN NEW; END; $$; REVOKE ALL ON FUNCTION public.enforce_meeting_template_owner_v1() FROM PUBLIC, anon, authenticated; CREATE TRIGGER meetings_template_owner_v1 BEFORE INSERT OR UPDATE OF user_id, template_id ON public.meetings FOR EACH ROW EXECUTE FUNCTION public.enforce_meeting_template_owner_v1(); 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 OR p_template_id 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 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; -- Keep legacy/action-created meetings nullable until real STT succeeds, then -- bind the meeting SSOT to the same validated language stored in the job result. CREATE OR REPLACE FUNCTION public.mobile_complete_meeting_processing( p_meeting_id uuid, p_audio_file_id uuid, p_idempotency_key text, p_transcript text, p_language text, p_provider text, p_duration_ms bigint, p_stt_latency_ms integer ) RETURNS public.meetings LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); safe_language text := lower(trim(p_language)); owned_audio public.audio_files; owned_job public.processing_jobs; result public.meetings; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501'; END IF; IF p_transcript IS NULL OR length(p_transcript) > 1000000 OR p_language IS NULL OR char_length(safe_language) NOT BETWEEN 2 AND 35 OR safe_language !~ '^[a-z]{2,3}(-[a-z0-9]{2,8})*$' OR p_provider IS NULL OR length(p_provider) NOT BETWEEN 1 AND 120 OR p_duration_ms < 0 OR p_stt_latency_ms < 0 THEN RAISE EXCEPTION 'invalid transcription result' USING ERRCODE = '22023'; END IF; SELECT * INTO owned_audio FROM public.audio_files WHERE id = p_audio_file_id AND user_id = current_user_id AND meeting_id = p_meeting_id AND history_id IS NULL AND upload_status = 'uploaded' FOR UPDATE; IF owned_audio.id IS NULL THEN RAISE EXCEPTION 'uploaded meeting audio not found' USING ERRCODE = '22023'; END IF; SELECT * INTO owned_job FROM public.processing_jobs WHERE user_id = current_user_id AND meeting_id = p_meeting_id AND audio_file_id = p_audio_file_id AND idempotency_key = p_idempotency_key AND kind = 'transcription' FOR UPDATE; IF owned_job.id IS NULL THEN RAISE EXCEPTION 'processing job not found' USING ERRCODE = '22023'; END IF; INSERT INTO public.transcripts ( meeting_id, segment_index, timestamp_ms, duration_ms, text, speaker, edited ) VALUES ( p_meeting_id, 0, 0, LEAST(p_duration_ms, 2147483647)::integer, p_transcript, NULL, false ) ON CONFLICT (meeting_id, segment_index) DO UPDATE SET duration_ms = EXCLUDED.duration_ms, text = EXCLUDED.text, speaker = NULL, edited = false, updated_at = now(); UPDATE public.meetings SET status = 'completed', ended_at = COALESCE(ended_at, now()), duration_ms = p_duration_ms, raw_transcript = p_transcript, edited_transcript = NULL, stt_model = p_provider, stt_latency_ms = p_stt_latency_ms, error_message = NULL, audio_storage_key = owned_audio.storage_key, language = safe_language WHERE id = p_meeting_id AND user_id = current_user_id RETURNING * INTO result; UPDATE public.processing_jobs SET status = 'succeeded', progress = 100, error_code = NULL, error_message = NULL, result = jsonb_build_object( 'audio_file_id', p_audio_file_id, 'language', safe_language, 'provider', p_provider, 'duration_ms', p_duration_ms ), completed_at = now() WHERE id = owned_job.id; RETURN result; END; $$; REVOKE ALL ON FUNCTION public.mobile_complete_meeting_processing( uuid, uuid, text, text, text, text, bigint, integer ) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.mobile_complete_meeting_processing( uuid, uuid, text, text, text, text, bigint, integer ) TO authenticated;