From b9d891d3424f371642cb032ecfd0e2564470a72c Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Thu, 14 Aug 2025 20:45:30 +0200 Subject: [PATCH] feat: delete recording with transcript (#547) * Delete recording with transcript * Delete confirmation dialog * Use aws storage abstraction for recording deletion * Test recording deleted with transcript * Use get transcript storage * Fix the test * Add env vars for recording storage --- server/reflector/db/recordings.py | 4 ++ server/reflector/db/transcripts.py | 35 +++++++++- server/reflector/settings.py | 10 ++- server/reflector/storage/__init__.py | 10 ++- server/reflector/whereby.py | 2 +- server/reflector/worker/process.py | 2 +- .../test_transcripts_recording_deletion.py | 34 +++++++++ .../_components/DeleteTranscriptDialog.tsx | 57 +++++++++++---- www/app/(app)/browse/page.tsx | 70 ++++++++++++------- 9 files changed, 182 insertions(+), 42 deletions(-) create mode 100644 server/tests/test_transcripts_recording_deletion.py diff --git a/server/reflector/db/recordings.py b/server/reflector/db/recordings.py index da5fffde..0d05790d 100644 --- a/server/reflector/db/recordings.py +++ b/server/reflector/db/recordings.py @@ -53,5 +53,9 @@ class RecordingController: result = await get_database().fetch_one(query) return Recording(**result) if result else None + async def remove_by_id(self, id: str) -> None: + query = recordings.delete().where(recordings.c.id == id) + await get_database().execute(query) + recordings_controller = RecordingController() diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index 0ad6aae6..e9742576 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -16,11 +16,12 @@ from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.sql import false, or_ from reflector.db import get_database, metadata +from reflector.db.recordings import recordings_controller from reflector.db.rooms import rooms from reflector.db.utils import is_postgresql from reflector.processors.types import Word as ProcessorWord from reflector.settings import settings -from reflector.storage import get_transcripts_storage +from reflector.storage import get_transcripts_storage, get_recordings_storage from reflector.utils import generate_uuid4 from reflector.utils.webvtt import topics_to_webvtt @@ -593,7 +594,39 @@ class TranscriptController: return if user_id is not None and transcript.user_id != user_id: return + if transcript.audio_location == "storage" and not transcript.audio_deleted: + try: + await get_transcripts_storage().delete_file( + transcript.storage_audio_path + ) + except Exception as e: + logger.warning( + "Failed to delete transcript audio from storage", + error=str(e), + transcript_id=transcript.id, + ) transcript.unlink() + if transcript.recording_id: + try: + recording = await recordings_controller.get_by_id( + transcript.recording_id + ) + if recording: + try: + await get_recordings_storage().delete_file(recording.object_key) + except Exception as e: + logger.warning( + "Failed to delete recording object from S3", + error=str(e), + recording_id=transcript.recording_id, + ) + await recordings_controller.remove_by_id(transcript.recording_id) + except Exception as e: + logger.warning( + "Failed to delete recording row", + error=str(e), + recording_id=transcript.recording_id, + ) query = transcripts.delete().where(transcripts.c.id == transcript_id) await get_database().execute(query) diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 0e40de21..463881b2 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -39,6 +39,15 @@ class Settings(BaseSettings): TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None + # Recording storage + RECORDING_STORAGE_BACKEND: str | None = None + + # Recording storage configuration for AWS + RECORDING_STORAGE_AWS_BUCKET_NAME: str = "recording-bucket" + RECORDING_STORAGE_AWS_REGION: str = "us-east-1" + RECORDING_STORAGE_AWS_ACCESS_KEY_ID: str | None = None + RECORDING_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None + # Translate into the target language TRANSLATION_BACKEND: str = "passthrough" TRANSLATE_URL: str | None = None @@ -104,7 +113,6 @@ class Settings(BaseSettings): WHEREBY_API_URL: str = "https://api.whereby.dev/v1" WHEREBY_API_KEY: str | None = None WHEREBY_WEBHOOK_SECRET: str | None = None - AWS_WHEREBY_S3_BUCKET: str | None = None AWS_WHEREBY_ACCESS_KEY_ID: str | None = None AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None diff --git a/server/reflector/storage/__init__.py b/server/reflector/storage/__init__.py index ee6c7318..419462ca 100644 --- a/server/reflector/storage/__init__.py +++ b/server/reflector/storage/__init__.py @@ -1,10 +1,16 @@ from .base import Storage # noqa +from reflector.settings import settings def get_transcripts_storage() -> Storage: - from reflector.settings import settings - return Storage.get_instance( name=settings.TRANSCRIPT_STORAGE_BACKEND, settings_prefix="TRANSCRIPT_STORAGE_", ) + + +def get_recordings_storage() -> Storage: + return Storage.get_instance( + name=settings.RECORDING_STORAGE_BACKEND, + settings_prefix="RECORDING_STORAGE_", + ) diff --git a/server/reflector/whereby.py b/server/reflector/whereby.py index a90a8498..deaa5274 100644 --- a/server/reflector/whereby.py +++ b/server/reflector/whereby.py @@ -23,7 +23,7 @@ async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room): "type": room.recording_type, "destination": { "provider": "s3", - "bucket": settings.AWS_WHEREBY_S3_BUCKET, + "bucket": settings.RECORDING_STORAGE_AWS_BUCKET_NAME, "accessKeyId": settings.AWS_WHEREBY_ACCESS_KEY_ID, "accessKeySecret": settings.AWS_WHEREBY_ACCESS_KEY_SECRET, "fileFormat": "mp4", diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index 1b739302..c3704207 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -185,7 +185,7 @@ async def reprocess_failed_recordings(): reprocessed_count = 0 try: paginator = s3.get_paginator("list_objects_v2") - bucket_name = settings.AWS_WHEREBY_S3_BUCKET + bucket_name = settings.RECORDING_STORAGE_AWS_BUCKET_NAME pages = paginator.paginate(Bucket=bucket_name) for page in pages: diff --git a/server/tests/test_transcripts_recording_deletion.py b/server/tests/test_transcripts_recording_deletion.py new file mode 100644 index 00000000..810fe567 --- /dev/null +++ b/server/tests/test_transcripts_recording_deletion.py @@ -0,0 +1,34 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from reflector.db.recordings import Recording, recordings_controller +from reflector.db.transcripts import SourceKind, transcripts_controller + + +@pytest.mark.asyncio +async def test_recording_deleted_with_transcript(): + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="recording.mp4", + recorded_at=datetime.now(timezone.utc), + ) + ) + transcript = await transcripts_controller.add( + name="Test Transcript", + source_kind=SourceKind.ROOM, + recording_id=recording.id, + ) + + with patch("reflector.db.transcripts.get_recordings_storage") as mock_get_storage: + storage_instance = mock_get_storage.return_value + storage_instance.delete_file = AsyncMock() + + await transcripts_controller.remove_by_id(transcript.id) + + storage_instance.delete_file.assert_awaited_once_with(recording.object_key) + + assert await recordings_controller.get_by_id(recording.id) is None + assert await transcripts_controller.get_by_id(transcript.id) is None diff --git a/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx b/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx index 16338384..fc019f77 100644 --- a/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx +++ b/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx @@ -1,12 +1,15 @@ import React from "react"; -import { Button } from "@chakra-ui/react"; -// import { Dialog } from "@chakra-ui/react"; +import { Button, Dialog, Text } from "@chakra-ui/react"; interface DeleteTranscriptDialogProps { isOpen: boolean; onClose: () => void; onConfirm: () => void; cancelRef: React.RefObject; + isLoading?: boolean; + title?: string; + date?: string; + source?: string; } export default function DeleteTranscriptDialog({ @@ -14,35 +17,65 @@ export default function DeleteTranscriptDialog({ onClose, onConfirm, cancelRef, + isLoading, + title, + date, + source, }: DeleteTranscriptDialogProps) { - // Temporarily return null to fix import issues - return null; - - /* return ( + return ( !e.open && onClose()} + onOpenChange={(e) => { + if (!e.open) onClose(); + }} initialFocusEl={() => cancelRef.current} > - Delete Transcript + Delete transcript - Are you sure? You can't undo this action afterwards. + Are you sure you want to delete this transcript? This action cannot + be undone. + {title && ( + + {title} + + )} + {date && ( + + Date: {date} + + )} + {source && ( + + Source: {source} + + )} - - - ); */ + ); } diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx index cbfdac50..dd2f68e5 100644 --- a/www/app/(app)/browse/page.tsx +++ b/www/app/(app)/browse/page.tsx @@ -13,6 +13,7 @@ import SearchBar from "./_components/SearchBar"; import TranscriptTable from "./_components/TranscriptTable"; import TranscriptCards from "./_components/TranscriptCards"; import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; +import { formatLocalDate } from "../../lib/time"; export default function TranscriptBrowser() { const [selectedSourceKind, setSelectedSourceKind] = @@ -25,7 +26,7 @@ export default function TranscriptBrowser() { page, selectedSourceKind, selectedRoomId, - searchTerm, + searchTerm ); const userName = useSessionUser().name; const [deletionLoading, setDeletionLoading] = useState(false); @@ -50,7 +51,7 @@ export default function TranscriptBrowser() { const handleFilterTranscripts = ( sourceKind: SourceKind | null, - roomId: string, + roomId: string ) => { setSelectedSourceKind(sourceKind); setSelectedRoomId(roomId); @@ -96,26 +97,28 @@ export default function TranscriptBrowser() { const onCloseDeletion = () => setTranscriptToDeleteId(undefined); - const handleDeleteTranscript = (transcriptId) => (e) => { - e.stopPropagation(); - if (api && !deletionLoading) { - setDeletionLoading(true); - api - .v1TranscriptDelete({ transcriptId }) - .then(() => { - refetch(); - setDeletionLoading(false); - onCloseDeletion(); - setDeletedItemIds((deletedItemIds) => [ - deletedItemIds, - ...transcriptId, - ]); - }) - .catch((err) => { - setDeletionLoading(false); - setError(err, "There was an error deleting the transcript"); - }); - } + const confirmDeleteTranscript = (transcriptId: string) => { + if (!api || deletionLoading) return; + setDeletionLoading(true); + api + .v1TranscriptDelete({ transcriptId }) + .then(() => { + refetch(); + setDeletionLoading(false); + onCloseDeletion(); + setDeletedItemIds((prev) => + prev ? [...prev, transcriptId] : [transcriptId] + ); + }) + .catch((err) => { + setDeletionLoading(false); + setError(err, "There was an error deleting the transcript"); + }); + }; + + const handleDeleteTranscript = (transcriptId: string) => (e: any) => { + e?.stopPropagation?.(); + setTranscriptToDeleteId(transcriptId); }; const handleProcessTranscript = (transcriptId) => (e) => { @@ -127,7 +130,7 @@ export default function TranscriptBrowser() { if (status === "already running") { setError( new Error("Processing is already running, please wait"), - "Processing is already running, please wait", + "Processing is already running, please wait" ); } }) @@ -137,6 +140,19 @@ export default function TranscriptBrowser() { } }; + const transcriptToDelete = response?.items?.find( + (i) => i.id === transcriptToDeleteId + ); + const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript"; + const dialogDate = transcriptToDelete?.created_at + ? formatLocalDate(transcriptToDelete.created_at) + : undefined; + const dialogSource = transcriptToDelete + ? transcriptToDelete.source_kind === "room" + ? transcriptToDelete.room_name || undefined + : transcriptToDelete.source_kind + : undefined; + return ( handleDeleteTranscript(transcriptToDeleteId)(null)} + onConfirm={() => + transcriptToDeleteId && confirmDeleteTranscript(transcriptToDeleteId) + } cancelRef={cancelRef} + isLoading={deletionLoading} + title={dialogTitle} + date={dialogDate} + source={dialogSource} /> );