consent preparation

This commit is contained in:
Igor Loskutov
2025-06-17 12:18:41 -04:00
parent d60deebe7a
commit 7bb2962f94
10 changed files with 911 additions and 3 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ server/exportdanswer
.vercel
.env*.local
dump.rdb
.yarn
ngrok.log
.claude/settings.local.json

425
GUIDE.md Normal file
View 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
View 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
View 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.

View File

@@ -72,6 +72,12 @@ Start with `cd www`.
### 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:
```bash

49
REQUIREMENTS.md Normal file
View 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

View File

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

View File

@@ -542,7 +542,7 @@ class TranscriptController:
topic: TranscriptTopic,
) -> TranscriptEvent:
"""
Append an event to a transcript
Upsert topics to a transcript
"""
transcript.upsert_topic(topic)
await self.update(

View File

@@ -8,9 +8,11 @@ interface WherebyEmbedProps {
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);
// TODO extract common toast logic / styles to be used by consent toast on normal rooms
const toast = useToast();
useEffect(() => {
if (roomUrl && !localStorage.getItem("recording-notice-dismissed")) {

View File

@@ -5,7 +5,7 @@ import Image from "next/image";
import { notFound } from "next/navigation";
import useRoomMeeting from "../../[roomName]/useRoomMeeting";
import dynamic from "next/dynamic";
const WherebyEmbed = dynamic(() => import("../../lib/WherebyEmbed"), {
const WherebyEmbed = dynamic(() => import("../../lib/./WherebyWebinarEmbed"), {
ssr: false,
});
import { FormEvent } from "react";