diff --git a/docs/docs/installation/daily-setup.md b/docs/docs/installation/daily-setup.md index ea13a304..59bc1afa 100644 --- a/docs/docs/installation/daily-setup.md +++ b/docs/docs/installation/daily-setup.md @@ -95,6 +95,12 @@ DAILYCO_STORAGE_AWS_BUCKET_NAME= DAILYCO_STORAGE_AWS_REGION=us-east-1 DAILYCO_STORAGE_AWS_ROLE_ARN= +# Worker credentials for reading/deleting recordings from Daily's S3 bucket. +# Required when transcript storage uses a different bucket or credentials +# (e.g., selfhosted with Garage or a separate S3 account). +DAILYCO_STORAGE_AWS_ACCESS_KEY_ID= +DAILYCO_STORAGE_AWS_SECRET_ACCESS_KEY= + # Transcript storage (should already be configured from main setup) # TRANSCRIPT_STORAGE_BACKEND=aws # TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID= @@ -103,6 +109,19 @@ DAILYCO_STORAGE_AWS_ROLE_ARN= # TRANSCRIPT_STORAGE_AWS_REGION= ``` +:::info Two separate credential sets for Daily.co + +- **`ROLE_ARN`** — Used by Daily's API to *write* recordings into your S3 bucket (configured via Daily dashboard). +- **`ACCESS_KEY_ID` / `SECRET_ACCESS_KEY`** — Used by Reflector workers to *read* recordings for transcription and *delete* them on consent denial or permanent transcript deletion. + +Required IAM permissions for the worker key on the Daily recordings bucket: +- `s3:GetObject` — Download recording files for processing +- `s3:DeleteObject` — Remove files on consent denial, trash destroy, or data retention cleanup +- `s3:ListBucket` — Scan for recordings needing reprocessing + +If the worker keys are not set, Reflector falls back to the transcript storage master key, which then needs cross-bucket access to the Daily bucket. +::: + --- ## Restart Services diff --git a/docsv2/selfhosted-production.md b/docsv2/selfhosted-production.md index 443c2eda..f043ab2f 100644 --- a/docsv2/selfhosted-production.md +++ b/docsv2/selfhosted-production.md @@ -305,6 +305,48 @@ TRANSCRIPT_STORAGE_AWS_REGION=us-east-1 TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL=http://minio:9000 ``` +### S3 IAM Permissions Reference + +Reflector uses up to 3 separate S3 credential sets, each scoped to a specific bucket. When using AWS IAM in production, each key should have only the permissions it needs. + +**Transcript storage key** (`TRANSCRIPT_STORAGE_AWS_*`) — the main bucket for processed files: + +```json +{ + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::reflector-media/*", "arn:aws:s3:::reflector-media"] +} +``` + +Used for: processed MP3 audio, waveform JSON, temporary pipeline files. Deletions happen during trash "Destroy", consent-denied cleanup, and public mode data retention. + +**Daily.co worker key** (`DAILYCO_STORAGE_AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY`) — for reading and cleaning up Daily recordings: + +```json +{ + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:DeleteObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::your-daily-bucket/*", "arn:aws:s3:::your-daily-bucket"] +} +``` + +Used for: downloading multitrack recording files for processing, deleting track files and composed video on consent denial or trash destroy. No `s3:PutObject` needed — Daily's own API writes via the Role ARN. + +**Whereby worker key** (`WHEREBY_STORAGE_AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY`) — same pattern as Daily: + +```json +{ + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:DeleteObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::your-whereby-bucket/*", "arn:aws:s3:::your-whereby-bucket"] +} +``` + +> **Fallback behavior:** If platform-specific worker keys are not set, Reflector falls back to the transcript storage master key with a bucket override. This means the master key would need cross-bucket access to the Daily/Whereby buckets. For least-privilege, configure platform-specific keys so each only accesses its own bucket. + +> **Garage / single-bucket setups:** When using Garage or a single S3 bucket for everything, one master key with full permissions on that bucket is sufficient. The IAM scoping above only matters when using separate buckets per platform (typical in AWS production). + ## What Authentication Enables By default, Reflector runs in **public mode** (`AUTH_BACKEND=none`, `PUBLIC_MODE=true`) — anyone can create and view transcripts without logging in. Transcripts are anonymous (not linked to any user) and cannot be edited or deleted after creation. diff --git a/server/reflector/db/recordings.py b/server/reflector/db/recordings.py index a9b12c1b..9859ff33 100644 --- a/server/reflector/db/recordings.py +++ b/server/reflector/db/recordings.py @@ -78,6 +78,14 @@ class RecordingController: ) await get_database().execute(query) + async def restore_by_id(self, id: str) -> None: + query = recordings.update().where(recordings.c.id == id).values(deleted_at=None) + await get_database().execute(query) + + async def hard_delete_by_id(self, id: str) -> None: + query = recordings.delete().where(recordings.c.id == id) + await get_database().execute(query) + async def set_meeting_id( self, recording_id: NonEmptyString, diff --git a/server/reflector/db/search.py b/server/reflector/db/search.py index 1f103587..09a092e0 100644 --- a/server/reflector/db/search.py +++ b/server/reflector/db/search.py @@ -138,6 +138,7 @@ class SearchParameters(BaseModel): source_kind: SourceKind | None = None from_datetime: datetime | None = None to_datetime: datetime | None = None + include_deleted: bool = False class SearchResultDB(BaseModel): @@ -387,7 +388,10 @@ class SearchController: transcripts.join(rooms, transcripts.c.room_id == rooms.c.id, isouter=True) ) - base_query = base_query.where(transcripts.c.deleted_at.is_(None)) + if params.include_deleted: + base_query = base_query.where(transcripts.c.deleted_at.isnot(None)) + else: + base_query = base_query.where(transcripts.c.deleted_at.is_(None)) if params.query_text is not None: # because already initialized based on params.query_text presence above @@ -396,7 +400,13 @@ class SearchController: transcripts.c.search_vector_en.op("@@")(search_query) ) - if params.user_id: + if params.include_deleted: + # Trash view: only show user's own deleted transcripts. + # Defense-in-depth: require user_id to prevent leaking all users' trash. + if not params.user_id: + return [], 0 + base_query = base_query.where(transcripts.c.user_id == params.user_id) + elif params.user_id: base_query = base_query.where( sqlalchemy.or_( transcripts.c.user_id == params.user_id, rooms.c.is_shared @@ -421,6 +431,8 @@ class SearchController: if params.query_text is not None: order_by = sqlalchemy.desc(sqlalchemy.text("rank")) + elif params.include_deleted: + order_by = sqlalchemy.desc(transcripts.c.deleted_at) else: order_by = sqlalchemy.desc(transcripts.c.created_at) diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index 2adcea85..d903c263 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -24,7 +24,7 @@ from reflector.db.utils import is_postgresql from reflector.logger import logger 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_source_storage, get_transcripts_storage from reflector.utils import generate_uuid4 from reflector.utils.webvtt import topics_to_webvtt @@ -676,6 +676,126 @@ class TranscriptController: ) await get_database().execute(query) + async def restore_by_id( + self, + transcript_id: str, + user_id: str | None = None, + ) -> bool: + """ + Restore a soft-deleted transcript by clearing deleted_at. + + Also restores the associated recording if present. + Returns True if the transcript was restored, False otherwise. + """ + transcript = await self.get_by_id(transcript_id) + if not transcript: + return False + if transcript.deleted_at is None: + return False + if user_id is not None and transcript.user_id != user_id: + return False + + query = ( + transcripts.update() + .where(transcripts.c.id == transcript_id) + .values(deleted_at=None) + ) + await get_database().execute(query) + + if transcript.recording_id: + try: + await recordings_controller.restore_by_id(transcript.recording_id) + except Exception as e: + logger.warning( + "Failed to restore recording", + exc_info=e, + recording_id=transcript.recording_id, + ) + + return True + + async def hard_delete(self, transcript_id: str) -> None: + """ + Permanently delete a transcript, its recording, and all associated files. + + Only deletes transcript-owned resources: + - Transcript row and recording row from DB (first, to make data inaccessible) + - Transcript audio in S3 storage + - Recording files in S3 (both object_key and track_keys, since a recording can have both) + - Local files (data_path directory) + + Does NOT delete: meetings, consent records, rooms, or any shared entity. + Requires the transcript to be soft-deleted first (deleted_at must be set). + """ + transcript = await self.get_by_id(transcript_id) + if not transcript: + return + if transcript.deleted_at is None: + return + + # Collect file references before deleting DB rows + recording = None + recording_storage = None + if transcript.recording_id: + recording = await recordings_controller.get_by_id(transcript.recording_id) + # Determine the correct storage backend for recording files. + # Recordings from different platforms (daily, whereby) live in + # platform-specific buckets with separate credentials. + if recording and recording.meeting_id: + from reflector.db.meetings import meetings_controller # noqa: PLC0415 + + meeting = await meetings_controller.get_by_id(recording.meeting_id) + if meeting: + recording_storage = get_source_storage(meeting.platform) + if recording_storage is None: + recording_storage = get_transcripts_storage() + + # 1. Hard-delete DB rows first (makes data inaccessible immediately) + if recording: + await recordings_controller.hard_delete_by_id(recording.id) + await get_database().execute( + transcripts.delete().where(transcripts.c.id == transcript_id) + ) + + # 2. Delete transcript audio from S3 (always uses transcript storage) + transcript_storage = get_transcripts_storage() + if transcript.audio_location == "storage" and not transcript.audio_deleted: + try: + await transcript_storage.delete_file(transcript.storage_audio_path) + except Exception as e: + logger.warning( + "Failed to delete transcript audio from storage", + exc_info=e, + transcript_id=transcript_id, + path=transcript.storage_audio_path, + ) + + # 3. Delete recording files from S3 (both object_key and track_keys — + # a recording can have both, unlike consent cleanup which uses elif). + # Uses platform-specific storage resolved above. + if recording and recording.bucket_name and recording_storage: + keys_to_delete = [] + if recording.track_keys: + keys_to_delete = recording.track_keys + if recording.object_key: + keys_to_delete.append(recording.object_key) + + for key in keys_to_delete: + try: + await recording_storage.delete_file( + key, bucket=recording.bucket_name + ) + except Exception as e: + logger.warning( + "Failed to delete recording file", + exc_info=e, + key=key, + bucket=recording.bucket_name, + ) + + # 4. Delete local files + transcript.unlink() + async def remove_by_recording_id(self, recording_id: str): """ Soft-delete a transcript by recording_id diff --git a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py index 9a49990e..eee164fe 100644 --- a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py +++ b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py @@ -1277,6 +1277,7 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult: return ConsentResult() consent_denied = False + meeting = None if transcript.meeting_id: meeting = await meetings_controller.get_by_id(transcript.meeting_id) if meeting: @@ -1339,6 +1340,22 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult: logger.error(error_msg, exc_info=True) deletion_errors.append(error_msg) + # Delete cloud video if present + if meeting and meeting.daily_composed_video_s3_key: + try: + source_storage = get_source_storage("daily") + await source_storage.delete_file(meeting.daily_composed_video_s3_key) + await meetings_controller.update_meeting( + meeting.id, + daily_composed_video_s3_key=None, + daily_composed_video_duration=None, + ) + ctx.log(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}") + except Exception as e: + error_msg = f"Failed to delete cloud video: {e}" + logger.error(error_msg, exc_info=True) + deletion_errors.append(error_msg) + if deletion_errors: logger.warning( "[Hatchet] cleanup_consent completed with errors", @@ -1349,7 +1366,7 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult: ctx.log(f"cleanup_consent completed with {len(deletion_errors)} errors") else: await transcripts_controller.update(transcript, {"audio_deleted": True}) - ctx.log("cleanup_consent: all audio deleted successfully") + ctx.log("cleanup_consent: all audio and video deleted successfully") return ConsentResult() diff --git a/server/reflector/hatchet/workflows/file_pipeline.py b/server/reflector/hatchet/workflows/file_pipeline.py index 5bd5caed..6ec38345 100644 --- a/server/reflector/hatchet/workflows/file_pipeline.py +++ b/server/reflector/hatchet/workflows/file_pipeline.py @@ -688,7 +688,10 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu ) from reflector.db.recordings import recordings_controller # noqa: PLC0415 from reflector.db.transcripts import transcripts_controller # noqa: PLC0415 - from reflector.storage import get_transcripts_storage # noqa: PLC0415 + from reflector.storage import ( # noqa: PLC0415 + get_source_storage, + get_transcripts_storage, + ) transcript = await transcripts_controller.get_by_id(input.transcript_id) if not transcript: @@ -697,6 +700,7 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu consent_denied = False recording = None + meeting = None if transcript.recording_id: recording = await recordings_controller.get_by_id(transcript.recording_id) if recording and recording.meeting_id: @@ -756,6 +760,22 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu logger.error(error_msg, exc_info=True) deletion_errors.append(error_msg) + # Delete cloud video if present + if meeting and meeting.daily_composed_video_s3_key: + try: + source_storage = get_source_storage("daily") + await source_storage.delete_file(meeting.daily_composed_video_s3_key) + await meetings_controller.update_meeting( + meeting.id, + daily_composed_video_s3_key=None, + daily_composed_video_duration=None, + ) + ctx.log(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}") + except Exception as e: + error_msg = f"Failed to delete cloud video: {e}" + logger.error(error_msg, exc_info=True) + deletion_errors.append(error_msg) + if deletion_errors: logger.warning( "[Hatchet] cleanup_consent completed with errors", @@ -764,7 +784,7 @@ async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResu ) else: await transcripts_controller.update(transcript, {"audio_deleted": True}) - ctx.log("cleanup_consent: all audio deleted successfully") + ctx.log("cleanup_consent: all audio and video deleted successfully") return ConsentResult() diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py index 322130fd..b25910fe 100644 --- a/server/reflector/pipelines/main_live_pipeline.py +++ b/server/reflector/pipelines/main_live_pipeline.py @@ -61,7 +61,7 @@ from reflector.processors.types import ( ) from reflector.processors.types import Transcript as TranscriptProcessorType from reflector.settings import settings -from reflector.storage import get_transcripts_storage +from reflector.storage import get_source_storage, get_transcripts_storage from reflector.views.transcripts import GetTranscriptTopic from reflector.ws_events import TranscriptEventName from reflector.ws_manager import WebsocketManager, get_ws_manager @@ -671,6 +671,22 @@ async def cleanup_consent(transcript: Transcript, logger: Logger): logger.error(error_msg, exc_info=e) deletion_errors.append(error_msg) + # Delete cloud video if present + if meeting and meeting.daily_composed_video_s3_key: + try: + source_storage = get_source_storage("daily") + await source_storage.delete_file(meeting.daily_composed_video_s3_key) + await meetings_controller.update_meeting( + meeting.id, + daily_composed_video_s3_key=None, + daily_composed_video_duration=None, + ) + logger.info(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}") + except Exception as e: + error_msg = f"Failed to delete cloud video: {e}" + logger.error(error_msg, exc_info=e) + deletion_errors.append(error_msg) + if deletion_errors: logger.warning( f"Consent cleanup completed with {len(deletion_errors)} errors", @@ -678,7 +694,7 @@ async def cleanup_consent(transcript: Transcript, logger: Logger): ) else: await transcripts_controller.update(transcript, {"audio_deleted": True}) - logger.info("Consent cleanup done - all audio deleted") + logger.info("Consent cleanup done - all audio and video deleted") @get_transcript diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index ec5a854c..171c3d81 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -309,6 +309,7 @@ async def transcripts_search( source_kind: Optional[SourceKind] = None, from_datetime: SearchFromDatetimeParam = None, to_datetime: SearchToDatetimeParam = None, + include_deleted: bool = False, user: Annotated[ Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode) ] = None, @@ -316,6 +317,12 @@ async def transcripts_search( """Full-text search across transcript titles and content.""" user_id = user["sub"] if user else None + if include_deleted and not user_id: + raise HTTPException( + status_code=401, + detail="Authentication required to view deleted transcripts", + ) + if from_datetime and to_datetime and from_datetime > to_datetime: raise HTTPException( status_code=400, detail="'from' must be less than or equal to 'to'" @@ -330,6 +337,7 @@ async def transcripts_search( source_kind=source_kind, from_datetime=from_datetime, to_datetime=to_datetime, + include_deleted=include_deleted, ) results, total = await search_controller.search_transcripts(search_params) @@ -615,6 +623,54 @@ async def transcript_delete( return DeletionStatus(status="ok") +@router.post("/transcripts/{transcript_id}/restore", response_model=DeletionStatus) +async def transcript_restore( + transcript_id: str, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + """Restore a soft-deleted transcript.""" + user_id = user["sub"] + transcript = await transcripts_controller.get_by_id(transcript_id) + if not transcript: + raise HTTPException(status_code=404, detail="Transcript not found") + if transcript.deleted_at is None: + raise HTTPException(status_code=400, detail="Transcript is not deleted") + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") + + await transcripts_controller.restore_by_id(transcript.id, user_id=user_id) + await get_ws_manager().send_json( + room_id=f"user:{user_id}", + message={"event": "TRANSCRIPT_RESTORED", "data": {"id": transcript.id}}, + ) + return DeletionStatus(status="ok") + + +@router.delete("/transcripts/{transcript_id}/destroy", response_model=DeletionStatus) +async def transcript_destroy( + transcript_id: str, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + """Permanently delete a transcript and all associated files.""" + user_id = user["sub"] + transcript = await transcripts_controller.get_by_id(transcript_id) + if not transcript: + raise HTTPException(status_code=404, detail="Transcript not found") + if transcript.deleted_at is None: + raise HTTPException( + status_code=400, detail="Transcript must be soft-deleted first" + ) + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") + + await transcripts_controller.hard_delete(transcript.id) + await get_ws_manager().send_json( + room_id=f"user:{user_id}", + message={"event": "TRANSCRIPT_DELETED", "data": {"id": transcript.id}}, + ) + return DeletionStatus(status="ok") + + @router.get( "/transcripts/{transcript_id}/topics", response_model=list[GetTranscriptTopic], diff --git a/server/reflector/ws_events.py b/server/reflector/ws_events.py index 3ac89b44..195c1dfe 100644 --- a/server/reflector/ws_events.py +++ b/server/reflector/ws_events.py @@ -113,6 +113,7 @@ TranscriptWsEvent = Annotated[ UserEventName = Literal[ "TRANSCRIPT_CREATED", "TRANSCRIPT_DELETED", + "TRANSCRIPT_RESTORED", "TRANSCRIPT_STATUS", "TRANSCRIPT_FINAL_TITLE", "TRANSCRIPT_DURATION", @@ -161,6 +162,15 @@ class UserWsTranscriptDeleted(BaseModel): data: UserTranscriptDeletedData +class UserTranscriptRestoredData(BaseModel): + id: NonEmptyString + + +class UserWsTranscriptRestored(BaseModel): + event: Literal["TRANSCRIPT_RESTORED"] = "TRANSCRIPT_RESTORED" + data: UserTranscriptRestoredData + + class UserWsTranscriptStatus(BaseModel): event: Literal["TRANSCRIPT_STATUS"] = "TRANSCRIPT_STATUS" data: UserTranscriptStatusData @@ -180,6 +190,7 @@ UserWsEvent = Annotated[ Union[ UserWsTranscriptCreated, UserWsTranscriptDeleted, + UserWsTranscriptRestored, UserWsTranscriptStatus, UserWsTranscriptFinalTitle, UserWsTranscriptDuration, diff --git a/server/tests/test_transcripts.py b/server/tests/test_transcripts.py index 6f9e7e3a..136b673f 100644 --- a/server/tests/test_transcripts.py +++ b/server/tests/test_transcripts.py @@ -1,5 +1,9 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + import pytest +from reflector.db.meetings import meetings_controller from reflector.db.recordings import Recording, recordings_controller from reflector.db.rooms import rooms_controller from reflector.db.transcripts import SourceKind, transcripts_controller @@ -390,3 +394,463 @@ async def test_transcripts_list_filtered_by_room_id(authenticated_client, client ids = [t["id"] for t in items] assert in_room.id in ids assert other.id not in ids + + +# --------------------------------------------------------------------------- +# Restore tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_transcript_restore(authenticated_client, client): + """Soft-delete then restore, verify accessible again.""" + response = await client.post("/transcripts", json={"name": "restore-me"}) + assert response.status_code == 200 + tid = response.json()["id"] + + # Soft-delete + response = await client.delete(f"/transcripts/{tid}") + assert response.status_code == 200 + + # 404 while deleted + response = await client.get(f"/transcripts/{tid}") + assert response.status_code == 404 + + # Restore + response = await client.post(f"/transcripts/{tid}/restore") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + # Accessible again + response = await client.get(f"/transcripts/{tid}") + assert response.status_code == 200 + assert response.json()["name"] == "restore-me" + + # deleted_at is cleared + transcript = await transcripts_controller.get_by_id(tid) + assert transcript.deleted_at is None + + +@pytest.mark.asyncio +async def test_transcript_restore_recording_also_restored(authenticated_client, client): + """Restoring a transcript also restores its recording.""" + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="restore-test.mp4", + recorded_at=datetime.now(timezone.utc), + ) + ) + transcript = await transcripts_controller.add( + name="restore-with-recording", + source_kind=SourceKind.ROOM, + recording_id=recording.id, + user_id="randomuserid", + ) + + # Soft-delete + response = await client.delete(f"/transcripts/{transcript.id}") + assert response.status_code == 200 + + # Both should be soft-deleted + rec = await recordings_controller.get_by_id(recording.id) + assert rec.deleted_at is not None + + # Restore + response = await client.post(f"/transcripts/{transcript.id}/restore") + assert response.status_code == 200 + + # Recording also restored + rec = await recordings_controller.get_by_id(recording.id) + assert rec.deleted_at is None + + tr = await transcripts_controller.get_by_id(transcript.id) + assert tr.deleted_at is None + + +@pytest.mark.asyncio +async def test_transcript_restore_not_deleted(authenticated_client, client): + """Restoring a non-deleted transcript returns 400.""" + response = await client.post("/transcripts", json={"name": "not-deleted"}) + assert response.status_code == 200 + tid = response.json()["id"] + + response = await client.post(f"/transcripts/{tid}/restore") + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_transcript_restore_not_found(authenticated_client, client): + """Restoring a nonexistent transcript returns 404.""" + response = await client.post("/transcripts/nonexistent-id/restore") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_transcript_restore_forbidden(authenticated_client, client): + """Cannot restore another user's deleted transcript.""" + # Create transcript owned by a different user + transcript = await transcripts_controller.add( + name="other-user-restore", + source_kind=SourceKind.FILE, + user_id="some-other-user", + ) + # Soft-delete directly in DB + await transcripts_controller.remove_by_id(transcript.id, user_id="some-other-user") + + # Try to restore as randomuserid (authenticated_client) + response = await client.post(f"/transcripts/{transcript.id}/restore") + assert response.status_code == 403 + + +# --------------------------------------------------------------------------- +# Destroy tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_destroy_storage(): + """Mock storage backends so hard_delete doesn't require S3 credentials.""" + with ( + patch( + "reflector.db.transcripts.get_transcripts_storage", + return_value=AsyncMock(delete_file=AsyncMock()), + ), + patch( + "reflector.db.transcripts.get_source_storage", + return_value=AsyncMock(delete_file=AsyncMock()), + ), + ): + yield + + +@pytest.mark.asyncio +async def test_transcript_destroy(authenticated_client, client, mock_destroy_storage): + """Soft-delete then destroy, verify transcript gone from DB.""" + response = await client.post("/transcripts", json={"name": "destroy-me"}) + assert response.status_code == 200 + tid = response.json()["id"] + + # Soft-delete first + response = await client.delete(f"/transcripts/{tid}") + assert response.status_code == 200 + + # Destroy + response = await client.delete(f"/transcripts/{tid}/destroy") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + # Gone from DB entirely + transcript = await transcripts_controller.get_by_id(tid) + assert transcript is None + + +@pytest.mark.asyncio +async def test_transcript_destroy_not_soft_deleted(authenticated_client, client): + """Cannot destroy a transcript that hasn't been soft-deleted.""" + response = await client.post("/transcripts", json={"name": "not-soft-deleted"}) + assert response.status_code == 200 + tid = response.json()["id"] + + response = await client.delete(f"/transcripts/{tid}/destroy") + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_transcript_destroy_with_recording( + authenticated_client, client, mock_destroy_storage +): + """Destroying a transcript also hard-deletes its recording from DB.""" + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="destroy-test.mp4", + recorded_at=datetime.now(timezone.utc), + ) + ) + transcript = await transcripts_controller.add( + name="destroy-with-recording", + source_kind=SourceKind.ROOM, + recording_id=recording.id, + user_id="randomuserid", + ) + + # Soft-delete + response = await client.delete(f"/transcripts/{transcript.id}") + assert response.status_code == 200 + + # Destroy + response = await client.delete(f"/transcripts/{transcript.id}/destroy") + assert response.status_code == 200 + + # Both gone from DB + assert await transcripts_controller.get_by_id(transcript.id) is None + assert await recordings_controller.get_by_id(recording.id) is None + + +@pytest.mark.asyncio +async def test_transcript_destroy_forbidden(authenticated_client, client): + """Cannot destroy another user's deleted transcript.""" + transcript = await transcripts_controller.add( + name="other-user-destroy", + source_kind=SourceKind.FILE, + user_id="some-other-user", + ) + await transcripts_controller.remove_by_id(transcript.id, user_id="some-other-user") + + # Try to destroy as randomuserid (authenticated_client) + response = await client.delete(f"/transcripts/{transcript.id}/destroy") + assert response.status_code == 403 + + +# --------------------------------------------------------------------------- +# Isolation tests — verify unrelated data is NOT deleted +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_transcript_destroy_does_not_delete_meeting( + authenticated_client, client, mock_destroy_storage +): + """Destroying a transcript must NOT delete its associated meeting.""" + room = await rooms_controller.add( + name="room-for-meeting-isolation", + user_id="randomuserid", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="", + webhook_secret="", + ) + now = datetime.now(timezone.utc) + meeting = await meetings_controller.create( + id="meeting-isolation-test", + room_name=room.name, + room_url="https://example.com/room", + host_room_url="https://example.com/room-host", + start_date=now, + end_date=now + timedelta(hours=1), + room=room, + ) + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="meeting-iso.mp4", + recorded_at=now, + meeting_id=meeting.id, + ) + ) + transcript = await transcripts_controller.add( + name="transcript-with-meeting", + source_kind=SourceKind.ROOM, + recording_id=recording.id, + meeting_id=meeting.id, + room_id=room.id, + user_id="randomuserid", + ) + + # Soft-delete then destroy + await transcripts_controller.remove_by_id(transcript.id, user_id="randomuserid") + response = await client.delete(f"/transcripts/{transcript.id}/destroy") + assert response.status_code == 200 + + # Transcript and recording are gone + assert await transcripts_controller.get_by_id(transcript.id) is None + assert await recordings_controller.get_by_id(recording.id) is None + + # Meeting still exists + m = await meetings_controller.get_by_id(meeting.id) + assert m is not None + assert m.id == meeting.id + + +@pytest.mark.asyncio +async def test_transcript_destroy_does_not_affect_other_transcripts( + authenticated_client, client, mock_destroy_storage +): + """Destroying one transcript must not affect another transcript or its recording.""" + user_id = "randomuserid" + rec1 = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="sibling1.mp4", + recorded_at=datetime.now(timezone.utc), + ) + ) + rec2 = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="sibling2.mp4", + recorded_at=datetime.now(timezone.utc), + ) + ) + t1 = await transcripts_controller.add( + name="sibling-1", + source_kind=SourceKind.FILE, + recording_id=rec1.id, + user_id=user_id, + ) + t2 = await transcripts_controller.add( + name="sibling-2", + source_kind=SourceKind.FILE, + recording_id=rec2.id, + user_id=user_id, + ) + + # Soft-delete and destroy t1 + await transcripts_controller.remove_by_id(t1.id, user_id=user_id) + response = await client.delete(f"/transcripts/{t1.id}/destroy") + assert response.status_code == 200 + + # t1 and rec1 gone + assert await transcripts_controller.get_by_id(t1.id) is None + assert await recordings_controller.get_by_id(rec1.id) is None + + # t2 and rec2 untouched + t2_after = await transcripts_controller.get_by_id(t2.id) + assert t2_after is not None + assert t2_after.deleted_at is None + rec2_after = await recordings_controller.get_by_id(rec2.id) + assert rec2_after is not None + assert rec2_after.deleted_at is None + + +@pytest.mark.asyncio +async def test_transcript_destroy_meeting_with_multiple_transcripts( + authenticated_client, client, mock_destroy_storage +): + """Destroying one transcript from a meeting must not affect the other + transcript, its recording, or the shared meeting.""" + user_id = "randomuserid" + room = await rooms_controller.add( + name="room-multi-transcript", + user_id=user_id, + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="", + webhook_secret="", + ) + now = datetime.now(timezone.utc) + meeting = await meetings_controller.create( + id="meeting-multi-transcript-test", + room_name=room.name, + room_url="https://example.com/room", + host_room_url="https://example.com/room-host", + start_date=now, + end_date=now + timedelta(hours=1), + room=room, + ) + rec1 = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="multi1.mp4", + recorded_at=now, + meeting_id=meeting.id, + ) + ) + rec2 = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="multi2.mp4", + recorded_at=now, + meeting_id=meeting.id, + ) + ) + t1 = await transcripts_controller.add( + name="multi-t1", + source_kind=SourceKind.ROOM, + recording_id=rec1.id, + meeting_id=meeting.id, + room_id=room.id, + user_id=user_id, + ) + t2 = await transcripts_controller.add( + name="multi-t2", + source_kind=SourceKind.ROOM, + recording_id=rec2.id, + meeting_id=meeting.id, + room_id=room.id, + user_id=user_id, + ) + + # Soft-delete and destroy t1 + await transcripts_controller.remove_by_id(t1.id, user_id=user_id) + response = await client.delete(f"/transcripts/{t1.id}/destroy") + assert response.status_code == 200 + + # t1 + rec1 gone + assert await transcripts_controller.get_by_id(t1.id) is None + assert await recordings_controller.get_by_id(rec1.id) is None + + # t2 + rec2 + meeting all still exist + assert (await transcripts_controller.get_by_id(t2.id)) is not None + assert (await recordings_controller.get_by_id(rec2.id)) is not None + assert (await meetings_controller.get_by_id(meeting.id)) is not None + + +# --------------------------------------------------------------------------- +# Search tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_include_deleted(authenticated_client, client): + """Search with include_deleted=true returns only deleted transcripts.""" + response = await client.post("/transcripts", json={"name": "search-deleted"}) + assert response.status_code == 200 + tid = response.json()["id"] + + # Soft-delete + response = await client.delete(f"/transcripts/{tid}") + assert response.status_code == 200 + + # Normal search should not include it + response = await client.get("/transcripts/search", params={"q": ""}) + assert response.status_code == 200 + ids = [r["id"] for r in response.json()["results"]] + assert tid not in ids + + # Search with include_deleted should include it + response = await client.get( + "/transcripts/search", params={"q": "", "include_deleted": True} + ) + assert response.status_code == 200 + ids = [r["id"] for r in response.json()["results"]] + assert tid in ids + + +@pytest.mark.asyncio +async def test_search_exclude_deleted_by_default(authenticated_client, client): + """Normal search excludes deleted transcripts by default.""" + response = await client.post( + "/transcripts", json={"name": "search-exclude-deleted"} + ) + assert response.status_code == 200 + tid = response.json()["id"] + + # Verify it appears in search + response = await client.get("/transcripts/search", params={"q": ""}) + assert response.status_code == 200 + ids = [r["id"] for r in response.json()["results"]] + assert tid in ids + + # Soft-delete + response = await client.delete(f"/transcripts/{tid}") + assert response.status_code == 200 + + # Verify it no longer appears in default search + response = await client.get("/transcripts/search", params={"q": ""}) + assert response.status_code == 200 + ids = [r["id"] for r in response.json()["results"]] + assert tid not in ids diff --git a/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx b/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx index fc019f77..e072ba41 100644 --- a/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx +++ b/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx @@ -34,11 +34,11 @@ export default function DeleteTranscriptDialog({ - Delete transcript + Move to Trash - Are you sure you want to delete this transcript? This action cannot - be undone. + This transcript will be moved to the trash. You can restore it later + from the Trash view. {title && ( {title} @@ -71,7 +71,7 @@ export default function DeleteTranscriptDialog({ ml={3} disabled={!!isLoading} > - Delete + Move to Trash diff --git a/www/app/(app)/browse/_components/DestroyTranscriptDialog.tsx b/www/app/(app)/browse/_components/DestroyTranscriptDialog.tsx new file mode 100644 index 00000000..aaed664e --- /dev/null +++ b/www/app/(app)/browse/_components/DestroyTranscriptDialog.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Button, Dialog, Text } from "@chakra-ui/react"; + +interface DestroyTranscriptDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + cancelRef: React.RefObject; + isLoading?: boolean; + title?: string; + date?: string; + source?: string; +} + +export default function DestroyTranscriptDialog({ + isOpen, + onClose, + onConfirm, + cancelRef, + isLoading, + title, + date, + source, +}: DestroyTranscriptDialogProps) { + return ( + { + if (!e.open) onClose(); + }} + initialFocusEl={() => cancelRef.current} + > + + + + + Permanently Destroy Transcript + + + + This will permanently delete this transcript and all its + associated audio files. This action cannot be undone. + + {title && ( + + {title} + + )} + {date && ( + + Date: {date} + + )} + {source && ( + + Source: {source} + + )} + + + + + + + + + ); +} diff --git a/www/app/(app)/browse/_components/FilterSidebar.tsx b/www/app/(app)/browse/_components/FilterSidebar.tsx index 91060c9c..d31b4372 100644 --- a/www/app/(app)/browse/_components/FilterSidebar.tsx +++ b/www/app/(app)/browse/_components/FilterSidebar.tsx @@ -1,8 +1,9 @@ "use client"; import React from "react"; -import { Box, Stack, Link, Heading } from "@chakra-ui/react"; +import { Box, Stack, Link, Heading, Flex } from "@chakra-ui/react"; import NextLink from "next/link"; +import { LuTrash2 } from "react-icons/lu"; import type { components } from "../../../reflector-api"; type Room = components["schemas"]["Room"]; @@ -13,6 +14,9 @@ interface FilterSidebarProps { selectedSourceKind: SourceKind | null; selectedRoomId: string; onFilterChange: (sourceKind: SourceKind | null, roomId: string) => void; + isTrashView: boolean; + onTrashClick: () => void; + isAuthenticated: boolean; } export default function FilterSidebar({ @@ -20,6 +24,9 @@ export default function FilterSidebar({ selectedSourceKind, selectedRoomId, onFilterChange, + isTrashView, + onTrashClick, + isAuthenticated, }: FilterSidebarProps) { const myRooms = rooms.filter((room) => !room.is_shared); const sharedRooms = rooms.filter((room) => room.is_shared); @@ -32,8 +39,14 @@ export default function FilterSidebar({ fontSize="sm" href="#" onClick={() => onFilterChange(null, "")} - color={selectedSourceKind === null ? "blue.500" : "gray.600"} - fontWeight={selectedSourceKind === null ? "bold" : "normal"} + color={ + !isTrashView && selectedSourceKind === null + ? "blue.500" + : "gray.600" + } + fontWeight={ + !isTrashView && selectedSourceKind === null ? "bold" : "normal" + } > All Transcripts @@ -51,12 +64,16 @@ export default function FilterSidebar({ href="#" onClick={() => onFilterChange("room", room.id)} color={ - selectedSourceKind === "room" && selectedRoomId === room.id + !isTrashView && + selectedSourceKind === "room" && + selectedRoomId === room.id ? "blue.500" : "gray.600" } fontWeight={ - selectedSourceKind === "room" && selectedRoomId === room.id + !isTrashView && + selectedSourceKind === "room" && + selectedRoomId === room.id ? "bold" : "normal" } @@ -79,12 +96,16 @@ export default function FilterSidebar({ href="#" onClick={() => onFilterChange("room" as SourceKind, room.id)} color={ - selectedSourceKind === "room" && selectedRoomId === room.id + !isTrashView && + selectedSourceKind === "room" && + selectedRoomId === room.id ? "blue.500" : "gray.600" } fontWeight={ - selectedSourceKind === "room" && selectedRoomId === room.id + !isTrashView && + selectedSourceKind === "room" && + selectedRoomId === room.id ? "bold" : "normal" } @@ -101,9 +122,15 @@ export default function FilterSidebar({ as={NextLink} href="#" onClick={() => onFilterChange("live", "")} - color={selectedSourceKind === "live" ? "blue.500" : "gray.600"} + color={ + !isTrashView && selectedSourceKind === "live" + ? "blue.500" + : "gray.600" + } _hover={{ color: "blue.300" }} - fontWeight={selectedSourceKind === "live" ? "bold" : "normal"} + fontWeight={ + !isTrashView && selectedSourceKind === "live" ? "bold" : "normal" + } fontSize="sm" > Live Transcripts @@ -112,13 +139,39 @@ export default function FilterSidebar({ as={NextLink} href="#" onClick={() => onFilterChange("file", "")} - color={selectedSourceKind === "file" ? "blue.500" : "gray.600"} + color={ + !isTrashView && selectedSourceKind === "file" + ? "blue.500" + : "gray.600" + } _hover={{ color: "blue.300" }} - fontWeight={selectedSourceKind === "file" ? "bold" : "normal"} + fontWeight={ + !isTrashView && selectedSourceKind === "file" ? "bold" : "normal" + } fontSize="sm" > Uploaded Files + + {isAuthenticated && ( + <> + + + + + Trash + + + + )} ); diff --git a/www/app/(app)/browse/_components/TranscriptActionsMenu.tsx b/www/app/(app)/browse/_components/TranscriptActionsMenu.tsx index 1119a4b7..86439dae 100644 --- a/www/app/(app)/browse/_components/TranscriptActionsMenu.tsx +++ b/www/app/(app)/browse/_components/TranscriptActionsMenu.tsx @@ -1,17 +1,21 @@ import React from "react"; -import { IconButton, Icon, Menu } from "@chakra-ui/react"; -import { LuMenu, LuTrash, LuRotateCw } from "react-icons/lu"; +import { IconButton, Menu } from "@chakra-ui/react"; +import { LuMenu, LuTrash, LuRotateCw, LuUndo2 } from "react-icons/lu"; interface TranscriptActionsMenuProps { transcriptId: string; - onDelete: (transcriptId: string) => void; - onReprocess: (transcriptId: string) => void; + onDelete?: (transcriptId: string) => void; + onReprocess?: (transcriptId: string) => void; + onRestore?: (transcriptId: string) => void; + onDestroy?: (transcriptId: string) => void; } export default function TranscriptActionsMenu({ transcriptId, onDelete, onReprocess, + onRestore, + onDestroy, }: TranscriptActionsMenuProps) { return ( @@ -22,21 +26,42 @@ export default function TranscriptActionsMenu({ - onReprocess(transcriptId)} - > - Reprocess - - { - e.stopPropagation(); - onDelete(transcriptId); - }} - > - Delete - + {onReprocess && ( + onReprocess(transcriptId)} + > + Reprocess + + )} + {onDelete && ( + { + e.stopPropagation(); + onDelete(transcriptId); + }} + > + Delete + + )} + {onRestore && ( + onRestore(transcriptId)}> + Restore + + )} + {onDestroy && ( + { + e.stopPropagation(); + onDestroy(transcriptId); + }} + > + Destroy + + )} diff --git a/www/app/(app)/browse/_components/TranscriptCards.tsx b/www/app/(app)/browse/_components/TranscriptCards.tsx index b907e3af..cd69555a 100644 --- a/www/app/(app)/browse/_components/TranscriptCards.tsx +++ b/www/app/(app)/browse/_components/TranscriptCards.tsx @@ -29,8 +29,11 @@ interface TranscriptCardsProps { results: SearchResult[]; query: string; isLoading?: boolean; - onDelete: (transcriptId: string) => void; - onReprocess: (transcriptId: string) => void; + isTrash?: boolean; + onDelete?: (transcriptId: string) => void; + onReprocess?: (transcriptId: string) => void; + onRestore?: (transcriptId: string) => void; + onDestroy?: (transcriptId: string) => void; } function highlightText(text: string, query: string): React.ReactNode { @@ -102,13 +105,19 @@ const transcriptHref = ( function TranscriptCard({ result, query, + isTrash, onDelete, onReprocess, + onRestore, + onDestroy, }: { result: SearchResult; query: string; - onDelete: (transcriptId: string) => void; - onReprocess: (transcriptId: string) => void; + isTrash?: boolean; + onDelete?: (transcriptId: string) => void; + onReprocess?: (transcriptId: string) => void; + onRestore?: (transcriptId: string) => void; + onDestroy?: (transcriptId: string) => void; }) { const [isExpanded, setIsExpanded] = useState(false); @@ -136,22 +145,36 @@ function TranscriptCard({ }; return ( - + - {/* Title with highlighting and text fragment for deep linking */} - - {highlightText(resultTitle, query)} - + {/* Title — plain text in trash (deleted transcripts return 404) */} + {isTrash ? ( + + {highlightText(resultTitle, query)} + + ) : ( + + {highlightText(resultTitle, query)} + + )} {/* Metadata - Horizontal on desktop, vertical on mobile */} @@ -284,8 +309,11 @@ export default function TranscriptCards({ results, query, isLoading, + isTrash, onDelete, onReprocess, + onRestore, + onDestroy, }: TranscriptCardsProps) { return ( @@ -315,8 +343,11 @@ export default function TranscriptCards({ key={result.id} result={result} query={query} + isTrash={isTrash} onDelete={onDelete} onReprocess={onReprocess} + onRestore={onRestore} + onDestroy={onDestroy} /> ))} diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx index 05d8d5da..a266b75b 100644 --- a/www/app/(app)/browse/page.tsx +++ b/www/app/(app)/browse/page.tsx @@ -19,6 +19,7 @@ import { parseAsStringLiteral, } from "nuqs"; import { LuX } from "react-icons/lu"; +import { toaster } from "../../components/ui/toaster"; import type { components } from "../../reflector-api"; type Room = components["schemas"]["Room"]; @@ -29,6 +30,9 @@ import { useTranscriptsSearch, useTranscriptDelete, useTranscriptProcess, + useTranscriptRestore, + useTranscriptDestroy, + useAuthReady, } from "../../lib/apiHooks"; import FilterSidebar from "./_components/FilterSidebar"; import Pagination, { @@ -40,6 +44,7 @@ import Pagination, { } from "./_components/Pagination"; import TranscriptCards from "./_components/TranscriptCards"; import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; +import DestroyTranscriptDialog from "./_components/DestroyTranscriptDialog"; import { formatLocalDate } from "../../lib/time"; import { RECORD_A_MEETING_URL } from "../../api/urls"; import { useUserName } from "../../lib/useUserName"; @@ -175,14 +180,17 @@ const UnderSearchFormFilterIndicators: React.FC<{ const EmptyResult: React.FC<{ searchQuery: string; -}> = ({ searchQuery }) => { + isTrash?: boolean; +}> = ({ searchQuery, isTrash }) => { return ( - {searchQuery - ? `No results found for "${searchQuery}". Try adjusting your search terms.` - : "No transcripts found, but you can "} - {!searchQuery && ( + {isTrash + ? "Trash is empty." + : searchQuery + ? `No results found for "${searchQuery}". Try adjusting your search terms.` + : "No transcripts found, but you can "} + {!isTrash && !searchQuery && ( <> record a meeting @@ -196,6 +204,8 @@ const EmptyResult: React.FC<{ }; export default function TranscriptBrowser() { + const { isAuthenticated } = useAuthReady(); + const [urlSearchQuery, setUrlSearchQuery] = useQueryState( "q", parseAsString.withDefault("").withOptions({ shallow: false }), @@ -216,6 +226,12 @@ export default function TranscriptBrowser() { parseAsString.withDefault("").withOptions({ shallow: false }), ); + const [urlTrash, setUrlTrash] = useQueryState( + "trash", + parseAsStringLiteral(["1"] as const).withOptions({ shallow: false }), + ); + const isTrashView = urlTrash === "1"; + const [urlPage, setPage] = useQueryState( "page", parseAsInteger.withDefault(1).withOptions({ shallow: false }), @@ -231,7 +247,7 @@ export default function TranscriptBrowser() { return; } _setSafePage(maybePage.value); - }, [urlPage]); + }, [urlPage, setPage]); const pageSize = 20; @@ -240,11 +256,12 @@ export default function TranscriptBrowser() { () => ({ q: urlSearchQuery, extras: { - room_id: urlRoomId || undefined, - source_kind: urlSourceKind || undefined, + room_id: isTrashView ? undefined : urlRoomId || undefined, + source_kind: isTrashView ? undefined : urlSourceKind || undefined, + include_deleted: isTrashView ? true : undefined, }, }), - [urlSearchQuery, urlRoomId, urlSourceKind], + [urlSearchQuery, urlRoomId, urlSourceKind, isTrashView], ); const { @@ -266,35 +283,55 @@ export default function TranscriptBrowser() { const totalPages = getTotalPages(totalResults, pageSize); - // reset pagination when search results change (detected by total change; good enough approximation) + // reset pagination when search filters change useEffect(() => { // operation is idempotent setPage(FIRST_PAGE).then(() => {}); - }, [JSON.stringify(searchFilters)]); + }, [searchFilters, setPage]); const userName = useUserName(); - const [deletionLoading, setDeletionLoading] = useState(false); + const [actionLoading, setActionLoading] = useState(false); const cancelRef = React.useRef(null); + const destroyCancelRef = React.useRef(null); + + // Delete (soft-delete / move to trash) const [transcriptToDeleteId, setTranscriptToDeleteId] = React.useState(); + // Destroy (hard-delete) + const [transcriptToDestroyId, setTranscriptToDestroyId] = + React.useState(); + const handleFilterTranscripts = ( sourceKind: SourceKind | null, roomId: string, ) => { + if (isTrashView) { + setUrlTrash(null); + } setUrlSourceKind(sourceKind); setUrlRoomId(roomId); setPage(1); }; + const handleTrashClick = () => { + setUrlTrash(isTrashView ? null : "1"); + setUrlSourceKind(null); + setUrlRoomId(null); + setPage(1); + }; + const onCloseDeletion = () => setTranscriptToDeleteId(undefined); + const onCloseDestroy = () => setTranscriptToDestroyId(undefined); const deleteTranscript = useTranscriptDelete(); const processTranscript = useTranscriptProcess(); + const restoreTranscript = useTranscriptRestore(); + const destroyTranscript = useTranscriptDestroy(); const confirmDeleteTranscript = (transcriptId: string) => { - if (deletionLoading) return; - setDeletionLoading(true); + if (actionLoading) return; + setActionLoading(true); deleteTranscript.mutate( { params: { @@ -303,12 +340,12 @@ export default function TranscriptBrowser() { }, { onSuccess: () => { - setDeletionLoading(false); + setActionLoading(false); onCloseDeletion(); reloadSearch(); }, onError: () => { - setDeletionLoading(false); + setActionLoading(false); }, }, ); @@ -322,18 +359,83 @@ export default function TranscriptBrowser() { }); }; + const handleRestoreTranscript = (transcriptId: string) => { + if (actionLoading) return; + setActionLoading(true); + restoreTranscript.mutate( + { + params: { + path: { transcript_id: transcriptId }, + }, + }, + { + onSuccess: () => { + setActionLoading(false); + reloadSearch(); + toaster.create({ + duration: 3000, + render: () => ( + + Transcript restored + + ), + }); + }, + onError: () => { + setActionLoading(false); + }, + }, + ); + }; + + const confirmDestroyTranscript = (transcriptId: string) => { + if (actionLoading) return; + setActionLoading(true); + destroyTranscript.mutate( + { + params: { + path: { transcript_id: transcriptId }, + }, + }, + { + onSuccess: () => { + setActionLoading(false); + onCloseDestroy(); + reloadSearch(); + }, + onError: () => { + setActionLoading(false); + }, + }, + ); + }; + + // Dialog data for delete const transcriptToDelete = results?.find( (i) => i.id === transcriptToDeleteId, ); - const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript"; - const dialogDate = transcriptToDelete?.created_at + const deleteDialogTitle = transcriptToDelete?.title || "Unnamed Transcript"; + const deleteDialogDate = transcriptToDelete?.created_at ? formatLocalDate(transcriptToDelete.created_at) : undefined; - const dialogSource = + const deleteDialogSource = transcriptToDelete?.source_kind === "room" && transcriptToDelete?.room_id ? transcriptToDelete.room_name || transcriptToDelete.room_id : transcriptToDelete?.source_kind; + // Dialog data for destroy + const transcriptToDestroy = results?.find( + (i) => i.id === transcriptToDestroyId, + ); + const destroyDialogTitle = transcriptToDestroy?.title || "Unnamed Transcript"; + const destroyDialogDate = transcriptToDestroy?.created_at + ? formatLocalDate(transcriptToDestroy.created_at) + : undefined; + const destroyDialogSource = + transcriptToDestroy?.source_kind === "room" && transcriptToDestroy?.room_id + ? transcriptToDestroy.room_name || transcriptToDestroy.room_id + : transcriptToDestroy?.source_kind; + if (searchLoading && results.length === 0) { return ( - {userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "} - {(searchLoading || deletionLoading) && } + {isTrashView + ? "Trash" + : userName + ? `${userName}'s Transcriptions` + : "Your Transcriptions"}{" "} + {(searchLoading || actionLoading) && } {!searchLoading && results.length === 0 && ( - + )} @@ -423,10 +535,24 @@ export default function TranscriptBrowser() { transcriptToDeleteId && confirmDeleteTranscript(transcriptToDeleteId) } cancelRef={cancelRef} - isLoading={deletionLoading} - title={dialogTitle} - date={dialogDate} - source={dialogSource} + isLoading={actionLoading} + title={deleteDialogTitle} + date={deleteDialogDate} + source={deleteDialogSource} + /> + + + transcriptToDestroyId && + confirmDestroyTranscript(transcriptToDestroyId) + } + cancelRef={destroyCancelRef} + isLoading={actionLoading} + title={destroyDialogTitle} + date={destroyDialogDate} + source={destroyDialogSource} /> ); diff --git a/www/app/lib/UserEventsProvider.tsx b/www/app/lib/UserEventsProvider.tsx index 454429ce..80ade50a 100644 --- a/www/app/lib/UserEventsProvider.tsx +++ b/www/app/lib/UserEventsProvider.tsx @@ -136,6 +136,7 @@ export function UserEventsProvider({ switch (msg.event) { case "TRANSCRIPT_CREATED": case "TRANSCRIPT_DELETED": + case "TRANSCRIPT_RESTORED": case "TRANSCRIPT_STATUS": case "TRANSCRIPT_FINAL_TITLE": case "TRANSCRIPT_DURATION": diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index ab73cfbe..4ece77e2 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -57,6 +57,7 @@ export function useTranscriptsSearch( offset?: number; room_id?: string; source_kind?: SourceKind; + include_deleted?: boolean; } = {}, ) { return $api.useQuery( @@ -70,6 +71,7 @@ export function useTranscriptsSearch( offset: options.offset, room_id: options.room_id, source_kind: options.source_kind, + include_deleted: options.include_deleted, }, }, }, @@ -105,6 +107,38 @@ export function useTranscriptProcess() { }); } +export function useTranscriptRestore() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/v1/transcripts/{transcript_id}/restore", { + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: ["get", TRANSCRIPT_SEARCH_URL], + }); + }, + onError: (error) => { + setError(error as Error, "There was an error restoring the transcript"); + }, + }); +} + +export function useTranscriptDestroy() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("delete", "/v1/transcripts/{transcript_id}/destroy", { + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: ["get", TRANSCRIPT_SEARCH_URL], + }); + }, + onError: (error) => { + setError(error as Error, "There was an error destroying the transcript"); + }, + }); +} + const ACTIVE_TRANSCRIPT_STATUSES = new Set([ "processing", "uploaded", diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index f01ecabc..4b349f82 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -388,6 +388,46 @@ export interface paths { patch: operations["v1_transcript_update"]; trace?: never; }; + "/v1/transcripts/{transcript_id}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Transcript Restore + * @description Restore a soft-deleted transcript. + */ + post: operations["v1_transcript_restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/destroy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Transcript Destroy + * @description Permanently delete a transcript and all associated files. + */ + delete: operations["v1_transcript_destroy"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/transcripts/{transcript_id}/topics": { parameters: { query?: never; @@ -2391,6 +2431,14 @@ export interface components { */ title: string; }; + /** UserTranscriptRestoredData */ + UserTranscriptRestoredData: { + /** + * Id + * @description A non-empty string + */ + id: string; + }; /** UserTranscriptStatusData */ UserTranscriptStatusData: { /** @@ -2446,6 +2494,15 @@ export interface components { event: "TRANSCRIPT_FINAL_TITLE"; data: components["schemas"]["UserTranscriptFinalTitleData"]; }; + /** UserWsTranscriptRestored */ + UserWsTranscriptRestored: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + event: "TRANSCRIPT_RESTORED"; + data: components["schemas"]["UserTranscriptRestoredData"]; + }; /** UserWsTranscriptStatus */ UserWsTranscriptStatus: { /** @@ -3293,6 +3350,7 @@ export interface operations { from?: string | null; /** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */ to?: string | null; + include_deleted?: boolean; }; header?: never; path?: never; @@ -3427,6 +3485,68 @@ export interface operations { }; }; }; + v1_transcript_restore: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_destroy: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; v1_transcript_get_topics: { parameters: { query?: never; @@ -3995,9 +4115,7 @@ export interface operations { }; v1_transcript_get_video_url: { parameters: { - query?: { - token?: string | null; - }; + query?: never; header?: never; path: { transcript_id: string; @@ -4254,6 +4372,7 @@ export interface operations { "application/json": | components["schemas"]["UserWsTranscriptCreated"] | components["schemas"]["UserWsTranscriptDeleted"] + | components["schemas"]["UserWsTranscriptRestored"] | components["schemas"]["UserWsTranscriptStatus"] | components["schemas"]["UserWsTranscriptFinalTitle"] | components["schemas"]["UserWsTranscriptDuration"];