fix(mobile-recording): never lose a meeting capture on offline stop, oversize, or re-record

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 80538c0f23
commit 5b1621d314
9 changed files with 1238 additions and 56 deletions

View file

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

View file

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