mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-15 17:56:54 +00:00
feat: send email in share transcript and add email sending in room (#924)
* fix: add source language for file pipeline * feat: send email in share transcript and add email sending in room * fix: hide audio and video streaming for unauthenticated users * fix: security order
This commit is contained in:
committed by
GitHub
parent
74b9b97453
commit
e2ba502697
@@ -0,0 +1,28 @@
|
|||||||
|
"""add email_transcript_to to room
|
||||||
|
|
||||||
|
Revision ID: b4c7e8f9a012
|
||||||
|
Revises: a2b3c4d5e6f7
|
||||||
|
Create Date: 2026-03-24 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "b4c7e8f9a012"
|
||||||
|
down_revision: Union[str, None] = "a2b3c4d5e6f7"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"room",
|
||||||
|
sa.Column("email_transcript_to", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("room", "email_transcript_to")
|
||||||
@@ -13,6 +13,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
|
|||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
from reflector.metrics import metrics_init
|
from reflector.metrics import metrics_init
|
||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
|
from reflector.views.config import router as config_router
|
||||||
from reflector.views.daily import router as daily_router
|
from reflector.views.daily import router as daily_router
|
||||||
from reflector.views.meetings import router as meetings_router
|
from reflector.views.meetings import router as meetings_router
|
||||||
from reflector.views.rooms import router as rooms_router
|
from reflector.views.rooms import router as rooms_router
|
||||||
@@ -107,6 +108,7 @@ app.include_router(transcripts_process_router, prefix="/v1")
|
|||||||
app.include_router(user_router, prefix="/v1")
|
app.include_router(user_router, prefix="/v1")
|
||||||
app.include_router(user_api_keys_router, prefix="/v1")
|
app.include_router(user_api_keys_router, prefix="/v1")
|
||||||
app.include_router(user_ws_router, prefix="/v1")
|
app.include_router(user_ws_router, prefix="/v1")
|
||||||
|
app.include_router(config_router, prefix="/v1")
|
||||||
app.include_router(zulip_router, prefix="/v1")
|
app.include_router(zulip_router, prefix="/v1")
|
||||||
app.include_router(whereby_router, prefix="/v1")
|
app.include_router(whereby_router, prefix="/v1")
|
||||||
app.include_router(daily_router, prefix="/v1/daily")
|
app.include_router(daily_router, prefix="/v1/daily")
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ rooms = sqlalchemy.Table(
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=sqlalchemy.sql.false(),
|
server_default=sqlalchemy.sql.false(),
|
||||||
),
|
),
|
||||||
|
sqlalchemy.Column("email_transcript_to", sqlalchemy.String, nullable=True),
|
||||||
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
||||||
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
|
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
|
||||||
)
|
)
|
||||||
@@ -92,6 +93,7 @@ class Room(BaseModel):
|
|||||||
ics_last_etag: str | None = None
|
ics_last_etag: str | None = None
|
||||||
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
|
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
|
||||||
skip_consent: bool = False
|
skip_consent: bool = False
|
||||||
|
email_transcript_to: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RoomController:
|
class RoomController:
|
||||||
@@ -147,6 +149,7 @@ class RoomController:
|
|||||||
ics_enabled: bool = False,
|
ics_enabled: bool = False,
|
||||||
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
|
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
|
||||||
skip_consent: bool = False,
|
skip_consent: bool = False,
|
||||||
|
email_transcript_to: str | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add a new room
|
Add a new room
|
||||||
@@ -172,6 +175,7 @@ class RoomController:
|
|||||||
"ics_enabled": ics_enabled,
|
"ics_enabled": ics_enabled,
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
"skip_consent": skip_consent,
|
"skip_consent": skip_consent,
|
||||||
|
"email_transcript_to": email_transcript_to,
|
||||||
}
|
}
|
||||||
|
|
||||||
room = Room(**room_data)
|
room = Room(**room_data)
|
||||||
|
|||||||
@@ -1501,13 +1501,30 @@ async def send_email(input: PipelineInput, ctx: Context) -> EmailResult:
|
|||||||
if recording and recording.meeting_id:
|
if recording and recording.meeting_id:
|
||||||
meeting = await meetings_controller.get_by_id(recording.meeting_id)
|
meeting = await meetings_controller.get_by_id(recording.meeting_id)
|
||||||
|
|
||||||
if not meeting or not meeting.email_recipients:
|
recipients = (
|
||||||
|
list(meeting.email_recipients)
|
||||||
|
if meeting and meeting.email_recipients
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also check room-level email
|
||||||
|
from reflector.db.rooms import rooms_controller # noqa: PLC0415
|
||||||
|
|
||||||
|
if transcript.room_id:
|
||||||
|
room = await rooms_controller.get_by_id(transcript.room_id)
|
||||||
|
if room and room.email_transcript_to:
|
||||||
|
if room.email_transcript_to not in recipients:
|
||||||
|
recipients.append(room.email_transcript_to)
|
||||||
|
|
||||||
|
if not recipients:
|
||||||
ctx.log("send_email skipped (no email recipients)")
|
ctx.log("send_email skipped (no email recipients)")
|
||||||
return EmailResult(skipped=True)
|
return EmailResult(skipped=True)
|
||||||
|
|
||||||
await transcripts_controller.update(transcript, {"share_mode": "public"})
|
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
|
||||||
|
if meeting and meeting.email_recipients:
|
||||||
|
await transcripts_controller.update(transcript, {"share_mode": "public"})
|
||||||
|
|
||||||
count = await send_transcript_email(meeting.email_recipients, transcript)
|
count = await send_transcript_email(recipients, transcript)
|
||||||
ctx.log(f"send_email complete: sent {count} emails")
|
ctx.log(f"send_email complete: sent {count} emails")
|
||||||
|
|
||||||
return EmailResult(emails_sent=count)
|
return EmailResult(emails_sent=count)
|
||||||
|
|||||||
@@ -896,14 +896,30 @@ async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult:
|
|||||||
if recording and recording.meeting_id:
|
if recording and recording.meeting_id:
|
||||||
meeting = await meetings_controller.get_by_id(recording.meeting_id)
|
meeting = await meetings_controller.get_by_id(recording.meeting_id)
|
||||||
|
|
||||||
if not meeting or not meeting.email_recipients:
|
recipients = (
|
||||||
|
list(meeting.email_recipients)
|
||||||
|
if meeting and meeting.email_recipients
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also check room-level email
|
||||||
|
from reflector.db.rooms import rooms_controller # noqa: PLC0415
|
||||||
|
|
||||||
|
if transcript.room_id:
|
||||||
|
room = await rooms_controller.get_by_id(transcript.room_id)
|
||||||
|
if room and room.email_transcript_to:
|
||||||
|
if room.email_transcript_to not in recipients:
|
||||||
|
recipients.append(room.email_transcript_to)
|
||||||
|
|
||||||
|
if not recipients:
|
||||||
ctx.log("send_email skipped (no email recipients)")
|
ctx.log("send_email skipped (no email recipients)")
|
||||||
return EmailResult(skipped=True)
|
return EmailResult(skipped=True)
|
||||||
|
|
||||||
# Set transcript to public so the link works for anyone
|
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
|
||||||
await transcripts_controller.update(transcript, {"share_mode": "public"})
|
if meeting and meeting.email_recipients:
|
||||||
|
await transcripts_controller.update(transcript, {"share_mode": "public"})
|
||||||
|
|
||||||
count = await send_transcript_email(meeting.email_recipients, transcript)
|
count = await send_transcript_email(recipients, transcript)
|
||||||
ctx.log(f"send_email complete: sent {count} emails")
|
ctx.log(f"send_email complete: sent {count} emails")
|
||||||
|
|
||||||
return EmailResult(emails_sent=count)
|
return EmailResult(emails_sent=count)
|
||||||
|
|||||||
@@ -397,13 +397,30 @@ async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult:
|
|||||||
if recording and recording.meeting_id:
|
if recording and recording.meeting_id:
|
||||||
meeting = await meetings_controller.get_by_id(recording.meeting_id)
|
meeting = await meetings_controller.get_by_id(recording.meeting_id)
|
||||||
|
|
||||||
if not meeting or not meeting.email_recipients:
|
recipients = (
|
||||||
|
list(meeting.email_recipients)
|
||||||
|
if meeting and meeting.email_recipients
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also check room-level email
|
||||||
|
from reflector.db.rooms import rooms_controller # noqa: PLC0415
|
||||||
|
|
||||||
|
if transcript.room_id:
|
||||||
|
room = await rooms_controller.get_by_id(transcript.room_id)
|
||||||
|
if room and room.email_transcript_to:
|
||||||
|
if room.email_transcript_to not in recipients:
|
||||||
|
recipients.append(room.email_transcript_to)
|
||||||
|
|
||||||
|
if not recipients:
|
||||||
ctx.log("send_email skipped (no email recipients)")
|
ctx.log("send_email skipped (no email recipients)")
|
||||||
return EmailResult(skipped=True)
|
return EmailResult(skipped=True)
|
||||||
|
|
||||||
await transcripts_controller.update(transcript, {"share_mode": "public"})
|
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
|
||||||
|
if meeting and meeting.email_recipients:
|
||||||
|
await transcripts_controller.update(transcript, {"share_mode": "public"})
|
||||||
|
|
||||||
count = await send_transcript_email(meeting.email_recipients, transcript)
|
count = await send_transcript_email(recipients, transcript)
|
||||||
ctx.log(f"send_email complete: sent {count} emails")
|
ctx.log(f"send_email complete: sent {count} emails")
|
||||||
|
|
||||||
return EmailResult(emails_sent=count)
|
return EmailResult(emails_sent=count)
|
||||||
|
|||||||
@@ -116,9 +116,12 @@ class Storage:
|
|||||||
expires_in: int = 3600,
|
expires_in: int = 3600,
|
||||||
*,
|
*,
|
||||||
bucket: str | None = None,
|
bucket: str | None = None,
|
||||||
|
extra_params: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate presigned URL. bucket: override instance default if provided."""
|
"""Generate presigned URL. bucket: override instance default if provided."""
|
||||||
return await self._get_file_url(filename, operation, expires_in, bucket=bucket)
|
return await self._get_file_url(
|
||||||
|
filename, operation, expires_in, bucket=bucket, extra_params=extra_params
|
||||||
|
)
|
||||||
|
|
||||||
async def _get_file_url(
|
async def _get_file_url(
|
||||||
self,
|
self,
|
||||||
@@ -127,6 +130,7 @@ class Storage:
|
|||||||
expires_in: int = 3600,
|
expires_in: int = 3600,
|
||||||
*,
|
*,
|
||||||
bucket: str | None = None,
|
bucket: str | None = None,
|
||||||
|
extra_params: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|||||||
@@ -170,16 +170,23 @@ class AwsStorage(Storage):
|
|||||||
expires_in: int = 3600,
|
expires_in: int = 3600,
|
||||||
*,
|
*,
|
||||||
bucket: str | None = None,
|
bucket: str | None = None,
|
||||||
|
extra_params: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
actual_bucket = bucket or self._bucket_name
|
actual_bucket = bucket or self._bucket_name
|
||||||
folder = self.aws_folder
|
folder = self.aws_folder
|
||||||
s3filename = f"{folder}/{filename}" if folder else filename
|
s3filename = f"{folder}/{filename}" if folder else filename
|
||||||
|
params = {}
|
||||||
|
if extra_params:
|
||||||
|
params.update(extra_params)
|
||||||
|
# Always set Bucket/Key after extra_params to prevent overrides
|
||||||
|
params["Bucket"] = actual_bucket
|
||||||
|
params["Key"] = s3filename
|
||||||
async with self.session.client(
|
async with self.session.client(
|
||||||
"s3", config=self.boto_config, endpoint_url=self._endpoint_url
|
"s3", config=self.boto_config, endpoint_url=self._endpoint_url
|
||||||
) as client:
|
) as client:
|
||||||
presigned_url = await client.generate_presigned_url(
|
presigned_url = await client.generate_presigned_url(
|
||||||
operation,
|
operation,
|
||||||
Params={"Bucket": actual_bucket, "Key": s3filename},
|
Params=params,
|
||||||
ExpiresIn=expires_in,
|
ExpiresIn=expires_in,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
20
server/reflector/views/config.py
Normal file
20
server/reflector/views/config.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from reflector.email import is_email_configured
|
||||||
|
from reflector.settings import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigResponse(BaseModel):
|
||||||
|
zulip_enabled: bool
|
||||||
|
email_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=ConfigResponse)
|
||||||
|
async def get_config():
|
||||||
|
return ConfigResponse(
|
||||||
|
zulip_enabled=bool(settings.ZULIP_REALM),
|
||||||
|
email_enabled=is_email_configured(),
|
||||||
|
)
|
||||||
@@ -44,6 +44,7 @@ class Room(BaseModel):
|
|||||||
ics_last_etag: Optional[str] = None
|
ics_last_etag: Optional[str] = None
|
||||||
platform: Platform
|
platform: Platform
|
||||||
skip_consent: bool = False
|
skip_consent: bool = False
|
||||||
|
email_transcript_to: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RoomDetails(Room):
|
class RoomDetails(Room):
|
||||||
@@ -93,6 +94,7 @@ class CreateRoom(BaseModel):
|
|||||||
ics_enabled: bool = False
|
ics_enabled: bool = False
|
||||||
platform: Platform
|
platform: Platform
|
||||||
skip_consent: bool = False
|
skip_consent: bool = False
|
||||||
|
email_transcript_to: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoom(BaseModel):
|
class UpdateRoom(BaseModel):
|
||||||
@@ -112,6 +114,7 @@ class UpdateRoom(BaseModel):
|
|||||||
ics_enabled: Optional[bool] = None
|
ics_enabled: Optional[bool] = None
|
||||||
platform: Optional[Platform] = None
|
platform: Optional[Platform] = None
|
||||||
skip_consent: Optional[bool] = None
|
skip_consent: Optional[bool] = None
|
||||||
|
email_transcript_to: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CreateRoomMeeting(BaseModel):
|
class CreateRoomMeeting(BaseModel):
|
||||||
@@ -253,6 +256,7 @@ async def rooms_create(
|
|||||||
ics_enabled=room.ics_enabled,
|
ics_enabled=room.ics_enabled,
|
||||||
platform=room.platform,
|
platform=room.platform,
|
||||||
skip_consent=room.skip_consent,
|
skip_consent=room.skip_consent,
|
||||||
|
email_transcript_to=room.email_transcript_to,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from reflector.db.transcripts import (
|
|||||||
transcripts_controller,
|
transcripts_controller,
|
||||||
)
|
)
|
||||||
from reflector.db.users import user_controller
|
from reflector.db.users import user_controller
|
||||||
|
from reflector.email import is_email_configured, send_transcript_email
|
||||||
from reflector.processors.types import Transcript as ProcessorTranscript
|
from reflector.processors.types import Transcript as ProcessorTranscript
|
||||||
from reflector.processors.types import Word
|
from reflector.processors.types import Word
|
||||||
from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment
|
from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment
|
||||||
@@ -718,3 +719,31 @@ async def transcript_post_to_zulip(
|
|||||||
await transcripts_controller.update(
|
await transcripts_controller.update(
|
||||||
transcript, {"zulip_message_id": response["id"]}
|
transcript, {"zulip_message_id": response["id"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SendEmailRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class SendEmailResponse(BaseModel):
|
||||||
|
sent: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transcripts/{transcript_id}/email", response_model=SendEmailResponse)
|
||||||
|
async def transcript_send_email(
|
||||||
|
transcript_id: str,
|
||||||
|
request: SendEmailRequest,
|
||||||
|
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||||
|
):
|
||||||
|
if not is_email_configured():
|
||||||
|
raise HTTPException(status_code=400, detail="Email not configured")
|
||||||
|
user_id = user["sub"]
|
||||||
|
transcript = await transcripts_controller.get_by_id_for_http(
|
||||||
|
transcript_id, user_id=user_id
|
||||||
|
)
|
||||||
|
if not transcript:
|
||||||
|
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||||
|
if not transcripts_controller.user_can_mutate(transcript, user_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
sent = await send_transcript_email([request.email], transcript)
|
||||||
|
return SendEmailResponse(sent=sent)
|
||||||
|
|||||||
@@ -53,9 +53,22 @@ async def transcript_get_audio_mp3(
|
|||||||
else:
|
else:
|
||||||
user_id = token_user["sub"]
|
user_id = token_user["sub"]
|
||||||
|
|
||||||
transcript = await transcripts_controller.get_by_id_for_http(
|
if not user_id and not token:
|
||||||
transcript_id, user_id=user_id
|
# No authentication provided at all. Only anonymous transcripts
|
||||||
)
|
# (user_id=None) are accessible without auth, to preserve
|
||||||
|
# pipeline access via _generate_local_audio_link().
|
||||||
|
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||||
|
if not transcript or transcript.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||||
|
if transcript.user_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
transcript = await transcripts_controller.get_by_id_for_http(
|
||||||
|
transcript_id, user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
if transcript.audio_location == "storage":
|
if transcript.audio_location == "storage":
|
||||||
# proxy S3 file, to prevent issue with CORS
|
# proxy S3 file, to prevent issue with CORS
|
||||||
@@ -94,16 +107,16 @@ async def transcript_get_audio_mp3(
|
|||||||
request,
|
request,
|
||||||
transcript.audio_mp3_filename,
|
transcript.audio_mp3_filename,
|
||||||
content_type="audio/mpeg",
|
content_type="audio/mpeg",
|
||||||
content_disposition=f"attachment; filename={filename}",
|
content_disposition=f"inline; filename={filename}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transcripts/{transcript_id}/audio/waveform")
|
@router.get("/transcripts/{transcript_id}/audio/waveform")
|
||||||
async def transcript_get_audio_waveform(
|
async def transcript_get_audio_waveform(
|
||||||
transcript_id: str,
|
transcript_id: str,
|
||||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||||
) -> AudioWaveform:
|
) -> AudioWaveform:
|
||||||
user_id = user["sub"] if user else None
|
user_id = user["sub"]
|
||||||
transcript = await transcripts_controller.get_by_id_for_http(
|
transcript = await transcripts_controller.get_by_id_for_http(
|
||||||
transcript_id, user_id=user_id
|
transcript_id, user_id=user_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,16 +2,14 @@
|
|||||||
Transcript cloud video endpoint — returns a presigned URL for streaming playback.
|
Transcript cloud video endpoint — returns a presigned URL for streaming playback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated
|
||||||
|
|
||||||
import jwt
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import reflector.auth as auth
|
import reflector.auth as auth
|
||||||
from reflector.db.meetings import meetings_controller
|
from reflector.db.meetings import meetings_controller
|
||||||
from reflector.db.transcripts import transcripts_controller
|
from reflector.db.transcripts import transcripts_controller
|
||||||
from reflector.settings import settings
|
|
||||||
from reflector.storage import get_source_storage
|
from reflector.storage import get_source_storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -30,26 +28,9 @@ class VideoUrlResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
async def transcript_get_video_url(
|
async def transcript_get_video_url(
|
||||||
transcript_id: str,
|
transcript_id: str,
|
||||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
|
||||||
token: str | None = None,
|
|
||||||
):
|
):
|
||||||
user_id = user["sub"] if user else None
|
user_id = user["sub"]
|
||||||
if not user_id and token:
|
|
||||||
try:
|
|
||||||
token_user = await auth.verify_raw_token(token)
|
|
||||||
except Exception:
|
|
||||||
token_user = None
|
|
||||||
if not token_user:
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
|
||||||
user_id = payload.get("sub")
|
|
||||||
except jwt.PyJWTError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid or expired token",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
user_id = token_user["sub"]
|
|
||||||
|
|
||||||
transcript = await transcripts_controller.get_by_id_for_http(
|
transcript = await transcripts_controller.get_by_id_for_http(
|
||||||
transcript_id, user_id=user_id
|
transcript_id, user_id=user_id
|
||||||
@@ -66,7 +47,11 @@ async def transcript_get_video_url(
|
|||||||
url = await source_storage.get_file_url(
|
url = await source_storage.get_file_url(
|
||||||
meeting.daily_composed_video_s3_key,
|
meeting.daily_composed_video_s3_key,
|
||||||
operation="get_object",
|
operation="get_object",
|
||||||
expires_in=3600,
|
expires_in=900,
|
||||||
|
extra_params={
|
||||||
|
"ResponseContentDisposition": "inline",
|
||||||
|
"ResponseContentType": "video/mp4",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return VideoUrlResponse(
|
return VideoUrlResponse(
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ async def mock_storage():
|
|||||||
operation: str = "get_object",
|
operation: str = "get_object",
|
||||||
expires_in: int = 3600,
|
expires_in: int = 3600,
|
||||||
bucket=None,
|
bucket=None,
|
||||||
|
extra_params=None,
|
||||||
):
|
):
|
||||||
return f"http://test-storage/{path}"
|
return f"http://test-storage/{path}"
|
||||||
|
|
||||||
|
|||||||
@@ -373,9 +373,9 @@ async def test_audio_mp3_requires_token_for_owned_transcript(
|
|||||||
tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
|
tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy(audio_path, tr.audio_mp3_filename)
|
shutil.copy(audio_path, tr.audio_mp3_filename)
|
||||||
|
|
||||||
# Anonymous GET without token should be 403 or 404 depending on access; we call mp3
|
# Anonymous GET without token should be 401 (auth required)
|
||||||
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
|
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 401
|
||||||
|
|
||||||
# With token should succeed
|
# With token should succeed
|
||||||
token = create_access_token(
|
token = create_access_token(
|
||||||
@@ -898,7 +898,7 @@ async def test_anonymous_transcript_in_list_when_public_mode(client, monkeypatch
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir):
|
async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir):
|
||||||
"""Anonymous transcript audio (mp3) is accessible without authentication
|
"""Anonymous transcript audio (mp3) is accessible without authentication
|
||||||
because user_id=None bypasses share_mode checks."""
|
because user_id=None bypasses the auth requirement (pipeline access)."""
|
||||||
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
|
||||||
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
|
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
|
||||||
|
|
||||||
@@ -920,7 +920,7 @@ async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir
|
|||||||
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
|
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
|
||||||
assert (
|
assert (
|
||||||
resp.status_code == 200
|
resp.status_code == 200
|
||||||
), f"Anonymous transcript audio should be accessible: {resp.text}"
|
), f"Anonymous transcript audio should be accessible for pipeline: {resp.text}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async def fake_transcript(tmpdir, client, monkeypatch):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_transcript_audio_download(
|
async def test_transcript_audio_download(
|
||||||
fake_transcript, url_suffix, content_type, client
|
authenticated_client, fake_transcript, url_suffix, content_type, client
|
||||||
):
|
):
|
||||||
response = await client.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
|
response = await client.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -61,7 +61,7 @@ async def test_transcript_audio_download(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_transcript_audio_download_head(
|
async def test_transcript_audio_download_head(
|
||||||
fake_transcript, url_suffix, content_type, client
|
authenticated_client, fake_transcript, url_suffix, content_type, client
|
||||||
):
|
):
|
||||||
response = await client.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
|
response = await client.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -82,7 +82,7 @@ async def test_transcript_audio_download_head(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_transcript_audio_download_range(
|
async def test_transcript_audio_download_range(
|
||||||
fake_transcript, url_suffix, content_type, client
|
authenticated_client, fake_transcript, url_suffix, content_type, client
|
||||||
):
|
):
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
|
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
|
||||||
@@ -102,7 +102,7 @@ async def test_transcript_audio_download_range(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_transcript_audio_download_range_with_seek(
|
async def test_transcript_audio_download_range_with_seek(
|
||||||
fake_transcript, url_suffix, content_type, client
|
authenticated_client, fake_transcript, url_suffix, content_type, client
|
||||||
):
|
):
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
|
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
|
||||||
|
|||||||
@@ -98,10 +98,10 @@ async def private_transcript(tmpdir):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_audio_mp3_private_no_auth_returns_403(private_transcript, client):
|
async def test_audio_mp3_private_no_auth_returns_401(private_transcript, client):
|
||||||
"""Without auth, accessing a private transcript's audio returns 403."""
|
"""Without auth, accessing a private transcript's audio returns 401."""
|
||||||
response = await client.get(f"/transcripts/{private_transcript.id}/audio/mp3")
|
response = await client.get(f"/transcripts/{private_transcript.id}/audio/mp3")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -125,8 +125,8 @@ async def test_audio_mp3_with_bearer_header(private_transcript, client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
|
async def test_audio_mp3_public_transcript_no_auth_returns_401(tmpdir, client):
|
||||||
"""Public transcripts are accessible without any auth."""
|
"""Public transcripts require authentication for audio access."""
|
||||||
from reflector.db.transcripts import SourceKind, transcripts_controller
|
from reflector.db.transcripts import SourceKind, transcripts_controller
|
||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
|
|
||||||
@@ -146,8 +146,7 @@ async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
|
|||||||
shutil.copy(mp3_source, audio_filename)
|
shutil.copy(mp3_source, audio_filename)
|
||||||
|
|
||||||
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
|
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 401
|
||||||
assert response.headers["content-type"] == "audio/mpeg"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -299,11 +298,9 @@ async def test_local_audio_link_token_works_with_authentik_backend(
|
|||||||
"""_generate_local_audio_link creates an HS256 token via create_access_token.
|
"""_generate_local_audio_link creates an HS256 token via create_access_token.
|
||||||
|
|
||||||
When the Authentik (RS256) auth backend is active, verify_raw_token uses
|
When the Authentik (RS256) auth backend is active, verify_raw_token uses
|
||||||
JWTAuth which expects RS256 + public key. The HS256 token created by
|
JWTAuth which expects RS256 + public key. The HS256 token fails RS256
|
||||||
_generate_local_audio_link will fail verification, returning 401.
|
verification, but the audio endpoint's HS256 fallback (jwt.decode with
|
||||||
|
SECRET_KEY) correctly handles it, so the request succeeds with 200.
|
||||||
This test documents the bug: the internal audio URL generated for the
|
|
||||||
diarization pipeline is unusable under the JWT auth backend.
|
|
||||||
"""
|
"""
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
@@ -322,6 +319,55 @@ async def test_local_audio_link_token_works_with_authentik_backend(
|
|||||||
f"/transcripts/{private_transcript.id}/audio/mp3?token={token}"
|
f"/transcripts/{private_transcript.id}/audio/mp3?token={token}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# BUG: this should be 200 (the token was created by our own server),
|
# The HS256 fallback in the audio endpoint handles this correctly.
|
||||||
# but the Authentik backend rejects it because it's HS256, not RS256.
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Waveform endpoint auth tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_waveform_requires_authentication(client):
|
||||||
|
"""Waveform endpoint returns 401 for unauthenticated requests."""
|
||||||
|
response = await client.get("/transcripts/any-id/audio/waveform")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_audio_mp3_authenticated_user_accesses_anonymous_transcript(
|
||||||
|
tmpdir, client
|
||||||
|
):
|
||||||
|
"""Authenticated user can access audio for an anonymous (user_id=None) transcript."""
|
||||||
|
from reflector.app import app
|
||||||
|
from reflector.auth import current_user, current_user_optional
|
||||||
|
from reflector.db.transcripts import SourceKind, transcripts_controller
|
||||||
|
from reflector.settings import settings
|
||||||
|
|
||||||
|
settings.DATA_DIR = Path(tmpdir)
|
||||||
|
|
||||||
|
transcript = await transcripts_controller.add(
|
||||||
|
"Anonymous audio test",
|
||||||
|
source_kind=SourceKind.FILE,
|
||||||
|
user_id=None,
|
||||||
|
share_mode="private",
|
||||||
|
)
|
||||||
|
await transcripts_controller.update(transcript, {"status": "ended"})
|
||||||
|
|
||||||
|
audio_filename = transcript.audio_mp3_filename
|
||||||
|
mp3_source = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
|
||||||
|
audio_filename.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy(mp3_source, audio_filename)
|
||||||
|
|
||||||
|
_user = lambda: {"sub": "some-authenticated-user", "email": "user@example.com"}
|
||||||
|
app.dependency_overrides[current_user] = _user
|
||||||
|
app.dependency_overrides[current_user_optional] = _user
|
||||||
|
try:
|
||||||
|
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
|
||||||
|
finally:
|
||||||
|
del app.dependency_overrides[current_user]
|
||||||
|
del app.dependency_overrides[current_user_optional]
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "audio/mpeg"
|
||||||
|
|||||||
@@ -103,3 +103,162 @@ async def test_transcript_get_includes_video_fields(authenticated_client, client
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["has_cloud_video"] is False
|
assert data["has_cloud_video"] is False
|
||||||
assert data["cloud_video_duration"] is None
|
assert data["cloud_video_duration"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_video_url_requires_authentication(client):
|
||||||
|
"""Test that video URL endpoint returns 401 for unauthenticated requests."""
|
||||||
|
response = await client.get("/transcripts/any-id/video/url")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_video_url_presigned_params(authenticated_client, client):
|
||||||
|
"""Test that presigned URL is generated with short expiry and inline disposition."""
|
||||||
|
from reflector.db import get_database
|
||||||
|
from reflector.db.meetings import meetings
|
||||||
|
|
||||||
|
meeting_id = "test-meeting-presigned-params"
|
||||||
|
await get_database().execute(
|
||||||
|
meetings.insert().values(
|
||||||
|
id=meeting_id,
|
||||||
|
room_name="Presigned Params Meeting",
|
||||||
|
room_url="https://example.com",
|
||||||
|
host_room_url="https://example.com/host",
|
||||||
|
start_date=datetime.now(timezone.utc),
|
||||||
|
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||||
|
room_id=None,
|
||||||
|
daily_composed_video_s3_key="recordings/video.mp4",
|
||||||
|
daily_composed_video_duration=60,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
transcript = await transcripts_controller.add(
|
||||||
|
name="presigned-params",
|
||||||
|
source_kind=SourceKind.ROOM,
|
||||||
|
meeting_id=meeting_id,
|
||||||
|
user_id="randomuserid",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
mock_instance.get_file_url = AsyncMock(
|
||||||
|
return_value="https://s3.example.com/presigned-url"
|
||||||
|
)
|
||||||
|
mock_storage.return_value = mock_instance
|
||||||
|
|
||||||
|
await client.get(f"/transcripts/{transcript.id}/video/url")
|
||||||
|
|
||||||
|
mock_instance.get_file_url.assert_called_once_with(
|
||||||
|
"recordings/video.mp4",
|
||||||
|
operation="get_object",
|
||||||
|
expires_in=900,
|
||||||
|
extra_params={
|
||||||
|
"ResponseContentDisposition": "inline",
|
||||||
|
"ResponseContentType": "video/mp4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_meeting_with_video(meeting_id):
|
||||||
|
"""Helper to create a meeting with cloud video."""
|
||||||
|
from reflector.db import get_database
|
||||||
|
from reflector.db.meetings import meetings
|
||||||
|
|
||||||
|
await get_database().execute(
|
||||||
|
meetings.insert().values(
|
||||||
|
id=meeting_id,
|
||||||
|
room_name="Video Meeting",
|
||||||
|
room_url="https://example.com",
|
||||||
|
host_room_url="https://example.com/host",
|
||||||
|
start_date=datetime.now(timezone.utc),
|
||||||
|
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||||
|
room_id=None,
|
||||||
|
daily_composed_video_s3_key="recordings/video.mp4",
|
||||||
|
daily_composed_video_duration=60,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_video_url_private_transcript_denies_non_owner(
|
||||||
|
authenticated_client, client
|
||||||
|
):
|
||||||
|
"""Authenticated non-owner cannot access video for a private transcript."""
|
||||||
|
meeting_id = "test-meeting-private-deny"
|
||||||
|
await _create_meeting_with_video(meeting_id)
|
||||||
|
|
||||||
|
transcript = await transcripts_controller.add(
|
||||||
|
name="private-video",
|
||||||
|
source_kind=SourceKind.ROOM,
|
||||||
|
meeting_id=meeting_id,
|
||||||
|
user_id="other-owner",
|
||||||
|
share_mode="private",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
mock_instance.get_file_url = AsyncMock(
|
||||||
|
return_value="https://s3.example.com/url"
|
||||||
|
)
|
||||||
|
mock_storage.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await client.get(f"/transcripts/{transcript.id}/video/url")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_video_url_public_transcript_allows_authenticated_non_owner(
|
||||||
|
authenticated_client, client
|
||||||
|
):
|
||||||
|
"""Authenticated non-owner can access video for a public transcript."""
|
||||||
|
meeting_id = "test-meeting-public-allow"
|
||||||
|
await _create_meeting_with_video(meeting_id)
|
||||||
|
|
||||||
|
transcript = await transcripts_controller.add(
|
||||||
|
name="public-video",
|
||||||
|
source_kind=SourceKind.ROOM,
|
||||||
|
meeting_id=meeting_id,
|
||||||
|
user_id="other-owner",
|
||||||
|
share_mode="public",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
mock_instance.get_file_url = AsyncMock(
|
||||||
|
return_value="https://s3.example.com/url"
|
||||||
|
)
|
||||||
|
mock_storage.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await client.get(f"/transcripts/{transcript.id}/video/url")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_video_url_semi_private_allows_authenticated_non_owner(
|
||||||
|
authenticated_client, client
|
||||||
|
):
|
||||||
|
"""Authenticated non-owner can access video for a semi-private transcript."""
|
||||||
|
meeting_id = "test-meeting-semi-private-allow"
|
||||||
|
await _create_meeting_with_video(meeting_id)
|
||||||
|
|
||||||
|
transcript = await transcripts_controller.add(
|
||||||
|
name="semi-private-video",
|
||||||
|
source_kind=SourceKind.ROOM,
|
||||||
|
meeting_id=meeting_id,
|
||||||
|
user_id="other-owner",
|
||||||
|
share_mode="semi-private",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
mock_instance.get_file_url = AsyncMock(
|
||||||
|
return_value="https://s3.example.com/url"
|
||||||
|
)
|
||||||
|
mock_storage.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await client.get(f"/transcripts/{transcript.id}/video/url")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
useZulipTopics,
|
useZulipTopics,
|
||||||
useRoomGet,
|
useRoomGet,
|
||||||
useRoomTestWebhook,
|
useRoomTestWebhook,
|
||||||
|
useConfig,
|
||||||
} from "../../lib/apiHooks";
|
} from "../../lib/apiHooks";
|
||||||
import { RoomList } from "./_components/RoomList";
|
import { RoomList } from "./_components/RoomList";
|
||||||
import { PaginationPage } from "../browse/_components/Pagination";
|
import { PaginationPage } from "../browse/_components/Pagination";
|
||||||
@@ -92,6 +93,7 @@ const roomInitialState = {
|
|||||||
icsFetchInterval: 5,
|
icsFetchInterval: 5,
|
||||||
platform: "whereby",
|
platform: "whereby",
|
||||||
skipConsent: false,
|
skipConsent: false,
|
||||||
|
emailTranscriptTo: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RoomsList() {
|
export default function RoomsList() {
|
||||||
@@ -133,11 +135,15 @@ export default function RoomsList() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||||
|
const [emailTranscriptEnabled, setEmailTranscriptEnabled] = useState(false);
|
||||||
|
|
||||||
const createRoomMutation = useRoomCreate();
|
const createRoomMutation = useRoomCreate();
|
||||||
const updateRoomMutation = useRoomUpdate();
|
const updateRoomMutation = useRoomUpdate();
|
||||||
const deleteRoomMutation = useRoomDelete();
|
const deleteRoomMutation = useRoomDelete();
|
||||||
const { data: streams = [] } = useZulipStreams();
|
const { data: config } = useConfig();
|
||||||
|
const zulipEnabled = config?.zulip_enabled ?? false;
|
||||||
|
const emailEnabled = config?.email_enabled ?? false;
|
||||||
|
const { data: streams = [] } = useZulipStreams(zulipEnabled);
|
||||||
const { data: topics = [] } = useZulipTopics(selectedStreamId);
|
const { data: topics = [] } = useZulipTopics(selectedStreamId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -177,6 +183,7 @@ export default function RoomsList() {
|
|||||||
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
|
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
|
||||||
platform: detailedEditedRoom.platform,
|
platform: detailedEditedRoom.platform,
|
||||||
skipConsent: detailedEditedRoom.skip_consent || false,
|
skipConsent: detailedEditedRoom.skip_consent || false,
|
||||||
|
emailTranscriptTo: detailedEditedRoom.email_transcript_to || "",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
[detailedEditedRoom],
|
[detailedEditedRoom],
|
||||||
@@ -329,6 +336,7 @@ export default function RoomsList() {
|
|||||||
ics_fetch_interval: room.icsFetchInterval,
|
ics_fetch_interval: room.icsFetchInterval,
|
||||||
platform,
|
platform,
|
||||||
skip_consent: room.skipConsent,
|
skip_consent: room.skipConsent,
|
||||||
|
email_transcript_to: room.emailTranscriptTo || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -369,6 +377,7 @@ export default function RoomsList() {
|
|||||||
// Reset states
|
// Reset states
|
||||||
setShowWebhookSecret(false);
|
setShowWebhookSecret(false);
|
||||||
setWebhookTestResult(null);
|
setWebhookTestResult(null);
|
||||||
|
setEmailTranscriptEnabled(!!roomData.email_transcript_to);
|
||||||
|
|
||||||
setRoomInput({
|
setRoomInput({
|
||||||
name: roomData.name,
|
name: roomData.name,
|
||||||
@@ -392,6 +401,7 @@ export default function RoomsList() {
|
|||||||
icsFetchInterval: roomData.ics_fetch_interval || 5,
|
icsFetchInterval: roomData.ics_fetch_interval || 5,
|
||||||
platform: roomData.platform,
|
platform: roomData.platform,
|
||||||
skipConsent: roomData.skip_consent || false,
|
skipConsent: roomData.skip_consent || false,
|
||||||
|
emailTranscriptTo: roomData.email_transcript_to || "",
|
||||||
});
|
});
|
||||||
setEditRoomId(roomId);
|
setEditRoomId(roomId);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@@ -469,6 +479,7 @@ export default function RoomsList() {
|
|||||||
setNameError("");
|
setNameError("");
|
||||||
setShowWebhookSecret(false);
|
setShowWebhookSecret(false);
|
||||||
setWebhookTestResult(null);
|
setWebhookTestResult(null);
|
||||||
|
setEmailTranscriptEnabled(false);
|
||||||
onOpen();
|
onOpen();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -504,7 +515,9 @@ export default function RoomsList() {
|
|||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
|
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="share">Share</Tabs.Trigger>
|
{(zulipEnabled || emailEnabled) && (
|
||||||
|
<Tabs.Trigger value="share">Share</Tabs.Trigger>
|
||||||
|
)}
|
||||||
<Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
|
<Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
@@ -831,96 +844,144 @@ export default function RoomsList() {
|
|||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="share" pt={6}>
|
<Tabs.Content value="share" pt={6}>
|
||||||
<Field.Root>
|
{emailEnabled && (
|
||||||
<Checkbox.Root
|
<>
|
||||||
name="zulipAutoPost"
|
<Field.Root>
|
||||||
checked={room.zulipAutoPost}
|
<Checkbox.Root
|
||||||
onCheckedChange={(e) => {
|
checked={emailTranscriptEnabled}
|
||||||
const syntheticEvent = {
|
onCheckedChange={(e) => {
|
||||||
target: {
|
setEmailTranscriptEnabled(!!e.checked);
|
||||||
name: "zulipAutoPost",
|
if (!e.checked) {
|
||||||
type: "checkbox",
|
setRoomInput({
|
||||||
checked: e.checked,
|
...room,
|
||||||
},
|
emailTranscriptTo: "",
|
||||||
};
|
});
|
||||||
handleRoomChange(syntheticEvent);
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox.HiddenInput />
|
<Checkbox.HiddenInput />
|
||||||
<Checkbox.Control>
|
<Checkbox.Control>
|
||||||
<Checkbox.Indicator />
|
<Checkbox.Indicator />
|
||||||
</Checkbox.Control>
|
</Checkbox.Control>
|
||||||
<Checkbox.Label>
|
<Checkbox.Label>
|
||||||
Automatically post transcription to Zulip
|
Email me transcript when processed
|
||||||
</Checkbox.Label>
|
</Checkbox.Label>
|
||||||
</Checkbox.Root>
|
</Checkbox.Root>
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
<Field.Root mt={4}>
|
{emailTranscriptEnabled && (
|
||||||
<Field.Label>Zulip stream</Field.Label>
|
<Field.Root mt={2}>
|
||||||
<Select.Root
|
<Input
|
||||||
value={room.zulipStream ? [room.zulipStream] : []}
|
name="emailTranscriptTo"
|
||||||
onValueChange={(e) =>
|
type="email"
|
||||||
setRoomInput({
|
placeholder="your@email.com"
|
||||||
...room,
|
value={room.emailTranscriptTo}
|
||||||
zulipStream: e.value[0],
|
onChange={handleRoomChange}
|
||||||
zulipTopic: "",
|
/>
|
||||||
})
|
<Field.HelperText>
|
||||||
}
|
Transcript will be emailed to this address after
|
||||||
collection={streamCollection}
|
processing
|
||||||
disabled={!room.zulipAutoPost}
|
</Field.HelperText>
|
||||||
>
|
</Field.Root>
|
||||||
<Select.HiddenSelect />
|
)}
|
||||||
<Select.Control>
|
</>
|
||||||
<Select.Trigger>
|
)}
|
||||||
<Select.ValueText placeholder="Select stream" />
|
{zulipEnabled && (
|
||||||
</Select.Trigger>
|
<>
|
||||||
<Select.IndicatorGroup>
|
<Field.Root mt={emailEnabled ? 4 : 0}>
|
||||||
<Select.Indicator />
|
<Checkbox.Root
|
||||||
</Select.IndicatorGroup>
|
name="zulipAutoPost"
|
||||||
</Select.Control>
|
checked={room.zulipAutoPost}
|
||||||
<Select.Positioner>
|
onCheckedChange={(e) => {
|
||||||
<Select.Content>
|
const syntheticEvent = {
|
||||||
{streamOptions.map((option) => (
|
target: {
|
||||||
<Select.Item key={option.value} item={option}>
|
name: "zulipAutoPost",
|
||||||
{option.label}
|
type: "checkbox",
|
||||||
<Select.ItemIndicator />
|
checked: e.checked,
|
||||||
</Select.Item>
|
},
|
||||||
))}
|
};
|
||||||
</Select.Content>
|
handleRoomChange(syntheticEvent);
|
||||||
</Select.Positioner>
|
}}
|
||||||
</Select.Root>
|
>
|
||||||
</Field.Root>
|
<Checkbox.HiddenInput />
|
||||||
<Field.Root mt={4}>
|
<Checkbox.Control>
|
||||||
<Field.Label>Zulip topic</Field.Label>
|
<Checkbox.Indicator />
|
||||||
<Select.Root
|
</Checkbox.Control>
|
||||||
value={room.zulipTopic ? [room.zulipTopic] : []}
|
<Checkbox.Label>
|
||||||
onValueChange={(e) =>
|
Automatically post transcription to Zulip
|
||||||
setRoomInput({ ...room, zulipTopic: e.value[0] })
|
</Checkbox.Label>
|
||||||
}
|
</Checkbox.Root>
|
||||||
collection={topicCollection}
|
</Field.Root>
|
||||||
disabled={!room.zulipAutoPost}
|
<Field.Root mt={4}>
|
||||||
>
|
<Field.Label>Zulip stream</Field.Label>
|
||||||
<Select.HiddenSelect />
|
<Select.Root
|
||||||
<Select.Control>
|
value={room.zulipStream ? [room.zulipStream] : []}
|
||||||
<Select.Trigger>
|
onValueChange={(e) =>
|
||||||
<Select.ValueText placeholder="Select topic" />
|
setRoomInput({
|
||||||
</Select.Trigger>
|
...room,
|
||||||
<Select.IndicatorGroup>
|
zulipStream: e.value[0],
|
||||||
<Select.Indicator />
|
zulipTopic: "",
|
||||||
</Select.IndicatorGroup>
|
})
|
||||||
</Select.Control>
|
}
|
||||||
<Select.Positioner>
|
collection={streamCollection}
|
||||||
<Select.Content>
|
disabled={!room.zulipAutoPost}
|
||||||
{topicOptions.map((option) => (
|
>
|
||||||
<Select.Item key={option.value} item={option}>
|
<Select.HiddenSelect />
|
||||||
{option.label}
|
<Select.Control>
|
||||||
<Select.ItemIndicator />
|
<Select.Trigger>
|
||||||
</Select.Item>
|
<Select.ValueText placeholder="Select stream" />
|
||||||
))}
|
</Select.Trigger>
|
||||||
</Select.Content>
|
<Select.IndicatorGroup>
|
||||||
</Select.Positioner>
|
<Select.Indicator />
|
||||||
</Select.Root>
|
</Select.IndicatorGroup>
|
||||||
</Field.Root>
|
</Select.Control>
|
||||||
|
<Select.Positioner>
|
||||||
|
<Select.Content>
|
||||||
|
{streamOptions.map((option) => (
|
||||||
|
<Select.Item key={option.value} item={option}>
|
||||||
|
{option.label}
|
||||||
|
<Select.ItemIndicator />
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Positioner>
|
||||||
|
</Select.Root>
|
||||||
|
</Field.Root>
|
||||||
|
<Field.Root mt={4}>
|
||||||
|
<Field.Label>Zulip topic</Field.Label>
|
||||||
|
<Select.Root
|
||||||
|
value={room.zulipTopic ? [room.zulipTopic] : []}
|
||||||
|
onValueChange={(e) =>
|
||||||
|
setRoomInput({
|
||||||
|
...room,
|
||||||
|
zulipTopic: e.value[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
collection={topicCollection}
|
||||||
|
disabled={!room.zulipAutoPost}
|
||||||
|
>
|
||||||
|
<Select.HiddenSelect />
|
||||||
|
<Select.Control>
|
||||||
|
<Select.Trigger>
|
||||||
|
<Select.ValueText placeholder="Select topic" />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.IndicatorGroup>
|
||||||
|
<Select.Indicator />
|
||||||
|
</Select.IndicatorGroup>
|
||||||
|
</Select.Control>
|
||||||
|
<Select.Positioner>
|
||||||
|
<Select.Content>
|
||||||
|
{topicOptions.map((option) => (
|
||||||
|
<Select.Item key={option.value} item={option}>
|
||||||
|
{option.label}
|
||||||
|
<Select.ItemIndicator />
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Positioner>
|
||||||
|
</Select.Root>
|
||||||
|
</Field.Root>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="webhook" pt={6}>
|
<Tabs.Content value="webhook" pt={6}>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useTranscriptGet } from "../../../lib/apiHooks";
|
import { useTranscriptGet } from "../../../lib/apiHooks";
|
||||||
import { TranscriptStatus } from "../../../lib/transcript";
|
import { TranscriptStatus } from "../../../lib/transcript";
|
||||||
|
import { useAuth } from "../../../lib/AuthProvider";
|
||||||
|
import { featureEnabled } from "../../../lib/features";
|
||||||
|
|
||||||
type TranscriptDetails = {
|
type TranscriptDetails = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -57,7 +59,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
const [finalSummaryElement, setFinalSummaryElement] =
|
const [finalSummaryElement, setFinalSummaryElement] =
|
||||||
useState<HTMLDivElement | null>(null);
|
useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const hasCloudVideo = !!transcript.data?.has_cloud_video;
|
const auth = useAuth();
|
||||||
|
const isAuthenticated =
|
||||||
|
auth.status === "authenticated" || !featureEnabled("requireLogin");
|
||||||
|
const hasCloudVideo = !!transcript.data?.has_cloud_video && isAuthenticated;
|
||||||
const [videoExpanded, setVideoExpanded] = useState(false);
|
const [videoExpanded, setVideoExpanded] = useState(false);
|
||||||
const [videoNewBadge, setVideoNewBadge] = useState(() => {
|
const [videoNewBadge, setVideoNewBadge] = useState(() => {
|
||||||
if (typeof window === "undefined") return true;
|
if (typeof window === "undefined") return true;
|
||||||
@@ -145,7 +150,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
|||||||
mt={4}
|
mt={4}
|
||||||
mb={4}
|
mb={4}
|
||||||
>
|
>
|
||||||
{!mp3.audioDeleted && (
|
{isAuthenticated && !mp3.audioDeleted && (
|
||||||
<>
|
<>
|
||||||
{waveform.waveform && mp3.media && topics.topics ? (
|
{waveform.waveform && mp3.media && topics.topics ? (
|
||||||
<Player
|
<Player
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { useAuth } from "../../../lib/AuthProvider";
|
|||||||
import { featureEnabled } from "../../../lib/features";
|
import { featureEnabled } from "../../../lib/features";
|
||||||
import { SearchableLanguageSelect } from "../../../components/SearchableLanguageSelect";
|
import { SearchableLanguageSelect } from "../../../components/SearchableLanguageSelect";
|
||||||
|
|
||||||
|
const sourceLanguages = supportedLanguages.filter(
|
||||||
|
(l) => l.value && l.value !== "NOTRANSLATION",
|
||||||
|
);
|
||||||
|
|
||||||
const TranscriptCreate = () => {
|
const TranscriptCreate = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -33,8 +37,13 @@ const TranscriptCreate = () => {
|
|||||||
const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setName(event.target.value);
|
setName(event.target.value);
|
||||||
};
|
};
|
||||||
|
const [sourceLanguage, setSourceLanguage] = useState<string>("");
|
||||||
const [targetLanguage, setTargetLanguage] = useState<string>("NOTRANSLATION");
|
const [targetLanguage, setTargetLanguage] = useState<string>("NOTRANSLATION");
|
||||||
|
|
||||||
|
const onSourceLanguageChange = (newval) => {
|
||||||
|
(!newval || typeof newval === "string") &&
|
||||||
|
setSourceLanguage(newval || "en");
|
||||||
|
};
|
||||||
const onLanguageChange = (newval) => {
|
const onLanguageChange = (newval) => {
|
||||||
(!newval || typeof newval === "string") && setTargetLanguage(newval);
|
(!newval || typeof newval === "string") && setTargetLanguage(newval);
|
||||||
};
|
};
|
||||||
@@ -55,7 +64,7 @@ const TranscriptCreate = () => {
|
|||||||
const targetLang = getTargetLanguage();
|
const targetLang = getTargetLanguage();
|
||||||
createTranscript.create({
|
createTranscript.create({
|
||||||
name,
|
name,
|
||||||
source_language: "en",
|
source_language: sourceLanguage || "en",
|
||||||
target_language: targetLang || "en",
|
target_language: targetLang || "en",
|
||||||
source_kind: "live",
|
source_kind: "live",
|
||||||
});
|
});
|
||||||
@@ -67,7 +76,7 @@ const TranscriptCreate = () => {
|
|||||||
const targetLang = getTargetLanguage();
|
const targetLang = getTargetLanguage();
|
||||||
createTranscript.create({
|
createTranscript.create({
|
||||||
name,
|
name,
|
||||||
source_language: "en",
|
source_language: sourceLanguage || "en",
|
||||||
target_language: targetLang || "en",
|
target_language: targetLang || "en",
|
||||||
source_kind: "file",
|
source_kind: "file",
|
||||||
});
|
});
|
||||||
@@ -160,6 +169,15 @@ const TranscriptCreate = () => {
|
|||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box mb={4}>
|
||||||
|
<Text mb={1}>Audio language</Text>
|
||||||
|
<SearchableLanguageSelect
|
||||||
|
options={sourceLanguages}
|
||||||
|
value={sourceLanguage}
|
||||||
|
onChange={onSourceLanguageChange}
|
||||||
|
placeholder="Select language"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Box mb={4}>
|
<Box mb={4}>
|
||||||
<Text mb={1}>Do you want to enable live translation?</Text>
|
<Text mb={1}>Do you want to enable live translation?</Text>
|
||||||
<SearchableLanguageSelect
|
<SearchableLanguageSelect
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import {
|
|||||||
createListCollection,
|
createListCollection,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { LuShare2 } from "react-icons/lu";
|
import { LuShare2 } from "react-icons/lu";
|
||||||
import { useTranscriptUpdate } from "../../lib/apiHooks";
|
import { useTranscriptUpdate, useConfig } from "../../lib/apiHooks";
|
||||||
import ShareLink from "./shareLink";
|
import ShareLink from "./shareLink";
|
||||||
import ShareCopy from "./shareCopy";
|
import ShareCopy from "./shareCopy";
|
||||||
import ShareZulip from "./shareZulip";
|
import ShareZulip from "./shareZulip";
|
||||||
|
import ShareEmail from "./shareEmail";
|
||||||
import { useAuth } from "../../lib/AuthProvider";
|
import { useAuth } from "../../lib/AuthProvider";
|
||||||
|
|
||||||
import { featureEnabled } from "../../lib/features";
|
import { featureEnabled } from "../../lib/features";
|
||||||
@@ -55,6 +56,9 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
|
|||||||
const [shareLoading, setShareLoading] = useState(false);
|
const [shareLoading, setShareLoading] = useState(false);
|
||||||
const requireLogin = featureEnabled("requireLogin");
|
const requireLogin = featureEnabled("requireLogin");
|
||||||
const updateTranscriptMutation = useTranscriptUpdate();
|
const updateTranscriptMutation = useTranscriptUpdate();
|
||||||
|
const { data: config } = useConfig();
|
||||||
|
const zulipEnabled = config?.zulip_enabled ?? false;
|
||||||
|
const emailEnabled = config?.email_enabled ?? false;
|
||||||
|
|
||||||
const updateShareMode = async (selectedValue: string) => {
|
const updateShareMode = async (selectedValue: string) => {
|
||||||
const selectedOption = shareOptionsData.find(
|
const selectedOption = shareOptionsData.find(
|
||||||
@@ -169,14 +173,20 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
|
|||||||
<Text fontSize="sm" mb="2" fontWeight={"bold"}>
|
<Text fontSize="sm" mb="2" fontWeight={"bold"}>
|
||||||
Share options
|
Share options
|
||||||
</Text>
|
</Text>
|
||||||
<Flex gap={2} mb={2}>
|
<Flex gap={2} mb={2} flexWrap="wrap">
|
||||||
{requireLogin && (
|
{requireLogin && zulipEnabled && (
|
||||||
<ShareZulip
|
<ShareZulip
|
||||||
transcript={props.transcript}
|
transcript={props.transcript}
|
||||||
topics={props.topics}
|
topics={props.topics}
|
||||||
disabled={toShareMode(shareMode.value) === "private"}
|
disabled={toShareMode(shareMode.value) === "private"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{emailEnabled && (
|
||||||
|
<ShareEmail
|
||||||
|
transcript={props.transcript}
|
||||||
|
disabled={toShareMode(shareMode.value) === "private"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ShareCopy
|
<ShareCopy
|
||||||
finalSummaryElement={props.finalSummaryElement}
|
finalSummaryElement={props.finalSummaryElement}
|
||||||
transcript={props.transcript}
|
transcript={props.transcript}
|
||||||
|
|||||||
110
www/app/(app)/transcripts/shareEmail.tsx
Normal file
110
www/app/(app)/transcripts/shareEmail.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { components } from "../../reflector-api";
|
||||||
|
|
||||||
|
type GetTranscriptWithParticipants =
|
||||||
|
components["schemas"]["GetTranscriptWithParticipants"];
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
CloseButton,
|
||||||
|
Input,
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { LuMail } from "react-icons/lu";
|
||||||
|
import { useTranscriptSendEmail } from "../../lib/apiHooks";
|
||||||
|
|
||||||
|
type ShareEmailProps = {
|
||||||
|
transcript: GetTranscriptWithParticipants;
|
||||||
|
disabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ShareEmail(props: ShareEmailProps) {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const sendEmailMutation = useTranscriptSendEmail();
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!email) return;
|
||||||
|
try {
|
||||||
|
await sendEmailMutation.mutateAsync({
|
||||||
|
params: {
|
||||||
|
path: { transcript_id: props.transcript.id },
|
||||||
|
},
|
||||||
|
body: { email },
|
||||||
|
});
|
||||||
|
setSent(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSent(false);
|
||||||
|
setShowModal(false);
|
||||||
|
setEmail("");
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending email:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button disabled={props.disabled} onClick={() => setShowModal(true)}>
|
||||||
|
<LuMail /> Send Email
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
open={showModal}
|
||||||
|
onOpenChange={(e) => {
|
||||||
|
setShowModal(e.open);
|
||||||
|
if (!e.open) {
|
||||||
|
setSent(false);
|
||||||
|
setEmail("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Dialog.Backdrop />
|
||||||
|
<Dialog.Positioner>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Send Transcript via Email</Dialog.Title>
|
||||||
|
<Dialog.CloseTrigger asChild>
|
||||||
|
<CloseButton />
|
||||||
|
</Dialog.CloseTrigger>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Body>
|
||||||
|
{sent ? (
|
||||||
|
<Text color="green.500">Email sent successfully!</Text>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Text mb={2}>
|
||||||
|
Enter the email address to send this transcript to:
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="recipient@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Dialog.Body>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="ghost" onClick={() => setShowModal(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{!sent && (
|
||||||
|
<Button
|
||||||
|
disabled={!email || sendEmailMutation.isPending}
|
||||||
|
onClick={handleSend}
|
||||||
|
>
|
||||||
|
{sendEmailMutation.isPending ? "Sending..." : "Send"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Positioner>
|
||||||
|
</Dialog.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
|
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!transcriptId || later || !transcript) return;
|
if (!transcriptId || later || !transcript || !accessTokenInfo) return;
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let audioElement: HTMLAudioElement | null = null;
|
let audioElement: HTMLAudioElement | null = null;
|
||||||
@@ -113,7 +113,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
|||||||
if (handleError) audioElement.removeEventListener("error", handleError);
|
if (handleError) audioElement.removeEventListener("error", handleError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [transcriptId, transcript, later]);
|
}, [transcriptId, transcript, later, accessTokenInfo]);
|
||||||
|
|
||||||
const getNow = () => {
|
const getNow = () => {
|
||||||
setLater(false);
|
setLater(false);
|
||||||
|
|||||||
@@ -39,17 +39,16 @@ export default function VideoPlayer({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url`;
|
||||||
if (accessToken) {
|
|
||||||
params.set("token", accessToken);
|
|
||||||
}
|
|
||||||
const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url?${params}`;
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
headers["Authorization"] = `Bearer ${accessToken}`;
|
headers["Authorization"] = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
const resp = await fetch(url, { headers });
|
const resp = await fetch(url, { headers });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
if (resp.status === 401) {
|
||||||
|
throw new Error("Sign in to view the video recording");
|
||||||
|
}
|
||||||
throw new Error("Failed to load video");
|
throw new Error("Failed to load video");
|
||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -90,7 +89,7 @@ export default function VideoPlayer({
|
|||||||
w="fit-content"
|
w="fit-content"
|
||||||
maxW="100%"
|
maxW="100%"
|
||||||
>
|
>
|
||||||
<Text fontSize="sm">Failed to load video recording</Text>
|
<Text fontSize="sm">{error || "Failed to load video recording"}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -132,10 +131,14 @@ export default function VideoPlayer({
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
{/* Video element with visible controls */}
|
{/* Video element with visible controls */}
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
<video
|
<video
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
|
controlsList="nodownload"
|
||||||
|
disablePictureInPicture
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function SearchableLanguageSelect({
|
|||||||
|
|
||||||
const collection = useMemo(() => createListCollection({ items }), [items]);
|
const collection = useMemo(() => createListCollection({ items }), [items]);
|
||||||
|
|
||||||
const selectedValues = value && value !== "NOTRANSLATION" ? [value] : [];
|
const selectedValues = value ? [value] : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox.Root
|
<Combobox.Root
|
||||||
|
|||||||
@@ -228,7 +228,11 @@ export function useRoomDelete() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useZulipStreams() {
|
export function useConfig() {
|
||||||
|
return $api.useQuery("get", "/v1/config", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZulipStreams(enabled: boolean = true) {
|
||||||
const { isAuthenticated } = useAuthReady();
|
const { isAuthenticated } = useAuthReady();
|
||||||
|
|
||||||
return $api.useQuery(
|
return $api.useQuery(
|
||||||
@@ -236,7 +240,7 @@ export function useZulipStreams() {
|
|||||||
"/v1/zulip/streams",
|
"/v1/zulip/streams",
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
enabled: isAuthenticated,
|
enabled: enabled && isAuthenticated,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -291,6 +295,16 @@ export function useTranscriptPostToZulip() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTranscriptSendEmail() {
|
||||||
|
const { setError } = useError();
|
||||||
|
|
||||||
|
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/email", {
|
||||||
|
onError: (error) => {
|
||||||
|
setError(error as Error, "There was an error sending the email");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useTranscriptUploadAudio() {
|
export function useTranscriptUploadAudio() {
|
||||||
const { setError } = useError();
|
const { setError } = useError();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
114
www/app/reflector-api.d.ts
vendored
114
www/app/reflector-api.d.ts
vendored
@@ -456,6 +456,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/transcripts/{transcript_id}/email": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Transcript Send Email */
|
||||||
|
post: operations["v1_transcript_send_email"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/transcripts/{transcript_id}/audio/mp3": {
|
"/v1/transcripts/{transcript_id}/audio/mp3": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -739,6 +756,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Config */
|
||||||
|
get: operations["v1_get_config"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/zulip/streams": {
|
"/v1/zulip/streams": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -942,6 +976,13 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
/** ConfigResponse */
|
||||||
|
ConfigResponse: {
|
||||||
|
/** Zulip Enabled */
|
||||||
|
zulip_enabled: boolean;
|
||||||
|
/** Email Enabled */
|
||||||
|
email_enabled: boolean;
|
||||||
|
};
|
||||||
/** CreateApiKeyRequest */
|
/** CreateApiKeyRequest */
|
||||||
CreateApiKeyRequest: {
|
CreateApiKeyRequest: {
|
||||||
/** Name */
|
/** Name */
|
||||||
@@ -1025,6 +1066,8 @@ export interface components {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
skip_consent: boolean;
|
skip_consent: boolean;
|
||||||
|
/** Email Transcript To */
|
||||||
|
email_transcript_to?: string | null;
|
||||||
};
|
};
|
||||||
/** CreateRoomMeeting */
|
/** CreateRoomMeeting */
|
||||||
CreateRoomMeeting: {
|
CreateRoomMeeting: {
|
||||||
@@ -1844,6 +1887,8 @@ export interface components {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
skip_consent: boolean;
|
skip_consent: boolean;
|
||||||
|
/** Email Transcript To */
|
||||||
|
email_transcript_to?: string | null;
|
||||||
};
|
};
|
||||||
/** RoomDetails */
|
/** RoomDetails */
|
||||||
RoomDetails: {
|
RoomDetails: {
|
||||||
@@ -1900,6 +1945,8 @@ export interface components {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
skip_consent: boolean;
|
skip_consent: boolean;
|
||||||
|
/** Email Transcript To */
|
||||||
|
email_transcript_to?: string | null;
|
||||||
/** Webhook Url */
|
/** Webhook Url */
|
||||||
webhook_url: string | null;
|
webhook_url: string | null;
|
||||||
/** Webhook Secret */
|
/** Webhook Secret */
|
||||||
@@ -1984,6 +2031,16 @@ export interface components {
|
|||||||
/** Change Seq */
|
/** Change Seq */
|
||||||
change_seq?: number | null;
|
change_seq?: number | null;
|
||||||
};
|
};
|
||||||
|
/** SendEmailRequest */
|
||||||
|
SendEmailRequest: {
|
||||||
|
/** Email */
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
/** SendEmailResponse */
|
||||||
|
SendEmailResponse: {
|
||||||
|
/** Sent */
|
||||||
|
sent: number;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* SourceKind
|
* SourceKind
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -2264,6 +2321,8 @@ export interface components {
|
|||||||
platform?: ("whereby" | "daily") | null;
|
platform?: ("whereby" | "daily") | null;
|
||||||
/** Skip Consent */
|
/** Skip Consent */
|
||||||
skip_consent?: boolean | null;
|
skip_consent?: boolean | null;
|
||||||
|
/** Email Transcript To */
|
||||||
|
email_transcript_to?: string | null;
|
||||||
};
|
};
|
||||||
/** UpdateTranscript */
|
/** UpdateTranscript */
|
||||||
UpdateTranscript: {
|
UpdateTranscript: {
|
||||||
@@ -3497,6 +3556,41 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
v1_transcript_send_email: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
transcript_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SendEmailRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SendEmailResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
v1_transcript_get_audio_mp3: {
|
v1_transcript_get_audio_mp3: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -4167,6 +4261,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
v1_get_config: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ConfigResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
v1_zulip_get_streams: {
|
v1_zulip_get_streams: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Reference in New Issue
Block a user