mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
meeting consent vibe
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,4 +9,4 @@ dump.rdb
|
||||
ngrok.log
|
||||
.claude/settings.local.json
|
||||
restart-dev.sh
|
||||
backend-output.log
|
||||
*.log
|
||||
425
GUIDE.md
425
GUIDE.md
@@ -1,425 +0,0 @@
|
||||
# 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.
|
||||
36
PLAN.md
36
PLAN.md
@@ -106,33 +106,8 @@ useEffect(() => {
|
||||
}
|
||||
}, [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"}
|
||||
# Backend: Consent storage using meeting_consent table
|
||||
# Use meeting_consent table for proper normalization
|
||||
```
|
||||
|
||||
### Phase 4: Frontend Changes
|
||||
@@ -152,7 +127,7 @@ if (!isAuthenticated) {
|
||||
}
|
||||
```
|
||||
|
||||
**Add Consent Dialog Component:** `www/app/(app)/transcripts/components/AudioConsentDialog.tsx`
|
||||
**Add Consent Dialog Component:** `www/app/(app)/rooms/audioConsentDialog.tsx`
|
||||
|
||||
Based on `shareModal.tsx` patterns:
|
||||
|
||||
@@ -259,9 +234,8 @@ async def process_recording(bucket_name: str, object_key: str):
|
||||
_, 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())
|
||||
# Check if any participant denied consent using meeting_consent_controller
|
||||
should_delete = await meeting_consent_controller.has_any_denial(meeting.id)
|
||||
# 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")
|
||||
|
||||
2
TODO.md
Normal file
2
TODO.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- non-auth user consent - store on frontend per session? per meeting? (get meeting from the iframe)
|
||||
- consent field userIdentity itself - optional
|
||||
@@ -18,3 +18,4 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run
|
||||
BASE_URL=https://xxxxx.ngrok.app
|
||||
DIARIZATION_ENABLED=false
|
||||
|
||||
SQS_POLLING_TIMEOUT_SECONDS=60
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add meeting consent table
|
||||
|
||||
Revision ID: 20250617140003
|
||||
Revises: f819277e5169
|
||||
Create Date: 2025-06-17 14:00:03.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20250617140003"
|
||||
down_revision: Union[str, None] = "f819277e5169"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create meeting_consent table
|
||||
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),
|
||||
sa.Column('consent_given', sa.Boolean(), nullable=False),
|
||||
sa.Column('consent_timestamp', sa.DateTime(), nullable=False),
|
||||
sa.Column('user_agent', sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['meeting_id'], ['meeting.id']),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop meeting_consent table
|
||||
op.drop_table('meeting_consent')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""make user_identifier optional in meeting_consent
|
||||
|
||||
Revision ID: 38e116c82385
|
||||
Revises: 20250617140003
|
||||
Create Date: 2025-06-17 15:23:41.346980
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '38e116c82385'
|
||||
down_revision: Union[str, None] = '20250617140003'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Make user_identifier column nullable
|
||||
op.alter_column('meeting_consent', 'user_identifier',
|
||||
existing_type=sa.String(),
|
||||
nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert user_identifier back to non-nullable
|
||||
op.alter_column('meeting_consent', 'user_identifier',
|
||||
existing_type=sa.String(),
|
||||
nullable=False)
|
||||
@@ -11,6 +11,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
|
||||
from reflector.logger import logger
|
||||
from reflector.metrics import metrics_init
|
||||
from reflector.settings import settings
|
||||
from reflector.views.meetings import router as meetings_router
|
||||
from reflector.views.rooms import router as rooms_router
|
||||
from reflector.views.rtc_offer import router as rtc_offer_router
|
||||
from reflector.views.transcripts import router as transcripts_router
|
||||
@@ -71,6 +72,7 @@ metrics_init(app, instrumentator)
|
||||
|
||||
# register views
|
||||
app.include_router(rtc_offer_router)
|
||||
app.include_router(meetings_router, prefix="/v1")
|
||||
app.include_router(rooms_router, prefix="/v1")
|
||||
app.include_router(transcripts_router, prefix="/v1")
|
||||
app.include_router(transcripts_audio_router, prefix="/v1")
|
||||
|
||||
@@ -3,9 +3,10 @@ from typing import Literal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from reflector.db import database, metadata
|
||||
from reflector.db.rooms import Room
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
meetings = sa.Table(
|
||||
"meeting",
|
||||
@@ -41,6 +42,26 @@ meetings = sa.Table(
|
||||
),
|
||||
)
|
||||
|
||||
meeting_consent = sa.Table(
|
||||
"meeting_consent",
|
||||
metadata,
|
||||
sa.Column("id", sa.String, primary_key=True),
|
||||
sa.Column("meeting_id", sa.String, sa.ForeignKey("meeting.id")),
|
||||
sa.Column("user_identifier", sa.String, nullable=True),
|
||||
sa.Column("consent_given", sa.Boolean),
|
||||
sa.Column("consent_timestamp", sa.DateTime),
|
||||
sa.Column("user_agent", sa.String, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
class MeetingConsent(BaseModel):
|
||||
id: str = Field(default_factory=generate_uuid4)
|
||||
meeting_id: str
|
||||
user_identifier: str | None = None
|
||||
consent_given: bool
|
||||
consent_timestamp: datetime
|
||||
user_agent: str | None = None
|
||||
|
||||
|
||||
class Meeting(BaseModel):
|
||||
id: str
|
||||
@@ -116,7 +137,7 @@ class MeetingController:
|
||||
|
||||
async def get_active(self, room: Room, current_time: datetime) -> Meeting:
|
||||
"""
|
||||
Get latest meeting for a room.
|
||||
Get latest active meeting for a room.
|
||||
"""
|
||||
end_date = getattr(meetings.c, "end_date")
|
||||
query = (
|
||||
@@ -125,6 +146,7 @@ class MeetingController:
|
||||
sa.and_(
|
||||
meetings.c.room_id == room.id,
|
||||
meetings.c.end_date > current_time,
|
||||
meetings.c.is_active == True,
|
||||
)
|
||||
)
|
||||
.order_by(end_date.desc())
|
||||
@@ -167,4 +189,57 @@ class MeetingController:
|
||||
await database.execute(query)
|
||||
|
||||
|
||||
class MeetingConsentController:
|
||||
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 get_by_meeting_and_user(self, meeting_id: str, user_identifier: str) -> MeetingConsent | None:
|
||||
"""Get existing consent for a specific user and meeting"""
|
||||
query = meeting_consent.select().where(
|
||||
meeting_consent.c.meeting_id == meeting_id,
|
||||
meeting_consent.c.user_identifier == user_identifier
|
||||
)
|
||||
result = await database.fetch_one(query)
|
||||
return MeetingConsent(**result) if result else None
|
||||
|
||||
async def create_or_update(self, consent: MeetingConsent) -> MeetingConsent:
|
||||
"""Create new consent or update existing one for authenticated users"""
|
||||
if consent.user_identifier:
|
||||
# For authenticated users, check if consent already exists
|
||||
existing = await self.get_by_meeting_and_user(consent.meeting_id, consent.user_identifier)
|
||||
if existing:
|
||||
# Update existing consent
|
||||
query = meeting_consent.update().where(
|
||||
meeting_consent.c.id == existing.id
|
||||
).values(
|
||||
consent_given=consent.consent_given,
|
||||
consent_timestamp=consent.consent_timestamp,
|
||||
user_agent=consent.user_agent
|
||||
)
|
||||
await database.execute(query)
|
||||
|
||||
# Return updated consent object
|
||||
existing.consent_given = consent.consent_given
|
||||
existing.consent_timestamp = consent.consent_timestamp
|
||||
existing.user_agent = consent.user_agent
|
||||
return existing
|
||||
|
||||
# For anonymous users or first-time authenticated users, create new record
|
||||
query = meeting_consent.insert().values(**consent.model_dump())
|
||||
await database.execute(query)
|
||||
return consent
|
||||
|
||||
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
|
||||
|
||||
|
||||
meetings_controller = MeetingController()
|
||||
meeting_consent_controller = MeetingConsentController()
|
||||
|
||||
@@ -138,6 +138,7 @@ class Settings(BaseSettings):
|
||||
HEALTHCHECK_URL: str | None = None
|
||||
|
||||
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None
|
||||
SQS_POLLING_TIMEOUT_SECONDS: int = 60
|
||||
|
||||
WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
|
||||
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
from .base import Storage # noqa
|
||||
|
||||
def get_storage() -> Storage:
|
||||
from reflector.settings import settings
|
||||
return Storage.get_instance(
|
||||
name=settings.TRANSCRIPT_STORAGE_BACKEND,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def generate_uuid4() -> str:
|
||||
return str(uuid4())
|
||||
42
server/reflector/views/meetings.py
Normal file
42
server/reflector/views/meetings.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from reflector.db.meetings import (
|
||||
MeetingConsent,
|
||||
meeting_consent_controller,
|
||||
meetings_controller,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MeetingConsentRequest(BaseModel):
|
||||
consent_given: bool
|
||||
user_identifier: str | None = None
|
||||
|
||||
|
||||
@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 (create or update for authenticated users)
|
||||
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")
|
||||
)
|
||||
|
||||
# Use create_or_update to handle consent overrides for authenticated users
|
||||
updated_consent = await meeting_consent_controller.create_or_update(consent)
|
||||
|
||||
return {"status": "success", "consent_id": updated_consent.id}
|
||||
@@ -25,11 +25,11 @@ else:
|
||||
app.conf.beat_schedule = {
|
||||
"process_messages": {
|
||||
"task": "reflector.worker.process.process_messages",
|
||||
"schedule": 60.0,
|
||||
"schedule": float(settings.SQS_POLLING_TIMEOUT_SECONDS),
|
||||
},
|
||||
"process_meetings": {
|
||||
"task": "reflector.worker.process.process_meetings",
|
||||
"schedule": 60.0,
|
||||
"schedule": float(settings.SQS_POLLING_TIMEOUT_SECONDS),
|
||||
},
|
||||
"reprocess_failed_recordings": {
|
||||
"task": "reflector.worker.process.reprocess_failed_recordings",
|
||||
|
||||
@@ -9,10 +9,11 @@ import structlog
|
||||
from celery import shared_task
|
||||
from celery.utils.log import get_task_logger
|
||||
from pydantic import ValidationError
|
||||
from reflector.db.meetings import meetings_controller
|
||||
from reflector.db.meetings import meeting_consent_controller, meetings_controller
|
||||
from reflector.db.recordings import Recording, recordings_controller
|
||||
from reflector.db.rooms import rooms_controller
|
||||
from reflector.db.transcripts import SourceKind, transcripts_controller
|
||||
from reflector.storage import get_storage
|
||||
from reflector.pipelines.main_live_pipeline import asynctask, task_pipeline_process
|
||||
from reflector.settings import settings
|
||||
from reflector.whereby import get_room_sessions
|
||||
@@ -130,6 +131,51 @@ async def process_recording(bucket_name: str, object_key: str):
|
||||
await transcripts_controller.update(transcript, {"status": "uploaded"})
|
||||
|
||||
task_pipeline_process.delay(transcript_id=transcript.id)
|
||||
|
||||
# Check if any participant denied consent after transcript processing is complete
|
||||
should_delete = await meeting_consent_controller.has_any_denial(meeting.id)
|
||||
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)
|
||||
|
||||
|
||||
async def delete_audio_files_only(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)
|
||||
if hasattr(transcript, 'audio_mp3_filename') and transcript.audio_mp3_filename:
|
||||
transcript.audio_mp3_filename.unlink(missing_ok=True)
|
||||
if hasattr(transcript, 'audio_wav_filename') and transcript.audio_wav_filename:
|
||||
transcript.audio_wav_filename.unlink(missing_ok=True)
|
||||
|
||||
upload_path = transcript.data_path / f"upload{os.path.splitext(object_key)[1]}"
|
||||
upload_path.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)}")
|
||||
|
||||
|
||||
@shared_task
|
||||
|
||||
48
www/app/(app)/rooms/audioConsentDialog.tsx
Normal file
48
www/app/(app)/rooms/audioConsentDialog.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
Button,
|
||||
Text,
|
||||
HStack,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
interface AudioConsentDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConsent: (given: boolean) => void;
|
||||
}
|
||||
|
||||
const AudioConsentDialog = ({ isOpen, onClose, onConsent }: AudioConsentDialogProps) => {
|
||||
const handleConsent = (given: boolean) => {
|
||||
onConsent(given);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} closeOnOverlayClick={false} closeOnEsc={false}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Audio Storage Consent</ModalHeader>
|
||||
<ModalBody pb={6}>
|
||||
<Text mb={4}>
|
||||
Can we have your permission to store this meeting's audio recording on our servers?
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
<Button colorScheme="green" onClick={() => handleConsent(true)}>
|
||||
Yes, store the audio
|
||||
</Button>
|
||||
<Button colorScheme="red" onClick={() => handleConsent(false)}>
|
||||
No, delete after transcription
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioConsentDialog;
|
||||
@@ -13,6 +13,8 @@ import useMp3 from "../../useMp3";
|
||||
import WaveformLoading from "../../waveformLoading";
|
||||
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
|
||||
import LiveTrancription from "../../liveTranscription";
|
||||
import AudioConsentDialog from "../../components/AudioConsentDialog";
|
||||
import useApi from "../../../../lib/useApi";
|
||||
|
||||
type TranscriptDetails = {
|
||||
params: {
|
||||
@@ -24,6 +26,9 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
||||
const transcript = useTranscript(details.params.transcriptId);
|
||||
const [transcriptStarted, setTranscriptStarted] = useState(false);
|
||||
const useActiveTopic = useState<Topic | null>(null);
|
||||
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
||||
const [consentStatus, setConsentStatus] = useState<string>('');
|
||||
const api = useApi();
|
||||
|
||||
const webSockets = useWebSockets(details.params.transcriptId);
|
||||
|
||||
@@ -64,14 +69,60 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Show consent dialog when recording starts and meeting_id is available
|
||||
useEffect(() => {
|
||||
if (status === "recording" && transcript.response?.meeting_id && !consentStatus) {
|
||||
setShowConsentDialog(true);
|
||||
}
|
||||
}, [status, transcript.response?.meeting_id, consentStatus]);
|
||||
|
||||
const handleConsentResponse = async (consentGiven: boolean) => {
|
||||
const meetingId = transcript.response?.meeting_id;
|
||||
if (!meetingId || !api) {
|
||||
console.error('No meeting_id available or API not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use a simple user identifier - could be improved with actual user ID
|
||||
const userIdentifier = `user_${Date.now()}`;
|
||||
|
||||
const response = await fetch(`/v1/meetings/${meetingId}/consent`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
consent_given: consentGiven,
|
||||
user_identifier: userIdentifier
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setConsentStatus(consentGiven ? 'given' : 'denied');
|
||||
console.log('Consent recorded successfully');
|
||||
} else {
|
||||
console.error('Failed to record consent');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error recording consent:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid
|
||||
templateColumns="1fr"
|
||||
templateRows="auto minmax(0, 1fr) "
|
||||
gap={4}
|
||||
mt={4}
|
||||
mb={4}
|
||||
>
|
||||
<>
|
||||
<AudioConsentDialog
|
||||
isOpen={showConsentDialog}
|
||||
onClose={() => setShowConsentDialog(false)}
|
||||
onConsent={handleConsentResponse}
|
||||
/>
|
||||
<Grid
|
||||
templateColumns="1fr"
|
||||
templateRows="auto minmax(0, 1fr) "
|
||||
gap={4}
|
||||
mt={4}
|
||||
mb={4}
|
||||
>
|
||||
{status == "processing" ? (
|
||||
<WaveformLoading />
|
||||
) : (
|
||||
@@ -124,6 +175,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
|
||||
</Flex>
|
||||
</VStack>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import "@whereby.com/browser-sdk/embed";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, useContext } from "react";
|
||||
import { Box, Button, Text, VStack, HStack, Spinner } from "@chakra-ui/react";
|
||||
import useRoomMeeting from "./useRoomMeeting";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import useSessionStatus from "../lib/useSessionStatus";
|
||||
import AudioConsentDialog from "../(app)/rooms/audioConsentDialog";
|
||||
import { DomainContext } from "../domainContext";
|
||||
import useSessionAccessToken from "../lib/useSessionAccessToken";
|
||||
import useSessionUser from "../lib/useSessionUser";
|
||||
|
||||
export type RoomDetails = {
|
||||
params: {
|
||||
@@ -20,8 +24,12 @@ export default function Room(details: RoomDetails) {
|
||||
const meeting = useRoomMeeting(roomName);
|
||||
const router = useRouter();
|
||||
const { isLoading, isAuthenticated } = useSessionStatus();
|
||||
|
||||
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
||||
const [consentGiven, setConsentGiven] = useState<boolean | null>(null);
|
||||
const { api_url } = useContext(DomainContext);
|
||||
const { accessToken } = useSessionAccessToken();
|
||||
const { id: userId } = useSessionUser();
|
||||
|
||||
|
||||
const roomUrl = meeting?.response?.host_room_url
|
||||
? meeting?.response?.host_room_url
|
||||
@@ -31,9 +39,49 @@ export default function Room(details: RoomDetails) {
|
||||
router.push("/browse");
|
||||
}, [router]);
|
||||
|
||||
const handleConsent = (consent: boolean) => {
|
||||
setConsentGiven(consent);
|
||||
};
|
||||
const getUserIdentifier = useCallback(() => {
|
||||
if (isAuthenticated && userId) {
|
||||
return userId; // Send actual user ID for authenticated users
|
||||
}
|
||||
|
||||
// For anonymous users, send no identifier
|
||||
return null;
|
||||
}, [isAuthenticated, userId]);
|
||||
|
||||
const handleConsent = useCallback(async (given: boolean) => {
|
||||
setConsentGiven(given);
|
||||
setShowConsentDialog(false); // Close dialog after consent is given
|
||||
|
||||
if (meeting?.response?.id && api_url) {
|
||||
try {
|
||||
const userIdentifier = getUserIdentifier();
|
||||
const requestBody: any = {
|
||||
consent_given: given
|
||||
};
|
||||
|
||||
// Only include user_identifier if we have one (authenticated users)
|
||||
if (userIdentifier) {
|
||||
requestBody.user_identifier = userIdentifier;
|
||||
}
|
||||
|
||||
const response = await fetch(`${api_url}/v1/meetings/${meeting.response.id}/consent`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken && { 'Authorization': `Bearer ${accessToken}` })
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to submit consent');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting consent:', error);
|
||||
}
|
||||
}
|
||||
}, [meeting?.response?.id, api_url, accessToken]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -46,6 +94,13 @@ export default function Room(details: RoomDetails) {
|
||||
}
|
||||
}, [isLoading, meeting?.error]);
|
||||
|
||||
// Show consent dialog when meeting is loaded and consent hasn't been given yet
|
||||
useEffect(() => {
|
||||
if (meeting?.response?.id && consentGiven === null && !showConsentDialog) {
|
||||
setShowConsentDialog(true);
|
||||
}
|
||||
}, [meeting?.response?.id, consentGiven, showConsentDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !isAuthenticated || !roomUrl) return;
|
||||
|
||||
@@ -77,51 +132,6 @@ export default function Room(details: RoomDetails) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !consentGiven) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<VStack
|
||||
spacing={6}
|
||||
p={10}
|
||||
width="400px"
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
shadow="md"
|
||||
textAlign="center"
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
You cannot join the meeting without consenting to being
|
||||
recorded.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -132,6 +142,11 @@ export default function Room(details: RoomDetails) {
|
||||
style={{ width: "100vw", height: "100vh" }}
|
||||
/>
|
||||
)}
|
||||
<AudioConsentDialog
|
||||
isOpen={showConsentDialog}
|
||||
onClose={() => {}} // No-op: ESC should not close without consent
|
||||
onConsent={handleConsent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
6814
www/yarn.lock
6814
www/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user