diff --git a/apps/mobile-rn/__tests__/record-screen-redteam-r2-27.test.tsx b/apps/mobile-rn/__tests__/record-screen-redteam-r2-27.test.tsx new file mode 100644 index 0000000..db150da --- /dev/null +++ b/apps/mobile-rn/__tests__/record-screen-redteam-r2-27.test.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import type { AudioPipelineResult, LocalAudioInput } from '../src/features/import/audio-import-types' + +const USER_ID = '11111111-1111-4111-8111-111111111111' +const MEETING_ID = '22222222-2222-4222-8222-222222222222' + +const mockBeginMeetingRecording = jest.fn(async () => ({})) +const mockCancelMeetingRecording = jest.fn(async () => ({})) +const mockFailMeetingRecording = jest.fn(async () => ({})) +const mockGetMeetingRecordingLanguage = jest.fn(async (): Promise => 'ko') +const mockEnqueueAudioProcessing = jest.fn() +const mockProcessQueuedAudioNow = jest.fn() +const mockValidateLocalAudio = jest.fn() +const mockCleanup = jest.fn(async () => undefined) +const mockStart = jest.fn(async () => undefined) +const mockStop = jest.fn() + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})) +jest.mock('@d3ro/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key, locale: 'ko' }), +})) +jest.mock('../src/lib/auth-context', () => ({ + useAuth: () => ({ user: { id: '11111111-1111-4111-8111-111111111111' } }), +})) +jest.mock('../src/lib/preferences-context', () => ({ + useMobilePreferences: () => ({ + palette: { + bg: { app: 'app', card: 'card', inset: 'inset' }, + accent: { main: 'accent' }, + tag: { red: 'red' }, + }, + }), +})) +jest.mock('@d3ro/ui-native', () => { + const { Pressable, Text, View } = require('react-native') as typeof import('react-native') + const Passthrough = ({ children }: React.PropsWithChildren>) => {children} + return { + AppStatusBar: () => null, + Header: () => null, + Led: () => null, + WaveBars: () => null, + NativeThemeProvider: Passthrough, + PhosphorText: ({ children, ...props }: React.PropsWithChildren>) => ( + {children} + ), + PhysicalButton: ({ label, onPress, disabled, testID }: { + label: string + onPress?: () => void + disabled?: boolean + testID?: string + }) => ( + + {label} + + ), + d3roNativeFonts: { mono: 'mono' }, + } +}) +jest.mock('../src/lib/audio-recorder', () => { + class RecorderBusyError extends Error {} + const session = { + restore: async () => ({ state: 'idle', recording: null, meetingId: null, interruptionReason: null }), + start: () => mockStart(), + stop: () => mockStop(), + cleanup: () => mockCleanup(), + cancel: async () => undefined, + pause: async () => undefined, + resume: async () => undefined, + } + return { + RecorderBusyError, + audioRecorder: { + acquire: async () => session, + requestPermission: async () => true, + }, + } +}) +jest.mock('../src/features/import/audio-validation', () => ({ + validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options), +})) +jest.mock('../src/features/import/audio-file-picker', () => ({ pickAudioFile: jest.fn() })) +jest.mock('../src/features/import/incoming-media-intent', () => ({ consumeIncomingMediaAudio: jest.fn() })) +jest.mock('../src/features/notifications/notification-native', () => ({ + showLocalTranscriptionCompleted: jest.fn(async () => true), +})) +jest.mock('../src/features/recording/durable-processing-queue', () => { + class QueuedAudioPendingError extends Error {} + return { + QueuedAudioPendingError, + discardQueuedAudio: jest.fn(async () => undefined), + enqueueAudioProcessing: (...args: unknown[]) => mockEnqueueAudioProcessing(...args), + processQueuedAudioNow: (...args: unknown[]) => mockProcessQueuedAudioNow(...args), + retryQueuedAudio: jest.fn(async () => undefined), + subscribeToDurableQueue: () => () => undefined, + } +}) +jest.mock('../src/features/meetings/meetings-service', () => ({ + beginMeetingRecording: (...args: unknown[]) => mockBeginMeetingRecording(...(args as [])), + cancelMeetingRecording: (...args: unknown[]) => mockCancelMeetingRecording(...(args as [])), + failMeetingRecording: (...args: unknown[]) => mockFailMeetingRecording(...(args as [])), + getMeetingRecordingLanguage: (...args: unknown[]) => mockGetMeetingRecordingLanguage(...(args as [])), +})) + +import RecordScreen from '../src/screens/RecordScreen' +import { AudioPipelineError } from '../src/features/import/audio-import-types' + +const RECORDING = { + uri: 'file:///mock/capture.wav', + path: '/mock/capture.wav', + fileName: 'capture.wav', + mimeType: 'audio/wav', + size: 64, + durationMs: 20 * 60_000, +} + +function validInput(): LocalAudioInput { + return { + uri: RECORDING.uri, + path: RECORDING.path, + fileName: RECORDING.fileName, + mimeType: 'audio/wav', + sizeBytes: RECORDING.size, + durationMs: RECORDING.durationMs, + source: 'recording', + dispose: async () => undefined, + } +} + +function meetingResult(): AudioPipelineResult { + return { + historyId: null, + audioFileId: 'audio', + meetingId: MEETING_ID, + processingJobId: 'job', + transcript: 'meeting transcript', + provider: 'test', + language: 'ko', + durationSeconds: 1, + deduplicated: false, + } +} + +type Params = { meetingId?: string; meetingTitle?: string; meetingLanguage?: string } + +async function render(params: Params, setParams: (next: Params) => void): Promise { + let renderer: ReactTestRenderer | undefined + await act(async () => { + renderer = create( + undefined, setParams }} + />, + ) + }) + if (renderer === undefined) throw new Error('RecordScreen did not render') + return renderer +} + +async function press(renderer: ReactTestRenderer, testID: string): Promise { + await act(async () => { + renderer.root.findByProps({ testID }).props.onPress() + }) +} + +function has(renderer: ReactTestRenderer, testID: string): boolean { + return renderer.root.findAllByProps({ testID }).length > 0 +} + +describe('RecordScreen meeting durability (redteam r2-27)', () => { + beforeEach(() => { + jest.clearAllMocks() + mockStop.mockResolvedValue(RECORDING) + mockValidateLocalAudio.mockImplementation(async () => validInput()) + }) + + test('a hand-off that fails before persistence keeps the capture recoverable', async () => { + mockEnqueueAudioProcessing.mockRejectedValue(new AudioPipelineError('file-read', 'disk full')) + const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, jest.fn()) + + await press(renderer, 'record-start') + await press(renderer, 'record-stop') + + expect(mockCleanup).not.toHaveBeenCalled() + expect(has(renderer, 'record-process-recovered')).toBe(true) + expect(has(renderer, 'record-discard-recovered')).toBe(true) + }) + + test('a recording over the upload limit is kept instead of deleted on STOP', async () => { + mockValidateLocalAudio.mockRejectedValue( + new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'), + ) + const renderer = await render({}, jest.fn()) + + await press(renderer, 'record-start') + await press(renderer, 'record-stop') + + expect(mockCleanup).not.toHaveBeenCalled() + expect(mockEnqueueAudioProcessing).not.toHaveBeenCalled() + expect(has(renderer, 'record-process-recovered')).toBe(true) + }) + + test('an offline meeting language lookup does not block the hand-off', async () => { + mockGetMeetingRecordingLanguage.mockRejectedValueOnce(new TypeError('Network request failed')) + mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' }) + mockProcessQueuedAudioNow.mockResolvedValue(meetingResult()) + const renderer = await render({ meetingId: MEETING_ID }, jest.fn()) + + await press(renderer, 'record-start') + await press(renderer, 'record-stop') + + expect(mockEnqueueAudioProcessing).toHaveBeenCalledTimes(1) + expect(has(renderer, 'record-transcript-result')).toBe(true) + }) + + test('a finished meeting capture releases the binding so NEW RECORDING cannot restart it', async () => { + mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' }) + mockProcessQueuedAudioNow.mockResolvedValue(meetingResult()) + const setParams = jest.fn() + const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, setParams) + + await press(renderer, 'record-start') + expect(mockBeginMeetingRecording).toHaveBeenCalledWith(USER_ID, MEETING_ID) + await press(renderer, 'record-stop') + + expect(setParams).toHaveBeenCalledWith({ + meetingId: undefined, + meetingTitle: undefined, + meetingLanguage: undefined, + }) + expect(has(renderer, 'record-view-meeting')).toBe(true) + expect(has(renderer, 'record-meeting-context')).toBe(false) + + // The tab bar can bring the screen back with its old params still set. + await press(renderer, 'record-start') + expect(mockBeginMeetingRecording).toHaveBeenCalledTimes(1) + expect(mockStart).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/mobile-rn/__tests__/recording-durability-redteam-r2-27.test.ts b/apps/mobile-rn/__tests__/recording-durability-redteam-r2-27.test.ts new file mode 100644 index 0000000..c463a79 --- /dev/null +++ b/apps/mobile-rn/__tests__/recording-durability-redteam-r2-27.test.ts @@ -0,0 +1,281 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { FileSystem } from 'react-native-file-access' +import { + AudioPipelineError, + MAX_IMPORT_BYTES, + type AudioPipelineResult, + type LocalAudioInput, +} from '../src/features/import/audio-import-types' +import type { RecordedAudio } from '../src/lib/audio-recorder' + +const mockProcessAudioInput = jest.fn, [LocalAudioInput, unknown]>() +const mockQueueMeetingRecording = jest.fn, [string, string, number]>() +const mockMarkMeetingProcessingFailure = jest.fn(async () => undefined) +const mockFailMeetingRecording = jest.fn(async () => undefined) +const mockValidateLocalAudio = jest.fn, [unknown]>() + +jest.mock('../src/features/import/audio-transcription-service', () => ({ + processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options), +})) +jest.mock('../src/features/import/audio-validation', () => ({ + validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options), +})) +jest.mock('../src/features/meetings/meetings-service', () => { + class MeetingServiceError extends Error { + readonly code: string + constructor(code: string, message: string) { + super(message) + this.code = code + this.name = 'MeetingServiceError' + } + } + return { + MeetingServiceError, + queueMeetingRecording: (userId: string, meetingId: string, durationMs: number) => + mockQueueMeetingRecording(userId, meetingId, durationMs), + markMeetingProcessingFailure: () => mockMarkMeetingProcessingFailure(), + failMeetingRecording: () => mockFailMeetingRecording(), + } +}) + +import { MeetingServiceError } from '../src/features/meetings/meetings-service' +import { + clearQueuedAudioForUser, + durableQueueTestContract, + enqueueAudioProcessing, + processQueuedAudioNow, + QueuedAudioPendingError, + type DurableQueueItem, +} from '../src/features/recording/durable-processing-queue' +import { classifyMarkQueuedFailure } from '../src/features/recording/meeting-lifecycle-port' +import { mayDiscardOriginalOnFailure } from '../src/features/import/audio-input-ownership' +import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input' + +const START_MS = 1_700_000_000_000 +const USER = '44444444-4444-4444-8444-444444444444' +const MEETING = '55555555-5555-4555-8555-555555555555' +const ORIGINAL = '/mock/CacheDir/meeting-capture.wav' + +const mockFileSystem = FileSystem as typeof FileSystem & { + filesystem: Map + mkdir: (path: string) => Promise +} + +function recordingInput(dispose: () => Promise, source: LocalAudioInput['source'] = 'recording'): LocalAudioInput { + return { + uri: `file://${ORIGINAL}`, + path: ORIGINAL, + fileName: 'meeting-capture.wav', + mimeType: 'audio/wav', + sizeBytes: 5, + durationMs: 1_234, + source, + dispose, + } +} + +function result(): AudioPipelineResult { + return { + historyId: null, + audioFileId: 'audio-file', + meetingId: MEETING, + processingJobId: 'job', + transcript: 'minutes', + provider: 'test', + language: 'ko', + durationSeconds: 1, + deduplicated: false, + } +} + +async function stored(): Promise { + return durableQueueTestContract.parseQueue( + await AsyncStorage.getItem(durableQueueTestContract.storageKey), + ) +} + +describe('meeting recording durability (redteam r2-27)', () => { + beforeEach(async () => { + jest.useFakeTimers({ now: START_MS }) + await AsyncStorage.clear() + mockFileSystem.filesystem.clear() + mockFileSystem.filesystem.set(ORIGINAL, 'audio') + // The upstream jest mock has no mkdir; the queue creates its directory. + mockFileSystem.mkdir = jest.fn(async (path: string) => path) + mockProcessAudioInput.mockReset() + mockQueueMeetingRecording.mockReset() + mockMarkMeetingProcessingFailure.mockClear() + mockFailMeetingRecording.mockClear() + mockValidateLocalAudio.mockReset() + }) + + afterEach(async () => { + await clearQueuedAudioForUser(USER).catch(() => undefined) + for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0) + jest.useRealTimers() + }) + + test('an offline stop keeps the meeting recording queued instead of deleting it', async () => { + mockQueueMeetingRecording.mockRejectedValue( + new MeetingServiceError('network', 'Network request failed'), + ) + const dispose = jest.fn(async () => { + mockFileSystem.filesystem.delete(ORIGINAL) + }) + + const item = await enqueueAudioProcessing(recordingInput(dispose), { + userId: USER, + languageCode: 'ko', + meetingId: MEETING, + }) + + // Enqueue only persists; it never talks to the server. + expect(mockQueueMeetingRecording).not.toHaveBeenCalled() + expect(item).toMatchObject({ meetingId: MEETING, meetingQueuePending: true, status: 'pending' }) + expect(dispose).toHaveBeenCalledTimes(1) + expect(mockFileSystem.filesystem.get(item.path)).toBe('audio') + + await expect(processQueuedAudioNow(USER, item.id)).rejects.toBeInstanceOf(QueuedAudioPendingError) + expect(mockProcessAudioInput).not.toHaveBeenCalled() + const [retrying] = await stored() + expect(retrying).toMatchObject({ + id: item.id, + status: 'retry', + lastErrorCode: 'persist', + meetingQueuePending: true, + }) + expect(mockFileSystem.filesystem.get(item.path)).toBe('audio') + + // Back online: the idempotent mark runs first, then processing. + mockQueueMeetingRecording.mockResolvedValue({}) + mockProcessAudioInput.mockResolvedValue(result()) + await expect(processQueuedAudioNow(USER, item.id)).resolves.toMatchObject({ transcript: 'minutes' }) + expect(mockQueueMeetingRecording).toHaveBeenLastCalledWith(USER, MEETING, 1_234) + expect(mockQueueMeetingRecording).toHaveBeenCalledTimes(2) + expect(await stored()).toEqual([]) + expect(mockFileSystem.filesystem.has(item.path)).toBe(false) + }) + + test('a successful mark is not repeated when processing itself is retried', async () => { + mockQueueMeetingRecording.mockResolvedValue({}) + mockProcessAudioInput.mockRejectedValueOnce(new AudioPipelineError('upload', 'offline')) + const item = await enqueueAudioProcessing(recordingInput(async () => undefined), { + userId: USER, + languageCode: 'ko', + meetingId: MEETING, + }) + + await expect(processQueuedAudioNow(USER, item.id)).rejects.toBeInstanceOf(QueuedAudioPendingError) + expect((await stored())[0]).toMatchObject({ status: 'retry', meetingQueuePending: false }) + + mockProcessAudioInput.mockResolvedValue(result()) + await processQueuedAudioNow(USER, item.id) + // ended_at on the server is not pushed to the retry time. + expect(mockQueueMeetingRecording).toHaveBeenCalledTimes(1) + }) + + test('a meeting the server no longer considers recordable is handed to processing, not retried forever', async () => { + mockQueueMeetingRecording.mockRejectedValue( + new MeetingServiceError('validation', 'recordable meeting not found'), + ) + mockProcessAudioInput.mockResolvedValue(result()) + const item = await enqueueAudioProcessing(recordingInput(async () => undefined), { + userId: USER, + languageCode: 'ko', + meetingId: MEETING, + }) + + await expect(processQueuedAudioNow(USER, item.id)).resolves.toMatchObject({ meetingId: MEETING }) + expect(mockProcessAudioInput).toHaveBeenCalledTimes(1) + }) + + test('items persisted before the pending flag existed are not re-marked', async () => { + mockProcessAudioInput.mockResolvedValue(result()) + const path = `${durableQueueTestContract.queueDirectory}/queued-abc123.wav` + mockFileSystem.filesystem.set(path, 'audio') + const legacy: DurableQueueItem = { + schemaVersion: 1, + id: 'abc123', + userId: USER, + meetingId: MEETING, + path, + uri: `file://${path}`, + fileName: 'legacy.wav', + mimeType: 'audio/wav', + sizeBytes: 5, + durationMs: 100, + source: 'recording', + languageCode: 'ko', + status: 'pending', + phase: null, + attempts: 0, + uploadedBytes: 0, + nextAttemptAtMs: START_MS, + lastErrorCode: null, + lastErrorMessage: null, + createdAtMs: 1, + updatedAtMs: 1, + } + await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify([legacy])) + + await processQueuedAudioNow(USER, legacy.id) + expect(mockQueueMeetingRecording).not.toHaveBeenCalled() + expect(durableQueueTestContract.parseQueue(JSON.stringify([{ ...legacy, meetingQueuePending: 'yes' }]))).toEqual([]) + }) + + test('a failed copy keeps the original recording but still cleans an imported temp copy', async () => { + const recordingDispose = jest.fn(async () => undefined) + mockFileSystem.filesystem.delete(ORIGINAL) + await expect(enqueueAudioProcessing(recordingInput(recordingDispose), { + userId: USER, + languageCode: 'ko', + meetingId: MEETING, + })).rejects.toThrow() + expect(recordingDispose).not.toHaveBeenCalled() + expect(await stored()).toEqual([]) + + const importDispose = jest.fn(async () => undefined) + await expect(enqueueAudioProcessing(recordingInput(importDispose, 'file-picker'), { + userId: USER, + languageCode: 'ko', + })).rejects.toThrow() + expect(importDispose).toHaveBeenCalledTimes(1) + }) + + test('a recording over the upload limit is retained for Record and still disposed for Talk', async () => { + const recording: RecordedAudio = { + uri: `file://${ORIGINAL}`, + path: ORIGINAL, + fileName: 'meeting-capture.wav', + mimeType: 'audio/wav', + size: MAX_IMPORT_BYTES + 1, + durationMs: 20 * 60_000, + } as RecordedAudio + mockValidateLocalAudio.mockRejectedValue( + new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'), + ) + + const retained = jest.fn(async () => undefined) + await expect(prepareRecordedAudio(recording, retained, { onInvalid: 'retain' })) + .rejects.toMatchObject({ code: 'file-too-large' }) + expect(retained).not.toHaveBeenCalled() + + const talk = jest.fn(async () => undefined) + await expect(prepareRecordedAudio(recording, talk)).rejects.toMatchObject({ code: 'file-too-large' }) + expect(talk).toHaveBeenCalledTimes(1) + }) + + test('ownership and mark-queued policies', () => { + expect(mayDiscardOriginalOnFailure('recording')).toBe(false) + expect(mayDiscardOriginalOnFailure('file-picker')).toBe(true) + expect(mayDiscardOriginalOnFailure('share-intent')).toBe(true) + + expect(classifyMarkQueuedFailure(new MeetingServiceError('network', 'x'))).toBe('retry') + expect(classifyMarkQueuedFailure(new MeetingServiceError('auth', 'x'))).toBe('retry') + expect(classifyMarkQueuedFailure(new MeetingServiceError('server', 'x'))).toBe('retry') + expect(classifyMarkQueuedFailure(new MeetingServiceError('validation', 'x'))).toBe('proceed') + expect(classifyMarkQueuedFailure(new MeetingServiceError('forbidden', 'x'))).toBe('proceed') + expect(classifyMarkQueuedFailure(new MeetingServiceError('not-found', 'x'))).toBe('proceed') + expect(classifyMarkQueuedFailure(new TypeError('Network request failed'))).toBe('retry') + }) +}) diff --git a/apps/mobile-rn/src/features/import/audio-input-ownership.ts b/apps/mobile-rn/src/features/import/audio-input-ownership.ts new file mode 100644 index 0000000..2dbc392 --- /dev/null +++ b/apps/mobile-rn/src/features/import/audio-input-ownership.ts @@ -0,0 +1,18 @@ +import type { LocalAudioInput } from './audio-import-types'; + +/** + * Who may delete the original audio when a hand-off fails before the audio + * is safely persisted somewhere else. + * + * A capture is the only copy of what the user said, so a failed hand-off must + * leave it recoverable for the caller (Record keeps it as a recovered + * recording the user can process again or discard explicitly). Picked and + * shared files are app-owned temporary copies of a file the user still has + * elsewhere, so they are cleaned up as before. + */ +export function mayDiscardOriginalOnFailure(source: LocalAudioInput['source']): boolean { + return source !== 'recording'; +} + +/** What prepareRecordedAudio does with the capture when validation rejects it. */ +export type InvalidRecordingPolicy = 'dispose' | 'retain'; diff --git a/apps/mobile-rn/src/features/import/recorded-audio-input.ts b/apps/mobile-rn/src/features/import/recorded-audio-input.ts index 069a26b..8bd7bf0 100644 --- a/apps/mobile-rn/src/features/import/recorded-audio-input.ts +++ b/apps/mobile-rn/src/features/import/recorded-audio-input.ts @@ -1,10 +1,22 @@ import type { RecordedAudio } from '../../lib/audio-recorder'; import type { LocalAudioInput } from './audio-import-types'; +import type { InvalidRecordingPolicy } from './audio-input-ownership'; import { validateLocalAudio } from './audio-validation'; +export interface PrepareRecordedAudioOptions { + /** + * 'dispose' (default) deletes a capture that fails validation, which suits + * ephemeral dictation (Talk). 'retain' leaves it untouched so the caller can + * keep it recoverable: a long meeting over the upload size limit must not + * be deleted the moment the user presses STOP. + */ + onInvalid?: InvalidRecordingPolicy; +} + export async function prepareRecordedAudio( recording: RecordedAudio, dispose: () => Promise, + options: PrepareRecordedAudioOptions = {}, ): Promise { try { return await validateLocalAudio({ @@ -18,7 +30,7 @@ export async function prepareRecordedAudio( dispose, }); } catch (error) { - await dispose(); + if ((options.onInvalid ?? 'dispose') === 'dispose') await dispose(); throw error; } } diff --git a/apps/mobile-rn/src/features/recording/durable-processing-queue.ts b/apps/mobile-rn/src/features/recording/durable-processing-queue.ts index 085649c..36e23c4 100644 --- a/apps/mobile-rn/src/features/recording/durable-processing-queue.ts +++ b/apps/mobile-rn/src/features/recording/durable-processing-queue.ts @@ -8,14 +8,15 @@ import { type LocalAudioInput, } from '../import/audio-import-types'; import { processAudioInput } from '../import/audio-transcription-service'; -import { - failMeetingRecording, - markMeetingProcessingFailure, - queueMeetingRecording, -} from '../meetings/meetings-service'; +import { mayDiscardOriginalOnFailure } from '../import/audio-input-ownership'; import { createUuidV4 } from '../../lib/random-id'; import { createQueueRepository, type QueueRepository } from './durable-queue-repository'; import { createRetryScheduler, type RetryScheduler } from './retry-scheduler'; +import { + classifyMarkQueuedFailure, + meetingsServiceLifecyclePort, + type MeetingLifecyclePort, +} from './meeting-lifecycle-port'; const STORAGE_KEY = '@d3ro/audio-processing-queue/v1'; const QUEUE_DIRECTORY = `${Dirs.DocumentDir}/d3ro-processing-queue`; @@ -51,6 +52,13 @@ export interface DurableQueueItem { lastErrorMessage: string | null; createdAtMs: number; updatedAtMs: number; + /** + * True while the server has not yet been told that this meeting capture + * stopped and is waiting for processing. Absent on non-meeting items and on + * items persisted before this field existed (their enqueue only kept the + * item after that call had succeeded). + */ + meetingQueuePending?: boolean; } export type DurableQueueEvent = @@ -125,7 +133,8 @@ function parseQueue(raw: string | null): DurableQueueItem[] { (item.lastErrorCode === null || (typeof item.lastErrorCode === 'string' && item.lastErrorCode.length <= 64)) && (item.lastErrorMessage === null || (typeof item.lastErrorMessage === 'string' && item.lastErrorMessage.length <= MAX_ERROR_LENGTH)) && typeof item.createdAtMs === 'number' && Number.isFinite(item.createdAtMs) && - typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs) + typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs) && + (item.meetingQueuePending === undefined || typeof item.meetingQueuePending === 'boolean') ); }); } @@ -137,6 +146,8 @@ const repository: QueueRepository = createQueueRepository({ parse: parseQueue, }); +let meetingLifecycle: MeetingLifecyclePort = meetingsServiceLifecyclePort; + const scheduler: RetryScheduler = createRetryScheduler({ now: () => Date.now(), setTimer: (callback, delayMs) => setTimeout(callback, delayMs), @@ -176,22 +187,34 @@ function fileExtension(fileName: string, mimeType: string): string { return 'audio'; } +/** + * Makes the audio durable and nothing else: copy into queue storage, verify + * the copy, persist the item. The caller's original is released only after + * the item is persisted. When persisting fails, a recording is left untouched + * so the caller can keep it recoverable; an imported temporary copy is + * cleaned up as before. Server state transitions happen later, in the + * retryable processing step, so a network error can never undo a persisted + * item. + */ export async function enqueueAudioProcessing( input: LocalAudioInput, options: { userId: string; languageCode: MeetingLanguage; meetingId?: string }, ): Promise { + const releaseOnFailure = async (): Promise => { + if (mayDiscardOriginalOnFailure(input.source)) await input.dispose(); + }; if (!/^[0-9a-f-]{36}$/i.test(options.userId)) { - await input.dispose(); + await releaseOnFailure(); throw new AudioPipelineError('auth', 'A valid account is required for the processing queue'); } if (options.meetingId !== undefined && !/^[0-9a-f-]{36}$/i.test(options.meetingId)) { - await input.dispose(); + await releaseOnFailure(); throw new AudioPipelineError('persist', 'Meeting id is invalid'); } const id = queueId(); const target = `${QUEUE_DIRECTORY}/queued-${id}.${fileExtension(input.fileName, input.mimeType)}`; - let persisted = false; + let item: DurableQueueItem; try { await ensureQueueDirectory(); await FileSystem.cp(input.path, target); @@ -200,7 +223,7 @@ export async function enqueueAudioProcessing( throw new AudioPipelineError('file-read', 'Durable audio copy could not be verified'); } const now = Date.now(); - const item: DurableQueueItem = { + const created: DurableQueueItem = { schemaVersion: 1, id, userId: options.userId, @@ -222,25 +245,19 @@ export async function enqueueAudioProcessing( lastErrorMessage: null, createdAtMs: now, updatedAtMs: now, + ...(options.meetingId !== undefined ? { meetingQueuePending: true } : {}), }; - await mutateQueue(items => [...items.filter(current => current.id !== id), item]); - persisted = true; - if (item.meetingId !== null) { - await queueMeetingRecording( - item.userId, - item.meetingId, - item.durationMs ?? 0, - ); - } - emit({ type: 'changed', item }); - return item; + await mutateQueue(items => [...items.filter(current => current.id !== id), created]); + item = created; } catch (error) { - if (persisted) await mutateQueue(items => items.filter(item => item.id !== id)); await removeOwnedFile(target).catch(() => undefined); + await releaseOnFailure().catch(() => undefined); throw error; - } finally { - await input.dispose(); } + // The durable copy now owns the audio; the original is no longer needed. + await input.dispose().catch(() => undefined); + emit({ type: 'changed', item }); + return item; } function queueInput(item: DurableQueueItem): LocalAudioInput { @@ -286,33 +303,59 @@ async function reportMeetingFailure( terminal: boolean, ): Promise { if (item.meetingId === null) return; + const meetingId = item.meetingId; const idempotencyKey = await FileSystem.hash(item.path, 'SHA-256') - .then(hash => `mobile-meeting:${item.meetingId}:${hash.toLowerCase()}`) + .then(hash => `mobile-meeting:${meetingId}:${hash.toLowerCase()}`) .catch(() => null); if (idempotencyKey !== null) { try { - await markMeetingProcessingFailure( - item.userId, - item.meetingId, + await meetingLifecycle.markProcessingFailure({ + userId: item.userId, + meetingId, idempotencyKey, - error.code, - error.message.slice(0, MAX_ERROR_LENGTH), + errorCode: error.code, + errorMessage: error.message.slice(0, MAX_ERROR_LENGTH), terminal, - ); + }); return; } catch { // Upload failures happen before the server processing job exists. } } if (terminal) { - await failMeetingRecording( + await meetingLifecycle.failRecording( item.userId, - item.meetingId, + meetingId, error.message.slice(0, MAX_ERROR_LENGTH), ).catch(() => undefined); } } +/** + * First, retryable processing step for a meeting capture: tell the server the + * recording stopped and is queued. Runs until it succeeds once per item. A + * reachable server that says the meeting is not recordable is not asked again + * (see classifyMarkQueuedFailure), while an unreachable one leaves the item + * for a later attempt instead of spending an upload on it. + */ +async function markMeetingQueued(item: DurableQueueItem): Promise { + if (item.meetingId === null || item.meetingQueuePending !== true) return item; + try { + await meetingLifecycle.markQueued(item.userId, item.meetingId, item.durationMs ?? 0); + } catch (cause) { + if (classifyMarkQueuedFailure(cause) === 'retry') { + throw new AudioPipelineError('persist', 'Meeting recording could not be marked as queued', cause); + } + } + // Discarded while the call was in flight: do not resurrect the item. + if (discardedItems.has(item.id)) { + throw new AudioPipelineError('cancelled', 'Queued audio processing was cancelled'); + } + const updated: DurableQueueItem = { ...item, meetingQueuePending: false, updatedAtMs: Date.now() }; + await replaceItem(updated); + return updated; +} + async function processItemCore(item: DurableQueueItem): Promise { if (!(await FileSystem.exists(item.path))) { throw new AudioPipelineError('file-read', 'Queued audio file is missing'); @@ -329,6 +372,7 @@ async function processItemCore(item: DurableQueueItem): Promise void { + meetingLifecycle = port; + return () => { + meetingLifecycle = meetingsServiceLifecyclePort; + }; + }, }; diff --git a/apps/mobile-rn/src/features/recording/meeting-lifecycle-port.ts b/apps/mobile-rn/src/features/recording/meeting-lifecycle-port.ts new file mode 100644 index 0000000..faaf321 --- /dev/null +++ b/apps/mobile-rn/src/features/recording/meeting-lifecycle-port.ts @@ -0,0 +1,67 @@ +import { + failMeetingRecording, + markMeetingProcessingFailure, + queueMeetingRecording, +} from '../meetings/meetings-service'; + +/** + * Server-side meeting state transitions the durable queue needs. The queue + * owns the local audio; these calls only tell the server what happened to it, + * so none of them may decide whether the local audio survives. + */ +export interface MeetingLifecyclePort { + /** Idempotent: recording -> processing with the captured duration. */ + markQueued(userId: string, meetingId: string, durationMs: number): Promise; + markProcessingFailure(failure: { + userId: string; + meetingId: string; + idempotencyKey: string; + errorCode: string; + errorMessage: string; + terminal: boolean; + }): Promise; + failRecording(userId: string, meetingId: string, errorMessage: string): Promise; +} + +export const meetingsServiceLifecyclePort: MeetingLifecyclePort = { + async markQueued(userId, meetingId, durationMs) { + await queueMeetingRecording(userId, meetingId, durationMs); + }, + async markProcessingFailure(failure) { + await markMeetingProcessingFailure( + failure.userId, + failure.meetingId, + failure.idempotencyKey, + failure.errorCode, + failure.errorMessage, + failure.terminal, + ); + }, + async failRecording(userId, meetingId, errorMessage) { + await failMeetingRecording(userId, meetingId, errorMessage); + }, +}; + +/** + * What a failed markQueued means for the queued item. + * + * - 'retry': the server could not be reached or refused the session for now + * (network, auth, server, conflict). Keep the item and try again later; + * uploading now would fail for the same reason. + * - 'proceed': the server answered that the meeting is not in a recordable + * state (validation, forbidden, not-found), e.g. after this item already + * failed terminally once and the user retries it. Marking it queued will + * never succeed, so stop asking and let processing, whose begin step is the + * authoritative transition, decide. + */ +export type MeetingQueueFailurePolicy = 'retry' | 'proceed'; + +const NOT_RECORDABLE_CODES: ReadonlySet = new Set(['validation', 'forbidden', 'not-found']); + +export function classifyMarkQueuedFailure(error: unknown): MeetingQueueFailurePolicy { + // Structural check: meetings-service maps every RPC failure to a + // MeetingServiceError whose `code` is one of its MeetingServiceErrorCode. + if (!(error instanceof Error) || error.name !== 'MeetingServiceError') return 'retry'; + const code: unknown = (error as Error & { code?: unknown }).code; + return typeof code === 'string' && NOT_RECORDABLE_CODES.has(code) ? 'proceed' : 'retry'; +} diff --git a/apps/mobile-rn/src/screens/RecordScreen.tsx b/apps/mobile-rn/src/screens/RecordScreen.tsx index b1b7ec3..6a27974 100644 --- a/apps/mobile-rn/src/screens/RecordScreen.tsx +++ b/apps/mobile-rn/src/screens/RecordScreen.tsx @@ -36,6 +36,7 @@ import { type LocalAudioInput, } from '../features/import/audio-import-types'; import { prepareRecordedAudio } from '../features/import/recorded-audio-input'; +import { mayDiscardOriginalOnFailure } from '../features/import/audio-input-ownership'; import { useMobilePreferences } from '../lib/preferences-context'; import { showLocalTranscriptionCompleted } from '../features/notifications/notification-native'; import { @@ -115,10 +116,18 @@ function isPipelineState(state: RecordingState): boolean { ); } +interface RecordScreenMeetingParams { + meetingId?: string; + meetingTitle?: string; + meetingLanguage?: string; +} + interface RecordScreenProps { - route: { params?: { meetingId?: string; meetingTitle?: string; meetingLanguage?: string; incomingMediaId?: string } } + route: { params?: RecordScreenMeetingParams & { incomingMediaId?: string } } navigation: { getParent: () => { navigate: (name: 'MeetingDetail', params: { meetingId: string }) => void } | undefined + /** Clears the meeting binding once its capture is committed. */ + setParams?: (params: RecordScreenMeetingParams) => void } } @@ -139,6 +148,8 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): const [actionPending, setActionPending] = useState(false); const [sharePending, setSharePending] = useState(false); const [pendingIncomingMediaId, setPendingIncomingMediaId] = useState(null); + // Meeting whose capture produced the result on screen (for "view meeting"). + const [completedMeetingId, setCompletedMeetingId] = useState(null); const actionPendingRef = useRef(false); const mountedRef = useRef(true); const recorderActiveRef = useRef(false); @@ -146,12 +157,38 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): const queueItemIdRef = useRef(null); const recoveredRecordingRef = useRef(null); const activeMeetingIdRef = useRef(route.params?.meetingId ?? null); + // Meeting the next START binds to. MeetingDetail arms it through route + // params; it is released once that meeting's capture is committed, so a + // later "NEW RECORDING" cannot re-begin (and restart) a finished meeting. + const armedMeetingIdRef = useRef(route.params?.meetingId ?? null); // Record's owner-scoped handle on the shared recorder; see recordSession(). const recorderSessionRef = useRef(null); const routeMeetingId = route.params?.meetingId ?? null; const routeMeetingTitle = route.params?.meetingTitle ?? null; const routeIncomingMediaId = route.params?.incomingMediaId ?? null; const handledIncomingMediaIds = useRef(new Set()); + // Latest route binding, read by callbacks registered on an earlier render. + const routeMeetingIdRef = useRef(routeMeetingId); + routeMeetingIdRef.current = routeMeetingId; + + /** + * A meeting binding covers one capture. Once the capture's transcript is + * committed, Record falls back to plain recording and the route params are + * cleared, so returning through the tab bar does not re-arm the meeting. + * MeetingDetail re-arms it explicitly ("record again") with fresh params. + */ + function releaseMeetingBinding(meetingId: string | null): void { + setCompletedMeetingId(meetingId); + if (meetingId === null) return; + if (armedMeetingIdRef.current === meetingId) armedMeetingIdRef.current = null; + if (activeMeetingIdRef.current === meetingId) activeMeetingIdRef.current = null; + if (routeMeetingIdRef.current === meetingId) { + navigation.setParams?.({ meetingId: undefined, meetingTitle: undefined, meetingLanguage: undefined }); + } + } + // Latest release callback, for subscriptions registered on an earlier render. + const releaseMeetingBindingRef = useRef(releaseMeetingBinding); + releaseMeetingBindingRef.current = releaseMeetingBinding; /** * Record owns meeting captures, including one the Android foreground service @@ -222,6 +259,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): if (event.itemId !== queueItemIdRef.current || !mountedRef.current) return; queueItemIdRef.current = null; operationUserRef.current = null; + releaseMeetingBindingRef.current(event.result.meetingId ?? activeMeetingIdRef.current); setTranscript(event.result.transcript); setProvider(event.result.provider); setDurationMs(Math.max(0, Math.round(event.result.durationSeconds * 1_000))); @@ -254,6 +292,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): }, [user?.id]); useEffect(() => { + armedMeetingIdRef.current = routeMeetingId; if (!recorderActiveRef.current && recoveredRecordingRef.current === null) { activeMeetingIdRef.current = routeMeetingId; } @@ -275,19 +314,29 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): if (mountedRef.current) setActionPending(false); } + /** + * Hands the input to the durable queue and processes it. `onPersisted` runs + * once the queue owns its own copy; before that, a recording is still the + * caller's only copy and must not be deleted on failure. + */ async function processInput( input: LocalAudioInput, meetingId: string | null = activeMeetingIdRef.current, + onPersisted?: () => void, ): Promise { if (user === null) { - await input.dispose(); + if (mayDiscardOriginalOnFailure(input.source)) await input.dispose(); throw new AudioPipelineError('auth', t('mobile.rec.loginRequired')); } operationUserRef.current = user.id; - const routeLanguage = route.params?.meetingLanguage; + const routeLanguage = meetingId !== null && meetingId === route.params?.meetingId + ? route.params?.meetingLanguage + : undefined; + // The language lookup is an optimisation, not a precondition: offline it + // falls back to the app locale instead of blocking the hand-off. const storedLanguage = meetingId !== null && routeLanguage === undefined - ? await getMeetingRecordingLanguage(user.id, meetingId) + ? await getMeetingRecordingLanguage(user.id, meetingId).catch(() => null) : null; const queued = await enqueueAudioProcessing(input, { userId: user.id, @@ -295,9 +344,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): meetingId: meetingId ?? undefined, }); queueItemIdRef.current = queued.id; + onPersisted?.(); try { const result = await processQueuedAudioNow(user.id, queued.id); if (!mountedRef.current) return; + releaseMeetingBinding(result.meetingId ?? meetingId); setTranscript(result.transcript); setProvider(result.provider); setDurationMs(Math.max(0, Math.round(result.durationSeconds * 1000))); @@ -335,9 +386,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): setDeduplicated(false); setDurationMs(0); setMeteringDb(null); + setCompletedMeetingId(null); recoveredRecordingRef.current = null; setState('starting'); recorderActiveRef.current = true; + const meetingToBegin = armedMeetingIdRef.current; let meetingBegun = false; try { // Re-check on every start (not the cached handle) so a busy recorder is @@ -352,11 +405,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): setState('error'); return; } - if (routeMeetingId !== null) { + if (meetingToBegin !== null) { if (user === null) throw new AudioPipelineError('auth', t('mobile.rec.loginRequired')); - await beginMeetingRecording(user.id, routeMeetingId); + await beginMeetingRecording(user.id, meetingToBegin); meetingBegun = true; - activeMeetingIdRef.current = routeMeetingId; + activeMeetingIdRef.current = meetingToBegin; } else { activeMeetingIdRef.current = null; } @@ -367,10 +420,10 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): if (mountedRef.current) setState('recording'); } catch (recordingError) { recorderActiveRef.current = false; - if (meetingBegun && user !== null && routeMeetingId !== null) { + if (meetingBegun && user !== null && meetingToBegin !== null) { await failMeetingRecording( user.id, - routeMeetingId, + meetingToBegin, errorMessage(recordingError, t('mobile.rec.startFailed')), ).catch(() => undefined); } @@ -428,10 +481,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): } setDurationMs(recording.durationMs); setFileName(recording.fileName); - const input = await prepareRecordedAudio(recording, async () => { - await recorder.cleanup(recording); - }); - await processInput(input, activeMeetingIdRef.current); + await handOffRecording(recorder, recording); } catch (recordingError) { recorderActiveRef.current = false; if (!mountedRef.current) return; @@ -471,6 +521,40 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): if (snapshot === null || snapshot.state === 'idle') setState('error'); } + /** + * Hands a finished capture to the durable queue. Until the queue has + * persisted its own copy the capture is the only copy, so a failure before + * that point (too large to upload, unreadable, no session, storage full) + * keeps it as a recovered recording the user can process again or discard + * explicitly, instead of deleting it. Failures after that point belong to + * the queue item and are rethrown. + */ + async function handOffRecording( + recorder: RecorderSession, + recording: RecordedAudio, + ): Promise { + let persisted = false; + try { + const input = await prepareRecordedAudio( + recording, + async () => { + await recorder.cleanup(recording); + }, + { onInvalid: 'retain' }, + ); + await processInput(input, activeMeetingIdRef.current, () => { + persisted = true; + }); + } catch (handOffError) { + if (persisted) throw handOffError; + recoveredRecordingRef.current = recording; + if (!mountedRef.current) return; + setMeteringDb(null); + setError(errorMessage(handOffError, t('mobile.rec.processFailed'))); + setState('recoverable'); + } + } + async function processRecoveredRecording(): Promise { const recording = recoveredRecordingRef.current; if (recording === null || !beginAction()) return; @@ -478,11 +562,8 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): setState('validating'); try { const recorder = await recordSession(); - const input = await prepareRecordedAudio(recording, async () => { - await recorder.cleanup(recording); - }); recoveredRecordingRef.current = null; - await processInput(input, activeMeetingIdRef.current); + await handOffRecording(recorder, recording); } catch (processingError) { if (!mountedRef.current) return; setError(errorMessage(processingError, t('mobile.rec.processFailed'))); @@ -503,7 +584,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): if (meetingId !== null && user !== null) { await cancelMeetingRecording(user.id, meetingId).catch(() => undefined); } - activeMeetingIdRef.current = routeMeetingId; + activeMeetingIdRef.current = armedMeetingIdRef.current; if (mountedRef.current) { setState('idle'); setError(null); @@ -526,6 +607,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): let selectedForProcessing = false; setError(null); setState('selecting'); + setCompletedMeetingId(null); try { const input = await pickAudioFile(); selectedForProcessing = true; @@ -566,6 +648,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): const previousState = state; setError(null); setState('selecting'); + setCompletedMeetingId(null); try { const input = await consumeIncomingMediaAudio(); if (input === null) { @@ -644,7 +727,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): if (meetingId !== null && user !== null) { await cancelMeetingRecording(user.id, meetingId).catch(() => undefined); } - activeMeetingIdRef.current = routeMeetingId; + activeMeetingIdRef.current = armedMeetingIdRef.current; if (!mountedRef.current) return; setState('idle'); setTranscript(''); @@ -830,15 +913,14 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): /> )} - {state === 'done' && activeMeetingIdRef.current !== null && ( + {state === 'done' && completedMeetingId !== null && ( <> { - const meetingId = activeMeetingIdRef.current; - if (meetingId !== null) navigation.getParent()?.navigate('MeetingDetail', { meetingId }); + navigation.getParent()?.navigate('MeetingDetail', { meetingId: completedMeetingId }); }} testID="record-view-meeting" /> diff --git a/server/supabase/migrations/20260929002700_mobile_rerecord_preserves_meeting_content.sql b/server/supabase/migrations/20260929002700_mobile_rerecord_preserves_meeting_content.sql new file mode 100644 index 0000000..8e40a70 --- /dev/null +++ b/server/supabase/migrations/20260929002700_mobile_rerecord_preserves_meeting_content.sql @@ -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; diff --git a/server/supabase/tests/mobile-rerecord-preserves-meeting.integration.sql b/server/supabase/tests/mobile-rerecord-preserves-meeting.integration.sql new file mode 100644 index 0000000..3433fe6 --- /dev/null +++ b/server/supabase/tests/mobile-rerecord-preserves-meeting.integration.sql @@ -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;