feat: show trash for soft deleted transcripts and hard delete option (#942)

* feat: show trash for soft deleted transcripts and hard delete option

* fix: test fixtures

* docs: aws new permissions
This commit is contained in:
Juan Diego García
2026-03-31 13:15:52 -05:00
committed by GitHub
parent cc9c5cd4a5
commit ec8b49738e
20 changed files with 1351 additions and 94 deletions

View File

@@ -95,6 +95,12 @@ DAILYCO_STORAGE_AWS_BUCKET_NAME=<your-bucket-from-daily-setup>
DAILYCO_STORAGE_AWS_REGION=us-east-1
DAILYCO_STORAGE_AWS_ROLE_ARN=<your-role-arn-from-daily-setup>
# 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=<your-aws-access-key>
DAILYCO_STORAGE_AWS_SECRET_ACCESS_KEY=<your-aws-secret-key>
# Transcript storage (should already be configured from main setup)
# TRANSCRIPT_STORAGE_BACKEND=aws
# TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID=<your-key>
@@ -103,6 +109,19 @@ DAILYCO_STORAGE_AWS_ROLE_ARN=<your-role-arn-from-daily-setup>
# TRANSCRIPT_STORAGE_AWS_REGION=<your-bucket-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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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],

View File

@@ -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,

View File

@@ -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

View File

@@ -34,11 +34,11 @@ export default function DeleteTranscriptDialog({
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header fontSize="lg" fontWeight="bold">
Delete transcript
Move to Trash
</Dialog.Header>
<Dialog.Body>
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 && (
<Text mt={3} fontWeight="600">
{title}
@@ -71,7 +71,7 @@ export default function DeleteTranscriptDialog({
ml={3}
disabled={!!isLoading}
>
Delete
Move to Trash
</Button>
</Dialog.Footer>
</Dialog.Content>

View File

@@ -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<any>;
isLoading?: boolean;
title?: string;
date?: string;
source?: string;
}
export default function DestroyTranscriptDialog({
isOpen,
onClose,
onConfirm,
cancelRef,
isLoading,
title,
date,
source,
}: DestroyTranscriptDialogProps) {
return (
<Dialog.Root
open={isOpen}
onOpenChange={(e) => {
if (!e.open) onClose();
}}
initialFocusEl={() => cancelRef.current}
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header fontSize="lg" fontWeight="bold">
Permanently Destroy Transcript
</Dialog.Header>
<Dialog.Body>
<Text color="red.600" fontWeight="medium">
This will permanently delete this transcript and all its
associated audio files. This action cannot be undone.
</Text>
{title && (
<Text mt={3} fontWeight="600">
{title}
</Text>
)}
{date && (
<Text color="gray.600" fontSize="sm">
Date: {date}
</Text>
)}
{source && (
<Text color="gray.600" fontSize="sm">
Source: {source}
</Text>
)}
</Dialog.Body>
<Dialog.Footer>
<Button
ref={cancelRef as any}
onClick={onClose}
disabled={!!isLoading}
variant="outline"
colorPalette="gray"
>
Cancel
</Button>
<Button
colorPalette="red"
onClick={onConfirm}
ml={3}
disabled={!!isLoading}
>
Destroy
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
);
}

View File

@@ -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
</Link>
@@ -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
</Link>
{isAuthenticated && (
<>
<Box borderBottomWidth="1px" my={2} />
<Link
as={NextLink}
href="#"
onClick={onTrashClick}
color={isTrashView ? "red.600" : "red.500"}
_hover={{ color: "red.400" }}
fontWeight={isTrashView ? "bold" : "normal"}
fontSize="sm"
>
<Flex align="center" gap={1}>
<LuTrash2 />
Trash
</Flex>
</Link>
</>
)}
</Stack>
</Box>
);

View File

@@ -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 (
<Menu.Root closeOnSelect={true} lazyMount={true}>
@@ -22,21 +26,42 @@ export default function TranscriptActionsMenu({
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="reprocess"
onClick={() => onReprocess(transcriptId)}
>
<LuRotateCw /> Reprocess
</Menu.Item>
<Menu.Item
value="delete"
onClick={(e) => {
e.stopPropagation();
onDelete(transcriptId);
}}
>
<LuTrash /> Delete
</Menu.Item>
{onReprocess && (
<Menu.Item
value="reprocess"
onClick={() => onReprocess(transcriptId)}
>
<LuRotateCw /> Reprocess
</Menu.Item>
)}
{onDelete && (
<Menu.Item
value="delete"
onClick={(e) => {
e.stopPropagation();
onDelete(transcriptId);
}}
>
<LuTrash /> Delete
</Menu.Item>
)}
{onRestore && (
<Menu.Item value="restore" onClick={() => onRestore(transcriptId)}>
<LuUndo2 /> Restore
</Menu.Item>
)}
{onDestroy && (
<Menu.Item
value="destroy"
color="red.500"
onClick={(e) => {
e.stopPropagation();
onDestroy(transcriptId);
}}
>
<LuTrash /> Destroy
</Menu.Item>
)}
</Menu.Content>
</Menu.Positioner>
</Menu.Root>

View File

@@ -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 (
<Box borderWidth={1} p={4} borderRadius="md" fontSize="sm">
<Box
borderWidth={1}
p={4}
borderRadius="md"
fontSize="sm"
borderLeftWidth={isTrash ? "3px" : 1}
borderLeftColor={isTrash ? "red.400" : undefined}
bg={isTrash ? "gray.50" : undefined}
>
<Flex justify="space-between" alignItems="flex-start" gap="2">
<Box>
<TranscriptStatusIcon status={result.status} />
</Box>
<Box flex="1">
{/* Title with highlighting and text fragment for deep linking */}
<Link
as={NextLink}
href={transcriptHref(result.id, mainSnippet, query)}
fontWeight="600"
display="block"
mb={2}
>
{highlightText(resultTitle, query)}
</Link>
{/* Title — plain text in trash (deleted transcripts return 404) */}
{isTrash ? (
<Text fontWeight="600" mb={2} color="gray.600">
{highlightText(resultTitle, query)}
</Text>
) : (
<Link
as={NextLink}
href={transcriptHref(result.id, mainSnippet, query)}
fontWeight="600"
display="block"
mb={2}
>
{highlightText(resultTitle, query)}
</Link>
)}
{/* Metadata - Horizontal on desktop, vertical on mobile */}
<Flex
@@ -272,8 +295,10 @@ function TranscriptCard({
</Box>
<TranscriptActionsMenu
transcriptId={result.id}
onDelete={onDelete}
onReprocess={onReprocess}
onDelete={isTrash ? undefined : onDelete}
onReprocess={isTrash ? undefined : onReprocess}
onRestore={isTrash ? onRestore : undefined}
onDestroy={isTrash ? onDestroy : undefined}
/>
</Flex>
</Box>
@@ -284,8 +309,11 @@ export default function TranscriptCards({
results,
query,
isLoading,
isTrash,
onDelete,
onReprocess,
onRestore,
onDestroy,
}: TranscriptCardsProps) {
return (
<Box position="relative">
@@ -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}
/>
))}
</Stack>

View File

@@ -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 (
<Flex flexDir="column" alignItems="center" justifyContent="center" py={8}>
<Text textAlign="center">
{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 && (
<>
<Link href={RECORD_A_MEETING_URL} color="blue.500">
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<string>();
// Destroy (hard-delete)
const [transcriptToDestroyId, setTranscriptToDestroyId] =
React.useState<string>();
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: () => (
<Box bg="green.500" color="white" px={4} py={3} borderRadius="md">
<Text fontWeight="bold">Transcript restored</Text>
</Box>
),
});
},
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 (
<Flex
@@ -361,17 +463,24 @@ export default function TranscriptBrowser() {
mb={4}
>
<Heading size="lg">
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
{(searchLoading || deletionLoading) && <Spinner size="sm" />}
{isTrashView
? "Trash"
: userName
? `${userName}'s Transcriptions`
: "Your Transcriptions"}{" "}
{(searchLoading || actionLoading) && <Spinner size="sm" />}
</Heading>
</Flex>
<Flex flexDir={{ base: "column", md: "row" }}>
<FilterSidebar
rooms={rooms}
selectedSourceKind={urlSourceKind}
selectedRoomId={urlRoomId}
selectedSourceKind={isTrashView ? null : urlSourceKind}
selectedRoomId={isTrashView ? "" : urlRoomId}
onFilterChange={handleFilterTranscripts}
isTrashView={isTrashView}
onTrashClick={handleTrashClick}
isAuthenticated={isAuthenticated}
/>
<Flex
@@ -384,8 +493,8 @@ export default function TranscriptBrowser() {
>
<SearchForm
setPage={setPage}
sourceKind={urlSourceKind}
roomId={urlRoomId}
sourceKind={isTrashView ? null : urlSourceKind}
roomId={isTrashView ? null : urlRoomId}
searchQuery={urlSearchQuery}
setSearchQuery={setUrlSearchQuery}
setSourceKind={setUrlSourceKind}
@@ -406,12 +515,15 @@ export default function TranscriptBrowser() {
results={results}
query={urlSearchQuery}
isLoading={searchLoading}
onDelete={setTranscriptToDeleteId}
onReprocess={handleProcessTranscript}
isTrash={isTrashView}
onDelete={isTrashView ? undefined : setTranscriptToDeleteId}
onReprocess={isTrashView ? undefined : handleProcessTranscript}
onRestore={isTrashView ? handleRestoreTranscript : undefined}
onDestroy={isTrashView ? setTranscriptToDestroyId : undefined}
/>
{!searchLoading && results.length === 0 && (
<EmptyResult searchQuery={urlSearchQuery} />
<EmptyResult searchQuery={urlSearchQuery} isTrash={isTrashView} />
)}
</Flex>
</Flex>
@@ -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}
/>
<DestroyTranscriptDialog
isOpen={!!transcriptToDestroyId}
onClose={onCloseDestroy}
onConfirm={() =>
transcriptToDestroyId &&
confirmDestroyTranscript(transcriptToDestroyId)
}
cancelRef={destroyCancelRef}
isLoading={actionLoading}
title={destroyDialogTitle}
date={destroyDialogDate}
source={destroyDialogSource}
/>
</Flex>
);

View File

@@ -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":

View File

@@ -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<TranscriptStatus>([
"processing",
"uploaded",

View File

@@ -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"];