feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -0,0 +1,357 @@
|
|||
-- 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue