mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-03-31 19:36:47 +00:00
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:
committed by
GitHub
parent
cc9c5cd4a5
commit
ec8b49738e
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
83
www/app/(app)/browse/_components/DestroyTranscriptDialog.tsx
Normal file
83
www/app/(app)/browse/_components/DestroyTranscriptDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
125
www/app/reflector-api.d.ts
vendored
125
www/app/reflector-api.d.ts
vendored
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user