diff --git a/.gitignore b/.gitignore index e705c6b7..722ac817 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ server/exportdanswer .vercel .env*.local dump.rdb +.yarn +ngrok.log +.claude/settings.local.json \ No newline at end of file diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 00000000..c500c3e1 --- /dev/null +++ b/GUIDE.md @@ -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(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 ? ( + <> + + This meeting may be recorded. Do you consent to being recorded? + + + + + + +) : ( + // Lines 114-120: Rejection message + You cannot join the meeting without consenting... +)} +``` + +**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:** `` 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 +
+ {props.show && ( +
+
+ // Modal content... +
+
+ )} +
+``` + +### 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 + setShowModal(false)} size={"xl"}> + + + Share + + // Modal content... + + + +``` + +**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. \ No newline at end of file diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 00000000..c6878869 --- /dev/null +++ b/NOTES.md @@ -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 \ No newline at end of file diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..ddf5bf04 --- /dev/null +++ b/PLAN.md @@ -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(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 ( + + + + Audio Storage Consent + + + Do you consent to storing this audio recording? + The transcript will be generated regardless of your choice. + + + + + + + + + ); +}; +``` + +**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(''); + +// 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. \ No newline at end of file diff --git a/README.md b/README.md index 2a5600ce..2a395026 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 00000000..5f9e4f93 --- /dev/null +++ b/REQUIREMENTS.md @@ -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 \ No newline at end of file diff --git a/server/README.md b/server/README.md index e69de29b..74675085 100644 --- a/server/README.md +++ b/server/README.md @@ -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, ...) diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index b9ffe0d2..5953fe0b 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -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( diff --git a/www/app/lib/WherebyEmbed.tsx b/www/app/lib/WherebyWebinarEmbed.tsx similarity index 90% rename from www/app/lib/WherebyEmbed.tsx rename to www/app/lib/WherebyWebinarEmbed.tsx index 6a7df0c7..94ce5d53 100644 --- a/www/app/lib/WherebyEmbed.tsx +++ b/www/app/lib/WherebyWebinarEmbed.tsx @@ -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(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")) { diff --git a/www/app/webinars/[title]/page.tsx b/www/app/webinars/[title]/page.tsx index 914bd4c4..cc798e15 100644 --- a/www/app/webinars/[title]/page.tsx +++ b/www/app/webinars/[title]/page.tsx @@ -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";