357 lines
11 KiB
PL/PgSQL
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;
|