fix(mobile-recording): never lose a meeting capture on offline stop, oversize, or re-record
This commit is contained in:
parent
80538c0f23
commit
5b1621d314
9 changed files with 1238 additions and 56 deletions
|
|
@ -0,0 +1,231 @@
|
|||
-- Re-recording a meeting must not destroy its existing transcript before the
|
||||
-- new capture has produced one.
|
||||
--
|
||||
-- 20260821000022 made mobile_begin_meeting_recording wipe raw/edited
|
||||
-- transcript, minutes, transcripts rows and unlink the meeting's audio the
|
||||
-- moment a recording STARTS. A re-record that was then cancelled, failed or
|
||||
-- lost (offline stop, oversized capture) left the meeting in 'error' with its
|
||||
-- previous content gone for good, and since 20260929000004 the unlinked audio
|
||||
-- is also queued for storage purge.
|
||||
--
|
||||
-- New split of responsibilities:
|
||||
-- begin -> only marks the meeting as recording (status and timing).
|
||||
-- complete -> on the FIRST successful completion of a processing job,
|
||||
-- replaces the previous content: deletes old transcript rows,
|
||||
-- clears minutes/LLM metadata and detaches older audio. A replay
|
||||
-- of an already-succeeded job leaves derived content untouched.
|
||||
-- cancel -> a meeting that still holds earlier content returns to
|
||||
-- 'completed' instead of 'error'.
|
||||
-- Signatures, grants and return shapes are unchanged.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.mobile_begin_meeting_recording(p_meeting_id uuid)
|
||||
RETURNS public.meetings
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
current_user_id uuid := auth.uid();
|
||||
result public.meetings;
|
||||
BEGIN
|
||||
IF current_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
UPDATE public.meetings
|
||||
SET status = 'recording',
|
||||
started_at = now(),
|
||||
ended_at = NULL,
|
||||
error_message = NULL
|
||||
WHERE id = p_meeting_id
|
||||
AND user_id = current_user_id
|
||||
RETURNING * INTO result;
|
||||
|
||||
IF result.id IS NULL THEN
|
||||
RAISE EXCEPTION 'meeting not found or not owned' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.mobile_cancel_meeting_recording(p_meeting_id uuid)
|
||||
RETURNS public.meetings
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
current_user_id uuid := auth.uid();
|
||||
result public.meetings;
|
||||
BEGIN
|
||||
IF current_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'authentication required' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
UPDATE public.meetings m
|
||||
SET status = CASE WHEN has_content.present THEN 'completed' ELSE 'error' END,
|
||||
ended_at = now(),
|
||||
error_message = CASE
|
||||
WHEN has_content.present THEN NULL
|
||||
ELSE 'Recording was cancelled before processing.'
|
||||
END
|
||||
FROM (
|
||||
SELECT (
|
||||
mm.raw_transcript IS NOT NULL
|
||||
OR mm.edited_transcript IS NOT NULL
|
||||
OR mm.minutes_markdown IS NOT NULL
|
||||
OR mm.minutes_json IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM public.transcripts t WHERE t.meeting_id = mm.id)
|
||||
) AS present
|
||||
FROM public.meetings mm
|
||||
WHERE mm.id = p_meeting_id
|
||||
) AS has_content
|
||||
WHERE m.id = p_meeting_id
|
||||
AND m.user_id = current_user_id
|
||||
AND m.status = 'recording'
|
||||
RETURNING m.* INTO result;
|
||||
IF result.id IS NULL THEN
|
||||
RAISE EXCEPTION 'active meeting recording not found' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Same as 20260821000025 (language SSOT binding) plus the first-completion
|
||||
-- replacement of the previous recording's content.
|
||||
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;
|
||||
first_completion boolean;
|
||||
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;
|
||||
|
||||
-- A replay of an already committed job must not wipe minutes generated
|
||||
-- from this very transcript after it was committed.
|
||||
first_completion := owned_job.status IS DISTINCT FROM 'succeeded';
|
||||
|
||||
IF first_completion THEN
|
||||
DELETE FROM public.transcripts WHERE meeting_id = p_meeting_id;
|
||||
UPDATE public.audio_files
|
||||
SET meeting_id = NULL
|
||||
WHERE meeting_id = p_meeting_id
|
||||
AND user_id = current_user_id
|
||||
AND id <> p_audio_file_id;
|
||||
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,
|
||||
minutes_markdown = CASE WHEN first_completion THEN NULL ELSE minutes_markdown END,
|
||||
minutes_json = CASE WHEN first_completion THEN NULL ELSE minutes_json END,
|
||||
llm_model = CASE WHEN first_completion THEN NULL ELSE llm_model END,
|
||||
llm_latency_ms = CASE WHEN first_completion THEN NULL ELSE llm_latency_ms END,
|
||||
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_begin_meeting_recording(uuid) FROM PUBLIC, anon;
|
||||
REVOKE ALL ON FUNCTION public.mobile_cancel_meeting_recording(uuid) FROM PUBLIC, anon;
|
||||
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_begin_meeting_recording(uuid) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.mobile_cancel_meeting_recording(uuid) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.mobile_complete_meeting_processing(
|
||||
uuid, uuid, text, text, text, text, bigint, integer
|
||||
) TO authenticated;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
\set ON_ERROR_STOP on
|
||||
|
||||
-- Re-recording a meeting keeps its previous transcript until the new capture
|
||||
-- commits one (migration 20260929002700).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF condition IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'assertion_failed: %', message;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
INSERT INTO auth.users (
|
||||
id, aud, role, email, encrypted_password, email_confirmed_at,
|
||||
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
||||
) VALUES (
|
||||
'81000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated',
|
||||
'rerecord-owner@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
||||
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
||||
);
|
||||
|
||||
INSERT INTO public.meetings (
|
||||
id, user_id, title, status, raw_transcript, minutes_markdown, llm_model
|
||||
) VALUES
|
||||
(
|
||||
'82000000-0000-4000-8000-000000000001',
|
||||
'81000000-0000-4000-8000-000000000001',
|
||||
'Completed meeting', 'completed', 'old transcript', 'old minutes', 'old-llm'
|
||||
),
|
||||
(
|
||||
'82000000-0000-4000-8000-000000000002',
|
||||
'81000000-0000-4000-8000-000000000001',
|
||||
'Empty meeting', 'recording', NULL, NULL, NULL
|
||||
);
|
||||
|
||||
INSERT INTO public.transcripts (meeting_id, segment_index, timestamp_ms, duration_ms, text)
|
||||
VALUES
|
||||
('82000000-0000-4000-8000-000000000001', 0, 0, 1000, 'old segment 0'),
|
||||
('82000000-0000-4000-8000-000000000001', 1, 1000, 1000, 'old segment 1');
|
||||
|
||||
INSERT INTO public.audio_files (
|
||||
id, user_id, meeting_id, source, original_name, storage_key, mime_type,
|
||||
size_bytes, duration_ms, sha256, upload_status
|
||||
) VALUES (
|
||||
'83000000-0000-4000-8000-000000000001',
|
||||
'81000000-0000-4000-8000-000000000001',
|
||||
'82000000-0000-4000-8000-000000000001',
|
||||
'recording', 'old.wav',
|
||||
'81000000-0000-4000-8000-000000000001/imports/old.wav',
|
||||
'audio/wav', 32044, 1000, repeat('b', 64), 'uploaded'
|
||||
);
|
||||
|
||||
SET LOCAL ROLE authenticated;
|
||||
SELECT set_config(
|
||||
'request.jwt.claims',
|
||||
'{"sub":"81000000-0000-4000-8000-000000000001","role":"authenticated"}',
|
||||
true
|
||||
);
|
||||
|
||||
-- 1) Starting a re-record changes state only.
|
||||
SELECT public.mobile_begin_meeting_recording('82000000-0000-4000-8000-000000000001');
|
||||
SELECT pg_temp.assert_true(
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.meetings
|
||||
WHERE id = '82000000-0000-4000-8000-000000000001'
|
||||
AND status = 'recording'
|
||||
AND raw_transcript = 'old transcript'
|
||||
AND minutes_markdown = 'old minutes'
|
||||
),
|
||||
'begin keeps the previous transcript and minutes'
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
(SELECT count(*) FROM public.transcripts
|
||||
WHERE meeting_id = '82000000-0000-4000-8000-000000000001') = 2,
|
||||
'begin keeps the previous transcript rows'
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.audio_files
|
||||
WHERE id = '83000000-0000-4000-8000-000000000001'
|
||||
AND meeting_id = '82000000-0000-4000-8000-000000000001'
|
||||
),
|
||||
'begin keeps the previous audio linked'
|
||||
);
|
||||
|
||||
-- 2) Cancelling the re-record restores the completed meeting.
|
||||
SELECT public.mobile_cancel_meeting_recording('82000000-0000-4000-8000-000000000001');
|
||||
SELECT pg_temp.assert_true(
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.meetings
|
||||
WHERE id = '82000000-0000-4000-8000-000000000001'
|
||||
AND status = 'completed'
|
||||
AND error_message IS NULL
|
||||
AND raw_transcript = 'old transcript'
|
||||
),
|
||||
'cancel returns a meeting with content to completed'
|
||||
);
|
||||
|
||||
-- 3) Cancelling a first recording still reports an error.
|
||||
SELECT public.mobile_cancel_meeting_recording('82000000-0000-4000-8000-000000000002');
|
||||
SELECT pg_temp.assert_true(
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.meetings
|
||||
WHERE id = '82000000-0000-4000-8000-000000000002'
|
||||
AND status = 'error'
|
||||
AND error_message = 'Recording was cancelled before processing.'
|
||||
),
|
||||
'cancel of an empty meeting keeps the error outcome'
|
||||
);
|
||||
|
||||
-- 4) A committed re-record replaces the previous content.
|
||||
SELECT public.mobile_begin_meeting_recording('82000000-0000-4000-8000-000000000001');
|
||||
RESET ROLE;
|
||||
INSERT INTO public.audio_files (
|
||||
id, user_id, meeting_id, source, original_name, storage_key, mime_type,
|
||||
size_bytes, duration_ms, sha256, upload_status
|
||||
) VALUES (
|
||||
'83000000-0000-4000-8000-000000000002',
|
||||
'81000000-0000-4000-8000-000000000001',
|
||||
'82000000-0000-4000-8000-000000000001',
|
||||
'recording', 'new.wav',
|
||||
'81000000-0000-4000-8000-000000000001/imports/new.wav',
|
||||
'audio/wav', 32044, 2000, repeat('c', 64), 'uploaded'
|
||||
);
|
||||
SET LOCAL ROLE authenticated;
|
||||
SELECT set_config(
|
||||
'request.jwt.claims',
|
||||
'{"sub":"81000000-0000-4000-8000-000000000001","role":"authenticated"}',
|
||||
true
|
||||
);
|
||||
SELECT public.mobile_queue_meeting_recording('82000000-0000-4000-8000-000000000001', 2000);
|
||||
SELECT public.mobile_begin_meeting_processing(
|
||||
'82000000-0000-4000-8000-000000000001',
|
||||
'83000000-0000-4000-8000-000000000002',
|
||||
'mobile-meeting:rerecord:cccccccc'
|
||||
);
|
||||
SELECT public.mobile_complete_meeting_processing(
|
||||
'82000000-0000-4000-8000-000000000001',
|
||||
'83000000-0000-4000-8000-000000000002',
|
||||
'mobile-meeting:rerecord:cccccccc',
|
||||
'new transcript', 'ko', 'fixture-stt', 2000, 90
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.meetings
|
||||
WHERE id = '82000000-0000-4000-8000-000000000001'
|
||||
AND status = 'completed'
|
||||
AND raw_transcript = 'new transcript'
|
||||
AND minutes_markdown IS NULL
|
||||
AND llm_model IS NULL
|
||||
AND audio_storage_key = '81000000-0000-4000-8000-000000000001/imports/new.wav'
|
||||
),
|
||||
'completion replaces transcript and clears stale minutes'
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
(SELECT array_agg(text ORDER BY segment_index) FROM public.transcripts
|
||||
WHERE meeting_id = '82000000-0000-4000-8000-000000000001') = ARRAY['new transcript'],
|
||||
'completion removes every previous transcript row'
|
||||
);
|
||||
RESET ROLE;
|
||||
SELECT pg_temp.assert_true(
|
||||
(SELECT meeting_id FROM public.audio_files
|
||||
WHERE id = '83000000-0000-4000-8000-000000000001') IS NULL,
|
||||
'completion detaches the previous audio'
|
||||
);
|
||||
|
||||
-- 5) Replaying the committed job does not wipe minutes generated afterwards.
|
||||
UPDATE public.meetings
|
||||
SET minutes_markdown = 'fresh minutes'
|
||||
WHERE id = '82000000-0000-4000-8000-000000000001';
|
||||
SET LOCAL ROLE authenticated;
|
||||
SELECT set_config(
|
||||
'request.jwt.claims',
|
||||
'{"sub":"81000000-0000-4000-8000-000000000001","role":"authenticated"}',
|
||||
true
|
||||
);
|
||||
SELECT public.mobile_complete_meeting_processing(
|
||||
'82000000-0000-4000-8000-000000000001',
|
||||
'83000000-0000-4000-8000-000000000002',
|
||||
'mobile-meeting:rerecord:cccccccc',
|
||||
'new transcript', 'ko', 'fixture-stt', 2000, 90
|
||||
);
|
||||
SELECT pg_temp.assert_true(
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.meetings
|
||||
WHERE id = '82000000-0000-4000-8000-000000000001'
|
||||
AND minutes_markdown = 'fresh minutes'
|
||||
),
|
||||
'a replayed completion keeps minutes generated from the committed transcript'
|
||||
);
|
||||
|
||||
RESET ROLE;
|
||||
ROLLBACK;
|
||||
Loading…
Add table
Add a link
Reference in a new issue