meeting consent vibe

This commit is contained in:
Igor Loskutov
2025-06-17 16:30:23 -04:00
parent b85338754e
commit 91c7c8b83a
19 changed files with 3929 additions and 3836 deletions

2
.gitignore vendored
View File

@@ -9,4 +9,4 @@ dump.rdb
ngrok.log ngrok.log
.claude/settings.local.json .claude/settings.local.json
restart-dev.sh restart-dev.sh
backend-output.log *.log

425
GUIDE.md
View File

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

@@ -106,33 +106,8 @@ useEffect(() => {
} }
}, [meeting?.id]); }, [meeting?.id]);
# Backend: Consent storage in meeting record # Backend: Consent storage using meeting_consent table
# Add to Meeting model: # Use meeting_consent table for proper normalization
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 ### 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: 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) _, extension = os.path.splitext(object_key)
upload_filename = transcript.data_path / f"upload{extension}" upload_filename = transcript.data_path / f"upload{extension}"
# ... continue with full transcript processing ... # ... continue with full transcript processing ...
# Check if any participant denied consent (check dict values) # Check if any participant denied consent using meeting_consent_controller
consent_responses = meeting.participant_consent_responses or {} should_delete = await meeting_consent_controller.has_any_denial(meeting.id)
should_delete = any(consent is False for consent in consent_responses.values())
# AFTER transcript processing is complete, delete audio if consent denied # AFTER transcript processing is complete, delete audio if consent denied
if should_delete: if should_delete:
logger.info(f"Deleting audio files for {object_key} due to consent denial") logger.info(f"Deleting audio files for {object_key} due to consent denial")

2
TODO.md Normal file
View 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

View File

@@ -18,3 +18,4 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run
BASE_URL=https://xxxxx.ngrok.app BASE_URL=https://xxxxx.ngrok.app
DIARIZATION_ENABLED=false DIARIZATION_ENABLED=false
SQS_POLLING_TIMEOUT_SECONDS=60

View File

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

View File

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

View File

@@ -11,6 +11,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.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router from reflector.views.rooms import router as rooms_router
from reflector.views.rtc_offer import router as rtc_offer_router from reflector.views.rtc_offer import router as rtc_offer_router
from reflector.views.transcripts import router as transcripts_router from reflector.views.transcripts import router as transcripts_router
@@ -71,6 +72,7 @@ metrics_init(app, instrumentator)
# register views # register views
app.include_router(rtc_offer_router) app.include_router(rtc_offer_router)
app.include_router(meetings_router, prefix="/v1")
app.include_router(rooms_router, prefix="/v1") app.include_router(rooms_router, prefix="/v1")
app.include_router(transcripts_router, prefix="/v1") app.include_router(transcripts_router, prefix="/v1")
app.include_router(transcripts_audio_router, prefix="/v1") app.include_router(transcripts_audio_router, prefix="/v1")

View File

@@ -3,9 +3,10 @@ from typing import Literal
import sqlalchemy as sa import sqlalchemy as sa
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from reflector.db import database, metadata from reflector.db import database, metadata
from reflector.db.rooms import Room from reflector.db.rooms import Room
from reflector.utils import generate_uuid4
meetings = sa.Table( meetings = sa.Table(
"meeting", "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): class Meeting(BaseModel):
id: str id: str
@@ -116,7 +137,7 @@ class MeetingController:
async def get_active(self, room: Room, current_time: datetime) -> Meeting: 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") end_date = getattr(meetings.c, "end_date")
query = ( query = (
@@ -125,6 +146,7 @@ class MeetingController:
sa.and_( sa.and_(
meetings.c.room_id == room.id, meetings.c.room_id == room.id,
meetings.c.end_date > current_time, meetings.c.end_date > current_time,
meetings.c.is_active == True,
) )
) )
.order_by(end_date.desc()) .order_by(end_date.desc())
@@ -167,4 +189,57 @@ class MeetingController:
await database.execute(query) 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() meetings_controller = MeetingController()
meeting_consent_controller = MeetingConsentController()

View File

@@ -138,6 +138,7 @@ class Settings(BaseSettings):
HEALTHCHECK_URL: str | None = None HEALTHCHECK_URL: str | None = None
AWS_PROCESS_RECORDING_QUEUE_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" WHEREBY_API_URL: str = "https://api.whereby.dev/v1"

View File

@@ -1 +1,7 @@
from .base import Storage # noqa from .base import Storage # noqa
def get_storage() -> Storage:
from reflector.settings import settings
return Storage.get_instance(
name=settings.TRANSCRIPT_STORAGE_BACKEND,
)

View File

@@ -0,0 +1,5 @@
from uuid import uuid4
def generate_uuid4() -> str:
return str(uuid4())

View 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}

View File

@@ -25,11 +25,11 @@ else:
app.conf.beat_schedule = { app.conf.beat_schedule = {
"process_messages": { "process_messages": {
"task": "reflector.worker.process.process_messages", "task": "reflector.worker.process.process_messages",
"schedule": 60.0, "schedule": float(settings.SQS_POLLING_TIMEOUT_SECONDS),
}, },
"process_meetings": { "process_meetings": {
"task": "reflector.worker.process.process_meetings", "task": "reflector.worker.process.process_meetings",
"schedule": 60.0, "schedule": float(settings.SQS_POLLING_TIMEOUT_SECONDS),
}, },
"reprocess_failed_recordings": { "reprocess_failed_recordings": {
"task": "reflector.worker.process.reprocess_failed_recordings", "task": "reflector.worker.process.reprocess_failed_recordings",

View File

@@ -9,10 +9,11 @@ import structlog
from celery import shared_task from celery import shared_task
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from pydantic import ValidationError 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.recordings import Recording, recordings_controller
from reflector.db.rooms import rooms_controller from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import SourceKind, transcripts_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.pipelines.main_live_pipeline import asynctask, task_pipeline_process
from reflector.settings import settings from reflector.settings import settings
from reflector.whereby import get_room_sessions 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"}) await transcripts_controller.update(transcript, {"status": "uploaded"})
task_pipeline_process.delay(transcript_id=transcript.id) 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 @shared_task

View 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;

View File

@@ -13,6 +13,8 @@ import useMp3 from "../../useMp3";
import WaveformLoading from "../../waveformLoading"; import WaveformLoading from "../../waveformLoading";
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react"; import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
import LiveTrancription from "../../liveTranscription"; import LiveTrancription from "../../liveTranscription";
import AudioConsentDialog from "../../components/AudioConsentDialog";
import useApi from "../../../../lib/useApi";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -24,6 +26,9 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscript(details.params.transcriptId); const transcript = useTranscript(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null); 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); 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 ( return (
<Grid <>
templateColumns="1fr" <AudioConsentDialog
templateRows="auto minmax(0, 1fr) " isOpen={showConsentDialog}
gap={4} onClose={() => setShowConsentDialog(false)}
mt={4} onConsent={handleConsentResponse}
mb={4} />
> <Grid
templateColumns="1fr"
templateRows="auto minmax(0, 1fr) "
gap={4}
mt={4}
mb={4}
>
{status == "processing" ? ( {status == "processing" ? (
<WaveformLoading /> <WaveformLoading />
) : ( ) : (
@@ -124,6 +175,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
</Flex> </Flex>
</VStack> </VStack>
</Grid> </Grid>
</>
); );
}; };

View File

@@ -1,12 +1,16 @@
"use client"; "use client";
import "@whereby.com/browser-sdk/embed"; 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 { Box, Button, Text, VStack, HStack, Spinner } from "@chakra-ui/react";
import useRoomMeeting from "./useRoomMeeting"; import useRoomMeeting from "./useRoomMeeting";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import useSessionStatus from "../lib/useSessionStatus"; 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 = { export type RoomDetails = {
params: { params: {
@@ -20,8 +24,12 @@ export default function Room(details: RoomDetails) {
const meeting = useRoomMeeting(roomName); const meeting = useRoomMeeting(roomName);
const router = useRouter(); const router = useRouter();
const { isLoading, isAuthenticated } = useSessionStatus(); const { isLoading, isAuthenticated } = useSessionStatus();
const [showConsentDialog, setShowConsentDialog] = useState(false);
const [consentGiven, setConsentGiven] = useState<boolean | null>(null); 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 const roomUrl = meeting?.response?.host_room_url
? meeting?.response?.host_room_url ? meeting?.response?.host_room_url
@@ -31,9 +39,49 @@ export default function Room(details: RoomDetails) {
router.push("/browse"); router.push("/browse");
}, [router]); }, [router]);
const handleConsent = (consent: boolean) => { const getUserIdentifier = useCallback(() => {
setConsentGiven(consent); 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(() => { useEffect(() => {
if ( if (
@@ -46,6 +94,13 @@ export default function Room(details: RoomDetails) {
} }
}, [isLoading, meeting?.error]); }, [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(() => { useEffect(() => {
if (isLoading || !isAuthenticated || !roomUrl) return; 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 ( return (
<> <>
@@ -132,6 +142,11 @@ export default function Room(details: RoomDetails) {
style={{ width: "100vw", height: "100vh" }} style={{ width: "100vw", height: "100vh" }}
/> />
)} )}
<AudioConsentDialog
isOpen={showConsentDialog}
onClose={() => {}} // No-op: ESC should not close without consent
onConsent={handleConsent}
/>
</> </>
); );
} }

File diff suppressed because it is too large Load Diff