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

357 lines
11 KiB
PL/PgSQL

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