slop review

This commit is contained in:
Igor Loskutov
2025-06-17 19:42:32 -04:00
parent 0c91f5dd59
commit 782171d7be
9 changed files with 176 additions and 765 deletions

312
GUIDE.md
View File

@@ -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 && (
<AudioConsentDialog
isOpen={showConsentDialog}
onClose={() => {}} // 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 (
<Modal isOpen={isOpen} onClose={onClose} closeOnOverlayClick={false} closeOnEsc={false}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Audio Storage Consent</ModalHeader>
<ModalBody pb={6}>
<VStack spacing={4} align="start">
<Text>
Do you consent to storing this audio recording?
The transcript will be generated regardless of your choice.
</Text>
<HStack spacing={4} width="100%" justifyContent="center">
<Button colorScheme="green" onClick={() => onConsent(true)}>
Yes, store the audio
</Button>
<Button colorScheme="red" onClick={() => onConsent(false)}>
No, delete after transcription
</Button>
</HStack>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
);
};
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<string, boolean>;
}
interface RecordingConsentContextType {
state: ConsentState;
hasConsent: (meetingId: string) => boolean;
touch: (meetingId: string) => void;
}
const RecordingConsentContext = createContext<RecordingConsentContextType | undefined>(undefined);
export const RecordingConsentProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<ConsentState>({
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 (
<RecordingConsentContext.Provider value={{ state, hasConsent, touch }}>
{children}
</RecordingConsentContext.Provider>
);
};
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.

370
PLAN.md
View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -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: {

View File

@@ -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<V1MeetingAudioConsentResponse> {
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<V1WherebyWebhookResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/whereby",
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
}

View File

@@ -109,6 +109,10 @@ export type Meeting = {
end_date: string;
};
export type MeetingConsentRequest = {
consent_given: boolean;
};
export type Page_GetTranscript_ = {
items: Array<GetTranscript>;
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<Topic>;
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;
};
};
};
};

View File

@@ -29,17 +29,20 @@ interface RecordingConsentProviderProps {
children: React.ReactNode;
}
const LOCAL_STORAGE_KEY = "recording_consent_meetings";
export const RecordingConsentProvider: React.FC<RecordingConsentProviderProps> = ({ children }) => {
const [state, setState] = useState<ConsentContextState>({ 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<RecordingConsentProviderProps> =
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<RecordingConsentProviderProps> =
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<RecordingConsentProviderProps> =
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() });
}