mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
consent preparation
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,3 +5,6 @@ server/exportdanswer
|
|||||||
.vercel
|
.vercel
|
||||||
.env*.local
|
.env*.local
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
.yarn
|
||||||
|
ngrok.log
|
||||||
|
.claude/settings.local.json
|
||||||
425
GUIDE.md
Normal file
425
GUIDE.md
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# Codebase Review Guide: Audio Storage Consent Implementation
|
||||||
|
|
||||||
|
This guide walks through the relevant parts of the codebase for implementing the audio storage consent flow. **Important**: This implementation works with post-processing deletion, not real-time recording control, due to Whereby integration constraints.
|
||||||
|
|
||||||
|
## System Reality: Recording Detection Constraints
|
||||||
|
|
||||||
|
**Critical Understanding**:
|
||||||
|
- **No real-time recording detection** - System only discovers recordings after they complete via SQS polling (60+ second delay)
|
||||||
|
- **Cannot stop recordings in progress** - Whereby controls recording entirely based on room configuration
|
||||||
|
- **Limited webhooks** - Only `room.client.joined/left` events available, no recording events
|
||||||
|
- **Post-processing intervention only** - Can only mark recordings for deletion during SQS processing
|
||||||
|
|
||||||
|
## 1. Current Consent Implementation (TO BE REMOVED)
|
||||||
|
|
||||||
|
### File: `www/app/[roomName]/page.tsx`
|
||||||
|
**Purpose:** Room entry page with blocking consent dialog
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Line 24:** `const [consentGiven, setConsentGiven] = useState<boolean | null>(null);`
|
||||||
|
- **Lines 34-36:** `handleConsent` function that sets consent state
|
||||||
|
- **Lines 80-124:** Consent UI blocking room entry
|
||||||
|
- **Line 80:** `if (!isAuthenticated && !consentGiven)` - blocking condition
|
||||||
|
|
||||||
|
**Current Logic:**
|
||||||
|
```typescript
|
||||||
|
// Lines 99-111: Consent request UI
|
||||||
|
{consentGiven === null ? (
|
||||||
|
<>
|
||||||
|
<Text fontSize="lg" fontWeight="bold">
|
||||||
|
This meeting may be recorded. Do you consent to being recorded?
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={4}>
|
||||||
|
<Button variant="outline" onClick={() => handleConsent(false)}>
|
||||||
|
No, I do not consent
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="blue" onClick={() => handleConsent(true)}>
|
||||||
|
Yes, I consent
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Lines 114-120: Rejection message
|
||||||
|
<Text>You cannot join the meeting without consenting...</Text>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Change:** Remove entire consent blocking logic, allow direct room entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Whereby Integration Reality
|
||||||
|
|
||||||
|
### File: `www/app/[roomName]/page.tsx`
|
||||||
|
**Purpose:** Main room page where video call happens via whereby-embed
|
||||||
|
|
||||||
|
**Key Whereby Integration:**
|
||||||
|
- **Line 129:** `<whereby-embed>` element - this IS the video call
|
||||||
|
- **Lines 26-28:** Room URL from meeting API
|
||||||
|
- **Lines 48-57:** Event listeners for whereby events
|
||||||
|
|
||||||
|
**What Happens:**
|
||||||
|
1. `useRoomMeeting()` calls backend to create/get Whereby meeting
|
||||||
|
2. Whereby automatically records based on room `recording_trigger` configuration
|
||||||
|
3. **NO real-time recording status** - system doesn't know when recording starts/stops
|
||||||
|
|
||||||
|
### File: `www/app/[roomName]/useRoomMeeting.tsx`
|
||||||
|
**Purpose:** Creates or retrieves Whereby meeting for room
|
||||||
|
|
||||||
|
**Key Flow:**
|
||||||
|
- **Line 48:** Calls `v1RoomsCreateMeeting({ roomName })`
|
||||||
|
- **Lines 49-52:** Returns meeting with `room_url` and `host_room_url`
|
||||||
|
- Meeting includes recording configuration from room settings
|
||||||
|
|
||||||
|
**What to Add:** Consent dialog overlay on the whereby-embed - always ask for consent regardless of meeting configuration (simplified approach).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recording Discovery System (POST-PROCESSING ONLY)
|
||||||
|
|
||||||
|
### File: `server/reflector/worker/process.py`
|
||||||
|
**Purpose:** Discovers recordings after they complete via SQS polling
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 24-62:** `process_messages()` - polls SQS every 60 seconds
|
||||||
|
- **Lines 66-133:** `process_recording()` - processes discovered recording files
|
||||||
|
- **Lines 69-71:** Extracts meeting info from S3 object key format
|
||||||
|
|
||||||
|
**Current Discovery Flow:**
|
||||||
|
```python
|
||||||
|
# Lines 69-71: Parse S3 object key
|
||||||
|
room_name = f"/{object_key[:36]}" # First 36 chars = room GUID
|
||||||
|
recorded_at = datetime.fromisoformat(object_key[37:57]) # Timestamp
|
||||||
|
|
||||||
|
# Lines 73-74: Link to meeting
|
||||||
|
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||||
|
room = await rooms_controller.get_by_id(meeting.room_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Add:** Consent checking after transcript processing - always create transcript first, then delete only audio files if consent denied.
|
||||||
|
|
||||||
|
### File: `server/reflector/worker/app.py`
|
||||||
|
**Purpose:** Celery task scheduling
|
||||||
|
|
||||||
|
**Key Schedule:**
|
||||||
|
- **Lines 26-29:** `process_messages` runs every 60 seconds
|
||||||
|
- **Lines 30-33:** `process_meetings` runs every 60 seconds to check meeting status
|
||||||
|
|
||||||
|
**Reality:** consent must be requested during the meeting, not based on recording detection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Meeting-Based Consent Timing
|
||||||
|
|
||||||
|
### File: `server/reflector/views/whereby.py`
|
||||||
|
**Purpose:** Whereby webhook handler - receives participant join/leave events
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 69-72:** Handles `room.client.joined` and `room.client.left` events
|
||||||
|
- **Line 71:** Updates `num_clients` count in meeting record
|
||||||
|
|
||||||
|
**Current Logic:**
|
||||||
|
```python
|
||||||
|
# Lines 69-72: Participant tracking
|
||||||
|
if event.type in ["room.client.joined", "room.client.left"]:
|
||||||
|
await meetings_controller.update_meeting(
|
||||||
|
meeting.id, num_clients=event.data["numClients"]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Add:** ALWAYS ask for consent - no triggers, no conditions. Simple list field to track who denied consent.
|
||||||
|
|
||||||
|
### File: `server/reflector/db/meetings.py`
|
||||||
|
**Purpose:** Meeting database model and recording configuration
|
||||||
|
|
||||||
|
**Key Recording Config:**
|
||||||
|
- **Lines 56-59:** Recording trigger options:
|
||||||
|
- `"automatic"` - Recording starts immediately
|
||||||
|
- `"automatic-2nd-participant"` (default) - Recording starts when 2nd person joins
|
||||||
|
- `"prompt"` - Manual recording start
|
||||||
|
- `"none"` - No recording
|
||||||
|
|
||||||
|
**Current Meeting Model:**
|
||||||
|
```python
|
||||||
|
# Lines 56-59: Recording configuration
|
||||||
|
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
||||||
|
recording_trigger: Literal[
|
||||||
|
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||||
|
] = "automatic-2nd-participant"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Add:** Dictionary field `participant_consent_responses: dict[str, bool]` in Meeting model to store {user_id: true/false}. ALWAYS ask for consent - no complex logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Consent Implementation (NO WebSockets Needed)
|
||||||
|
|
||||||
|
**Consent is meeting-level, not transcript-level** - WebSocket events are for transcript processing, not consent.
|
||||||
|
|
||||||
|
### Simple Consent Flow:
|
||||||
|
1. **Frontend**: Show consent dialog when meeting loads
|
||||||
|
2. **User Response**: Direct API call to `/meetings/{meeting_id}/consent`
|
||||||
|
3. **Backend**: Store response in meeting record
|
||||||
|
4. **SQS Processing**: Check consent during recording processing
|
||||||
|
|
||||||
|
**No WebSocket events needed** - consent is a simple API interaction, not real-time transcript data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Backend WebSocket System
|
||||||
|
|
||||||
|
### File: `server/reflector/views/transcripts_websocket.py`
|
||||||
|
**Purpose:** Server-side WebSocket endpoint for real-time events
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 19-55:** `transcript_events_websocket` function
|
||||||
|
- **Line 32:** Room ID format: `room_id = f"ts:{transcript_id}"`
|
||||||
|
- **Lines 37-44:** Initial event sending to new connections
|
||||||
|
- **Lines 42-43:** Filtering events: `if name in ("TRANSCRIPT", "STATUS"): continue`
|
||||||
|
|
||||||
|
**Current Flow:**
|
||||||
|
1. WebSocket connects to `/transcripts/{transcript_id}/events`
|
||||||
|
2. Server adds user to Redis room `ts:{transcript_id}`
|
||||||
|
3. Server sends historical events (except TRANSCRIPT/STATUS)
|
||||||
|
4. Server waits for new events via Redis pub/sub
|
||||||
|
|
||||||
|
**What to Add:** Handle new consent events in the message flow.
|
||||||
|
|
||||||
|
### File: `server/reflector/ws_manager.py`
|
||||||
|
**Purpose:** Redis pub/sub WebSocket management
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 61-99:** `WebsocketManager` class
|
||||||
|
- **Lines 78-79:** `send_json` method for broadcasting
|
||||||
|
- **Lines 88-98:** `_pubsub_data_reader` for distributing messages
|
||||||
|
|
||||||
|
**Broadcasting Pattern:**
|
||||||
|
```python
|
||||||
|
# Line 78: How to broadcast to all users in a room
|
||||||
|
async def send_json(self, room_id: str, message: dict) -> None:
|
||||||
|
await self.pubsub_client.send_json(room_id, message)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Use:** This system for broadcasting consent requests and responses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Database Models and Migrations
|
||||||
|
|
||||||
|
### File: `server/reflector/db/transcripts.py`
|
||||||
|
**Purpose:** Transcript database model and controller
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 28-73:** `transcripts` SQLAlchemy table definition
|
||||||
|
- **Lines 149-172:** `Transcript` Pydantic model
|
||||||
|
- **Lines 304-614:** `TranscriptController` class with database operations
|
||||||
|
|
||||||
|
**Current Schema Fields:**
|
||||||
|
```python
|
||||||
|
# Lines 31-72: Key existing columns
|
||||||
|
sqlalchemy.Column("id", sqlalchemy.String, primary_key=True),
|
||||||
|
sqlalchemy.Column("status", sqlalchemy.String),
|
||||||
|
sqlalchemy.Column("duration", sqlalchemy.Integer),
|
||||||
|
sqlalchemy.Column("locked", sqlalchemy.Boolean),
|
||||||
|
sqlalchemy.Column("audio_location", sqlalchemy.String, server_default="local"),
|
||||||
|
# ... more columns
|
||||||
|
```
|
||||||
|
|
||||||
|
**Audio File Management:**
|
||||||
|
- **Lines 225-230:** Audio file path properties
|
||||||
|
- **Lines 252-284:** `get_audio_url` method for accessing audio
|
||||||
|
- **Lines 554-571:** `move_mp3_to_storage` for cloud storage
|
||||||
|
|
||||||
|
**What to Add:** New columns for consent tracking and deletion marking.
|
||||||
|
|
||||||
|
### File: `server/migrations/versions/b9348748bbbc_reviewed.py`
|
||||||
|
**Purpose:** Example migration pattern for adding boolean columns
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```python
|
||||||
|
# Lines 20-23: Adding boolean column with default
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('transcript', sa.Column('reviewed', sa.Boolean(),
|
||||||
|
server_default=sa.text('0'), nullable=False))
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('transcript', 'reviewed')
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Follow:** This pattern for adding consent columns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API Endpoint Patterns
|
||||||
|
|
||||||
|
### File: `server/reflector/views/transcripts.py`
|
||||||
|
**Purpose:** REST API endpoints for transcript operations
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 29-30:** Router setup: `router = APIRouter()`
|
||||||
|
- **Lines 70-85:** `CreateTranscript` and `UpdateTranscript` models
|
||||||
|
- **Lines 122-135:** Example POST endpoint: `transcripts_create`
|
||||||
|
|
||||||
|
**Endpoint Pattern:**
|
||||||
|
```python
|
||||||
|
# Lines 122-135: Standard endpoint structure
|
||||||
|
@router.post("/transcripts", response_model=GetTranscript)
|
||||||
|
async def transcripts_create(
|
||||||
|
info: CreateTranscript,
|
||||||
|
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||||
|
):
|
||||||
|
user_id = user["sub"] if user else None
|
||||||
|
return await transcripts_controller.add(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authentication Pattern:**
|
||||||
|
- **Line 125:** Optional user authentication dependency
|
||||||
|
- **Line 127:** Extract user ID: `user_id = user["sub"] if user else None`
|
||||||
|
|
||||||
|
**What to Follow:** This pattern for new consent endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Live Pipeline System
|
||||||
|
|
||||||
|
### File: `server/reflector/pipelines/main_live_pipeline.py`
|
||||||
|
**Purpose:** Real-time processing pipeline during recording
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 80-96:** `@broadcast_to_sockets` decorator for WebSocket events
|
||||||
|
- **Lines 98-104:** `@get_transcript` decorator for database access
|
||||||
|
- **Line 56:** WebSocket manager import: `from reflector.ws_manager import get_ws_manager`
|
||||||
|
|
||||||
|
**Event Broadcasting Pattern:**
|
||||||
|
```python
|
||||||
|
# Lines 80-95: Decorator for broadcasting events
|
||||||
|
def broadcast_to_sockets(func):
|
||||||
|
async def wrapper(self, *args, **kwargs):
|
||||||
|
resp = await func(self, *args, **kwargs)
|
||||||
|
if resp is None:
|
||||||
|
return
|
||||||
|
await self.ws_manager.send_json(
|
||||||
|
room_id=self.ws_room_id,
|
||||||
|
message=resp.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
return wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Modal/Dialog Patterns
|
||||||
|
|
||||||
|
### File: `www/app/(app)/transcripts/[transcriptId]/shareModal.tsx`
|
||||||
|
**Purpose:** Example modal implementation using fixed overlay
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 105-176:** Modal implementation using `fixed inset-0` overlay
|
||||||
|
- **Lines 107-108:** Overlay styling: `fixed inset-0 bg-gray-600 bg-opacity-50`
|
||||||
|
- **Lines 152-170:** Button patterns for actions
|
||||||
|
|
||||||
|
**Modal Structure:**
|
||||||
|
```typescript
|
||||||
|
// Lines 105-109: Modal overlay and container
|
||||||
|
<div className="absolute">
|
||||||
|
{props.show && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white">
|
||||||
|
// Modal content...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### File: `www/app/(app)/transcripts/shareAndPrivacy.tsx`
|
||||||
|
**Purpose:** Example using Chakra UI Modal components
|
||||||
|
|
||||||
|
**Key Areas:**
|
||||||
|
- **Lines 10-16:** Chakra UI Modal imports
|
||||||
|
- **Lines 86-100:** Chakra Modal structure
|
||||||
|
|
||||||
|
**Chakra Modal Pattern:**
|
||||||
|
```typescript
|
||||||
|
// Lines 86-94: Chakra UI Modal structure
|
||||||
|
<Modal isOpen={!!showModal} onClose={() => setShowModal(false)} size={"xl"}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Share</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
// Modal content...
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Choose:** Either pattern works - fixed overlay for simple cases, Chakra UI for consistent styling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Audio File Management
|
||||||
|
|
||||||
|
### File: `server/reflector/db/transcripts.py`
|
||||||
|
**Purpose:** Audio file storage and access
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- **Lines 225-230:** File path properties
|
||||||
|
- `audio_wav_filename`: Local WAV file path
|
||||||
|
- `audio_mp3_filename`: Local MP3 file path
|
||||||
|
- `storage_audio_path`: Cloud storage path
|
||||||
|
- **Lines 252-284:** `get_audio_url()` - Generate access URL
|
||||||
|
- **Lines 554-571:** `move_mp3_to_storage()` - Move to cloud
|
||||||
|
- **Lines 572-580:** `download_mp3_from_storage()` - Download from cloud
|
||||||
|
|
||||||
|
**File Path Properties:**
|
||||||
|
```python
|
||||||
|
# Lines 225-230: Audio file locations
|
||||||
|
@property
|
||||||
|
def audio_wav_filename(self):
|
||||||
|
return self.data_path / "audio.wav"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio_mp3_filename(self):
|
||||||
|
return self.data_path / "audio.mp3"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Storage Logic:**
|
||||||
|
- **Line 253:** Local files: `if self.audio_location == "local"`
|
||||||
|
- **Line 255:** Cloud storage: `elif self.audio_location == "storage"`
|
||||||
|
|
||||||
|
**What to Modify:** Add deletion logic and update `get_audio_url` to handle deleted files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Review Checklist
|
||||||
|
|
||||||
|
Before implementing, manually review these areas with the **meeting-based consent** approach:
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- [ ] **Room Entry**: Remove consent blocking in `www/app/[roomName]/page.tsx:80-124`
|
||||||
|
- [ ] **Meeting UI**: Add consent dialog overlay on `whereby-embed` in `www/app/[roomName]/page.tsx:126+`
|
||||||
|
- [ ] **Meeting Hook**: Update `www/app/[roomName]/useRoomMeeting.tsx` to provide meeting data for consent
|
||||||
|
- [ ] **WebSocket Events**: Add consent event handlers (meeting-based, not transcript-based)
|
||||||
|
- [ ] **User Identification**: Add browser fingerprinting for anonymous users
|
||||||
|
|
||||||
|
### Backend Changes - Meeting Scope
|
||||||
|
- [ ] **Database**: Create `meeting_consent` table migration following `server/migrations/versions/b9348748bbbc_reviewed.py` pattern
|
||||||
|
- [ ] **Meeting Model**: Add consent tracking in `server/reflector/db/meetings.py`
|
||||||
|
- [ ] **Recording Model**: Add deletion flags in `server/reflector/db/recordings.py`
|
||||||
|
- [ ] **API**: Add meeting consent endpoint in `server/reflector/views/meetings.py`
|
||||||
|
- [ ] **Whereby Webhook**: Update `server/reflector/views/whereby.py` to trigger consent based on participant count
|
||||||
|
- [ ] **SQS Processing**: Update `server/reflector/worker/process.py` to check consent before processing recordings
|
||||||
|
|
||||||
|
### Critical Integration Points
|
||||||
|
- [ ] **Consent Timing**: ALWAYS ask for consent - no conditions, no triggers, no participant count checks
|
||||||
|
- [ ] **SQS Processing**: Always create transcript first, then delete only audio files if consent denied
|
||||||
|
- [ ] **Meeting Scoping**: All consent tracking uses `meeting_id`, not `room_id` (rooms are reused)
|
||||||
|
- [ ] **Post-Processing Only**: No real-time recording control - all intervention happens during SQS processing
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
- [ ] **Multiple Participants**: Test consent collection from multiple users in same meeting
|
||||||
|
- [ ] **Room Reuse**: Verify consent doesn't affect other meetings in same room
|
||||||
|
- [ ] **Recording Triggers**: Test different `recording_trigger` configurations
|
||||||
|
- [ ] **SQS Deletion**: Verify recordings are deleted from S3 when consent denied
|
||||||
|
- [ ] **Timing Edge Cases**: Test consent given after recording already started
|
||||||
|
|
||||||
|
**Reality Check**: This implementation works with **post-processing deletion only**. We cannot stop recordings in progress or detect exactly when they start. Consent timing is estimated based on meeting configuration and participant events.
|
||||||
9
NOTES.md
Normal file
9
NOTES.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
frontend explicitly calls backend to create meeting. upsert semantic (meeting gets "stale" somehow - how?)
|
||||||
|
frontend only listens for users own "leave" event to redirect away
|
||||||
|
how do we know it starts recording? meeting has different meeting configurations. to simplify, probably show the consent ALWAYS
|
||||||
|
Q: how S3 and SQS gets filled? by what system?
|
||||||
|
|
||||||
|
|
||||||
|
we have meeting entity, we have users. let's always ask users for consent in an overlay and send this to server to attach to the meeting entity, if we have it
|
||||||
|
|
||||||
|
- consent endpoint
|
||||||
392
PLAN.md
Normal file
392
PLAN.md
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
# Audio Storage Consent Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Move consent from room entry to during recording, asking specifically about audio storage while allowing transcription to continue regardless of response.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Database Schema Changes
|
||||||
|
|
||||||
|
**Meeting Consent Table:** `server/migrations/versions/[timestamp]_add_meeting_consent_table.py`
|
||||||
|
|
||||||
|
Create new table for meeting-scoped consent (rooms are reused, consent is per-meeting):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('meeting_consent',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('meeting_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_identifier', sa.String(), nullable=False), # IP, session, or user ID
|
||||||
|
sa.Column('consent_given', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('consent_timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['meeting_id'], ['meeting.id']),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update Models:** `server/reflector/db/meetings.py` and `server/reflector/db/recordings.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# New model for meeting consent
|
||||||
|
class MeetingConsent(BaseModel):
|
||||||
|
id: str = Field(default_factory=generate_uuid4)
|
||||||
|
meeting_id: str
|
||||||
|
user_identifier: str
|
||||||
|
consent_given: bool
|
||||||
|
consent_timestamp: datetime
|
||||||
|
user_agent: str | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Backend API Changes
|
||||||
|
|
||||||
|
**New Consent Endpoint:** `server/reflector/views/meetings.py`
|
||||||
|
|
||||||
|
Meeting-based consent endpoint (since consent is per meeting session):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MeetingConsentRequest(BaseModel):
|
||||||
|
consent_given: bool
|
||||||
|
user_identifier: str # IP, session ID, or user ID
|
||||||
|
|
||||||
|
@router.post("/meetings/{meeting_id}/consent")
|
||||||
|
async def meeting_audio_consent(
|
||||||
|
meeting_id: str,
|
||||||
|
request: MeetingConsentRequest,
|
||||||
|
user_request: Request,
|
||||||
|
):
|
||||||
|
meeting = await meetings_controller.get_by_id(meeting_id)
|
||||||
|
if not meeting:
|
||||||
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
|
|
||||||
|
# Store consent in meeting_consent table
|
||||||
|
consent = MeetingConsent(
|
||||||
|
meeting_id=meeting_id,
|
||||||
|
user_identifier=request.user_identifier,
|
||||||
|
consent_given=request.consent_given,
|
||||||
|
consent_timestamp=datetime.utcnow(),
|
||||||
|
user_agent=user_request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
|
||||||
|
await meeting_consent_controller.create(consent)
|
||||||
|
|
||||||
|
# Broadcast consent event via WebSocket to room participants
|
||||||
|
ws_manager = get_ws_manager()
|
||||||
|
await ws_manager.send_json(
|
||||||
|
room_id=f"meeting:{meeting_id}",
|
||||||
|
message={
|
||||||
|
"event": "CONSENT_RESPONSE",
|
||||||
|
"data": {
|
||||||
|
"meeting_id": meeting_id,
|
||||||
|
"consent_given": request.consent_given,
|
||||||
|
"user_identifier": request.user_identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success", "consent_id": consent.id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: WebSocket Event System
|
||||||
|
|
||||||
|
**Consent Communication:** Use direct API calls instead of WebSocket events
|
||||||
|
|
||||||
|
Since consent is meeting-level (not transcript-level), use direct API calls:
|
||||||
|
- Frontend shows consent dialog immediately when meeting loads
|
||||||
|
- User response sent directly to `/meetings/{meeting_id}/consent` endpoint
|
||||||
|
- No need for new WebSocket events - keep it simple
|
||||||
|
|
||||||
|
**Consent Request:** ALWAYS ask - no conditions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
# Frontend: Show consent dialog immediately when meeting loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (meeting?.id) {
|
||||||
|
// ALWAYS show consent dialog - no conditions
|
||||||
|
showConsentDialog(meeting.id);
|
||||||
|
}
|
||||||
|
}, [meeting?.id]);
|
||||||
|
|
||||||
|
# Backend: Consent storage in meeting record
|
||||||
|
# Add to Meeting model:
|
||||||
|
participant_consent_responses: dict[str, bool] = Field(default_factory=dict) # {user_id: true/false}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Simple Consent Storage:** Track participant responses
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Update Meeting model to include:
|
||||||
|
participant_consent_responses: dict[str, bool] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Note that it must not be possible to call /consent on already finished meeting.
|
||||||
|
# Consent endpoint stores the response:
|
||||||
|
@router.post("/meetings/{meeting_id}/consent")
|
||||||
|
async def meeting_audio_consent(meeting_id: str, request: MeetingConsentRequest):
|
||||||
|
meeting = await meetings_controller.get_by_id(meeting_id)
|
||||||
|
|
||||||
|
# Store the consent response (true/false)
|
||||||
|
# Only store if they actually clicked something
|
||||||
|
consent_responses = meeting.participant_consent_responses or {}
|
||||||
|
consent_responses[request.user_identifier] = request.consent_given
|
||||||
|
|
||||||
|
await meetings_controller.update_meeting(
|
||||||
|
meeting_id, participant_consent_responses=consent_responses
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Frontend Changes
|
||||||
|
|
||||||
|
**Remove Room Entry Consent:** `www/app/[roomName]/page.tsx`
|
||||||
|
|
||||||
|
Remove lines 24, 34-36, 80-124:
|
||||||
|
```typescript
|
||||||
|
// Remove these lines:
|
||||||
|
const [consentGiven, setConsentGiven] = useState<boolean | null>(null);
|
||||||
|
const handleConsent = (consent: boolean) => { setConsentGiven(consent); };
|
||||||
|
// Remove entire consent UI block (lines 80-124)
|
||||||
|
|
||||||
|
// Simplify render condition:
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Show loading or direct room entry, no consent check
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add Consent Dialog Component:** `www/app/(app)/transcripts/components/AudioConsentDialog.tsx`
|
||||||
|
|
||||||
|
Based on `shareModal.tsx` patterns:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AudioConsentDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConsent: (given: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioConsentDialog = ({ isOpen, onClose, onConsent }: AudioConsentDialogProps) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} closeOnOverlayClick={false}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Audio Storage Consent</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<Text mb={4}>
|
||||||
|
Do you consent to storing this audio recording?
|
||||||
|
The transcript will be generated regardless of your choice.
|
||||||
|
</Text>
|
||||||
|
<HStack spacing={4}>
|
||||||
|
<Button colorScheme="green" onClick={() => onConsent(true)}>
|
||||||
|
Yes, store the audio
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="red" onClick={() => onConsent(false)}>
|
||||||
|
No, delete after transcription
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update Recording Interface:** `www/app/(app)/transcripts/[transcriptId]/record/page.tsx`
|
||||||
|
|
||||||
|
Add consent dialog state and handling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
||||||
|
const [consentStatus, setConsentStatus] = useState<string>('');
|
||||||
|
|
||||||
|
// Add to existing WebSocket event handlers
|
||||||
|
const handleConsentRequest = () => {
|
||||||
|
setShowConsentDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConsentResponse = async (consentGiven: boolean) => {
|
||||||
|
// Call API endpoint
|
||||||
|
await api.v1TranscriptAudioConsent({
|
||||||
|
transcriptId: details.params.transcriptId,
|
||||||
|
requestBody: { consent_given: consentGiven }
|
||||||
|
});
|
||||||
|
setShowConsentDialog(false);
|
||||||
|
setConsentStatus(consentGiven ? 'given' : 'denied');
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Phase 5: SQS Processing Integration
|
||||||
|
|
||||||
|
**Consent Check During Recording Processing:** `server/reflector/worker/process.py`
|
||||||
|
|
||||||
|
Update `process_recording()` to check consent before processing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@shared_task
|
||||||
|
@asynctask
|
||||||
|
async def process_recording(bucket_name: str, object_key: str):
|
||||||
|
logger.info("Processing recording: %s/%s", bucket_name, object_key)
|
||||||
|
|
||||||
|
# Extract meeting info from S3 object key
|
||||||
|
room_name = f"/{object_key[:36]}"
|
||||||
|
recorded_at = datetime.fromisoformat(object_key[37:57])
|
||||||
|
|
||||||
|
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||||
|
|
||||||
|
|
||||||
|
recording = await recordings_controller.get_by_object_key(bucket_name, object_key)
|
||||||
|
if not recording:
|
||||||
|
recording = await recordings_controller.create(
|
||||||
|
Recording(
|
||||||
|
bucket_name=bucket_name,
|
||||||
|
object_key=object_key,
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
meeting_id=meeting.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ALWAYS create transcript first (regardless of consent)
|
||||||
|
transcript = await transcripts_controller.get_by_recording_id(recording.id)
|
||||||
|
if transcript:
|
||||||
|
await transcripts_controller.update(transcript, {"topics": []})
|
||||||
|
else:
|
||||||
|
transcript = await transcripts_controller.add(
|
||||||
|
"", source_kind=SourceKind.ROOM, source_language="en",
|
||||||
|
target_language="en", user_id=room.user_id,
|
||||||
|
recording_id=recording.id, share_mode="public"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process transcript normally (transcription, topics, summaries)
|
||||||
|
_, extension = os.path.splitext(object_key)
|
||||||
|
upload_filename = transcript.data_path / f"upload{extension}"
|
||||||
|
# ... continue with full transcript processing ...
|
||||||
|
# Check if any participant denied consent (check dict values)
|
||||||
|
consent_responses = meeting.participant_consent_responses or {}
|
||||||
|
should_delete = any(consent is False for consent in consent_responses.values())
|
||||||
|
# AFTER transcript processing is complete, delete audio if consent denied
|
||||||
|
if should_delete:
|
||||||
|
logger.info(f"Deleting audio files for {object_key} due to consent denial")
|
||||||
|
await delete_audio_files_only(transcript, bucket_name, object_key)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Audio Deletion Function (AFTER transcript processing):**
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def delete_audio_files_only(transcript: Transcript, bucket_name: str, object_key: str):
|
||||||
|
"""Delete ONLY audio files from all locations, keep transcript data"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Delete original Whereby recording from S3
|
||||||
|
s3_whereby = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_WHEREBY_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
|
||||||
|
)
|
||||||
|
s3_whereby.delete_object(Bucket=bucket_name, Key=object_key)
|
||||||
|
logger.info(f"Deleted original Whereby recording: {bucket_name}/{object_key}")
|
||||||
|
|
||||||
|
# 2. Delete processed audio from transcript storage S3 bucket
|
||||||
|
if transcript.audio_location == "storage":
|
||||||
|
storage = get_storage()
|
||||||
|
await storage.delete_file(transcript.storage_audio_path)
|
||||||
|
logger.info(f"Deleted processed audio from storage: {transcript.storage_audio_path}")
|
||||||
|
|
||||||
|
# 3. Delete local audio files (if any remain)
|
||||||
|
transcript.audio_mp3_filename.unlink(missing_ok=True)
|
||||||
|
transcript.audio_wav_filename.unlink(missing_ok=True)
|
||||||
|
(transcript.data_path / "upload.mp4").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# 4. Update transcript to reflect audio deletion (keep all other data)
|
||||||
|
await transcripts_controller.update(transcript, {
|
||||||
|
'audio_location_deleted': True
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Deleted all audio files for transcript {transcript.id}, kept transcript data")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete audio files for {object_key}: {str(e)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Meeting Consent Controller:** `server/reflector/db/meeting_consent.py`
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MeetingConsentController:
|
||||||
|
async def create(self, consent: MeetingConsent):
|
||||||
|
query = meeting_consent.insert().values(**consent.model_dump())
|
||||||
|
await database.execute(query)
|
||||||
|
return consent
|
||||||
|
|
||||||
|
async def get_by_meeting_id(self, meeting_id: str) -> list[MeetingConsent]:
|
||||||
|
query = meeting_consent.select().where(meeting_consent.c.meeting_id == meeting_id)
|
||||||
|
results = await database.fetch_all(query)
|
||||||
|
return [MeetingConsent(**result) for result in results]
|
||||||
|
|
||||||
|
async def has_any_denial(self, meeting_id: str) -> bool:
|
||||||
|
"""Check if any participant denied consent for this meeting"""
|
||||||
|
query = meeting_consent.select().where(
|
||||||
|
meeting_consent.c.meeting_id == meeting_id,
|
||||||
|
meeting_consent.c.consent_given == False
|
||||||
|
)
|
||||||
|
result = await database.fetch_one(query)
|
||||||
|
return result is not None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: Testing Strategy
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
- Test consent API endpoint
|
||||||
|
- Test WebSocket event broadcasting
|
||||||
|
- Test audio deletion logic
|
||||||
|
- Test consent status tracking
|
||||||
|
|
||||||
|
**Integration Tests:**
|
||||||
|
- Test full consent flow during recording
|
||||||
|
- Test multiple participants consent handling
|
||||||
|
- Test recording continuation regardless of consent
|
||||||
|
- Test audio file cleanup
|
||||||
|
|
||||||
|
**Manual Testing:**
|
||||||
|
- Join room without consent (should work)
|
||||||
|
- Receive consent request during recording
|
||||||
|
- Verify transcription continues regardless of consent choice
|
||||||
|
- Verify audio deletion when consent denied
|
||||||
|
- Verify audio preservation when consent given
|
||||||
|
|
||||||
|
### Phase 7: Deployment Considerations
|
||||||
|
|
||||||
|
**Database Migration:**
|
||||||
|
```bash
|
||||||
|
# Run migration
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rollback Plan:**
|
||||||
|
- Keep old consent logic in feature flag
|
||||||
|
- Database migration includes downgrade function
|
||||||
|
- Frontend can toggle between old/new consent flows
|
||||||
|
|
||||||
|
**Monitoring:**
|
||||||
|
- Track consent request rates
|
||||||
|
- Monitor audio deletion operations
|
||||||
|
- Alert on consent-related errors
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Database migration** - Foundation for all changes
|
||||||
|
2. **Backend API endpoints** - Core consent handling logic
|
||||||
|
3. **WebSocket event system** - Real-time consent communication
|
||||||
|
4. **Remove room entry consent** - Unblock room joining
|
||||||
|
5. **Add recording consent dialog** - New consent UI
|
||||||
|
6. **Audio deletion logic** - Cleanup mechanism
|
||||||
|
7. **Testing and deployment** - Validation and rollout
|
||||||
|
|
||||||
|
## Risk Mitigation
|
||||||
|
|
||||||
|
- **Feature flags** for gradual rollout
|
||||||
|
- **Comprehensive logging** for consent operations
|
||||||
|
- **Rollback plan** if consent flow breaks
|
||||||
|
- **Audio file backup** before deletion (configurable)
|
||||||
|
- **Legal review** of consent language and timing
|
||||||
|
|
||||||
|
This plan maintains backward compatibility while implementing the new consent flow without interrupting core recording functionality.
|
||||||
@@ -72,6 +72,12 @@ Start with `cd www`.
|
|||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
|
**Note**: This project requires Node.js v20.18.1 to maintain yarn.lock compatibility. If using `n` for Node version management:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
n 20.18.1
|
||||||
|
```
|
||||||
|
|
||||||
To install the application, run:
|
To install the application, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
49
REQUIREMENTS.md
Normal file
49
REQUIREMENTS.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Audio Storage Consent Flow Requirements
|
||||||
|
|
||||||
|
## Current Problem
|
||||||
|
- Users must consent to recording **before** joining room
|
||||||
|
- Consent blocks room entry at `/app/[roomName]/page.tsx:80-124`
|
||||||
|
- Users cannot participate without prior consent
|
||||||
|
|
||||||
|
## System Reality: Recording Detection Constraints
|
||||||
|
- **No real-time recording detection**: System only discovers recordings after they complete (60+ second SQS delay)
|
||||||
|
- **Cannot stop recordings**: Whereby controls recording entirely based on room configuration
|
||||||
|
- **Limited webhook events**: Only `room.client.joined/left` available, no recording webhooks
|
||||||
|
- **Post-processing only**: Can only mark recordings for deletion during transcript processing
|
||||||
|
|
||||||
|
## Required Changes
|
||||||
|
|
||||||
|
### 1. Remove Pre-Entry Consent Blocking
|
||||||
|
- **Remove** consent dialog from room entry page
|
||||||
|
- Allow immediate room joining without consent check
|
||||||
|
|
||||||
|
### 2. Request Audio Storage Consent During Meeting Session
|
||||||
|
- Ask during meeting: **"Do you consent to storing this audio recording?"**
|
||||||
|
- **Timing**: ALWAYS ask - no conditions, no participant count checks, no configuration checks
|
||||||
|
- **Scope**: Per meeting session (`meeting_id`), not per room (rooms are reused)
|
||||||
|
- **Storage**: Dictionary of participants with their consent responses {user_id: true/false} in meeting record
|
||||||
|
|
||||||
|
### 3. Handle Consent Responses
|
||||||
|
- **If ANY participant denies consent:** Mark recording for deletion during post-processing
|
||||||
|
- **If ALL participants consent:** Keep audio file as normal
|
||||||
|
- **Always:** Continue meeting, recording, and transcription (cannot be interrupted)
|
||||||
|
|
||||||
|
### 4. Audio Deletion Logic
|
||||||
|
- **Always**: Create transcript, topics, summaries, waveforms first
|
||||||
|
- **Then**: If consent denied, delete only audio files (`upload.mp4`, `audio.mp3`, `audio.wav`)
|
||||||
|
- **Keep**: All transcript data, topics, summaries, waveforms (audio content is transcribed)
|
||||||
|
- **Scope**: Only affects specific meeting's audio files, not other sessions in same room
|
||||||
|
|
||||||
|
## Recording Trigger Context
|
||||||
|
Whereby recording starts based on room configuration:
|
||||||
|
- `"automatic-2nd-participant"` (default): Recording starts when 2nd person joins
|
||||||
|
- `"automatic"`: Recording starts immediately when meeting begins
|
||||||
|
- `"prompt"`: Manual recording start (host control)
|
||||||
|
- `"none"`: No recording
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- Users join rooms without barriers
|
||||||
|
- Audio storage consent requested during meeting (estimated timing)
|
||||||
|
- Post-processing checks consent and deletes audio if denied
|
||||||
|
- Transcription and analysis unaffected by consent choice
|
||||||
|
- Multiple meeting sessions in same room handled independently
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## AWS S3/SQS usage clarification
|
||||||
|
|
||||||
|
Whereby.com uploads recordings directly to our S3 bucket when meetings end.
|
||||||
|
|
||||||
|
SQS Queue (AWS_PROCESS_RECORDING_QUEUE_URL)
|
||||||
|
|
||||||
|
Filled by: AWS S3 Event Notifications
|
||||||
|
|
||||||
|
The S3 bucket is configured to send notifications to our SQS queue when new objects are created. This is standard AWS infrastructure - not in our codebase.
|
||||||
|
|
||||||
|
AWS S3 → SQS Event Configuration:
|
||||||
|
- Event Type: s3:ObjectCreated:*
|
||||||
|
- Filter: *.mp4 files
|
||||||
|
- Destination: Our SQS queue
|
||||||
|
|
||||||
|
Our System's Role
|
||||||
|
|
||||||
|
Polls SQS every 60 seconds via /server/reflector/worker/process.py:24-62:
|
||||||
|
|
||||||
|
# Every 60 seconds, check for new recordings
|
||||||
|
sqs = boto3.client("sqs", ...)
|
||||||
|
response = sqs.receive_message(QueueUrl=queue_url, ...)
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ class TranscriptController:
|
|||||||
topic: TranscriptTopic,
|
topic: TranscriptTopic,
|
||||||
) -> TranscriptEvent:
|
) -> TranscriptEvent:
|
||||||
"""
|
"""
|
||||||
Append an event to a transcript
|
Upsert topics to a transcript
|
||||||
"""
|
"""
|
||||||
transcript.upsert_topic(topic)
|
transcript.upsert_topic(topic)
|
||||||
await self.update(
|
await self.update(
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ interface WherebyEmbedProps {
|
|||||||
onLeave?: () => void;
|
onLeave?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WherebyEmbed({ roomUrl, onLeave }: WherebyEmbedProps) {
|
// currently used for webinars only
|
||||||
|
export default function WherebyWebinarEmbed({ roomUrl, onLeave }: WherebyEmbedProps) {
|
||||||
const wherebyRef = useRef<HTMLElement>(null);
|
const wherebyRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
// TODO extract common toast logic / styles to be used by consent toast on normal rooms
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (roomUrl && !localStorage.getItem("recording-notice-dismissed")) {
|
if (roomUrl && !localStorage.getItem("recording-notice-dismissed")) {
|
||||||
@@ -5,7 +5,7 @@ import Image from "next/image";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import useRoomMeeting from "../../[roomName]/useRoomMeeting";
|
import useRoomMeeting from "../../[roomName]/useRoomMeeting";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
const WherebyEmbed = dynamic(() => import("../../lib/WherebyEmbed"), {
|
const WherebyEmbed = dynamic(() => import("../../lib/./WherebyWebinarEmbed"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
import { FormEvent } from "react";
|
import { FormEvent } from "react";
|
||||||
|
|||||||
Reference in New Issue
Block a user