fix(mobile): purge uploaded audio when deleting history or meetings
This commit is contained in:
parent
5b1621d314
commit
65320288d5
4 changed files with 507 additions and 0 deletions
|
|
@ -0,0 +1,254 @@
|
|||
jest.mock('react-native-keychain', () => ({
|
||||
ACCESSIBLE: { AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'device-only' },
|
||||
SECURITY_LEVEL: { SECURE_SOFTWARE: 1 },
|
||||
STORAGE_TYPE: { AES_GCM_NO_AUTH: 'aes-gcm' },
|
||||
getGenericPassword: jest.fn().mockResolvedValue(false),
|
||||
setGenericPassword: jest.fn(),
|
||||
resetGenericPassword: jest.fn(),
|
||||
getAllGenericPasswordServices: jest.fn(),
|
||||
}))
|
||||
|
||||
interface RecordedQuery {
|
||||
table: string
|
||||
calls: Array<[string, unknown[]]>
|
||||
}
|
||||
|
||||
type QueryResult = { data: unknown; error: unknown }
|
||||
|
||||
const mockQueries: RecordedQuery[] = []
|
||||
let mockResults: QueryResult[] = []
|
||||
const mockStorageRemove = jest.fn()
|
||||
|
||||
function mockBuilder(table: string): Record<string, unknown> {
|
||||
const recorded: RecordedQuery = { table, calls: [] }
|
||||
mockQueries.push(recorded)
|
||||
const resolve = (): QueryResult => mockResults.shift() ?? { data: null, error: null }
|
||||
const builder: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'update', 'delete', 'eq', 'neq', 'in', 'is', 'order', 'limit']) {
|
||||
builder[method] = (...args: unknown[]) => {
|
||||
recorded.calls.push([method, args])
|
||||
return builder
|
||||
}
|
||||
}
|
||||
builder.maybeSingle = async () => {
|
||||
recorded.calls.push(['maybeSingle', []])
|
||||
return resolve()
|
||||
}
|
||||
builder.then = (
|
||||
onFulfilled: (value: QueryResult) => unknown,
|
||||
onRejected?: (reason: unknown) => unknown,
|
||||
) => Promise.resolve(resolve()).then(onFulfilled, onRejected)
|
||||
return builder
|
||||
}
|
||||
|
||||
jest.mock('../src/lib/supabase', () => ({
|
||||
supabase: {
|
||||
from: jest.fn((table: string) => mockBuilder(table)),
|
||||
storage: { from: jest.fn(() => ({ remove: mockStorageRemove })) },
|
||||
channel: jest.fn(),
|
||||
removeChannel: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import type { Meeting } from '@d3ro/api-client'
|
||||
import {
|
||||
createSupabaseLinkedAudioStore,
|
||||
purgeUnlinkedAudio,
|
||||
selectRemovableStorageKeys,
|
||||
type LinkedAudioRef,
|
||||
type LinkedAudioStore,
|
||||
} from '../src/features/audio/linked-audio-cleanup'
|
||||
import {
|
||||
deleteHistoryRevisionSafe,
|
||||
HistoryServiceError,
|
||||
} from '../src/features/history/history-service'
|
||||
import { deleteMeetingRevisionSafe } from '../src/features/meetings/meetings-service'
|
||||
|
||||
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER_USER = '33333333-3333-4333-8333-333333333333'
|
||||
const HISTORY_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const MEETING_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const AUDIO_ID = '55555555-5555-4555-8555-555555555555'
|
||||
const SHA = 'a'.repeat(64)
|
||||
const KEY = `${USER_ID}/imports/${SHA}/voice.m4a`
|
||||
|
||||
function audio(overrides: Partial<LinkedAudioRef> = {}): LinkedAudioRef {
|
||||
return { id: AUDIO_ID, storage_key: KEY, upload_status: 'uploaded', ...overrides }
|
||||
}
|
||||
|
||||
function fakeStore(linked: LinkedAudioRef[]): jest.Mocked<LinkedAudioStore> {
|
||||
return {
|
||||
findLinked: jest.fn(async () => linked),
|
||||
claimUnlinked: jest.fn(async (_userId: string, ids: string[]) =>
|
||||
linked
|
||||
.filter((row) => ids.includes(row.id))
|
||||
.map((row) => ({ ...row, upload_status: 'deleted' as const }))),
|
||||
findReferencedKeys: jest.fn(async () => []),
|
||||
removeObjects: jest.fn(async () => undefined),
|
||||
deleteRows: jest.fn(async () => undefined),
|
||||
restoreStatus: jest.fn(async () => undefined),
|
||||
}
|
||||
}
|
||||
|
||||
function meeting(overrides: Partial<Meeting> = {}): Meeting {
|
||||
return {
|
||||
id: MEETING_ID,
|
||||
user_id: USER_ID,
|
||||
team_id: null,
|
||||
title: 'Weekly sync',
|
||||
status: 'completed',
|
||||
started_at: '2026-08-21T00:00:00.000Z',
|
||||
ended_at: '2026-08-21T01:00:00.000Z',
|
||||
duration_ms: 3_600_000,
|
||||
raw_transcript: null,
|
||||
edited_transcript: null,
|
||||
minutes_markdown: null,
|
||||
minutes_json: null,
|
||||
stt_model: 'whisper',
|
||||
llm_model: null,
|
||||
stt_latency_ms: null,
|
||||
llm_latency_ms: null,
|
||||
error_message: null,
|
||||
audio_storage_key: KEY,
|
||||
created_at: '2026-08-21T00:00:00.000Z',
|
||||
updated_at: '2026-08-21T01:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueries.length = 0
|
||||
mockResults = []
|
||||
mockStorageRemove.mockReset()
|
||||
})
|
||||
|
||||
describe('history delete removes the uploaded original audio', () => {
|
||||
test('deleting a history entry removes its storage object and audio_files row', async () => {
|
||||
const store = fakeStore([audio()])
|
||||
mockResults = [{ data: { id: HISTORY_ID }, error: null }]
|
||||
|
||||
await deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store)
|
||||
|
||||
expect(store.findLinked).toHaveBeenCalledWith(USER_ID, 'history_id', HISTORY_ID)
|
||||
expect(store.claimUnlinked).toHaveBeenCalledWith(USER_ID, [AUDIO_ID])
|
||||
expect(store.removeObjects).toHaveBeenCalledWith([KEY])
|
||||
expect(store.deleteRows).toHaveBeenCalledWith(USER_ID, [AUDIO_ID])
|
||||
// 스냅샷은 부모 삭제 전에 찍어야 한다(삭제 후엔 history_id 가 NULL 이 된다).
|
||||
expect(store.findLinked.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(store.claimUnlinked.mock.invocationCallOrder[0])
|
||||
})
|
||||
|
||||
test('a revision conflict leaves the audio untouched', async () => {
|
||||
const store = fakeStore([audio()])
|
||||
mockResults = [{ data: null, error: null }]
|
||||
|
||||
await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store))
|
||||
.rejects.toMatchObject({ code: 'conflict' })
|
||||
expect(store.claimUnlinked).not.toHaveBeenCalled()
|
||||
expect(store.removeObjects).not.toHaveBeenCalled()
|
||||
expect(store.deleteRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('a failed audio lookup aborts the delete instead of orphaning audio', async () => {
|
||||
const store = fakeStore([audio()])
|
||||
store.findLinked.mockRejectedValueOnce(new TypeError('Network request failed'))
|
||||
|
||||
await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store))
|
||||
.rejects.toBeInstanceOf(HistoryServiceError)
|
||||
expect(mockQueries.filter((query) => query.table === 'history')).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('audio cleanup failure does not turn a completed delete into an error', async () => {
|
||||
const store = fakeStore([audio()])
|
||||
store.removeObjects.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
mockResults = [{ data: { id: HISTORY_ID }, error: null }]
|
||||
|
||||
await expect(deleteHistoryRevisionSafe(USER_ID, HISTORY_ID, 3, store)).resolves.toBeUndefined()
|
||||
// 저장소 삭제 실패 시 선점을 되돌려 같은 파일 재가져오기가 막히지 않게 한다.
|
||||
expect(store.restoreStatus).toHaveBeenCalledWith(USER_ID, AUDIO_ID, 'uploaded')
|
||||
expect(store.deleteRows).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('meeting delete removes the uploaded original audio', () => {
|
||||
test('deleting a meeting removes its storage object and audio_files row', async () => {
|
||||
const store = fakeStore([audio()])
|
||||
mockResults = [{ data: { id: MEETING_ID }, error: null }]
|
||||
|
||||
await deleteMeetingRevisionSafe(USER_ID, meeting(), store)
|
||||
|
||||
expect(store.findLinked).toHaveBeenCalledWith(USER_ID, 'meeting_id', MEETING_ID)
|
||||
expect(store.removeObjects).toHaveBeenCalledWith([KEY])
|
||||
expect(store.deleteRows).toHaveBeenCalledWith(USER_ID, [AUDIO_ID])
|
||||
})
|
||||
|
||||
test('a meeting conflict leaves the audio untouched', async () => {
|
||||
const store = fakeStore([audio()])
|
||||
mockResults = [{ data: null, error: null }]
|
||||
|
||||
await expect(deleteMeetingRevisionSafe(USER_ID, meeting(), store))
|
||||
.rejects.toMatchObject({ code: 'conflict' })
|
||||
expect(store.removeObjects).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('purgeUnlinkedAudio policy', () => {
|
||||
test('rows re-linked to another owner before cleanup are not removed', async () => {
|
||||
const store = fakeStore([audio()])
|
||||
store.claimUnlinked.mockResolvedValueOnce([])
|
||||
|
||||
const result = await purgeUnlinkedAudio(store, USER_ID, [audio()])
|
||||
|
||||
expect(result).toEqual({ removedKeys: [], deletedRowIds: [], failed: false })
|
||||
expect(store.removeObjects).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('a storage key still referenced by another row is kept', async () => {
|
||||
const store = fakeStore([audio()])
|
||||
store.findReferencedKeys.mockResolvedValueOnce([KEY])
|
||||
|
||||
const result = await purgeUnlinkedAudio(store, USER_ID, [audio()])
|
||||
|
||||
expect(store.removeObjects).not.toHaveBeenCalled()
|
||||
expect(result.deletedRowIds).toEqual([AUDIO_ID])
|
||||
})
|
||||
|
||||
test('selectRemovableStorageKeys keeps only own, unreferenced, unique keys', () => {
|
||||
const rows = [
|
||||
audio(),
|
||||
audio({ id: 'b' }),
|
||||
audio({ id: 'c', storage_key: `${OTHER_USER}/imports/${SHA}/x.m4a` }),
|
||||
audio({ id: 'd', storage_key: `${USER_ID}/../${OTHER_USER}/x.m4a` }),
|
||||
audio({ id: 'e', storage_key: `${USER_ID}/imports/${'b'.repeat(64)}/y.m4a` }),
|
||||
]
|
||||
expect(selectRemovableStorageKeys(USER_ID, rows, [`${USER_ID}/imports/${'b'.repeat(64)}/y.m4a`]))
|
||||
.toEqual([KEY])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Supabase linked audio adapter', () => {
|
||||
test('claims only rows that are still unlinked, scoped to the user', async () => {
|
||||
mockResults = [{ data: [audio({ upload_status: 'deleted' })], error: null }]
|
||||
const claimed = await createSupabaseLinkedAudioStore().claimUnlinked(USER_ID, [AUDIO_ID])
|
||||
|
||||
expect(claimed).toEqual([audio({ upload_status: 'deleted' })])
|
||||
const query = mockQueries[0]
|
||||
expect(query.table).toBe('audio_files')
|
||||
expect(query.calls).toEqual(expect.arrayContaining([
|
||||
['update', [{ upload_status: 'deleted' }]],
|
||||
['eq', ['user_id', USER_ID]],
|
||||
['in', ['id', [AUDIO_ID]]],
|
||||
['is', ['history_id', null]],
|
||||
['is', ['meeting_id', null]],
|
||||
]))
|
||||
})
|
||||
|
||||
test('removes objects from the audio bucket and surfaces storage errors', async () => {
|
||||
mockStorageRemove.mockResolvedValueOnce({ data: [], error: null })
|
||||
await createSupabaseLinkedAudioStore().removeObjects([KEY])
|
||||
expect(mockStorageRemove).toHaveBeenCalledWith([KEY])
|
||||
|
||||
mockStorageRemove.mockResolvedValueOnce({ data: null, error: new Error('denied') })
|
||||
await expect(createSupabaseLinkedAudioStore().removeObjects([KEY])).rejects.toThrow('denied')
|
||||
})
|
||||
})
|
||||
210
apps/mobile-rn/src/features/audio/linked-audio-cleanup.ts
Normal file
210
apps/mobile-rn/src/features/audio/linked-audio-cleanup.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import type { AudioFileUploadStatus, D3roSupabaseClient } from '@d3ro/api-client'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
|
||||
/**
|
||||
* 기록·회의를 지울 때 거기에 연결된 원본 음성(audio 버킷 객체 + audio_files 행)을 함께 정리한다.
|
||||
*
|
||||
* audio_files.history_id / meeting_id 는 ON DELETE SET NULL 이라 부모 행만 지우면 음성이
|
||||
* 고아가 되어 계정 삭제 전까지 클라우드에 남는다. 그래서 부모를 지우기 "전에" 연결된 음성을
|
||||
* 스냅샷해 두고, 부모 삭제가 성공한 "뒤에만" 정리한다(리비전 충돌로 삭제가 거부되면 음성은
|
||||
* 건드리지 않는다).
|
||||
*
|
||||
* 정리 순서: (1) 여전히 부모가 없는 행만 'deleted' 로 선점 → (2) 저장소 객체 삭제 →
|
||||
* (3) 행 삭제. 선점 덕분에 같은 파일 재가져오기(sha256 재사용)가 지워질 행을 집어 가지 않고,
|
||||
* 그 사이 다른 기록/회의에 다시 연결된 행은 선점 조건에서 빠진다. 저장소 삭제가 실패하면
|
||||
* 선점을 원래 상태로 되돌려 재가져오기 경로가 막히지 않게 한다. 행 저장 키는 결정적
|
||||
* (`<uid>/imports/<sha>/<name>`)이라 'deleted' 행이 남으면 같은 파일을 다시 올릴 수 없기 때문이다.
|
||||
*/
|
||||
|
||||
export type AudioOwnerColumn = 'history_id' | 'meeting_id'
|
||||
|
||||
export interface LinkedAudioRef {
|
||||
id: string
|
||||
storage_key: string
|
||||
upload_status: AudioFileUploadStatus
|
||||
}
|
||||
|
||||
/** audio_files / audio 버킷 IO 포트. 서비스는 이 인터페이스에만 의존한다. */
|
||||
export interface LinkedAudioStore {
|
||||
/** 부모에 연결되어 있고 아직 삭제 표시되지 않은 음성 행. */
|
||||
findLinked(userId: string, column: AudioOwnerColumn, ownerId: string): Promise<LinkedAudioRef[]>
|
||||
/** 여전히 어떤 부모에도 연결되지 않은 행만 'deleted' 로 표시하고, 표시된 행을 돌려준다. */
|
||||
claimUnlinked(userId: string, ids: string[]): Promise<LinkedAudioRef[]>
|
||||
/** 주어진 키 중 다른(삭제 표시되지 않은) 행이 아직 참조하는 저장 키. */
|
||||
findReferencedKeys(userId: string, keys: string[], excludeIds: string[]): Promise<string[]>
|
||||
removeObjects(keys: string[]): Promise<void>
|
||||
deleteRows(userId: string, ids: string[]): Promise<void>
|
||||
restoreStatus(userId: string, id: string, status: AudioFileUploadStatus): Promise<void>
|
||||
}
|
||||
|
||||
export interface LinkedAudioCleanupResult {
|
||||
removedKeys: string[]
|
||||
deletedRowIds: string[]
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
/** 정리 대상 저장 키 선택(순수 정책): 본인 경로이고, 다른 행이 참조하지 않는 키만, 중복 없이. */
|
||||
export function selectRemovableStorageKeys(
|
||||
userId: string,
|
||||
claimed: readonly LinkedAudioRef[],
|
||||
stillReferenced: readonly string[],
|
||||
): string[] {
|
||||
const referenced = new Set(stillReferenced)
|
||||
const prefix = `${userId}/`
|
||||
const keys = new Set<string>()
|
||||
for (const row of claimed) {
|
||||
const key = row.storage_key
|
||||
if (key.length <= prefix.length || !key.startsWith(prefix)) continue
|
||||
if (key.includes('..')) continue
|
||||
if (referenced.has(key)) continue
|
||||
keys.add(key)
|
||||
}
|
||||
return [...keys]
|
||||
}
|
||||
|
||||
/** 부모 삭제 전에 호출한다. 읽기 실패는 그대로 던져 부모 삭제도 하지 않게 한다. */
|
||||
export async function snapshotLinkedAudio(
|
||||
store: LinkedAudioStore,
|
||||
userId: string,
|
||||
column: AudioOwnerColumn,
|
||||
ownerId: string,
|
||||
): Promise<LinkedAudioRef[]> {
|
||||
return store.findLinked(userId, column, ownerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모 삭제가 성공한 뒤 호출한다. 부모는 이미 지워졌으므로 이 단계의 실패는 삭제 자체를
|
||||
* 실패로 돌리지 않는다(best effort) — 결과의 failed 로만 알린다.
|
||||
*/
|
||||
export async function purgeUnlinkedAudio(
|
||||
store: LinkedAudioStore,
|
||||
userId: string,
|
||||
snapshot: readonly LinkedAudioRef[],
|
||||
): Promise<LinkedAudioCleanupResult> {
|
||||
const result: LinkedAudioCleanupResult = { removedKeys: [], deletedRowIds: [], failed: false }
|
||||
const ids = [...new Set(snapshot.map((row) => row.id))]
|
||||
if (ids.length === 0) return result
|
||||
|
||||
let claimed: LinkedAudioRef[]
|
||||
try {
|
||||
claimed = await store.claimUnlinked(userId, ids)
|
||||
} catch {
|
||||
result.failed = true
|
||||
return result
|
||||
}
|
||||
if (claimed.length === 0) return result
|
||||
|
||||
const previousStatus = new Map(snapshot.map((row) => [row.id, row.upload_status]))
|
||||
const claimedIds = claimed.map((row) => row.id)
|
||||
|
||||
try {
|
||||
const referenced = await store.findReferencedKeys(
|
||||
userId,
|
||||
[...new Set(claimed.map((row) => row.storage_key))],
|
||||
claimedIds,
|
||||
)
|
||||
const keys = selectRemovableStorageKeys(userId, claimed, referenced)
|
||||
if (keys.length > 0) await store.removeObjects(keys)
|
||||
result.removedKeys = keys
|
||||
} catch {
|
||||
result.failed = true
|
||||
for (const row of claimed) {
|
||||
const status = previousStatus.get(row.id)
|
||||
if (status === undefined || status === 'deleted') continue
|
||||
try {
|
||||
await store.restoreStatus(userId, row.id, status)
|
||||
} catch {
|
||||
// 되돌리기 실패 시 행은 'deleted' 로 남아 이후 정리 대상임을 표시한다.
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
await store.deleteRows(userId, claimedIds)
|
||||
result.deletedRowIds = claimedIds
|
||||
} catch {
|
||||
// 객체는 이미 지워졌고 행은 'deleted' 로 남아 UI·재사용 조회에서 제외된다.
|
||||
result.failed = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function database(): D3roSupabaseClient {
|
||||
return supabase as unknown as D3roSupabaseClient
|
||||
}
|
||||
|
||||
const AUDIO_BUCKET = 'audio'
|
||||
|
||||
function toRefs(rows: ReadonlyArray<{ id: string; storage_key: string; upload_status: AudioFileUploadStatus }> | null): LinkedAudioRef[] {
|
||||
return (rows ?? []).map((row) => ({
|
||||
id: row.id,
|
||||
storage_key: row.storage_key,
|
||||
upload_status: row.upload_status,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Supabase 어댑터. RLS(audio_files_*_own, audio_*_own)가 본인 행·경로로 한정한다. */
|
||||
export function createSupabaseLinkedAudioStore(): LinkedAudioStore {
|
||||
return {
|
||||
async findLinked(userId, column, ownerId) {
|
||||
const { data, error } = await database()
|
||||
.from('audio_files')
|
||||
.select('id, storage_key, upload_status')
|
||||
.eq('user_id', userId)
|
||||
.eq(column, ownerId)
|
||||
.neq('upload_status', 'deleted')
|
||||
if (error !== null) throw error
|
||||
return toRefs(data)
|
||||
},
|
||||
async claimUnlinked(userId, ids) {
|
||||
const { data, error } = await database()
|
||||
.from('audio_files')
|
||||
.update({ upload_status: 'deleted' })
|
||||
.eq('user_id', userId)
|
||||
.in('id', ids)
|
||||
.is('history_id', null)
|
||||
.is('meeting_id', null)
|
||||
.neq('upload_status', 'deleted')
|
||||
.select('id, storage_key, upload_status')
|
||||
if (error !== null) throw error
|
||||
return toRefs(data)
|
||||
},
|
||||
async findReferencedKeys(userId, keys, excludeIds) {
|
||||
if (keys.length === 0) return []
|
||||
const { data, error } = await database()
|
||||
.from('audio_files')
|
||||
.select('id, storage_key')
|
||||
.eq('user_id', userId)
|
||||
.in('storage_key', keys)
|
||||
.neq('upload_status', 'deleted')
|
||||
if (error !== null) throw error
|
||||
const excluded = new Set(excludeIds)
|
||||
return (data ?? [])
|
||||
.filter((row) => !excluded.has(row.id))
|
||||
.map((row) => row.storage_key)
|
||||
},
|
||||
async removeObjects(keys) {
|
||||
const { error } = await supabase.storage.from(AUDIO_BUCKET).remove(keys)
|
||||
if (error !== null) throw error
|
||||
},
|
||||
async deleteRows(userId, ids) {
|
||||
const { error } = await database()
|
||||
.from('audio_files')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.in('id', ids)
|
||||
.eq('upload_status', 'deleted')
|
||||
if (error !== null) throw error
|
||||
},
|
||||
async restoreStatus(userId, id, status) {
|
||||
const { error } = await database()
|
||||
.from('audio_files')
|
||||
.update({ upload_status: status })
|
||||
.eq('user_id', userId)
|
||||
.eq('id', id)
|
||||
.eq('upload_status', 'deleted')
|
||||
if (error !== null) throw error
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@ import type {
|
|||
ProcessingJob,
|
||||
} from '@d3ro/api-client'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import {
|
||||
createSupabaseLinkedAudioStore,
|
||||
purgeUnlinkedAudio,
|
||||
snapshotLinkedAudio,
|
||||
type LinkedAudioRef,
|
||||
type LinkedAudioStore,
|
||||
} from '../audio/linked-audio-cleanup'
|
||||
|
||||
export type HistoryFilter = 'all' | 'favorites' | 'processing'
|
||||
|
||||
|
|
@ -306,16 +313,29 @@ export async function updateHistoryRevisionSafe(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리비전이 일치할 때만 기록을 지우고, 연결된 원본 음성(audio_files + audio 버킷)도 정리한다.
|
||||
* audio_files.history_id 는 ON DELETE SET NULL 이라 기록만 지우면 음성이 고아로 남기 때문에
|
||||
* 삭제 전에 연결을 스냅샷하고, 삭제가 성공한 뒤에만 정리한다.
|
||||
*/
|
||||
export async function deleteHistoryRevisionSafe(
|
||||
userId: string,
|
||||
historyId: string,
|
||||
expectedRevision: number,
|
||||
audioStore: LinkedAudioStore = createSupabaseLinkedAudioStore(),
|
||||
): Promise<void> {
|
||||
requireUserId(userId)
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) {
|
||||
throw new HistoryServiceError('validation', 'Expected revision is invalid')
|
||||
}
|
||||
|
||||
let linkedAudio: LinkedAudioRef[]
|
||||
try {
|
||||
linkedAudio = await snapshotLinkedAudio(audioStore, userId, 'history_id', historyId)
|
||||
} catch (error) {
|
||||
throw historyError(error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await client()
|
||||
.from('history')
|
||||
|
|
@ -336,6 +356,8 @@ export async function deleteHistoryRevisionSafe(
|
|||
} catch (error) {
|
||||
throw historyError(error)
|
||||
}
|
||||
|
||||
await purgeUnlinkedAudio(audioStore, userId, linkedAudio)
|
||||
}
|
||||
|
||||
export async function createAudioPlaybackUrl(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ import type {
|
|||
} from '@d3ro/api-client'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import { createUuidV4 } from '../../lib/random-id'
|
||||
import {
|
||||
createSupabaseLinkedAudioStore,
|
||||
purgeUnlinkedAudio,
|
||||
snapshotLinkedAudio,
|
||||
type LinkedAudioRef,
|
||||
type LinkedAudioStore,
|
||||
} from '../audio/linked-audio-cleanup'
|
||||
|
||||
export interface MeetingListOptions {
|
||||
userId: string
|
||||
|
|
@ -515,9 +522,14 @@ export async function updateMeetingTitleRevisionSafe(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경 시각이 일치할 때만 회의를 지우고, 연결된 원본 음성(audio_files + audio 버킷)도 정리한다.
|
||||
* audio_files.meeting_id 는 ON DELETE SET NULL 이라 회의만 지우면 음성이 고아로 남는다.
|
||||
*/
|
||||
export async function deleteMeetingRevisionSafe(
|
||||
userId: string,
|
||||
current: Meeting,
|
||||
audioStore: LinkedAudioStore = createSupabaseLinkedAudioStore(),
|
||||
): Promise<void> {
|
||||
requireUuid(userId, 'authenticated user')
|
||||
assertMeetingRow(current)
|
||||
|
|
@ -525,6 +537,13 @@ export async function deleteMeetingRevisionSafe(
|
|||
throw new MeetingServiceError('forbidden', 'Only the meeting owner can delete it')
|
||||
}
|
||||
|
||||
let linkedAudio: LinkedAudioRef[]
|
||||
try {
|
||||
linkedAudio = await snapshotLinkedAudio(audioStore, userId, 'meeting_id', current.id)
|
||||
} catch (error) {
|
||||
throw toMeetingServiceError(error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await client()
|
||||
.from('meetings')
|
||||
|
|
@ -544,6 +563,8 @@ export async function deleteMeetingRevisionSafe(
|
|||
} catch (error) {
|
||||
throw toMeetingServiceError(error)
|
||||
}
|
||||
|
||||
await purgeUnlinkedAudio(audioStore, userId, linkedAudio)
|
||||
}
|
||||
|
||||
export async function createMeetingMemo(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue