From 782171d7be4bc809d7e9fe27c9d57c1ce9e769a7 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Tue, 17 Jun 2025 19:42:32 -0400 Subject: [PATCH] slop review --- GUIDE.md | 312 ----------------------- PLAN.md | 370 ---------------------------- REQUIREMENTS.md | 49 ---- TODO.md | 7 +- www/app/[roomName]/page.tsx | 29 +-- www/app/api/schemas.gen.ts | 41 +++ www/app/api/services.gen.ts | 50 ++++ www/app/api/types.gen.ts | 57 +++++ www/app/recordingConsentContext.tsx | 26 +- 9 files changed, 176 insertions(+), 765 deletions(-) delete mode 100644 GUIDE.md delete mode 100644 PLAN.md delete mode 100644 REQUIREMENTS.md diff --git a/GUIDE.md b/GUIDE.md deleted file mode 100644 index e1e73cf5..00000000 --- a/GUIDE.md +++ /dev/null @@ -1,312 +0,0 @@ -# Audio Storage Consent Implementation Guide - -This guide documents the complete implementation of the audio storage consent feature based on the requirements in `REQUIREMENTS.md` and the plan outlined in `PLAN.md`. - -## Overview - -The implementation moves consent from room entry to during recording, asking specifically about audio storage while allowing transcription to continue regardless of response. The system now allows immediate room joining without consent barriers and handles consent responses during post-processing. - - - -## Backend API Implementation - -## SQS Processing and Background Tasks - -### 1. Enhanced SQS Polling - -**File:** `server/reflector/settings.py` - -Added configurable SQS polling timeout: - - - -## Frontend Implementation - -### 1. Room Page Changes - -**File:** `www/app/[roomName]/page.tsx` - -Completely restructured to add consent dialog functionality: - -```typescript -// Added imports for consent functionality -import AudioConsentDialog from "../(app)/rooms/audioConsentDialog"; -import { DomainContext } from "../domainContext"; -import { useRecordingConsent } from "../recordingConsentContext"; -import useSessionAccessToken from "../lib/useSessionAccessToken"; -import useSessionUser from "../lib/useSessionUser"; - -// Added state management for consent -const [showConsentDialog, setShowConsentDialog] = useState(false); -const [consentLoading, setConsentLoading] = useState(false); -const { state: consentState, touch, hasConsent } = useRecordingConsent(); -const { api_url } = useContext(DomainContext); -const { accessToken } = useSessionAccessToken(); -const { id: userId } = useSessionUser(); - -// User identification logic for authenticated vs anonymous users -const getUserIdentifier = useCallback(() => { - if (isAuthenticated && userId) { - return userId; // Send actual user ID for authenticated users - } - - // For anonymous users, send no identifier - return null; -}, [isAuthenticated, userId]); - -// Consent handling with proper API integration -const handleConsent = useCallback(async (meetingId: string, given: boolean) => { - setConsentLoading(true); - setShowConsentDialog(false); // Close dialog immediately - - 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) { - touch(meetingId); - } else { - console.error('Failed to submit consent'); - } - } catch (error) { - console.error('Error submitting consent:', error); - } finally { - setConsentLoading(false); - } - } else { - setConsentLoading(false); - } -}, [meeting?.response?.id, api_url, accessToken, touch, getUserIdentifier]); - -// Show consent dialog when meeting is loaded and consent hasn't been answered yet -useEffect(() => { - if ( - consentState.ready && - meetingId && - !hasConsent(meetingId) && - !showConsentDialog && - !consentLoading - ) { - setShowConsentDialog(true); - } -}, [consentState.ready, meetingId, hasConsent, showConsentDialog, consentLoading]); - -// Consent dialog in render -{meetingId && consentState.ready && !hasConsent(meetingId) && !consentLoading && ( - {}} // No-op: ESC should not close without consent - onConsent={b => handleConsent(meetingId, b)} - /> -)} -``` - -### 2. Consent Dialog Component - -**File:** `www/app/(app)/rooms/audioConsentDialog.tsx` - -Created new audio consent dialog component: - -```typescript -import { - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - Text, - Button, - VStack, - HStack, -} from "@chakra-ui/react"; - -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. - - - - - - - - - - ); -}; - -export default AudioConsentDialog; -``` - -### 3. Recording Consent Context - -**File:** `www/app/recordingConsentContext.tsx` - -Added context for managing consent state across the application: - -```typescript -import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; - -interface ConsentState { - ready: boolean; - consents: Record; -} - -interface RecordingConsentContextType { - state: ConsentState; - hasConsent: (meetingId: string) => boolean; - touch: (meetingId: string) => void; -} - -const RecordingConsentContext = createContext(undefined); - -export const RecordingConsentProvider = ({ children }: { children: ReactNode }) => { - const [state, setState] = useState({ - ready: true, - consents: {} - }); - - const hasConsent = useCallback((meetingId: string): boolean => { - return meetingId in state.consents; - }, [state.consents]); - - const touch = useCallback((meetingId: string) => { - setState(prev => ({ - ...prev, - consents: { - ...prev.consents, - [meetingId]: true - } - })); - }, []); - - return ( - - {children} - - ); -}; - -export const useRecordingConsent = () => { - const context = useContext(RecordingConsentContext); - if (context === undefined) { - throw new Error('useRecordingConsent must be used within a RecordingConsentProvider'); - } - return context; -}; -``` - -## Key Features Implemented - -### 1. User Identification System - -The system now properly distinguishes between authenticated and anonymous users: - -- **Authenticated users**: Use actual user ID, consent can be overridden in subsequent visits -- **Anonymous users**: No user identifier stored, each consent is treated as separate - -### 2. Consent Override Functionality - -For authenticated users, new consent responses override previous ones for the same meeting, ensuring users can change their mind during the same meeting session. - -### 3. ESC Key Behavior - -The consent dialog cannot be closed with ESC key (`closeOnEsc={false}`) and the onClose handler is a no-op, ensuring users must explicitly choose to give or deny consent. - -### 4. Meeting ID Persistence - -The system properly handles meeting ID persistence by checking both `end_date` and `is_active` flags to determine if a meeting should be reused or if a new one should be created. - -### 5. Background Processing Pipeline - -Complete SQS polling and Celery worker setup with: -- 5-second polling timeout for development -- Proper task registration and discovery -- Redis as message broker -- Comprehensive logging - -## Environment Setup - -### Development Environment Variables - -The implementation requires several environment variables to be properly configured: - -```bash -# SQS Configuration -AWS_PROCESS_RECORDING_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/950402358378/ProcessRecordingLocal -SQS_POLLING_TIMEOUT_SECONDS=5 - -# AWS Credentials with SQS permissions -TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID=***REMOVED*** -TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY="***REMOVED***" -``` - -### Services Required - -The system requires the following services to be running: - -1. **Backend Server**: FastAPI/Uvicorn on port 1250 -2. **Frontend Server**: Next.js on port 3000 -3. **Redis**: For Celery message broker -4. **Celery Worker**: For background task processing -5. **Celery Beat**: For scheduled SQS polling - -## Known Issues - -### Frontend SSR Issue - -The room page currently has a server-side rendering issue due to the Whereby SDK import: - -```typescript -import "@whereby.com/browser-sdk/embed"; -``` - -This causes "ReferenceError: document is not defined" during Next.js pre-rendering. The import should be moved to a client-side effect or use dynamic imports to resolve this issue. - -## Success Criteria Met - - **Users join rooms without barriers** - Removed pre-entry consent blocking - **Audio storage consent requested during meeting** - Dialog appears when meeting loads - **Post-processing handles consent** - SQS polling and background processing implemented - **Transcription unaffected by consent choice** - Full transcript processing continues - **Multiple meeting sessions handled independently** - Proper meeting ID persistence and scoping - **Authenticated vs anonymous user handling** - Proper user identification system - **Consent override functionality** - Authenticated users can change consent for same meeting - -The implementation successfully transforms the consent flow from a room-entry barrier to an in-meeting dialog while maintaining all transcript processing capabilities and properly handling both authenticated and anonymous users. \ No newline at end of file diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 93dca3bd..00000000 --- a/PLAN.md +++ /dev/null @@ -1,370 +0,0 @@ -# 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 using meeting_consent table -# Use meeting_consent table for proper normalization -``` - -### 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)/rooms/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 using meeting_consent_controller - should_delete = await meeting_consent_controller.has_any_denial(meeting.id) - # AFTER transcript processing is complete, delete audio if consent denied - if should_delete: - logger.info(f"Deleting audio files for {object_key} due to consent denial") - 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. - -## Extra notes - -Room creator must not be asked for consent \ No newline at end of file diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md deleted file mode 100644 index 5f9e4f93..00000000 --- a/REQUIREMENTS.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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/TODO.md b/TODO.md index 5ceb50fb..bb6dfeac 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,3 @@ -- consent popup itself - make much less invasive, somewhere in the corner -- non-auth user consent AND AUTH user consent - store on frontend per session - per meeting? (get meeting from the iframe) -- actually delete aws -- add externalId to the iframe with the logged in user \ No newline at end of file +- consent popup itself - make much less invasive, somewhere in the corner - essential +- actually delete aws - CHECK +- add externalId to the iframe with the logged in user - non essential \ No newline at end of file diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index 5f5eb4b7..4ca65107 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -12,6 +12,7 @@ import { DomainContext } from "../domainContext"; import { useRecordingConsent } from "../recordingConsentContext"; import useSessionAccessToken from "../lib/useSessionAccessToken"; import useSessionUser from "../lib/useSessionUser"; +import useApi from "../lib/useApi"; export type RoomDetails = { params: { @@ -31,6 +32,7 @@ export default function Room(details: RoomDetails) { const { api_url } = useContext(DomainContext); const { accessToken } = useSessionAccessToken(); const { id: userId } = useSessionUser(); + const api = useApi(); const roomUrl = meeting?.response?.host_room_url @@ -43,38 +45,25 @@ export default function Room(details: RoomDetails) { router.push("/browse"); }, [router]); - // TODO hook const handleConsent = useCallback(async (meetingId: string, given: boolean) => { + if (!api) return; setShowConsentDialog(false); setConsentLoading(true); try { - const requestBody = { - consent_given: given - }; - - // TODO generated API - const response = await fetch(`${api_url}/v1/meetings/${meetingId}/consent`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(accessToken && { 'Authorization': `Bearer ${accessToken}` }) - }, - body: JSON.stringify(requestBody), + await api.v1MeetingAudioConsent({ + meetingId, + requestBody: { consent_given: given } }); - - if (response.ok) { - touch(meetingId); - } else { - console.error('Failed to submit consent'); - } + + touch(meetingId); } catch (error) { console.error('Error submitting consent:', error); } finally { setConsentLoading(false); } - }, [api_url, accessToken, touch]); + }, [api, touch]); useEffect(() => { diff --git a/www/app/api/schemas.gen.ts b/www/app/api/schemas.gen.ts index c9b5e28d..359b6922 100644 --- a/www/app/api/schemas.gen.ts +++ b/www/app/api/schemas.gen.ts @@ -548,6 +548,18 @@ export const $Meeting = { title: "Meeting", } as const; +export const $MeetingConsentRequest = { + properties: { + consent_given: { + type: "boolean", + title: "Consent Given", + }, + }, + type: "object", + required: ["consent_given"], + title: "MeetingConsentRequest", +} as const; + export const $Page_GetTranscript_ = { properties: { items: { @@ -1166,6 +1178,35 @@ export const $ValidationError = { title: "ValidationError", } as const; +export const $WherebyWebhookEvent = { + properties: { + apiVersion: { + type: "string", + title: "Apiversion", + }, + id: { + type: "string", + title: "Id", + }, + createdAt: { + type: "string", + format: "date-time", + title: "Createdat", + }, + type: { + type: "string", + title: "Type", + }, + data: { + type: "object", + title: "Data", + }, + }, + type: "object", + required: ["apiVersion", "id", "createdAt", "type", "data"], + title: "WherebyWebhookEvent", +} as const; + export const $Word = { properties: { text: { diff --git a/www/app/api/services.gen.ts b/www/app/api/services.gen.ts index acf1b71f..a91155d1 100644 --- a/www/app/api/services.gen.ts +++ b/www/app/api/services.gen.ts @@ -4,6 +4,8 @@ import type { CancelablePromise } from "./core/CancelablePromise"; import type { BaseHttpRequest } from "./core/BaseHttpRequest"; import type { MetricsResponse, + V1MeetingAudioConsentData, + V1MeetingAudioConsentResponse, V1RoomsListData, V1RoomsListResponse, V1RoomsCreateData, @@ -64,6 +66,8 @@ import type { V1ZulipGetStreamsResponse, V1ZulipGetTopicsData, V1ZulipGetTopicsResponse, + V1WherebyWebhookData, + V1WherebyWebhookResponse, } from "./types.gen"; export class DefaultService { @@ -82,6 +86,31 @@ export class DefaultService { }); } + /** + * Meeting Audio Consent + * @param data The data for the request. + * @param data.meetingId + * @param data.requestBody + * @returns unknown Successful Response + * @throws ApiError + */ + public v1MeetingAudioConsent( + data: V1MeetingAudioConsentData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "POST", + url: "/v1/meetings/{meeting_id}/consent", + path: { + meeting_id: data.meetingId, + }, + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }); + } + /** * Rooms List * @param data The data for the request. @@ -807,4 +836,25 @@ export class DefaultService { }, }); } + + /** + * Whereby Webhook + * @param data The data for the request. + * @param data.requestBody + * @returns unknown Successful Response + * @throws ApiError + */ + public v1WherebyWebhook( + data: V1WherebyWebhookData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "POST", + url: "/v1/whereby", + body: data.requestBody, + mediaType: "application/json", + errors: { + 422: "Validation Error", + }, + }); + } } diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts index 9b456648..ef9ec43d 100644 --- a/www/app/api/types.gen.ts +++ b/www/app/api/types.gen.ts @@ -109,6 +109,10 @@ export type Meeting = { end_date: string; }; +export type MeetingConsentRequest = { + consent_given: boolean; +}; + export type Page_GetTranscript_ = { items: Array; total: number; @@ -229,6 +233,16 @@ export type ValidationError = { type: string; }; +export type WherebyWebhookEvent = { + apiVersion: string; + id: string; + createdAt: string; + type: string; + data: { + [key: string]: unknown; + }; +}; + export type Word = { text: string; start: number; @@ -238,6 +252,13 @@ export type Word = { export type MetricsResponse = unknown; +export type V1MeetingAudioConsentData = { + meetingId: string; + requestBody: MeetingConsentRequest; +}; + +export type V1MeetingAudioConsentResponse = unknown; + export type V1RoomsListData = { /** * Page number @@ -454,6 +475,12 @@ export type V1ZulipGetTopicsData = { export type V1ZulipGetTopicsResponse = Array; +export type V1WherebyWebhookData = { + requestBody: WherebyWebhookEvent; +}; + +export type V1WherebyWebhookResponse = unknown; + export type $OpenApiTs = { "/metrics": { get: { @@ -465,6 +492,21 @@ export type $OpenApiTs = { }; }; }; + "/v1/meetings/{meeting_id}/consent": { + post: { + req: V1MeetingAudioConsentData; + res: { + /** + * Successful Response + */ + 200: unknown; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; "/v1/rooms": { get: { req: V1RoomsListData; @@ -902,4 +944,19 @@ export type $OpenApiTs = { }; }; }; + "/v1/whereby": { + post: { + req: V1WherebyWebhookData; + res: { + /** + * Successful Response + */ + 200: unknown; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; }; diff --git a/www/app/recordingConsentContext.tsx b/www/app/recordingConsentContext.tsx index e4249f37..4c2e16ad 100644 --- a/www/app/recordingConsentContext.tsx +++ b/www/app/recordingConsentContext.tsx @@ -29,17 +29,20 @@ interface RecordingConsentProviderProps { children: React.ReactNode; } +const LOCAL_STORAGE_KEY = "recording_consent_meetings"; + export const RecordingConsentProvider: React.FC = ({ children }) => { const [state, setState] = useState({ ready: false }); const safeWriteToStorage = (meetingIds: string[]): void => { try { - localStorage.setItem("recording_consent_meetings", JSON.stringify(meetingIds)); + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(meetingIds)); } catch (error) { console.error("Failed to save consent data to localStorage:", error); } }; + // writes to local storage and to the state of context both const touch = (meetingId: string): void => { if (!state.ready) { @@ -47,13 +50,14 @@ export const RecordingConsentProvider: React.FC = return; } - // Update context state (always works) - const newSet = new Set([...state.consentAnsweredForMeetings, meetingId]); - + // has success regardless local storage write success: we don't handle that + // and don't want to crash anything with just consent functionality + const newSet = state.consentAnsweredForMeetings.has(meetingId) ? + state.consentAnsweredForMeetings : + new Set([...state.consentAnsweredForMeetings, meetingId]); + // note: preserves the set insertion order const array = Array.from(newSet).slice(-5); // Keep latest 5 safeWriteToStorage(array); - - // Update state regardless of storage success setState({ ready: true, consentAnsweredForMeetings: newSet }); }; @@ -62,10 +66,10 @@ export const RecordingConsentProvider: React.FC = return state.consentAnsweredForMeetings.has(meetingId); }; - // Initialize from localStorage on mount (client-side only) + // initialize on mount useEffect(() => { try { - const stored = localStorage.getItem("recording_consent_meetings"); + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); if (!stored) { setState({ ready: true, consentAnsweredForMeetings: new Set() }); return; @@ -77,10 +81,12 @@ export const RecordingConsentProvider: React.FC = setState({ ready: true, consentAnsweredForMeetings: new Set() }); return; } - - const consentAnsweredForMeetings = new Set(parsed.filter(id => typeof id === 'string')); + + // pre-historic way of parsing! + const consentAnsweredForMeetings = new Set(parsed.filter(id => !!id && typeof id === 'string')); setState({ ready: true, consentAnsweredForMeetings }); } catch (error) { + // we don't want to fail the page here; the component is not essential. console.error("Failed to parse consent data from localStorage:", error); setState({ ready: true, consentAnsweredForMeetings: new Set() }); }