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;