Compare commits

...

5 Commits

Author SHA1 Message Date
311d453e41 feat: implement frontend for calendar integration (Phase 3 & 4)
## Frontend Implementation

### Meeting Selection & Management
- Created MeetingSelection component for choosing between multiple active meetings
- Shows both active meetings and upcoming calendar events (30 min ahead)
- Displays meeting metadata with privacy controls (owner-only details)
- Supports creation of unscheduled meetings alongside calendar meetings

### Waiting Room
- Added waiting page for users joining before scheduled start time
- Shows countdown timer until meeting begins
- Auto-transitions to meeting when calendar event becomes active
- Handles early joining with proper routing

### Meeting Info Panel
- Created collapsible info panel showing meeting details
- Displays calendar metadata (title, description, attendees)
- Shows participant count and duration
- Privacy-aware: sensitive info only visible to room owners

### ICS Configuration UI
- Integrated ICS settings into room configuration dialog
- Test connection functionality with immediate feedback
- Manual sync trigger with detailed results
- Shows last sync time and ETag for monitoring
- Configurable sync intervals (1 min to 1 hour)

### Routing & Navigation
- New /room/{roomName} route for meeting selection
- Waiting room at /room/{roomName}/wait?eventId={id}
- Classic room page at /{roomName} with meeting info
- Uses sessionStorage to pass selected meeting between pages

### API Integration
- Added new endpoints for active/upcoming meetings
- Regenerated TypeScript client with latest OpenAPI spec
- Proper error handling and loading states
- Auto-refresh every 30 seconds for live updates

### UI/UX Improvements
- Color-coded badges for meeting status
- Attendee status indicators (accepted/declined/tentative)
- Responsive design with Chakra UI components
- Clear visual hierarchy between active and upcoming meetings
- Smart truncation for long attendee lists

This completes the frontend implementation for calendar integration,
enabling users to seamlessly join scheduled meetings from their
calendar applications.
2025-08-18 19:29:56 -06:00
f286f0882c feat: implement Phase 2 - Multiple active meetings per room with grace period
This commit adds support for multiple concurrent meetings per room, implementing
grace period logic and improved meeting lifecycle management for calendar integration.

## Database Changes
- Remove unique constraint preventing multiple active meetings per room
- Add last_participant_left_at field to track when meeting becomes empty
- Add grace_period_minutes field (default: 15) for configurable grace period

## Meeting Controller Enhancements
- Add get_all_active_for_room() to retrieve all active meetings for a room
- Add get_active_by_calendar_event() to find meetings by calendar event ID
- Maintain backward compatibility with existing get_active() method

## New API Endpoints
- GET /rooms/{room_name}/meetings/active - List all active meetings
- POST /rooms/{room_name}/meetings/{meeting_id}/join - Join specific meeting

## Meeting Lifecycle Improvements
- 15-minute grace period after last participant leaves
- Automatic reactivation when participant rejoins during grace period
- Force close calendar meetings 30 minutes after scheduled end time
- Update process_meetings task to handle multiple active meetings

## Whereby Integration
- Clear grace period when participants join via webhook events
- Track participant count for grace period management

## Testing
- Add comprehensive tests for multiple active meetings
- Test grace period behavior and participant rejoin scenarios
- Test calendar meeting force closure logic
- All 5 new tests passing

This enables proper calendar integration with overlapping meetings while
preventing accidental meeting closures through the grace period mechanism.
2025-08-18 19:03:41 -06:00
ffcafb3bf2 feat: add Celery background tasks for ICS sync 2025-08-18 17:22:41 -06:00
27075d840c feat: add ICS calendar API endpoints for room configuration and sync 2025-08-18 17:03:23 -06:00
30b5cd45e3 feat: calendar integration 2025-08-18 16:51:30 -06:00
33 changed files with 5820 additions and 58 deletions

497
ICS_IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,497 @@
# ICS Calendar Integration - Implementation Guide
## Overview
This document provides detailed implementation guidance for integrating ICS calendar feeds with Reflector rooms. Unlike CalDAV which requires complex authentication and protocol handling, ICS integration uses simple HTTP(S) fetching of calendar files.
## Key Differences from CalDAV Approach
| Aspect | CalDAV | ICS |
|--------|--------|-----|
| Protocol | WebDAV extension | HTTP/HTTPS GET |
| Authentication | Username/password, OAuth | Tokens embedded in URL |
| Data Access | Selective event queries | Full calendar download |
| Implementation | Complex (caldav library) | Simple (requests + icalendar) |
| Real-time Updates | Supported | Polling only |
| Write Access | Yes | No (read-only) |
## Technical Architecture
### 1. ICS Fetching Service
```python
# reflector/services/ics_sync.py
import requests
from icalendar import Calendar
from typing import List, Optional
from datetime import datetime, timedelta
class ICSFetchService:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({'User-Agent': 'Reflector/1.0'})
def fetch_ics(self, url: str) -> str:
"""Fetch ICS file from URL (authentication via URL token if needed)."""
response = self.session.get(url, timeout=30)
response.raise_for_status()
return response.text
def parse_ics(self, ics_content: str) -> Calendar:
"""Parse ICS content into calendar object."""
return Calendar.from_ical(ics_content)
def extract_room_events(self, calendar: Calendar, room_url: str) -> List[dict]:
"""Extract events that match the room URL."""
events = []
for component in calendar.walk():
if component.name == "VEVENT":
# Check if event matches this room
if self._event_matches_room(component, room_url):
events.append(self._parse_event(component))
return events
def _event_matches_room(self, event, room_url: str) -> bool:
"""Check if event location or description contains room URL."""
location = str(event.get('LOCATION', ''))
description = str(event.get('DESCRIPTION', ''))
# Support various URL formats
patterns = [
room_url,
room_url.replace('https://', ''),
room_url.split('/')[-1], # Just room name
]
for pattern in patterns:
if pattern in location or pattern in description:
return True
return False
```
### 2. Database Schema
```sql
-- Modify room table
ALTER TABLE room ADD COLUMN ics_url TEXT; -- encrypted to protect embedded tokens
ALTER TABLE room ADD COLUMN ics_fetch_interval INTEGER DEFAULT 300; -- seconds
ALTER TABLE room ADD COLUMN ics_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE room ADD COLUMN ics_last_sync TIMESTAMP;
ALTER TABLE room ADD COLUMN ics_last_etag TEXT; -- for caching
-- Calendar events table
CREATE TABLE calendar_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
room_id UUID REFERENCES room(id) ON DELETE CASCADE,
external_id TEXT NOT NULL, -- ICS UID
title TEXT,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
attendees JSONB,
location TEXT,
ics_raw_data TEXT, -- Store raw VEVENT for reference
last_synced TIMESTAMP DEFAULT NOW(),
is_deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, external_id)
);
-- Index for efficient queries
CREATE INDEX idx_calendar_event_room_start ON calendar_event(room_id, start_time);
CREATE INDEX idx_calendar_event_deleted ON calendar_event(is_deleted) WHERE NOT is_deleted;
```
### 3. Background Tasks
```python
# reflector/worker/tasks/ics_sync.py
from celery import shared_task
from datetime import datetime, timedelta
import hashlib
@shared_task
def sync_ics_calendars():
"""Sync all enabled ICS calendars based on their fetch intervals."""
rooms = Room.query.filter_by(ics_enabled=True).all()
for room in rooms:
# Check if it's time to sync based on fetch interval
if should_sync(room):
sync_room_calendar.delay(room.id)
@shared_task
def sync_room_calendar(room_id: str):
"""Sync calendar for a specific room."""
room = Room.query.get(room_id)
if not room or not room.ics_enabled:
return
try:
# Fetch ICS file (decrypt URL first)
service = ICSFetchService()
decrypted_url = decrypt_ics_url(room.ics_url)
ics_content = service.fetch_ics(decrypted_url)
# Check if content changed (using ETag or hash)
content_hash = hashlib.md5(ics_content.encode()).hexdigest()
if room.ics_last_etag == content_hash:
logger.info(f"No changes in ICS for room {room_id}")
return
# Parse and extract events
calendar = service.parse_ics(ics_content)
events = service.extract_room_events(calendar, room.url)
# Update database
sync_events_to_database(room_id, events)
# Update sync metadata
room.ics_last_sync = datetime.utcnow()
room.ics_last_etag = content_hash
db.session.commit()
except Exception as e:
logger.error(f"Failed to sync ICS for room {room_id}: {e}")
def should_sync(room) -> bool:
"""Check if room calendar should be synced."""
if not room.ics_last_sync:
return True
time_since_sync = datetime.utcnow() - room.ics_last_sync
return time_since_sync.total_seconds() >= room.ics_fetch_interval
```
### 4. Celery Beat Schedule
```python
# reflector/worker/celeryconfig.py
from celery.schedules import crontab
beat_schedule = {
'sync-ics-calendars': {
'task': 'reflector.worker.tasks.ics_sync.sync_ics_calendars',
'schedule': 60.0, # Check every minute which calendars need syncing
},
'pre-create-meetings': {
'task': 'reflector.worker.tasks.ics_sync.pre_create_calendar_meetings',
'schedule': 60.0, # Check every minute for upcoming meetings
},
}
```
## API Endpoints
### Room ICS Configuration
```python
# PATCH /v1/rooms/{room_id}
{
"ics_url": "https://calendar.google.com/calendar/ical/.../private-token/basic.ics",
"ics_fetch_interval": 300, # seconds
"ics_enabled": true
# URL will be encrypted in database to protect embedded tokens
}
```
### Manual Sync Trigger
```python
# POST /v1/rooms/{room_name}/ics/sync
# Response:
{
"status": "syncing",
"last_sync": "2024-01-15T10:30:00Z",
"events_found": 5
}
```
### ICS Status
```python
# GET /v1/rooms/{room_name}/ics/status
# Response:
{
"enabled": true,
"last_sync": "2024-01-15T10:30:00Z",
"next_sync": "2024-01-15T10:35:00Z",
"fetch_interval": 300,
"events_count": 12,
"upcoming_events": 3
}
```
## ICS Parsing Details
### Event Field Mapping
| ICS Field | Database Field | Notes |
|-----------|---------------|-------|
| UID | external_id | Unique identifier |
| SUMMARY | title | Event title |
| DESCRIPTION | description | Full description |
| DTSTART | start_time | Convert to UTC |
| DTEND | end_time | Convert to UTC |
| LOCATION | location | Check for room URL |
| ATTENDEE | attendees | Parse into JSON |
| ORGANIZER | attendees | Add as organizer |
| STATUS | (internal) | Filter cancelled events |
### Handling Recurring Events
```python
def expand_recurring_events(event, start_date, end_date):
"""Expand recurring events into individual occurrences."""
from dateutil.rrule import rrulestr
if 'RRULE' not in event:
return [event]
# Parse recurrence rule
rrule_str = event['RRULE'].to_ical().decode()
dtstart = event['DTSTART'].dt
# Generate occurrences
rrule = rrulestr(rrule_str, dtstart=dtstart)
occurrences = []
for dt in rrule.between(start_date, end_date):
# Clone event with new date
occurrence = event.copy()
occurrence['DTSTART'].dt = dt
if 'DTEND' in event:
duration = event['DTEND'].dt - event['DTSTART'].dt
occurrence['DTEND'].dt = dt + duration
# Unique ID for each occurrence
occurrence['UID'] = f"{event['UID']}_{dt.isoformat()}"
occurrences.append(occurrence)
return occurrences
```
### Timezone Handling
```python
def normalize_datetime(dt):
"""Convert various datetime formats to UTC."""
import pytz
from datetime import datetime
if hasattr(dt, 'dt'): # icalendar property
dt = dt.dt
if isinstance(dt, datetime):
if dt.tzinfo is None:
# Assume local timezone if naive
dt = pytz.timezone('UTC').localize(dt)
else:
# Convert to UTC
dt = dt.astimezone(pytz.UTC)
return dt
```
## Security Considerations
### 1. URL Validation
```python
def validate_ics_url(url: str) -> bool:
"""Validate ICS URL for security."""
from urllib.parse import urlparse
parsed = urlparse(url)
# Must be HTTPS in production
if not settings.DEBUG and parsed.scheme != 'https':
return False
# Prevent local file access
if parsed.scheme in ('file', 'ftp'):
return False
# Prevent internal network access
if is_internal_ip(parsed.hostname):
return False
return True
```
### 2. Rate Limiting
```python
# Implement per-room rate limiting
RATE_LIMITS = {
'min_fetch_interval': 60, # Minimum 1 minute between fetches
'max_requests_per_hour': 60, # Max 60 requests per hour per room
'max_file_size': 10 * 1024 * 1024, # Max 10MB ICS file
}
```
### 3. ICS URL Encryption
```python
from cryptography.fernet import Fernet
class URLEncryption:
def __init__(self):
self.cipher = Fernet(settings.ENCRYPTION_KEY)
def encrypt_url(self, url: str) -> str:
"""Encrypt ICS URL to protect embedded tokens."""
return self.cipher.encrypt(url.encode()).decode()
def decrypt_url(self, encrypted: str) -> str:
"""Decrypt ICS URL for fetching."""
return self.cipher.decrypt(encrypted.encode()).decode()
def mask_url(self, url: str) -> str:
"""Mask sensitive parts of URL for display."""
from urllib.parse import urlparse, urlunparse
parsed = urlparse(url)
# Keep scheme, host, and path structure but mask tokens
if '/private-' in parsed.path:
# Google Calendar format
parts = parsed.path.split('/private-')
masked_path = parts[0] + '/private-***' + parts[1].split('/')[-1]
elif 'token=' in url:
# Query parameter token
masked_path = parsed.path
parsed = parsed._replace(query='token=***')
else:
# Generic masking of path segments that look like tokens
import re
masked_path = re.sub(r'/[a-zA-Z0-9]{20,}/', '/***/', parsed.path)
return urlunparse(parsed._replace(path=masked_path))
```
## Testing Strategy
### 1. Unit Tests
```python
# tests/test_ics_sync.py
def test_ics_parsing():
"""Test ICS file parsing."""
ics_content = """BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:test-123
SUMMARY:Team Meeting
LOCATION:https://reflector.monadical.com/engineering
DTSTART:20240115T100000Z
DTEND:20240115T110000Z
END:VEVENT
END:VCALENDAR"""
service = ICSFetchService()
calendar = service.parse_ics(ics_content)
events = service.extract_room_events(
calendar,
"https://reflector.monadical.com/engineering"
)
assert len(events) == 1
assert events[0]['title'] == 'Team Meeting'
```
### 2. Integration Tests
```python
def test_full_sync_flow():
"""Test complete sync workflow."""
# Create room with ICS URL (encrypt URL to protect tokens)
encryption = URLEncryption()
room = Room(
name="test-room",
ics_url=encryption.encrypt_url("https://example.com/calendar.ics?token=secret"),
ics_enabled=True
)
# Mock ICS fetch
with patch('requests.get') as mock_get:
mock_get.return_value.text = sample_ics_content
# Run sync
sync_room_calendar(room.id)
# Verify events created
events = CalendarEvent.query.filter_by(room_id=room.id).all()
assert len(events) > 0
```
## Common ICS Provider Configurations
### Google Calendar
- URL Format: `https://calendar.google.com/calendar/ical/{calendar_id}/private-{token}/basic.ics`
- Authentication via token embedded in URL
- Updates every 3-8 hours by default
### Outlook/Office 365
- URL Format: `https://outlook.office365.com/owa/calendar/{id}/calendar.ics`
- May include token in URL path or query parameters
- Real-time updates
### Apple iCloud
- URL Format: `webcal://p{XX}-caldav.icloud.com/published/2/{token}`
- Convert webcal:// to https://
- Token embedded in URL path
- Public calendars only
### Nextcloud/ownCloud
- URL Format: `https://cloud.example.com/remote.php/dav/public-calendars/{token}`
- Token embedded in URL path
- Configurable update frequency
## Migration from CalDAV
If migrating from an existing CalDAV implementation:
1. **Database Migration**: Rename fields from `caldav_*` to `ics_*`
2. **URL Conversion**: Most CalDAV servers provide ICS export endpoints
3. **Authentication**: Convert from username/password to URL-embedded tokens
4. **Remove Dependencies**: Uninstall caldav library, add icalendar
5. **Update Background Tasks**: Replace CalDAV sync with ICS fetch
## Performance Optimizations
1. **Caching**: Use ETag/Last-Modified headers to avoid refetching unchanged calendars
2. **Incremental Sync**: Store last sync timestamp, only process new/modified events
3. **Batch Processing**: Process multiple room calendars in parallel
4. **Connection Pooling**: Reuse HTTP connections for multiple requests
5. **Compression**: Support gzip encoding for large ICS files
## Monitoring and Debugging
### Metrics to Track
- Sync success/failure rate per room
- Average sync duration
- ICS file sizes
- Number of events processed
- Failed event matches
### Debug Logging
```python
logger.debug(f"Fetching ICS from {room.ics_url}")
logger.debug(f"ICS content size: {len(ics_content)} bytes")
logger.debug(f"Found {len(events)} matching events")
logger.debug(f"Event UIDs: {[e['external_id'] for e in events]}")
```
### Common Issues
1. **SSL Certificate Errors**: Add certificate validation options
2. **Timeout Issues**: Increase timeout for large calendars
3. **Encoding Problems**: Handle various character encodings
4. **Timezone Mismatches**: Always convert to UTC
5. **Memory Issues**: Stream large ICS files instead of loading entirely

337
PLAN.md Normal file
View File

@@ -0,0 +1,337 @@
# ICS Calendar Integration Plan
## Core Concept
ICS calendar URLs are attached to rooms (not users) to enable automatic meeting tracking and management through periodic fetching of calendar data.
## Database Schema Updates
### 1. Add ICS configuration to rooms
- Add `ics_url` field to room table (URL to .ics file, may include auth token)
- Add `ics_fetch_interval` field to room table (default: 5 minutes, configurable)
- Add `ics_enabled` boolean field to room table
- Add `ics_last_sync` timestamp field to room table
### 2. Create calendar_events table
- `id` - UUID primary key
- `room_id` - Foreign key to room
- `external_id` - ICS event UID
- `title` - Event title
- `description` - Event description
- `start_time` - Event start timestamp
- `end_time` - Event end timestamp
- `attendees` - JSON field with attendee list and status
- `location` - Meeting location (should contain room name)
- `last_synced` - Last sync timestamp
- `is_deleted` - Boolean flag for soft delete (preserve past events)
- `ics_raw_data` - TEXT field to store raw VEVENT data for reference
### 3. Update meeting table
- Add `calendar_event_id` - Foreign key to calendar_events
- Add `calendar_metadata` - JSON field for additional calendar data
- Remove unique constraint on room_id + active status (allow multiple active meetings per room)
## Backend Implementation
### 1. ICS Sync Service
- Create background task that runs based on room's `ics_fetch_interval` (default: 5 minutes)
- For each room with ICS enabled, fetch the .ics file via HTTP/HTTPS
- Parse ICS file using icalendar library
- Extract VEVENT components and filter events looking for room URL (e.g., "https://reflector.monadical.com/max")
- Store matching events in calendar_events table
- Mark events as "upcoming" if start_time is within next 30 minutes
- Pre-create Whereby meetings 1 minute before start (ensures no delay when users join)
- Soft-delete future events that were removed from calendar (set is_deleted=true)
- Never delete past events (preserve for historical record)
- Support authenticated ICS feeds via tokens embedded in URL
### 2. Meeting Management Updates
- Allow multiple active meetings per room
- Pre-create meeting record 1 minute before calendar event starts (ensures meeting is ready)
- Link meeting to calendar_event for metadata
- Keep meeting active for 15 minutes after last participant leaves (grace period)
- Don't auto-close if new participant joins within grace period
### 3. API Endpoints
- `GET /v1/rooms/{room_name}/meetings` - List all active and upcoming meetings for a room
- Returns filtered data based on user role (owner vs participant)
- `GET /v1/rooms/{room_name}/meetings/upcoming` - List upcoming meetings (next 30 min)
- Returns filtered data based on user role
- `POST /v1/rooms/{room_name}/meetings/{meeting_id}/join` - Join specific meeting
- `PATCH /v1/rooms/{room_id}` - Update room settings (including ICS configuration)
- ICS fields only visible/editable by room owner
- `POST /v1/rooms/{room_name}/ics/sync` - Trigger manual ICS sync
- Only accessible by room owner
- `GET /v1/rooms/{room_name}/ics/status` - Get ICS sync status and last fetch time
- Only accessible by room owner
## Frontend Implementation
### 1. Room Settings Page
- Add ICS configuration section
- Field for ICS URL (e.g., Google Calendar private URL, Outlook ICS export)
- Field for fetch interval (dropdown: 1 min, 5 min, 10 min, 30 min, 1 hour)
- Test connection button (validates ICS file can be fetched and parsed)
- Manual sync button
- Show last sync time and next scheduled sync
### 2. Meeting Selection Page (New)
- Show when accessing `/room/{room_name}`
- **Host view** (room owner):
- Full calendar event details
- Meeting title and description
- Complete attendee list with RSVP status
- Number of current participants
- Duration (how long it's been running)
- **Participant view** (non-owners):
- Meeting title only
- Date and time
- Number of current participants
- Duration (how long it's been running)
- No attendee list or description (privacy)
- Display upcoming meetings (visible 30min before):
- Show countdown to start
- Can click to join early → redirected to waiting page
- Waiting page shows countdown until meeting starts
- Meeting pre-created by background task (ready when users arrive)
- Option to create unscheduled meeting (uses existing flow)
### 3. Meeting Room Updates
- Show calendar metadata in meeting info
- Display invited attendees vs actual participants
- Show meeting title from calendar event
## Meeting Lifecycle
### 1. Meeting Creation
- Automatic: Pre-created 1 minute before calendar event starts (ensures Whereby room is ready)
- Manual: User creates unscheduled meeting (existing `/rooms/{room_name}/meeting` endpoint)
- Background task handles pre-creation to avoid delays when users join
### 2. Meeting Join Rules
- Can join active meetings immediately
- Can see upcoming meetings 30 minutes before start
- Can click to join upcoming meetings early → sent to waiting page
- Waiting page automatically transitions to meeting at scheduled time
- Unscheduled meetings always joinable (current behavior)
### 3. Meeting Closure Rules
- All meetings: 15-minute grace period after last participant leaves
- If participant rejoins within grace period, keep meeting active
- Calendar meetings: Force close 30 minutes after scheduled end time
- Unscheduled meetings: Keep active for 8 hours (current behavior)
## ICS Parsing Logic
### 1. Event Matching
- Parse ICS file using Python icalendar library
- Iterate through VEVENT components
- Check LOCATION field for full FQDN URL (e.g., "https://reflector.monadical.com/max")
- Check DESCRIPTION for room URL or mention
- Support multiple formats:
- Full URL: "https://reflector.monadical.com/max"
- With /room path: "https://reflector.monadical.com/room/max"
- Partial paths: "room/max", "/max room"
### 2. Attendee Extraction
- Parse ATTENDEE properties from VEVENT
- Extract email (MAILTO), name (CN parameter), and RSVP status (PARTSTAT)
- Store as JSON in calendar_events.attendees
### 3. Sync Strategy
- Fetch complete ICS file (contains all events)
- Filter events from (now - 1 hour) to (now + 24 hours) for processing
- Update existing events if LAST-MODIFIED or SEQUENCE changed
- Delete future events that no longer exist in ICS (start_time > now)
- Keep past events for historical record (never delete if start_time < now)
- Handle recurring events (RRULE) - expand to individual instances
- Track deleted calendar events to clean up future meetings
- Cache ICS file hash to detect changes and skip unnecessary processing
## Security Considerations
### 1. ICS URL Security
- ICS URLs may contain authentication tokens (e.g., Google Calendar private URLs)
- Store full ICS URLs encrypted using Fernet to protect embedded tokens
- Validate ICS URLs (must be HTTPS for production)
- Never expose full ICS URLs in API responses (return masked version)
- Rate limit ICS fetching to prevent abuse
### 2. Room Access
- Only room owner can configure ICS URL
- ICS URL shown as masked version to room owner (hides embedded tokens)
- ICS settings not visible to other users
- Meeting list visible to all room participants
- ICS fetch logs only visible to room owner
### 3. Meeting Privacy
- Full calendar details visible only to room owner
- Participants see limited info: title, date/time only
- Attendee list and description hidden from non-owners
- Meeting titles visible in room listing to all
## Implementation Phases
### Phase 1: Database and ICS Setup (Week 1) ✅ COMPLETED (2025-08-18)
1. ✅ Created database migrations for ICS fields and calendar_events table
- Added ics_url, ics_fetch_interval, ics_enabled, ics_last_sync, ics_last_etag to room table
- Created calendar_event table with ics_uid (instead of external_id) and proper typing
- Added calendar_event_id and calendar_metadata (JSONB) to meeting table
- Removed server_default from datetime fields for consistency
2. ✅ Installed icalendar Python library for ICS parsing
- Added icalendar>=6.0.0 to dependencies
- No encryption needed - ICS URLs are read-only
3. ✅ Built ICS fetch and sync service
- Simple HTTP fetching without unnecessary validation
- Proper TypedDict typing for event data structures
- Supports any standard ICS format
- Event matching on full room URL only
4. ✅ API endpoints for ICS configuration
- Room model updated to support ICS fields via existing PATCH endpoint
- POST /v1/rooms/{room_name}/ics/sync - Trigger manual sync (owner only)
- GET /v1/rooms/{room_name}/ics/status - Get sync status (owner only)
- GET /v1/rooms/{room_name}/meetings - List meetings with privacy controls
- GET /v1/rooms/{room_name}/meetings/upcoming - List upcoming meetings
5. ✅ Celery background tasks for periodic sync
- sync_room_ics - Sync individual room calendar
- sync_all_ics_calendars - Check all rooms and queue sync based on fetch intervals
- pre_create_upcoming_meetings - Pre-create Whereby meetings 1 minute before start
- Tasks scheduled in beat schedule (every minute for checking, respects individual intervals)
6. ✅ Tests written and passing
- 6 tests for Room ICS fields
- 7 tests for CalendarEvent model
- 7 tests for ICS sync service
- 11 tests for API endpoints
- 6 tests for background tasks
- All 31 ICS-related tests passing
### Phase 2: Meeting Management (Week 2) ✅ COMPLETED (2025-08-19)
1. ✅ Updated meeting lifecycle logic with grace period support
- 15-minute grace period after last participant leaves
- Automatic reactivation when participants rejoin
- Force close calendar meetings 30 minutes after scheduled end
2. ✅ Support multiple active meetings per room
- Removed unique constraint on active meetings
- Added get_all_active_for_room() method
- Added get_active_by_calendar_event() method
3. ✅ Implemented grace period logic
- Added last_participant_left_at and grace_period_minutes fields
- Process meetings task handles grace period checking
- Whereby webhooks clear grace period on participant join
4. ✅ Link meetings to calendar events
- Pre-created meetings properly linked via calendar_event_id
- Calendar metadata stored with meeting
- API endpoints for listing and joining specific meetings
### Phase 3: Frontend Meeting Selection (Week 3)
1. Build meeting selection page
2. Show active and upcoming meetings
3. Implement waiting page for early joiners
4. Add automatic transition from waiting to meeting
5. Support unscheduled meeting creation
### Phase 4: Calendar Integration UI (Week 4)
1. Add ICS settings to room configuration
2. Display calendar metadata in meetings
3. Show attendee information
4. Add sync status indicators
5. Show fetch interval and next sync time
## Success Metrics
- Zero merged meetings from consecutive calendar events
- Successful ICS sync from major providers (Google Calendar, Outlook, Apple Calendar, Nextcloud)
- Meeting join accuracy: correct meeting 100% of the time
- Grace period prevents 90% of accidental meeting closures
- Configurable fetch intervals reduce unnecessary API calls
## Design Decisions
1. **ICS attached to room, not user** - Prevents duplicate meetings from multiple calendars
2. **Multiple active meetings per room** - Supported with meeting selection page
3. **Grace period for rejoining** - 15 minutes after last participant leaves
4. **Upcoming meeting visibility** - Show 30 minutes before, join only on time
5. **Calendar data storage** - Attached to meeting record for full context
6. **No "ad-hoc" meetings** - Use existing meeting creation flow (unscheduled meetings)
7. **ICS configuration via room PATCH** - Reuse existing room configuration endpoint
8. **Event deletion handling** - Soft-delete future events, preserve past meetings
9. **Configurable fetch interval** - Balance between freshness and server load
10. **ICS over CalDAV** - Simpler implementation, wider compatibility, no complex auth
## Phase 2 Implementation Files
### Database Migrations
- `/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py` - Remove unique constraint
- `/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py` - Add grace period fields
### Updated Models
- `/server/reflector/db/meetings.py` - Added grace period fields and new query methods
### Updated Services
- `/server/reflector/worker/process.py` - Enhanced with grace period logic and multiple meeting support
### Updated API
- `/server/reflector/views/rooms.py` - Added endpoints for listing active meetings and joining specific meetings
- `/server/reflector/views/whereby.py` - Clear grace period on participant join
### Tests
- `/server/tests/test_multiple_active_meetings.py` - Comprehensive tests for Phase 2 features (5 tests)
## Phase 1 Implementation Files Created
### Database Models
- `/server/reflector/db/rooms.py` - Updated with ICS fields (url, fetch_interval, enabled, last_sync, etag)
- `/server/reflector/db/calendar_events.py` - New CalendarEvent model with ics_uid and proper typing
- `/server/reflector/db/meetings.py` - Updated with calendar_event_id and calendar_metadata (JSONB)
### Services
- `/server/reflector/services/ics_sync.py` - ICS fetching and parsing with TypedDict for proper typing
### API Endpoints
- `/server/reflector/views/rooms.py` - Added ICS management endpoints with privacy controls
### Background Tasks
- `/server/reflector/worker/ics_sync.py` - Celery tasks for automatic periodic sync
- `/server/reflector/worker/app.py` - Updated beat schedule for ICS tasks
### Tests
- `/server/tests/test_room_ics.py` - Room model ICS fields tests (6 tests)
- `/server/tests/test_calendar_event.py` - CalendarEvent model tests (7 tests)
- `/server/tests/test_ics_sync.py` - ICS sync service tests (7 tests)
- `/server/tests/test_room_ics_api.py` - API endpoint tests (11 tests)
- `/server/tests/test_ics_background_tasks.py` - Background task tests (6 tests)
### Key Design Decisions
- No encryption needed - ICS URLs are read-only access
- Using ics_uid instead of external_id for clarity
- Proper TypedDict typing for event data structures
- Removed unnecessary URL validation and webcal handling
- calendar_metadata in meetings stores flexible calendar data (organizer, recurrence, etc)
- Background tasks query all rooms directly to avoid filtering issues
- Sync intervals respected per-room configuration
## Implementation Approach
### ICS Fetching vs CalDAV
- **ICS Benefits**:
- Simpler implementation (HTTP GET vs CalDAV protocol)
- Wider compatibility (all calendar apps can export ICS)
- No authentication complexity (simple URL with optional token)
- Easier debugging (ICS is plain text)
- Lower server requirements (no CalDAV library dependencies)
### Supported Calendar Providers
1. **Google Calendar**: Private ICS URL from calendar settings
2. **Outlook/Office 365**: ICS export URL from calendar sharing
3. **Apple Calendar**: Published calendar ICS URL
4. **Nextcloud**: Public/private calendar ICS export
5. **Any CalDAV server**: Via ICS export endpoint
### ICS URL Examples
- Google: `https://calendar.google.com/calendar/ical/{calendar_id}/private-{token}/basic.ics`
- Outlook: `https://outlook.live.com/owa/calendar/{id}/calendar.ics`
- Custom: `https://example.com/calendars/room-schedule.ics`
### Fetch Interval Configuration
- 1 minute: For critical/high-activity rooms
- 5 minutes (default): Balance of freshness and efficiency
- 10 minutes: Standard meeting rooms
- 30 minutes: Low-activity rooms
- 1 hour: Rarely-used rooms or stable schedules

View File

@@ -0,0 +1,53 @@
"""remove_one_active_meeting_per_room_constraint
Revision ID: 6025e9b2bef2
Revises: 9f5c78d352d6
Create Date: 2025-08-18 18:45:44.418392
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6025e9b2bef2"
down_revision: Union[str, None] = "9f5c78d352d6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Remove the unique constraint that prevents multiple active meetings per room
# This is needed to support calendar integration with overlapping meetings
# Check if index exists before trying to drop it
from alembic import context
if context.get_context().dialect.name == "postgresql":
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT 1 FROM pg_indexes WHERE indexname = 'idx_one_active_meeting_per_room'"
)
)
if result.fetchone():
op.drop_index("idx_one_active_meeting_per_room", table_name="meeting")
else:
# For SQLite, just try to drop it
try:
op.drop_index("idx_one_active_meeting_per_room", table_name="meeting")
except:
pass
def downgrade() -> None:
# Restore the unique constraint
op.create_index(
"idx_one_active_meeting_per_room",
"meeting",
["room_id"],
unique=True,
postgresql_where=sa.text("is_active = true"),
sqlite_where=sa.text("is_active = 1"),
)

View File

@@ -0,0 +1,34 @@
"""add_grace_period_fields_to_meeting
Revision ID: d4a1c446458c
Revises: 6025e9b2bef2
Create Date: 2025-08-18 18:50:37.768052
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d4a1c446458c"
down_revision: Union[str, None] = "6025e9b2bef2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add fields to track when participants left for grace period logic
op.add_column(
"meeting", sa.Column("last_participant_left_at", sa.DateTime(timezone=True))
)
op.add_column(
"meeting",
sa.Column("grace_period_minutes", sa.Integer, server_default=sa.text("15")),
)
def downgrade() -> None:
op.drop_column("meeting", "grace_period_minutes")
op.drop_column("meeting", "last_participant_left_at")

View File

@@ -41,6 +41,7 @@ dependencies = [
"llama-index-llms-openai-like>=0.4.0",
"pytest-env>=1.1.5",
"webvtt-py>=0.5.0",
"icalendar>=6.0.0",
]
[dependency-groups]

View File

@@ -24,6 +24,7 @@ def get_database() -> databases.Database:
# import models
import reflector.db.calendar_events # noqa
import reflector.db.meetings # noqa
import reflector.db.recordings # noqa
import reflector.db.rooms # noqa

View File

@@ -0,0 +1,193 @@
from datetime import datetime, timezone
from typing import Any
import sqlalchemy as sa
from pydantic import BaseModel, Field
from sqlalchemy.dialects.postgresql import JSONB
from reflector.db import get_database, metadata
from reflector.utils import generate_uuid4
calendar_events = sa.Table(
"calendar_event",
metadata,
sa.Column("id", sa.String, primary_key=True),
sa.Column(
"room_id",
sa.String,
sa.ForeignKey("room.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("ics_uid", sa.Text, nullable=False),
sa.Column("title", sa.Text),
sa.Column("description", sa.Text),
sa.Column("start_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("attendees", JSONB),
sa.Column("location", sa.Text),
sa.Column("ics_raw_data", sa.Text),
sa.Column("last_synced", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_deleted", sa.Boolean, nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("room_id", "ics_uid", name="uq_room_calendar_event"),
sa.Index("idx_calendar_event_room_start", "room_id", "start_time"),
sa.Index(
"idx_calendar_event_deleted",
"is_deleted",
postgresql_where=sa.text("NOT is_deleted"),
),
)
class CalendarEvent(BaseModel):
id: str = Field(default_factory=generate_uuid4)
room_id: str
ics_uid: str
title: str | None = None
description: str | None = None
start_time: datetime
end_time: datetime
attendees: list[dict[str, Any]] | None = None
location: str | None = None
ics_raw_data: str | None = None
last_synced: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class CalendarEventController:
async def get_by_room(
self,
room_id: str,
include_deleted: bool = False,
start_after: datetime | None = None,
end_before: datetime | None = None,
) -> list[CalendarEvent]:
"""Get calendar events for a room."""
query = calendar_events.select().where(calendar_events.c.room_id == room_id)
if not include_deleted:
query = query.where(calendar_events.c.is_deleted == False)
if start_after:
query = query.where(calendar_events.c.start_time >= start_after)
if end_before:
query = query.where(calendar_events.c.end_time <= end_before)
query = query.order_by(calendar_events.c.start_time.asc())
results = await get_database().fetch_all(query)
return [CalendarEvent(**result) for result in results]
async def get_upcoming(
self, room_id: str, minutes_ahead: int = 30
) -> list[CalendarEvent]:
"""Get upcoming events for a room within the specified minutes."""
now = datetime.now(timezone.utc)
future_time = now + timedelta(minutes=minutes_ahead)
query = (
calendar_events.select()
.where(
sa.and_(
calendar_events.c.room_id == room_id,
calendar_events.c.is_deleted == False,
calendar_events.c.start_time >= now,
calendar_events.c.start_time <= future_time,
)
)
.order_by(calendar_events.c.start_time.asc())
)
results = await get_database().fetch_all(query)
return [CalendarEvent(**result) for result in results]
async def get_by_ics_uid(self, room_id: str, ics_uid: str) -> CalendarEvent | None:
"""Get a calendar event by its ICS UID."""
query = calendar_events.select().where(
sa.and_(
calendar_events.c.room_id == room_id,
calendar_events.c.ics_uid == ics_uid,
)
)
result = await get_database().fetch_one(query)
return CalendarEvent(**result) if result else None
async def upsert(self, event: CalendarEvent) -> CalendarEvent:
"""Create or update a calendar event."""
existing = await self.get_by_ics_uid(event.room_id, event.ics_uid)
if existing:
# Update existing event
event.id = existing.id
event.created_at = existing.created_at
event.updated_at = datetime.now(timezone.utc)
query = (
calendar_events.update()
.where(calendar_events.c.id == existing.id)
.values(**event.model_dump())
)
else:
# Insert new event
query = calendar_events.insert().values(**event.model_dump())
await get_database().execute(query)
return event
async def soft_delete_missing(
self, room_id: str, current_ics_uids: list[str]
) -> int:
"""Soft delete future events that are no longer in the calendar."""
now = datetime.now(timezone.utc)
# First, get the IDs of events to delete
select_query = calendar_events.select().where(
sa.and_(
calendar_events.c.room_id == room_id,
calendar_events.c.start_time > now,
calendar_events.c.is_deleted == False,
calendar_events.c.ics_uid.notin_(current_ics_uids)
if current_ics_uids
else True,
)
)
to_delete = await get_database().fetch_all(select_query)
delete_count = len(to_delete)
if delete_count > 0:
# Now update them
update_query = (
calendar_events.update()
.where(
sa.and_(
calendar_events.c.room_id == room_id,
calendar_events.c.start_time > now,
calendar_events.c.is_deleted == False,
calendar_events.c.ics_uid.notin_(current_ics_uids)
if current_ics_uids
else True,
)
)
.values(is_deleted=True, updated_at=now)
)
await get_database().execute(update_query)
return delete_count
async def delete_by_room(self, room_id: str) -> int:
"""Hard delete all events for a room (used when room is deleted)."""
query = calendar_events.delete().where(calendar_events.c.room_id == room_id)
result = await get_database().execute(query)
return result.rowcount
# Add missing import
from datetime import timedelta
calendar_events_controller = CalendarEventController()

View File

@@ -1,9 +1,10 @@
from datetime import datetime
from typing import Literal
from typing import Any, Literal
import sqlalchemy as sa
from fastapi import HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.dialects.postgresql import JSONB
from reflector.db import get_database, metadata
from reflector.db.rooms import Room
@@ -41,13 +42,16 @@ meetings = sa.Table(
nullable=False,
server_default=sa.true(),
),
sa.Index("idx_meeting_room_id", "room_id"),
sa.Index(
"idx_one_active_meeting_per_room",
"room_id",
unique=True,
postgresql_where=sa.text("is_active = true"),
sa.Column(
"calendar_event_id",
sa.String,
sa.ForeignKey("calendar_event.id", ondelete="SET NULL"),
),
sa.Column("calendar_metadata", JSONB),
sa.Column("last_participant_left_at", sa.DateTime(timezone=True)),
sa.Column("grace_period_minutes", sa.Integer, server_default=sa.text("15")),
sa.Index("idx_meeting_room_id", "room_id"),
sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
)
meeting_consent = sa.Table(
@@ -85,6 +89,11 @@ class Meeting(BaseModel):
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
num_clients: int = 0
is_active: bool = True
calendar_event_id: str | None = None
calendar_metadata: dict[str, Any] | None = None
last_participant_left_at: datetime | None = None
grace_period_minutes: int = 15
class MeetingController:
@@ -98,6 +107,8 @@ class MeetingController:
end_date: datetime,
user_id: str,
room: Room,
calendar_event_id: str | None = None,
calendar_metadata: dict[str, Any] | None = None,
):
"""
Create a new meeting
@@ -115,6 +126,8 @@ class MeetingController:
room_mode=room.room_mode,
recording_type=room.recording_type,
recording_trigger=room.recording_trigger,
calendar_event_id=calendar_event_id,
calendar_metadata=calendar_metadata,
)
query = meetings.insert().values(**meeting.model_dump())
await get_database().execute(query)
@@ -144,6 +157,7 @@ class MeetingController:
async def get_active(self, room: Room, current_time: datetime) -> Meeting:
"""
Get latest active meeting for a room.
For backward compatibility, returns the most recent active meeting.
"""
end_date = getattr(meetings.c, "end_date")
query = (
@@ -163,6 +177,47 @@ class MeetingController:
return Meeting(**result)
async def get_all_active_for_room(
self, room: Room, current_time: datetime
) -> list[Meeting]:
"""
Get all active meetings for a room.
This supports multiple concurrent meetings per room.
"""
end_date = getattr(meetings.c, "end_date")
query = (
meetings.select()
.where(
sa.and_(
meetings.c.room_id == room.id,
meetings.c.end_date > current_time,
meetings.c.is_active,
)
)
.order_by(end_date.desc())
)
results = await get_database().fetch_all(query)
return [Meeting(**result) for result in results]
async def get_active_by_calendar_event(
self, room: Room, calendar_event_id: str, current_time: datetime
) -> Meeting | None:
"""
Get active meeting for a specific calendar event.
"""
query = meetings.select().where(
sa.and_(
meetings.c.room_id == room.id,
meetings.c.calendar_event_id == calendar_event_id,
meetings.c.end_date > current_time,
meetings.c.is_active,
)
)
result = await get_database().fetch_one(query)
if not result:
return None
return Meeting(**result)
async def get_by_id(self, meeting_id: str, **kwargs) -> Meeting | None:
"""
Get a meeting by id
@@ -190,6 +245,15 @@ class MeetingController:
return meeting
async def get_by_calendar_event(self, calendar_event_id: str) -> Meeting | None:
query = meetings.select().where(
meetings.c.calendar_event_id == calendar_event_id
)
result = await get_database().fetch_one(query)
if not result:
return None
return Meeting(**result)
async def update_meeting(self, meeting_id: str, **kwargs):
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
await get_database().execute(query)

View File

@@ -40,7 +40,15 @@ rooms = sqlalchemy.Table(
sqlalchemy.Column(
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
),
sqlalchemy.Column("ics_url", sqlalchemy.Text),
sqlalchemy.Column("ics_fetch_interval", sqlalchemy.Integer, server_default="300"),
sqlalchemy.Column(
"ics_enabled", sqlalchemy.Boolean, nullable=False, server_default=false()
),
sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)),
sqlalchemy.Column("ics_last_etag", sqlalchemy.Text),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -59,6 +67,11 @@ class Room(BaseModel):
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
is_shared: bool = False
ics_url: str | None = None
ics_fetch_interval: int = 300
ics_enabled: bool = False
ics_last_sync: datetime | None = None
ics_last_etag: str | None = None
class RoomController:
@@ -107,6 +120,9 @@ class RoomController:
recording_type: str,
recording_trigger: str,
is_shared: bool,
ics_url: str | None = None,
ics_fetch_interval: int = 300,
ics_enabled: bool = False,
):
"""
Add a new room
@@ -122,6 +138,9 @@ class RoomController:
recording_type=recording_type,
recording_trigger=recording_trigger,
is_shared=is_shared,
ics_url=ics_url,
ics_fetch_interval=ics_fetch_interval,
ics_enabled=ics_enabled,
)
query = rooms.insert().values(**room.model_dump())
try:

View File

@@ -0,0 +1,296 @@
import hashlib
from datetime import date, datetime, timedelta, timezone
from typing import TypedDict
import httpx
import pytz
from icalendar import Calendar, Event
from loguru import logger
from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
from reflector.db.rooms import Room, rooms_controller
from reflector.settings import settings
class AttendeeData(TypedDict, total=False):
email: str | None
name: str | None
status: str | None
role: str | None
class EventData(TypedDict):
ics_uid: str
title: str | None
description: str | None
location: str | None
start_time: datetime
end_time: datetime
attendees: list[AttendeeData]
ics_raw_data: str
class SyncStats(TypedDict):
events_created: int
events_updated: int
events_deleted: int
class ICSFetchService:
def __init__(self):
self.client = httpx.AsyncClient(
timeout=30.0, headers={"User-Agent": "Reflector/1.0"}
)
async def fetch_ics(self, url: str) -> str:
response = await self.client.get(url)
response.raise_for_status()
return response.text
def parse_ics(self, ics_content: str) -> Calendar:
return Calendar.from_ical(ics_content)
def extract_room_events(
self, calendar: Calendar, room_name: str, room_url: str
) -> list[EventData]:
events = []
now = datetime.now(timezone.utc)
window_start = now - timedelta(hours=1)
window_end = now + timedelta(hours=24)
for component in calendar.walk():
if component.name == "VEVENT":
# Skip cancelled events
status = component.get("STATUS", "").upper()
if status == "CANCELLED":
continue
# Check if event matches this room
if self._event_matches_room(component, room_name, room_url):
event_data = self._parse_event(component)
# Only include events in our time window
if (
event_data
and window_start <= event_data["start_time"] <= window_end
):
events.append(event_data)
return events
def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool:
location = str(event.get("LOCATION", ""))
description = str(event.get("DESCRIPTION", ""))
# Only match full room URL (with or without protocol)
patterns = [
room_url, # Full URL with protocol
room_url.replace("https://", ""), # Without https protocol
room_url.replace("http://", ""), # Without http protocol
]
# Check location and description for patterns
text_to_check = f"{location} {description}".lower()
for pattern in patterns:
if pattern.lower() in text_to_check:
return True
return False
def _parse_event(self, event: Event) -> EventData | None:
# Extract basic fields
uid = str(event.get("UID", ""))
summary = str(event.get("SUMMARY", ""))
description = str(event.get("DESCRIPTION", ""))
location = str(event.get("LOCATION", ""))
# Parse dates
dtstart = event.get("DTSTART")
dtend = event.get("DTEND")
if not dtstart:
return None
# Convert to datetime
start_time = self._normalize_datetime(
dtstart.dt if hasattr(dtstart, "dt") else dtstart
)
end_time = (
self._normalize_datetime(dtend.dt if hasattr(dtend, "dt") else dtend)
if dtend
else start_time + timedelta(hours=1)
)
# Parse attendees
attendees = self._parse_attendees(event)
# Get raw event data for storage
raw_data = event.to_ical().decode("utf-8")
return {
"ics_uid": uid,
"title": summary,
"description": description,
"location": location,
"start_time": start_time,
"end_time": end_time,
"attendees": attendees,
"ics_raw_data": raw_data,
}
def _normalize_datetime(self, dt) -> datetime:
# Handle date objects (all-day events)
if isinstance(dt, date) and not isinstance(dt, datetime):
# Convert to datetime at start of day in UTC
dt = datetime.combine(dt, datetime.min.time())
dt = pytz.UTC.localize(dt)
elif isinstance(dt, datetime):
# Add UTC timezone if naive
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
else:
# Convert to UTC
dt = dt.astimezone(pytz.UTC)
return dt
def _parse_attendees(self, event: Event) -> list[AttendeeData]:
attendees = []
# Parse ATTENDEE properties
for attendee in event.get("ATTENDEE", []):
if not isinstance(attendee, list):
attendee = [attendee]
for att in attendee:
att_data: AttendeeData = {
"email": str(att).replace("mailto:", "") if att else None,
"name": att.params.get("CN") if hasattr(att, "params") else None,
"status": att.params.get("PARTSTAT")
if hasattr(att, "params")
else None,
"role": att.params.get("ROLE") if hasattr(att, "params") else None,
}
attendees.append(att_data)
# Add organizer
organizer = event.get("ORGANIZER")
if organizer:
org_data: AttendeeData = {
"email": str(organizer).replace("mailto:", "") if organizer else None,
"name": organizer.params.get("CN")
if hasattr(organizer, "params")
else None,
"role": "ORGANIZER",
}
attendees.append(org_data)
return attendees
class ICSSyncService:
def __init__(self):
self.fetch_service = ICSFetchService()
async def sync_room_calendar(self, room: Room) -> dict:
if not room.ics_enabled or not room.ics_url:
return {"status": "skipped", "reason": "ICS not configured"}
try:
# Check if it's time to sync
if not self._should_sync(room):
return {"status": "skipped", "reason": "Not time to sync yet"}
# Fetch ICS file
ics_content = await self.fetch_service.fetch_ics(room.ics_url)
# Check if content changed
content_hash = hashlib.md5(ics_content.encode()).hexdigest()
if room.ics_last_etag == content_hash:
logger.info(f"No changes in ICS for room {room.id}")
return {"status": "unchanged", "hash": content_hash}
# Parse calendar
calendar = self.fetch_service.parse_ics(ics_content)
# Build room URL
room_url = f"{settings.BASE_URL}/room/{room.name}"
# Extract matching events
events = self.fetch_service.extract_room_events(
calendar, room.name, room_url
)
# Sync events to database
sync_result = await self._sync_events_to_database(room.id, events)
# Update room sync metadata
await rooms_controller.update(
room,
{
"ics_last_sync": datetime.now(timezone.utc),
"ics_last_etag": content_hash,
},
mutate=False,
)
return {
"status": "success",
"hash": content_hash,
"events_found": len(events),
**sync_result,
}
except Exception as e:
logger.error(f"Failed to sync ICS for room {room.id}: {e}")
return {"status": "error", "error": str(e)}
def _should_sync(self, room: Room) -> bool:
if not room.ics_last_sync:
return True
time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync
return time_since_sync.total_seconds() >= room.ics_fetch_interval
async def _sync_events_to_database(
self, room_id: str, events: list[EventData]
) -> SyncStats:
created = 0
updated = 0
# Track current event IDs
current_ics_uids = []
for event_data in events:
# Create CalendarEvent object
calendar_event = CalendarEvent(room_id=room_id, **event_data)
# Upsert event
existing = await calendar_events_controller.get_by_ics_uid(
room_id, event_data["ics_uid"]
)
if existing:
updated += 1
else:
created += 1
await calendar_events_controller.upsert(calendar_event)
current_ics_uids.append(event_data["ics_uid"])
# Soft delete events that are no longer in calendar
deleted = await calendar_events_controller.soft_delete_missing(
room_id, current_ics_uids
)
return {
"events_created": created,
"events_updated": updated,
"events_deleted": deleted,
}
# Global instance
ics_sync_service = ICSSyncService()

View File

@@ -42,6 +42,11 @@ class Room(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
ics_url: Optional[str] = None
ics_fetch_interval: int = 300
ics_enabled: bool = False
ics_last_sync: Optional[datetime] = None
ics_last_etag: Optional[str] = None
class Meeting(BaseModel):
@@ -64,18 +69,24 @@ class CreateRoom(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
ics_url: Optional[str] = None
ics_fetch_interval: int = 300
ics_enabled: bool = False
class UpdateRoom(BaseModel):
name: str
zulip_auto_post: bool
zulip_stream: str
zulip_topic: str
is_locked: bool
room_mode: str
recording_type: str
recording_trigger: str
is_shared: bool
name: Optional[str] = None
zulip_auto_post: Optional[bool] = None
zulip_stream: Optional[str] = None
zulip_topic: Optional[str] = None
is_locked: Optional[bool] = None
room_mode: Optional[str] = None
recording_type: Optional[str] = None
recording_trigger: Optional[str] = None
is_shared: Optional[bool] = None
ics_url: Optional[str] = None
ics_fetch_interval: Optional[int] = None
ics_enabled: Optional[bool] = None
class DeletionStatus(BaseModel):
@@ -117,6 +128,9 @@ async def rooms_create(
recording_type=room.recording_type,
recording_trigger=room.recording_trigger,
is_shared=room.is_shared,
ics_url=room.ics_url,
ics_fetch_interval=room.ics_fetch_interval,
ics_enabled=room.ics_enabled,
)
@@ -209,3 +223,217 @@ async def rooms_create_meeting(
meeting.host_room_url = ""
return meeting
class ICSStatus(BaseModel):
status: str
last_sync: Optional[datetime] = None
next_sync: Optional[datetime] = None
last_etag: Optional[str] = None
events_count: int = 0
class ICSSyncResult(BaseModel):
status: str
hash: Optional[str] = None
events_found: int = 0
events_created: int = 0
events_updated: int = 0
events_deleted: int = 0
error: Optional[str] = None
@router.post("/rooms/{room_name}/ics/sync", response_model=ICSSyncResult)
async def rooms_sync_ics(
room_name: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
if user_id != room.user_id:
raise HTTPException(
status_code=403, detail="Only room owner can trigger ICS sync"
)
if not room.ics_enabled or not room.ics_url:
raise HTTPException(status_code=400, detail="ICS not configured for this room")
from reflector.services.ics_sync import ics_sync_service
result = await ics_sync_service.sync_room_calendar(room)
if result["status"] == "error":
raise HTTPException(
status_code=500, detail=result.get("error", "Unknown error")
)
return ICSSyncResult(**result)
@router.get("/rooms/{room_name}/ics/status", response_model=ICSStatus)
async def rooms_ics_status(
room_name: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
if user_id != room.user_id:
raise HTTPException(
status_code=403, detail="Only room owner can view ICS status"
)
next_sync = None
if room.ics_enabled and room.ics_last_sync:
next_sync = room.ics_last_sync + timedelta(seconds=room.ics_fetch_interval)
from reflector.db.calendar_events import calendar_events_controller
events = await calendar_events_controller.get_by_room(
room.id, include_deleted=False
)
return ICSStatus(
status="enabled" if room.ics_enabled else "disabled",
last_sync=room.ics_last_sync,
next_sync=next_sync,
last_etag=room.ics_last_etag,
events_count=len(events),
)
class CalendarEventResponse(BaseModel):
id: str
room_id: str
ics_uid: str
title: Optional[str] = None
description: Optional[str] = None
start_time: datetime
end_time: datetime
attendees: Optional[list[dict]] = None
location: Optional[str] = None
last_synced: datetime
created_at: datetime
updated_at: datetime
@router.get("/rooms/{room_name}/meetings", response_model=list[CalendarEventResponse])
async def rooms_list_meetings(
room_name: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
from reflector.db.calendar_events import calendar_events_controller
events = await calendar_events_controller.get_by_room(
room.id, include_deleted=False
)
if user_id != room.user_id:
for event in events:
event.description = None
event.attendees = None
return events
@router.get(
"/rooms/{room_name}/meetings/upcoming", response_model=list[CalendarEventResponse]
)
async def rooms_list_upcoming_meetings(
room_name: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
minutes_ahead: int = 30,
):
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
from reflector.db.calendar_events import calendar_events_controller
events = await calendar_events_controller.get_upcoming(
room.id, minutes_ahead=minutes_ahead
)
if user_id != room.user_id:
for event in events:
event.description = None
event.attendees = None
return events
@router.get("/rooms/{room_name}/meetings/active", response_model=list[Meeting])
async def rooms_list_active_meetings(
room_name: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
"""List all active meetings for a room (supports multiple active meetings)"""
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
current_time = datetime.now(timezone.utc)
meetings = await meetings_controller.get_all_active_for_room(
room=room, current_time=current_time
)
# Hide host URLs from non-owners
if user_id != room.user_id:
for meeting in meetings:
meeting.host_room_url = ""
return meetings
@router.post("/rooms/{room_name}/meetings/{meeting_id}/join", response_model=Meeting)
async def rooms_join_meeting(
room_name: str,
meeting_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
"""Join a specific meeting by ID"""
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
meeting = await meetings_controller.get_by_id(meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
if meeting.room_id != room.id:
raise HTTPException(
status_code=403, detail="Meeting does not belong to this room"
)
if not meeting.is_active:
raise HTTPException(status_code=400, detail="Meeting is not active")
current_time = datetime.now(timezone.utc)
if meeting.end_date <= current_time:
raise HTTPException(status_code=400, detail="Meeting has ended")
# Hide host URL from non-owners
if user_id != room.user_id:
meeting.host_room_url = ""
return meeting

View File

@@ -68,8 +68,13 @@ async def whereby_webhook(event: WherebyWebhookEvent, request: Request):
raise HTTPException(status_code=404, detail="Meeting not found")
if event.type in ["room.client.joined", "room.client.left"]:
await meetings_controller.update_meeting(
meeting.id, num_clients=event.data["numClients"]
)
update_data = {"num_clients": event.data["numClients"]}
# Clear grace period if participant joined
if event.type == "room.client.joined" and event.data["numClients"] > 0:
if meeting.last_participant_left_at:
update_data["last_participant_left_at"] = None
await meetings_controller.update_meeting(meeting.id, **update_data)
return {"status": "ok"}

View File

@@ -19,6 +19,7 @@ else:
"reflector.pipelines.main_live_pipeline",
"reflector.worker.healthcheck",
"reflector.worker.process",
"reflector.worker.ics_sync",
]
)
@@ -36,6 +37,14 @@ else:
"task": "reflector.worker.process.reprocess_failed_recordings",
"schedule": crontab(hour=5, minute=0), # Midnight EST
},
"sync_all_ics_calendars": {
"task": "reflector.worker.ics_sync.sync_all_ics_calendars",
"schedule": 60.0, # Run every minute to check which rooms need sync
},
"pre_create_upcoming_meetings": {
"task": "reflector.worker.ics_sync.pre_create_upcoming_meetings",
"schedule": 30.0, # Run every 30 seconds to pre-create meetings
},
}
if settings.HEALTHCHECK_URL:

View File

@@ -0,0 +1,209 @@
from datetime import datetime, timedelta, timezone
import structlog
from celery import shared_task
from celery.utils.log import get_task_logger
from reflector.db import get_database
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms, rooms_controller
from reflector.services.ics_sync import ics_sync_service
from reflector.whereby import create_meeting, upload_logo
logger = structlog.wrap_logger(get_task_logger(__name__))
@shared_task
def sync_room_ics(room_id: str):
asynctask(_sync_room_ics_async(room_id))
async def _sync_room_ics_async(room_id: str):
try:
room = await rooms_controller.get_by_id(room_id)
if not room:
logger.warning("Room not found for ICS sync", room_id=room_id)
return
if not room.ics_enabled or not room.ics_url:
logger.debug("ICS not enabled for room", room_id=room_id)
return
logger.info("Starting ICS sync for room", room_id=room_id, room_name=room.name)
result = await ics_sync_service.sync_room_calendar(room)
if result["status"] == "success":
logger.info(
"ICS sync completed successfully",
room_id=room_id,
events_found=result.get("events_found", 0),
events_created=result.get("events_created", 0),
events_updated=result.get("events_updated", 0),
events_deleted=result.get("events_deleted", 0),
)
elif result["status"] == "unchanged":
logger.debug("ICS content unchanged", room_id=room_id)
elif result["status"] == "error":
logger.error("ICS sync failed", room_id=room_id, error=result.get("error"))
else:
logger.debug(
"ICS sync skipped", room_id=room_id, reason=result.get("reason")
)
except Exception as e:
logger.error("Unexpected error during ICS sync", room_id=room_id, error=str(e))
@shared_task
def sync_all_ics_calendars():
asynctask(_sync_all_ics_calendars_async())
async def _sync_all_ics_calendars_async():
try:
logger.info("Starting sync for all ICS-enabled rooms")
# Get ALL rooms - not filtered by is_shared
query = rooms.select().where(
rooms.c.ics_enabled == True, rooms.c.ics_url != None
)
all_rooms = await get_database().fetch_all(query)
ics_enabled_rooms = list(all_rooms)
logger.info(f"Found {len(ics_enabled_rooms)} rooms with ICS enabled")
for room_data in ics_enabled_rooms:
room_id = room_data["id"]
room = await rooms_controller.get_by_id(room_id)
if not room:
continue
if not _should_sync(room):
logger.debug("Skipping room, not time to sync yet", room_id=room_id)
continue
sync_room_ics.delay(room_id)
logger.info("Queued sync tasks for all eligible rooms")
except Exception as e:
logger.error("Error in sync_all_ics_calendars", error=str(e))
def _should_sync(room) -> bool:
if not room.ics_last_sync:
return True
time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync
return time_since_sync.total_seconds() >= room.ics_fetch_interval
@shared_task
def pre_create_upcoming_meetings():
asynctask(_pre_create_upcoming_meetings_async())
async def _pre_create_upcoming_meetings_async():
try:
logger.info("Starting pre-creation of upcoming meetings")
from reflector.db.calendar_events import calendar_events_controller
# Get ALL rooms with ICS enabled
query = rooms.select().where(
rooms.c.ics_enabled == True, rooms.c.ics_url != None
)
all_rooms = await get_database().fetch_all(query)
now = datetime.now(timezone.utc)
pre_create_window = now + timedelta(minutes=1)
for room_data in all_rooms:
room_id = room_data["id"]
room = await rooms_controller.get_by_id(room_id)
if not room:
continue
events = await calendar_events_controller.get_upcoming(
room_id, minutes_ahead=2
)
for event in events:
if event.start_time <= pre_create_window:
existing_meeting = await meetings_controller.get_by_calendar_event(
event.id
)
if not existing_meeting:
logger.info(
"Pre-creating meeting for calendar event",
room_id=room_id,
event_id=event.id,
event_title=event.title,
)
try:
end_date = event.end_time or (
event.start_time + timedelta(hours=1)
)
whereby_meeting = await create_meeting(
event.title or "Scheduled Meeting",
end_date=end_date,
room=room,
)
await upload_logo(
whereby_meeting["roomName"], "./images/logo.png"
)
meeting = await meetings_controller.create(
id=whereby_meeting["meetingId"],
room_name=whereby_meeting["roomName"],
room_url=whereby_meeting["roomUrl"],
host_room_url=whereby_meeting["hostRoomUrl"],
start_date=datetime.fromisoformat(
whereby_meeting["startDate"]
),
end_date=datetime.fromisoformat(
whereby_meeting["endDate"]
),
user_id=room.user_id,
room=room,
calendar_event_id=event.id,
calendar_metadata={
"title": event.title,
"description": event.description,
"attendees": event.attendees,
},
)
logger.info(
"Meeting pre-created successfully",
meeting_id=meeting.id,
event_id=event.id,
)
except Exception as e:
logger.error(
"Failed to pre-create meeting",
room_id=room_id,
event_id=event.id,
error=str(e),
)
logger.info("Completed pre-creation check for upcoming meetings")
except Exception as e:
logger.error("Error in pre_create_upcoming_meetings", error=str(e))
def asynctask(coro):
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()

View File

@@ -1,6 +1,6 @@
import json
import os
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from urllib.parse import unquote
import av
@@ -146,24 +146,76 @@ async def process_recording(bucket_name: str, object_key: str):
@shared_task
@asynctask
async def process_meetings():
"""
Checks which meetings are still active and deactivates those that have ended.
Supports multiple active meetings per room and grace period logic.
"""
logger.info("Processing meetings")
meetings = await meetings_controller.get_all_active()
current_time = datetime.now(timezone.utc)
for meeting in meetings:
is_active = False
should_deactivate = False
end_date = meeting.end_date
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
if end_date > datetime.now(timezone.utc):
# Check if meeting has passed its scheduled end time
if end_date <= current_time:
# For calendar meetings, force close 30 minutes after scheduled end
if meeting.calendar_event_id:
if current_time > end_date + timedelta(minutes=30):
should_deactivate = True
logger.info(
"Meeting %s forced closed 30 min after calendar end", meeting.id
)
else:
# Unscheduled meetings follow normal closure rules
should_deactivate = True
# Check Whereby room sessions only if not already deactivating
if not should_deactivate and end_date > current_time:
response = await get_room_sessions(meeting.room_name)
room_sessions = response.get("results", [])
is_active = not room_sessions or any(
has_active_sessions = room_sessions and any(
rs["endedAt"] is None for rs in room_sessions
)
if not is_active:
if not has_active_sessions:
# No active sessions - check grace period
if meeting.num_clients == 0:
if meeting.last_participant_left_at:
# Check if grace period has expired
grace_period = timedelta(minutes=meeting.grace_period_minutes)
if (
current_time
> meeting.last_participant_left_at + grace_period
):
should_deactivate = True
logger.info("Meeting %s grace period expired", meeting.id)
else:
# First time all participants left, record the time
await meetings_controller.update_meeting(
meeting.id, last_participant_left_at=current_time
)
logger.info(
"Meeting %s marked empty at %s", meeting.id, current_time
)
else:
# Has active sessions - clear grace period if set
if meeting.last_participant_left_at:
await meetings_controller.update_meeting(
meeting.id, last_participant_left_at=None
)
logger.info(
"Meeting %s reactivated - participant rejoined", meeting.id
)
if should_deactivate:
await meetings_controller.update_meeting(meeting.id, is_active=False)
logger.info("Meeting %s is deactivated", meeting.id)
logger.info("Processed meetings")
logger.info("Processed %d meetings", len(meetings))
@shared_task

View File

@@ -0,0 +1,351 @@
"""
Tests for CalendarEvent model.
"""
from datetime import datetime, timedelta, timezone
import pytest
from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
from reflector.db.rooms import rooms_controller
@pytest.mark.asyncio
async def test_calendar_event_create():
"""Test creating a calendar event."""
# Create a room first
room = await rooms_controller.add(
name="test-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
# Create calendar event
now = datetime.now(timezone.utc)
event = CalendarEvent(
room_id=room.id,
ics_uid="test-event-123",
title="Team Meeting",
description="Weekly team sync",
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2),
location=f"https://example.com/room/{room.name}",
attendees=[
{"email": "alice@example.com", "name": "Alice", "status": "ACCEPTED"},
{"email": "bob@example.com", "name": "Bob", "status": "TENTATIVE"},
],
)
# Save event
saved_event = await calendar_events_controller.upsert(event)
assert saved_event.ics_uid == "test-event-123"
assert saved_event.title == "Team Meeting"
assert saved_event.room_id == room.id
assert len(saved_event.attendees) == 2
@pytest.mark.asyncio
async def test_calendar_event_get_by_room():
"""Test getting calendar events for a room."""
# Create room
room = await rooms_controller.add(
name="events-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
now = datetime.now(timezone.utc)
# Create multiple events
for i in range(3):
event = CalendarEvent(
room_id=room.id,
ics_uid=f"event-{i}",
title=f"Meeting {i}",
start_time=now + timedelta(hours=i),
end_time=now + timedelta(hours=i + 1),
)
await calendar_events_controller.upsert(event)
# Get events for room
events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 3
assert all(e.room_id == room.id for e in events)
assert events[0].title == "Meeting 0"
assert events[1].title == "Meeting 1"
assert events[2].title == "Meeting 2"
@pytest.mark.asyncio
async def test_calendar_event_get_upcoming():
"""Test getting upcoming events within time window."""
# Create room
room = await rooms_controller.add(
name="upcoming-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
now = datetime.now(timezone.utc)
# Create events at different times
# Past event (should not be included)
past_event = CalendarEvent(
room_id=room.id,
ics_uid="past-event",
title="Past Meeting",
start_time=now - timedelta(hours=2),
end_time=now - timedelta(hours=1),
)
await calendar_events_controller.upsert(past_event)
# Upcoming event within 30 minutes
upcoming_event = CalendarEvent(
room_id=room.id,
ics_uid="upcoming-event",
title="Upcoming Meeting",
start_time=now + timedelta(minutes=15),
end_time=now + timedelta(minutes=45),
)
await calendar_events_controller.upsert(upcoming_event)
# Future event beyond 30 minutes
future_event = CalendarEvent(
room_id=room.id,
ics_uid="future-event",
title="Future Meeting",
start_time=now + timedelta(hours=2),
end_time=now + timedelta(hours=3),
)
await calendar_events_controller.upsert(future_event)
# Get upcoming events (default 30 minutes)
upcoming = await calendar_events_controller.get_upcoming(room.id)
assert len(upcoming) == 1
assert upcoming[0].ics_uid == "upcoming-event"
# Get upcoming with custom window
upcoming_extended = await calendar_events_controller.get_upcoming(
room.id, minutes_ahead=180
)
assert len(upcoming_extended) == 2
assert upcoming_extended[0].ics_uid == "upcoming-event"
assert upcoming_extended[1].ics_uid == "future-event"
@pytest.mark.asyncio
async def test_calendar_event_upsert():
"""Test upserting (create/update) calendar events."""
# Create room
room = await rooms_controller.add(
name="upsert-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
now = datetime.now(timezone.utc)
# Create new event
event = CalendarEvent(
room_id=room.id,
ics_uid="upsert-test",
title="Original Title",
start_time=now,
end_time=now + timedelta(hours=1),
)
created = await calendar_events_controller.upsert(event)
assert created.title == "Original Title"
# Update existing event
event.title = "Updated Title"
event.description = "Added description"
updated = await calendar_events_controller.upsert(event)
assert updated.title == "Updated Title"
assert updated.description == "Added description"
assert updated.ics_uid == "upsert-test"
# Verify only one event exists
events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 1
assert events[0].title == "Updated Title"
@pytest.mark.asyncio
async def test_calendar_event_soft_delete():
"""Test soft deleting events no longer in calendar."""
# Create room
room = await rooms_controller.add(
name="delete-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
now = datetime.now(timezone.utc)
# Create multiple events
for i in range(4):
event = CalendarEvent(
room_id=room.id,
ics_uid=f"event-{i}",
title=f"Meeting {i}",
start_time=now + timedelta(hours=i),
end_time=now + timedelta(hours=i + 1),
)
await calendar_events_controller.upsert(event)
# Soft delete events not in current list
current_ids = ["event-0", "event-2"] # Keep events 0 and 2
deleted_count = await calendar_events_controller.soft_delete_missing(
room.id, current_ids
)
assert deleted_count == 2 # Should delete events 1 and 3
# Get non-deleted events
events = await calendar_events_controller.get_by_room(
room.id, include_deleted=False
)
assert len(events) == 2
assert {e.ics_uid for e in events} == {"event-0", "event-2"}
# Get all events including deleted
all_events = await calendar_events_controller.get_by_room(
room.id, include_deleted=True
)
assert len(all_events) == 4
@pytest.mark.asyncio
async def test_calendar_event_past_events_not_deleted():
"""Test that past events are not soft deleted."""
# Create room
room = await rooms_controller.add(
name="past-events-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
now = datetime.now(timezone.utc)
# Create past event
past_event = CalendarEvent(
room_id=room.id,
ics_uid="past-event",
title="Past Meeting",
start_time=now - timedelta(hours=2),
end_time=now - timedelta(hours=1),
)
await calendar_events_controller.upsert(past_event)
# Create future event
future_event = CalendarEvent(
room_id=room.id,
ics_uid="future-event",
title="Future Meeting",
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2),
)
await calendar_events_controller.upsert(future_event)
# Try to soft delete all events (only future should be deleted)
deleted_count = await calendar_events_controller.soft_delete_missing(room.id, [])
assert deleted_count == 1 # Only future event deleted
# Verify past event still exists
events = await calendar_events_controller.get_by_room(
room.id, include_deleted=False
)
assert len(events) == 1
assert events[0].ics_uid == "past-event"
@pytest.mark.asyncio
async def test_calendar_event_with_raw_ics_data():
"""Test storing raw ICS data with calendar event."""
# Create room
room = await rooms_controller.add(
name="raw-ics-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
raw_ics = """BEGIN:VEVENT
UID:test-raw-123
SUMMARY:Test Event
DTSTART:20240101T100000Z
DTEND:20240101T110000Z
END:VEVENT"""
event = CalendarEvent(
room_id=room.id,
ics_uid="test-raw-123",
title="Test Event",
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc) + timedelta(hours=1),
ics_raw_data=raw_ics,
)
saved = await calendar_events_controller.upsert(event)
assert saved.ics_raw_data == raw_ics
# Retrieve and verify
retrieved = await calendar_events_controller.get_by_ics_uid(room.id, "test-raw-123")
assert retrieved is not None
assert retrieved.ics_raw_data == raw_ics

View File

@@ -0,0 +1,230 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from icalendar import Calendar, Event
from reflector.db.calendar_events import calendar_events_controller
from reflector.db.rooms import rooms_controller
from reflector.worker.ics_sync import (
_should_sync,
_sync_all_ics_calendars_async,
_sync_room_ics_async,
)
@pytest.mark.asyncio
async def test_sync_room_ics_task():
room = await rooms_controller.add(
name="task-test-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/task.ics",
ics_enabled=True,
)
cal = Calendar()
event = Event()
event.add("uid", "task-event-1")
event.add("summary", "Task Test Meeting")
from reflector.settings import settings
event.add("location", f"{settings.BASE_URL}/room/{room.name}")
now = datetime.now(timezone.utc)
event.add("dtstart", now + timedelta(hours=1))
event.add("dtend", now + timedelta(hours=2))
cal.add_component(event)
ics_content = cal.to_ical().decode("utf-8")
with patch(
"reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ics_content
await _sync_room_ics_async(room.id)
events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 1
assert events[0].ics_uid == "task-event-1"
@pytest.mark.asyncio
async def test_sync_room_ics_disabled():
room = await rooms_controller.add(
name="disabled-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_enabled=False,
)
await _sync_room_ics_async(room.id)
events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 0
@pytest.mark.asyncio
async def test_sync_all_ics_calendars():
room1 = await rooms_controller.add(
name="sync-all-1",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/1.ics",
ics_enabled=True,
)
room2 = await rooms_controller.add(
name="sync-all-2",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/2.ics",
ics_enabled=True,
)
room3 = await rooms_controller.add(
name="sync-all-3",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_enabled=False,
)
with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay:
await _sync_all_ics_calendars_async()
assert mock_delay.call_count == 2
called_room_ids = [call.args[0] for call in mock_delay.call_args_list]
assert room1.id in called_room_ids
assert room2.id in called_room_ids
assert room3.id not in called_room_ids
@pytest.mark.asyncio
async def test_should_sync_logic():
room = MagicMock()
room.ics_last_sync = None
assert _should_sync(room) is True
room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100)
room.ics_fetch_interval = 300
assert _should_sync(room) is False
room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400)
room.ics_fetch_interval = 300
assert _should_sync(room) is True
@pytest.mark.asyncio
async def test_sync_respects_fetch_interval():
now = datetime.now(timezone.utc)
room1 = await rooms_controller.add(
name="interval-test-1",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/interval.ics",
ics_enabled=True,
ics_fetch_interval=300,
)
await rooms_controller.update(
room1,
{"ics_last_sync": now - timedelta(seconds=100)},
)
room2 = await rooms_controller.add(
name="interval-test-2",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/interval2.ics",
ics_enabled=True,
ics_fetch_interval=60,
)
await rooms_controller.update(
room2,
{"ics_last_sync": now - timedelta(seconds=100)},
)
with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay:
await _sync_all_ics_calendars_async()
assert mock_delay.call_count == 1
assert mock_delay.call_args[0][0] == room2.id
@pytest.mark.asyncio
async def test_sync_handles_errors_gracefully():
room = await rooms_controller.add(
name="error-task-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/error.ics",
ics_enabled=True,
)
with patch(
"reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.side_effect = Exception("Network error")
await _sync_room_ics_async(room.id)
events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 0

View File

@@ -0,0 +1,289 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from icalendar import Calendar, Event
from reflector.db.calendar_events import calendar_events_controller
from reflector.db.rooms import rooms_controller
from reflector.services.ics_sync import ICSFetchService, ICSSyncService
@pytest.mark.asyncio
async def test_ics_fetch_service_event_matching():
service = ICSFetchService()
room_name = "test-room"
room_url = "https://example.com/room/test-room"
# Create test event
event = Event()
event.add("uid", "test-123")
event.add("summary", "Test Meeting")
# Test matching with full URL in location
event.add("location", "https://example.com/room/test-room")
assert service._event_matches_room(event, room_name, room_url) is True
# Test matching with URL without protocol
event["location"] = "example.com/room/test-room"
assert service._event_matches_room(event, room_name, room_url) is True
# Test matching in description
event["location"] = "Conference Room A"
event.add("description", f"Join at {room_url}")
assert service._event_matches_room(event, room_name, room_url) is True
# Test non-matching
event["location"] = "Different Room"
event["description"] = "No room URL here"
assert service._event_matches_room(event, room_name, room_url) is False
# Test partial paths should NOT match anymore
event["location"] = "/room/test-room"
assert service._event_matches_room(event, room_name, room_url) is False
event["location"] = f"Room: {room_name}"
assert service._event_matches_room(event, room_name, room_url) is False
@pytest.mark.asyncio
async def test_ics_fetch_service_parse_event():
service = ICSFetchService()
# Create test event
event = Event()
event.add("uid", "test-456")
event.add("summary", "Team Standup")
event.add("description", "Daily team sync")
event.add("location", "https://example.com/room/standup")
now = datetime.now(timezone.utc)
event.add("dtstart", now)
event.add("dtend", now + timedelta(hours=1))
# Add attendees
event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"})
event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"})
event.add("organizer", "mailto:carol@example.com", parameters={"CN": "Carol"})
# Parse event
result = service._parse_event(event)
assert result is not None
assert result["ics_uid"] == "test-456"
assert result["title"] == "Team Standup"
assert result["description"] == "Daily team sync"
assert result["location"] == "https://example.com/room/standup"
assert len(result["attendees"]) == 3 # 2 attendees + 1 organizer
@pytest.mark.asyncio
async def test_ics_fetch_service_extract_room_events():
service = ICSFetchService()
room_name = "meeting"
room_url = "https://example.com/room/meeting"
# Create calendar with multiple events
cal = Calendar()
# Event 1: Matches room
event1 = Event()
event1.add("uid", "match-1")
event1.add("summary", "Planning Meeting")
event1.add("location", room_url)
now = datetime.now(timezone.utc)
event1.add("dtstart", now + timedelta(hours=2))
event1.add("dtend", now + timedelta(hours=3))
cal.add_component(event1)
# Event 2: Doesn't match room
event2 = Event()
event2.add("uid", "no-match")
event2.add("summary", "Other Meeting")
event2.add("location", "https://example.com/room/other")
event2.add("dtstart", now + timedelta(hours=4))
event2.add("dtend", now + timedelta(hours=5))
cal.add_component(event2)
# Event 3: Matches room in description
event3 = Event()
event3.add("uid", "match-2")
event3.add("summary", "Review Session")
event3.add("description", f"Meeting link: {room_url}")
event3.add("dtstart", now + timedelta(hours=6))
event3.add("dtend", now + timedelta(hours=7))
cal.add_component(event3)
# Event 4: Cancelled event (should be skipped)
event4 = Event()
event4.add("uid", "cancelled")
event4.add("summary", "Cancelled Meeting")
event4.add("location", room_url)
event4.add("status", "CANCELLED")
event4.add("dtstart", now + timedelta(hours=8))
event4.add("dtend", now + timedelta(hours=9))
cal.add_component(event4)
# Extract events
events = service.extract_room_events(cal, room_name, room_url)
assert len(events) == 2
assert events[0]["ics_uid"] == "match-1"
assert events[1]["ics_uid"] == "match-2"
@pytest.mark.asyncio
async def test_ics_sync_service_sync_room_calendar():
# Create room
room = await rooms_controller.add(
name="sync-test",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/test.ics",
ics_enabled=True,
)
# Mock ICS content
cal = Calendar()
event = Event()
event.add("uid", "sync-event-1")
event.add("summary", "Sync Test Meeting")
# Use the actual BASE_URL from settings
from reflector.settings import settings
event.add("location", f"{settings.BASE_URL}/room/{room.name}")
now = datetime.now(timezone.utc)
event.add("dtstart", now + timedelta(hours=1))
event.add("dtend", now + timedelta(hours=2))
cal.add_component(event)
ics_content = cal.to_ical().decode("utf-8")
# Create sync service and mock fetch
sync_service = ICSSyncService()
with patch.object(
sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ics_content
# First sync
result = await sync_service.sync_room_calendar(room)
assert result["status"] == "success"
assert result["events_found"] == 1
assert result["events_created"] == 1
assert result["events_updated"] == 0
assert result["events_deleted"] == 0
# Verify event was created
events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 1
assert events[0].ics_uid == "sync-event-1"
assert events[0].title == "Sync Test Meeting"
# Second sync with same content (should be unchanged)
# Refresh room to get updated etag and force sync by setting old sync time
room = await rooms_controller.get_by_id(room.id)
await rooms_controller.update(
room, {"ics_last_sync": datetime.now(timezone.utc) - timedelta(minutes=10)}
)
result = await sync_service.sync_room_calendar(room)
assert result["status"] == "unchanged"
# Third sync with updated event
event["summary"] = "Updated Meeting Title"
cal = Calendar()
cal.add_component(event)
ics_content = cal.to_ical().decode("utf-8")
mock_fetch.return_value = ics_content
# Force sync by clearing etag
await rooms_controller.update(room, {"ics_last_etag": None})
result = await sync_service.sync_room_calendar(room)
assert result["status"] == "success"
assert result["events_created"] == 0
assert result["events_updated"] == 1
# Verify event was updated
events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 1
assert events[0].title == "Updated Meeting Title"
@pytest.mark.asyncio
async def test_ics_sync_service_should_sync():
service = ICSSyncService()
# Room never synced
room = MagicMock()
room.ics_last_sync = None
room.ics_fetch_interval = 300
assert service._should_sync(room) is True
# Room synced recently
room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100)
assert service._should_sync(room) is False
# Room sync due
room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400)
assert service._should_sync(room) is True
@pytest.mark.asyncio
async def test_ics_sync_service_skip_disabled():
service = ICSSyncService()
# Room with ICS disabled
room = MagicMock()
room.ics_enabled = False
room.ics_url = "https://calendar.example.com/test.ics"
result = await service.sync_room_calendar(room)
assert result["status"] == "skipped"
assert result["reason"] == "ICS not configured"
# Room without URL
room.ics_enabled = True
room.ics_url = None
result = await service.sync_room_calendar(room)
assert result["status"] == "skipped"
assert result["reason"] == "ICS not configured"
@pytest.mark.asyncio
async def test_ics_sync_service_error_handling():
# Create room
room = await rooms_controller.add(
name="error-test",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/error.ics",
ics_enabled=True,
)
sync_service = ICSSyncService()
with patch.object(
sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.side_effect = Exception("Network error")
result = await sync_service.sync_room_calendar(room)
assert result["status"] == "error"
assert "Network error" in result["error"]

View File

@@ -0,0 +1,283 @@
"""Tests for multiple active meetings per room functionality."""
from datetime import datetime, timedelta, timezone
import pytest
from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
@pytest.mark.asyncio
async def test_multiple_active_meetings_per_room():
"""Test that multiple active meetings can exist for the same room."""
# Create a room
room = await rooms_controller.add(
name="test-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
current_time = datetime.now(timezone.utc)
end_time = current_time + timedelta(hours=2)
# Create first meeting
meeting1 = await meetings_controller.create(
id="meeting-1",
room_name="test-meeting-1",
room_url="https://whereby.com/test-1",
host_room_url="https://whereby.com/test-1-host",
start_date=current_time,
end_date=end_time,
user_id="test-user",
room=room,
)
# Create second meeting for the same room (should succeed now)
meeting2 = await meetings_controller.create(
id="meeting-2",
room_name="test-meeting-2",
room_url="https://whereby.com/test-2",
host_room_url="https://whereby.com/test-2-host",
start_date=current_time,
end_date=end_time,
user_id="test-user",
room=room,
)
# Both meetings should be active
active_meetings = await meetings_controller.get_all_active_for_room(
room=room, current_time=current_time
)
assert len(active_meetings) == 2
assert meeting1.id in [m.id for m in active_meetings]
assert meeting2.id in [m.id for m in active_meetings]
@pytest.mark.asyncio
async def test_get_active_by_calendar_event():
"""Test getting active meeting by calendar event ID."""
# Create a room
room = await rooms_controller.add(
name="test-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
# Create a calendar event
event = CalendarEvent(
room_id=room.id,
ics_uid="test-event-uid",
title="Test Meeting",
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc) + timedelta(hours=1),
)
event = await calendar_events_controller.upsert(event)
current_time = datetime.now(timezone.utc)
end_time = current_time + timedelta(hours=2)
# Create meeting linked to calendar event
meeting = await meetings_controller.create(
id="meeting-cal-1",
room_name="test-meeting-cal",
room_url="https://whereby.com/test-cal",
host_room_url="https://whereby.com/test-cal-host",
start_date=current_time,
end_date=end_time,
user_id="test-user",
room=room,
calendar_event_id=event.id,
calendar_metadata={"title": event.title},
)
# Should find the meeting by calendar event
found_meeting = await meetings_controller.get_active_by_calendar_event(
room=room, calendar_event_id=event.id, current_time=current_time
)
assert found_meeting is not None
assert found_meeting.id == meeting.id
assert found_meeting.calendar_event_id == event.id
@pytest.mark.asyncio
async def test_grace_period_logic():
"""Test that meetings have a grace period after last participant leaves."""
# Create a room
room = await rooms_controller.add(
name="test-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
current_time = datetime.now(timezone.utc)
end_time = current_time + timedelta(hours=2)
# Create meeting
meeting = await meetings_controller.create(
id="meeting-grace",
room_name="test-meeting-grace",
room_url="https://whereby.com/test-grace",
host_room_url="https://whereby.com/test-grace-host",
start_date=current_time,
end_date=end_time,
user_id="test-user",
room=room,
)
# Test grace period logic by simulating different states
# Simulate first time all participants left
await meetings_controller.update_meeting(
meeting.id, num_clients=0, last_participant_left_at=current_time
)
# Within grace period (10 min) - should still be active
await meetings_controller.update_meeting(
meeting.id, last_participant_left_at=current_time - timedelta(minutes=10)
)
updated_meeting = await meetings_controller.get_by_id(meeting.id)
assert updated_meeting.is_active is True # Still active during grace period
# Simulate grace period expired (20 min) and deactivate
await meetings_controller.update_meeting(
meeting.id, last_participant_left_at=current_time - timedelta(minutes=20)
)
# Manually test the grace period logic that would be in process_meetings
updated_meeting = await meetings_controller.get_by_id(meeting.id)
if updated_meeting.last_participant_left_at:
grace_period = timedelta(minutes=updated_meeting.grace_period_minutes)
if current_time > updated_meeting.last_participant_left_at + grace_period:
await meetings_controller.update_meeting(meeting.id, is_active=False)
updated_meeting = await meetings_controller.get_by_id(meeting.id)
assert updated_meeting.is_active is False # Now deactivated
@pytest.mark.asyncio
async def test_calendar_meeting_force_close_after_30_min():
"""Test that calendar meetings force close 30 minutes after scheduled end."""
# Create a room
room = await rooms_controller.add(
name="test-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
# Create a calendar event
event = CalendarEvent(
room_id=room.id,
ics_uid="test-event-force",
title="Test Meeting Force Close",
start_time=datetime.now(timezone.utc) - timedelta(hours=2),
end_time=datetime.now(timezone.utc) - timedelta(minutes=35), # Ended 35 min ago
)
event = await calendar_events_controller.upsert(event)
current_time = datetime.now(timezone.utc)
# Create meeting linked to calendar event
meeting = await meetings_controller.create(
id="meeting-force",
room_name="test-meeting-force",
room_url="https://whereby.com/test-force",
host_room_url="https://whereby.com/test-force-host",
start_date=event.start_time,
end_date=event.end_time,
user_id="test-user",
room=room,
calendar_event_id=event.id,
)
# Test that calendar meetings force close 30 min after scheduled end
# The meeting ended 35 minutes ago, so it should be force closed
# Manually test the force close logic that would be in process_meetings
if meeting.calendar_event_id:
if current_time > meeting.end_date + timedelta(minutes=30):
await meetings_controller.update_meeting(meeting.id, is_active=False)
updated_meeting = await meetings_controller.get_by_id(meeting.id)
assert updated_meeting.is_active is False # Force closed after 30 min
@pytest.mark.asyncio
async def test_participant_rejoin_clears_grace_period():
"""Test that participant rejoining clears the grace period."""
# Create a room
room = await rooms_controller.add(
name="test-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
current_time = datetime.now(timezone.utc)
end_time = current_time + timedelta(hours=2)
# Create meeting with grace period already set
meeting = await meetings_controller.create(
id="meeting-rejoin",
room_name="test-meeting-rejoin",
room_url="https://whereby.com/test-rejoin",
host_room_url="https://whereby.com/test-rejoin-host",
start_date=current_time,
end_date=end_time,
user_id="test-user",
room=room,
)
# Set last_participant_left_at to simulate grace period
await meetings_controller.update_meeting(
meeting.id,
last_participant_left_at=current_time - timedelta(minutes=5),
num_clients=0,
)
# Simulate participant rejoining - clear grace period
await meetings_controller.update_meeting(
meeting.id, last_participant_left_at=None, num_clients=1
)
updated_meeting = await meetings_controller.get_by_id(meeting.id)
assert updated_meeting.last_participant_left_at is None # Grace period cleared
assert updated_meeting.is_active is True # Still active

View File

@@ -0,0 +1,225 @@
"""
Tests for Room model ICS calendar integration fields.
"""
from datetime import datetime, timezone
import pytest
from reflector.db.rooms import rooms_controller
@pytest.mark.asyncio
async def test_room_create_with_ics_fields():
"""Test creating a room with ICS calendar fields."""
room = await rooms_controller.add(
name="test-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.google.com/calendar/ical/test/private-token/basic.ics",
ics_fetch_interval=600,
ics_enabled=True,
)
assert room.name == "test-room"
assert (
room.ics_url
== "https://calendar.google.com/calendar/ical/test/private-token/basic.ics"
)
assert room.ics_fetch_interval == 600
assert room.ics_enabled is True
assert room.ics_last_sync is None
assert room.ics_last_etag is None
@pytest.mark.asyncio
async def test_room_update_ics_configuration():
"""Test updating room ICS configuration."""
# Create room without ICS
room = await rooms_controller.add(
name="update-test",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
assert room.ics_enabled is False
assert room.ics_url is None
# Update with ICS configuration
await rooms_controller.update(
room,
{
"ics_url": "https://outlook.office365.com/owa/calendar/test/calendar.ics",
"ics_fetch_interval": 300,
"ics_enabled": True,
},
)
assert (
room.ics_url == "https://outlook.office365.com/owa/calendar/test/calendar.ics"
)
assert room.ics_fetch_interval == 300
assert room.ics_enabled is True
@pytest.mark.asyncio
async def test_room_ics_sync_metadata():
"""Test updating room ICS sync metadata."""
room = await rooms_controller.add(
name="sync-test",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://example.com/calendar.ics",
ics_enabled=True,
)
# Update sync metadata
sync_time = datetime.now(timezone.utc)
await rooms_controller.update(
room,
{
"ics_last_sync": sync_time,
"ics_last_etag": "abc123hash",
},
)
assert room.ics_last_sync == sync_time
assert room.ics_last_etag == "abc123hash"
@pytest.mark.asyncio
async def test_room_get_with_ics_fields():
"""Test retrieving room with ICS fields."""
# Create room
created_room = await rooms_controller.add(
name="get-test",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="webcal://calendar.example.com/feed.ics",
ics_fetch_interval=900,
ics_enabled=True,
)
# Get by ID
room = await rooms_controller.get_by_id(created_room.id)
assert room is not None
assert room.ics_url == "webcal://calendar.example.com/feed.ics"
assert room.ics_fetch_interval == 900
assert room.ics_enabled is True
# Get by name
room = await rooms_controller.get_by_name("get-test")
assert room is not None
assert room.ics_url == "webcal://calendar.example.com/feed.ics"
assert room.ics_fetch_interval == 900
assert room.ics_enabled is True
@pytest.mark.asyncio
async def test_room_list_with_ics_enabled_filter():
"""Test listing rooms filtered by ICS enabled status."""
# Create rooms with and without ICS
room1 = await rooms_controller.add(
name="ics-enabled-1",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=True,
ics_enabled=True,
ics_url="https://calendar1.example.com/feed.ics",
)
room2 = await rooms_controller.add(
name="ics-disabled",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=True,
ics_enabled=False,
)
room3 = await rooms_controller.add(
name="ics-enabled-2",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=True,
ics_enabled=True,
ics_url="https://calendar2.example.com/feed.ics",
)
# Get all rooms
all_rooms = await rooms_controller.get_all()
assert len(all_rooms) == 3
# Filter for ICS-enabled rooms (would need to implement this in controller)
ics_rooms = [r for r in all_rooms if r["ics_enabled"]]
assert len(ics_rooms) == 2
assert all(r["ics_enabled"] for r in ics_rooms)
@pytest.mark.asyncio
async def test_room_default_ics_values():
"""Test that ICS fields have correct default values."""
room = await rooms_controller.add(
name="default-test",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
# Don't specify ICS fields
)
assert room.ics_url is None
assert room.ics_fetch_interval == 300 # Default 5 minutes
assert room.ics_enabled is False
assert room.ics_last_sync is None
assert room.ics_last_etag is None

View File

@@ -0,0 +1,385 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from icalendar import Calendar, Event
from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
from reflector.db.rooms import rooms_controller
@pytest.fixture
async def authenticated_client(client):
from reflector.app import app
from reflector.auth import current_user_optional
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "test-user",
"email": "test@example.com",
}
yield client
del app.dependency_overrides[current_user_optional]
@pytest.mark.asyncio
async def test_create_room_with_ics_fields(authenticated_client):
client = authenticated_client
response = await client.post(
"/rooms",
json={
"name": "test-ics-room",
"zulip_auto_post": False,
"zulip_stream": "",
"zulip_topic": "",
"is_locked": False,
"room_mode": "normal",
"recording_type": "cloud",
"recording_trigger": "automatic-2nd-participant",
"is_shared": False,
"ics_url": "https://calendar.example.com/test.ics",
"ics_fetch_interval": 600,
"ics_enabled": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "test-ics-room"
assert data["ics_url"] == "https://calendar.example.com/test.ics"
assert data["ics_fetch_interval"] == 600
assert data["ics_enabled"] is True
@pytest.mark.asyncio
async def test_update_room_ics_configuration(authenticated_client):
client = authenticated_client
response = await client.post(
"/rooms",
json={
"name": "update-ics-room",
"zulip_auto_post": False,
"zulip_stream": "",
"zulip_topic": "",
"is_locked": False,
"room_mode": "normal",
"recording_type": "cloud",
"recording_trigger": "automatic-2nd-participant",
"is_shared": False,
},
)
assert response.status_code == 200
room_id = response.json()["id"]
response = await client.patch(
f"/rooms/{room_id}",
json={
"ics_url": "https://calendar.google.com/updated.ics",
"ics_fetch_interval": 300,
"ics_enabled": True,
},
)
assert response.status_code == 200
data = response.json()
assert data["ics_url"] == "https://calendar.google.com/updated.ics"
assert data["ics_fetch_interval"] == 300
assert data["ics_enabled"] is True
@pytest.mark.asyncio
async def test_trigger_ics_sync(authenticated_client):
client = authenticated_client
room = await rooms_controller.add(
name="sync-api-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/api.ics",
ics_enabled=True,
)
cal = Calendar()
event = Event()
event.add("uid", "api-test-event")
event.add("summary", "API Test Meeting")
from reflector.settings import settings
event.add("location", f"{settings.BASE_URL}/room/{room.name}")
now = datetime.now(timezone.utc)
event.add("dtstart", now + timedelta(hours=1))
event.add("dtend", now + timedelta(hours=2))
cal.add_component(event)
ics_content = cal.to_ical().decode("utf-8")
with patch(
"reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = ics_content
response = await client.post(f"/rooms/{room.name}/ics/sync")
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["events_found"] == 1
assert data["events_created"] == 1
@pytest.mark.asyncio
async def test_trigger_ics_sync_unauthorized(client):
room = await rooms_controller.add(
name="sync-unauth-room",
user_id="owner-123",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/api.ics",
ics_enabled=True,
)
response = await client.post(f"/rooms/{room.name}/ics/sync")
assert response.status_code == 403
assert "Only room owner can trigger ICS sync" in response.json()["detail"]
@pytest.mark.asyncio
async def test_trigger_ics_sync_not_configured(authenticated_client):
client = authenticated_client
room = await rooms_controller.add(
name="sync-not-configured",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_enabled=False,
)
response = await client.post(f"/rooms/{room.name}/ics/sync")
assert response.status_code == 400
assert "ICS not configured" in response.json()["detail"]
@pytest.mark.asyncio
async def test_get_ics_status(authenticated_client):
client = authenticated_client
room = await rooms_controller.add(
name="status-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/status.ics",
ics_enabled=True,
ics_fetch_interval=300,
)
now = datetime.now(timezone.utc)
await rooms_controller.update(
room,
{"ics_last_sync": now, "ics_last_etag": "test-etag"},
)
response = await client.get(f"/rooms/{room.name}/ics/status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "enabled"
assert data["last_etag"] == "test-etag"
assert data["events_count"] == 0
@pytest.mark.asyncio
async def test_get_ics_status_unauthorized(client):
room = await rooms_controller.add(
name="status-unauth",
user_id="owner-456",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_url="https://calendar.example.com/status.ics",
ics_enabled=True,
)
response = await client.get(f"/rooms/{room.name}/ics/status")
assert response.status_code == 403
assert "Only room owner can view ICS status" in response.json()["detail"]
@pytest.mark.asyncio
async def test_list_room_meetings(authenticated_client):
client = authenticated_client
room = await rooms_controller.add(
name="meetings-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
now = datetime.now(timezone.utc)
event1 = CalendarEvent(
room_id=room.id,
ics_uid="meeting-1",
title="Past Meeting",
start_time=now - timedelta(hours=2),
end_time=now - timedelta(hours=1),
)
await calendar_events_controller.upsert(event1)
event2 = CalendarEvent(
room_id=room.id,
ics_uid="meeting-2",
title="Future Meeting",
description="Team sync",
start_time=now + timedelta(hours=1),
end_time=now + timedelta(hours=2),
attendees=[{"email": "test@example.com"}],
)
await calendar_events_controller.upsert(event2)
response = await client.get(f"/rooms/{room.name}/meetings")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["title"] == "Past Meeting"
assert data[1]["title"] == "Future Meeting"
assert data[1]["description"] == "Team sync"
assert data[1]["attendees"] == [{"email": "test@example.com"}]
@pytest.mark.asyncio
async def test_list_room_meetings_non_owner(client):
room = await rooms_controller.add(
name="meetings-privacy",
user_id="owner-789",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
event = CalendarEvent(
room_id=room.id,
ics_uid="private-meeting",
title="Meeting Title",
description="Sensitive info",
start_time=datetime.now(timezone.utc) + timedelta(hours=1),
end_time=datetime.now(timezone.utc) + timedelta(hours=2),
attendees=[{"email": "private@example.com"}],
)
await calendar_events_controller.upsert(event)
response = await client.get(f"/rooms/{room.name}/meetings")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Meeting Title"
assert data[0]["description"] is None
assert data[0]["attendees"] is None
@pytest.mark.asyncio
async def test_list_upcoming_meetings(authenticated_client):
client = authenticated_client
room = await rooms_controller.add(
name="upcoming-room",
user_id="test-user",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
)
now = datetime.now(timezone.utc)
past_event = CalendarEvent(
room_id=room.id,
ics_uid="past",
title="Past",
start_time=now - timedelta(hours=1),
end_time=now - timedelta(minutes=30),
)
await calendar_events_controller.upsert(past_event)
soon_event = CalendarEvent(
room_id=room.id,
ics_uid="soon",
title="Soon",
start_time=now + timedelta(minutes=15),
end_time=now + timedelta(minutes=45),
)
await calendar_events_controller.upsert(soon_event)
later_event = CalendarEvent(
room_id=room.id,
ics_uid="later",
title="Later",
start_time=now + timedelta(hours=2),
end_time=now + timedelta(hours=3),
)
await calendar_events_controller.upsert(later_event)
response = await client.get(f"/rooms/{room.name}/meetings/upcoming")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == "Soon"
response = await client.get(
f"/rooms/{room.name}/meetings/upcoming", params={"minutes_ahead": 180}
)
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["title"] == "Soon"
assert data[1]["title"] == "Later"
@pytest.mark.asyncio
async def test_room_not_found_endpoints(client):
response = await client.post("/rooms/nonexistent/ics/sync")
assert response.status_code == 404
response = await client.get("/rooms/nonexistent/ics/status")
assert response.status_code == 404
response = await client.get("/rooms/nonexistent/meetings")
assert response.status_code == 404
response = await client.get("/rooms/nonexistent/meetings/upcoming")
assert response.status_code == 404

17
server/uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12'",
@@ -1163,6 +1163,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
]
[[package]]
name = "icalendar"
version = "6.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/13/e5899c916dcf1343ea65823eb7278d3e1a1d679f383f6409380594b5f322/icalendar-6.3.1.tar.gz", hash = "sha256:a697ce7b678072941e519f2745704fc29d78ef92a2dc53d9108ba6a04aeba466", size = 177169, upload-time = "2025-05-20T07:42:50.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/25/b5fc00e85d2dfaf5c806ac8b5f1de072fa11630c5b15b4ae5bbc228abd51/icalendar-6.3.1-py3-none-any.whl", hash = "sha256:7ea1d1b212df685353f74cdc6ec9646bf42fa557d1746ea645ce8779fdfbecdd", size = 242349, upload-time = "2025-05-20T07:42:48.589Z" },
]
[[package]]
name = "idna"
version = "3.10"
@@ -2661,6 +2674,7 @@ dependencies = [
{ name = "fastapi-pagination" },
{ name = "faster-whisper" },
{ name = "httpx" },
{ name = "icalendar" },
{ name = "jsonschema" },
{ name = "llama-index" },
{ name = "llama-index-llms-openai-like" },
@@ -2727,6 +2741,7 @@ requires-dist = [
{ name = "fastapi-pagination", specifier = ">=0.12.6" },
{ name = "faster-whisper", specifier = ">=0.10.0" },
{ name = "httpx", specifier = ">=0.24.1" },
{ name = "icalendar", specifier = ">=6.0.0" },
{ name = "jsonschema", specifier = ">=4.23.0" },
{ name = "llama-index", specifier = ">=0.12.52" },
{ name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },

View File

@@ -0,0 +1,258 @@
import {
VStack,
HStack,
Field,
Input,
Select,
Checkbox,
Button,
Text,
Alert,
AlertIcon,
AlertTitle,
Badge,
createListCollection,
Spinner,
} from "@chakra-ui/react";
import { useState } from "react";
import { FaSync, FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
import useApi from "../../../lib/useApi";
interface ICSSettingsProps {
roomId?: string;
roomName?: string;
icsUrl?: string;
icsEnabled?: boolean;
icsFetchInterval?: number;
icsLastSync?: string;
icsLastEtag?: string;
onChange: (settings: Partial<ICSSettingsData>) => void;
isOwner?: boolean;
}
export interface ICSSettingsData {
ics_url: string;
ics_enabled: boolean;
ics_fetch_interval: number;
}
const fetchIntervalOptions = [
{ label: "1 minute", value: "1" },
{ label: "5 minutes", value: "5" },
{ label: "10 minutes", value: "10" },
{ label: "30 minutes", value: "30" },
{ label: "1 hour", value: "60" },
];
export default function ICSSettings({
roomId,
roomName,
icsUrl = "",
icsEnabled = false,
icsFetchInterval = 5,
icsLastSync,
icsLastEtag,
onChange,
isOwner = true,
}: ICSSettingsProps) {
const [syncStatus, setSyncStatus] = useState<
"idle" | "syncing" | "success" | "error"
>("idle");
const [syncMessage, setSyncMessage] = useState<string>("");
const [testResult, setTestResult] = useState<string>("");
const api = useApi();
const fetchIntervalCollection = createListCollection({
items: fetchIntervalOptions,
});
const handleTestConnection = async () => {
if (!api || !icsUrl || !roomName) return;
setSyncStatus("syncing");
setTestResult("");
try {
// First update the room with the ICS URL
await api.v1RoomsPartialUpdate({
roomId: roomId || roomName,
requestBody: {
ics_url: icsUrl,
ics_enabled: true,
ics_fetch_interval: icsFetchInterval,
},
});
// Then trigger a sync
const result = await api.v1RoomsTriggerIcsSync({ roomName });
if (result.status === "success") {
setSyncStatus("success");
setTestResult(
`Successfully synced! Found ${result.events_found} events.`,
);
} else {
setSyncStatus("error");
setTestResult(result.error || "Sync failed");
}
} catch (err: any) {
setSyncStatus("error");
setTestResult(err.body?.detail || "Failed to test ICS connection");
}
};
const handleManualSync = async () => {
if (!api || !roomName) return;
setSyncStatus("syncing");
setSyncMessage("");
try {
const result = await api.v1RoomsTriggerIcsSync({ roomName });
if (result.status === "success") {
setSyncStatus("success");
setSyncMessage(
`Sync complete! Found ${result.events_found} events, ` +
`created ${result.events_created}, updated ${result.events_updated}.`,
);
} else {
setSyncStatus("error");
setSyncMessage(result.error || "Sync failed");
}
} catch (err: any) {
setSyncStatus("error");
setSyncMessage(err.body?.detail || "Failed to sync calendar");
}
// Clear status after 5 seconds
setTimeout(() => {
setSyncStatus("idle");
setSyncMessage("");
}, 5000);
};
if (!isOwner) {
return null; // ICS settings only visible to room owner
}
return (
<VStack spacing={4} align="stretch" mt={6}>
<Text fontWeight="semibold" fontSize="lg">
Calendar Integration (ICS)
</Text>
<Field.Root>
<Checkbox.Root
checked={icsEnabled}
onCheckedChange={(e) => onChange({ ics_enabled: e.checked })}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Enable ICS calendar sync</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
{icsEnabled && (
<>
<Field.Root>
<Field.Label>ICS Calendar URL</Field.Label>
<Input
placeholder="https://calendar.google.com/calendar/ical/..."
value={icsUrl}
onChange={(e) => onChange({ ics_url: e.target.value })}
/>
<Field.HelperText>
Enter the ICS URL from Google Calendar, Outlook, or other calendar
services
</Field.HelperText>
</Field.Root>
<Field.Root>
<Field.Label>Sync Interval</Field.Label>
<Select.Root
collection={fetchIntervalCollection}
value={[icsFetchInterval.toString()]}
onValueChange={(details) => {
const value = parseInt(details.value[0]);
onChange({ ics_fetch_interval: value });
}}
>
<Select.Trigger>
<Select.ValueText />
</Select.Trigger>
<Select.Content>
{fetchIntervalOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<Field.HelperText>
How often to check for calendar updates
</Field.HelperText>
</Field.Root>
{icsUrl && (
<HStack spacing={3}>
<Button
size="sm"
variant="outline"
onClick={handleTestConnection}
disabled={syncStatus === "syncing"}
leftIcon={
syncStatus === "syncing" ? <Spinner size="sm" /> : undefined
}
>
Test Connection
</Button>
{roomName && icsLastSync && (
<Button
size="sm"
variant="outline"
onClick={handleManualSync}
disabled={syncStatus === "syncing"}
leftIcon={<FaSync />}
>
Sync Now
</Button>
)}
</HStack>
)}
{testResult && (
<Alert status={syncStatus === "success" ? "success" : "error"}>
<AlertIcon />
<Text fontSize="sm">{testResult}</Text>
</Alert>
)}
{syncMessage && (
<Alert status={syncStatus === "success" ? "success" : "error"}>
<AlertIcon />
<Text fontSize="sm">{syncMessage}</Text>
</Alert>
)}
{icsLastSync && (
<HStack spacing={4} fontSize="sm" color="gray.600">
<HStack>
<FaCheckCircle color="green" />
<Text>Last sync: {new Date(icsLastSync).toLocaleString()}</Text>
</HStack>
{icsLastEtag && (
<Badge colorScheme="blue" fontSize="xs">
ETag: {icsLastEtag.slice(0, 8)}...
</Badge>
)}
</HStack>
)}
</>
)}
</VStack>
);
}

View File

@@ -19,6 +19,7 @@ import useApi from "../../lib/useApi";
import useRoomList from "./useRoomList";
import { ApiError, Room } from "../../api";
import { RoomList } from "./_components/RoomList";
import ICSSettings from "./_components/ICSSettings";
interface SelectOption {
label: string;
@@ -54,6 +55,9 @@ const roomInitialState = {
recordingType: "cloud",
recordingTrigger: "automatic-2nd-participant",
isShared: false,
icsUrl: "",
icsEnabled: false,
icsFetchInterval: 5,
};
export default function RoomsList() {
@@ -170,6 +174,9 @@ export default function RoomsList() {
recording_type: room.recordingType,
recording_trigger: room.recordingTrigger,
is_shared: room.isShared,
ics_url: room.icsUrl,
ics_enabled: room.icsEnabled,
ics_fetch_interval: room.icsFetchInterval,
};
if (isEditing) {
@@ -215,6 +222,9 @@ export default function RoomsList() {
recordingType: roomData.recording_type,
recordingTrigger: roomData.recording_trigger,
isShared: roomData.is_shared,
icsUrl: roomData.ics_url || "",
icsEnabled: roomData.ics_enabled || false,
icsFetchInterval: roomData.ics_fetch_interval || 5,
});
setEditRoomId(roomId);
setIsEditing(true);
@@ -553,6 +563,32 @@ export default function RoomsList() {
<Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<ICSSettings
roomId={editRoomId}
roomName={room.name}
icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled}
icsFetchInterval={room.icsFetchInterval}
onChange={(settings) => {
setRoom({
...room,
icsUrl:
settings.ics_url !== undefined
? settings.ics_url
: room.icsUrl,
icsEnabled:
settings.ics_enabled !== undefined
? settings.ics_enabled
: room.icsEnabled,
icsFetchInterval:
settings.ics_fetch_interval !== undefined
? settings.ics_fetch_interval
: room.icsFetchInterval,
});
}}
isOwner={true}
/>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={onClose}>

View File

@@ -0,0 +1,203 @@
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Divider,
} from "@chakra-ui/react";
import { FaCalendarAlt, FaUsers, FaClock, FaInfoCircle } from "react-icons/fa";
import { Meeting } from "../api";
interface MeetingInfoProps {
meeting: Meeting;
isOwner: boolean;
}
export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
const formatDuration = (start: string | Date, end: string | Date) => {
const startDate = new Date(start);
const endDate = new Date(end);
const now = new Date();
// If meeting hasn't started yet
if (startDate > now) {
return `Scheduled for ${startDate.toLocaleTimeString()}`;
}
// Calculate duration
const durationMs = now.getTime() - startDate.getTime();
const hours = Math.floor(durationMs / (1000 * 60 * 60));
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes} minutes`;
};
const isCalendarMeeting = !!meeting.calendar_event_id;
const metadata = meeting.calendar_metadata;
return (
<Box
position="absolute"
top="56px"
right="8px"
bg="white"
borderRadius="md"
boxShadow="lg"
p={4}
maxW="300px"
zIndex={999}
>
<VStack align="stretch" spacing={3}>
{/* Meeting Title */}
<HStack>
<Icon
as={isCalendarMeeting ? FaCalendarAlt : FaInfoCircle}
color="blue.500"
/>
<Text fontWeight="semibold" fontSize="md">
{metadata?.title ||
(isCalendarMeeting ? "Calendar Meeting" : "Unscheduled Meeting")}
</Text>
</HStack>
{/* Meeting Status */}
<HStack spacing={2}>
{meeting.is_active && (
<Badge colorScheme="green" fontSize="xs">
Active
</Badge>
)}
{isCalendarMeeting && (
<Badge colorScheme="blue" fontSize="xs">
Calendar
</Badge>
)}
{meeting.is_locked && (
<Badge colorScheme="orange" fontSize="xs">
Locked
</Badge>
)}
</HStack>
<Divider />
{/* Meeting Details */}
<VStack align="stretch" spacing={2} fontSize="sm">
{/* Participants */}
<HStack>
<Icon as={FaUsers} color="gray.500" />
<Text>
{meeting.num_clients}{" "}
{meeting.num_clients === 1 ? "participant" : "participants"}
</Text>
</HStack>
{/* Duration */}
<HStack>
<Icon as={FaClock} color="gray.500" />
<Text>
Duration: {formatDuration(meeting.start_date, meeting.end_date)}
</Text>
</HStack>
{/* Calendar Description (Owner only) */}
{isOwner && metadata?.description && (
<>
<Divider />
<Box>
<Text
fontWeight="semibold"
fontSize="xs"
color="gray.600"
mb={1}
>
Description
</Text>
<Text fontSize="xs" color="gray.700">
{metadata.description}
</Text>
</Box>
</>
)}
{/* Attendees (Owner only) */}
{isOwner && metadata?.attendees && metadata.attendees.length > 0 && (
<>
<Divider />
<Box>
<Text
fontWeight="semibold"
fontSize="xs"
color="gray.600"
mb={1}
>
Invited Attendees ({metadata.attendees.length})
</Text>
<VStack align="stretch" spacing={1}>
{metadata.attendees
.slice(0, 5)
.map((attendee: any, idx: number) => (
<HStack key={idx} fontSize="xs">
<Badge
colorScheme={
attendee.status === "ACCEPTED"
? "green"
: attendee.status === "DECLINED"
? "red"
: attendee.status === "TENTATIVE"
? "yellow"
: "gray"
}
fontSize="xs"
size="sm"
>
{attendee.status?.charAt(0) || "?"}
</Badge>
<Text color="gray.700" isTruncated>
{attendee.name || attendee.email}
</Text>
</HStack>
))}
{metadata.attendees.length > 5 && (
<Text fontSize="xs" color="gray.500" fontStyle="italic">
+{metadata.attendees.length - 5} more
</Text>
)}
</VStack>
</Box>
</>
)}
{/* Recording Info */}
{meeting.recording_type !== "none" && (
<>
<Divider />
<HStack fontSize="xs">
<Badge colorScheme="red" fontSize="xs">
Recording
</Badge>
<Text color="gray.600">
{meeting.recording_type === "cloud" ? "Cloud" : "Local"}
{meeting.recording_trigger !== "none" &&
` (${meeting.recording_trigger})`}
</Text>
</HStack>
</>
)}
</VStack>
{/* Meeting Times */}
<Divider />
<VStack align="stretch" spacing={1} fontSize="xs" color="gray.600">
<Text>Start: {new Date(meeting.start_date).toLocaleString()}</Text>
<Text>End: {new Date(meeting.end_date).toLocaleString()}</Text>
</VStack>
</VStack>
</Box>
);
}

View File

@@ -0,0 +1,335 @@
"use client";
import {
Box,
VStack,
HStack,
Text,
Button,
Spinner,
Card,
CardBody,
CardHeader,
Badge,
Divider,
Icon,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
import { Meeting, CalendarEventResponse } from "../api";
import useApi from "../lib/useApi";
import { useRouter } from "next/navigation";
interface MeetingSelectionProps {
roomName: string;
isOwner: boolean;
onMeetingSelect: (meeting: Meeting) => void;
onCreateUnscheduled: () => void;
}
const formatDateTime = (date: string | Date) => {
const d = new Date(date);
return d.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatCountdown = (startTime: string | Date) => {
const now = new Date();
const start = new Date(startTime);
const diff = start.getTime() - now.getTime();
if (diff <= 0) return "Starting now";
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `Starts in ${hours}h ${minutes % 60}m`;
}
return `Starts in ${minutes} minutes`;
};
export default function MeetingSelection({
roomName,
isOwner,
onMeetingSelect,
onCreateUnscheduled,
}: MeetingSelectionProps) {
const [activeMeetings, setActiveMeetings] = useState<Meeting[]>([]);
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEventResponse[]>(
[],
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const api = useApi();
const router = useRouter();
useEffect(() => {
if (!api) return;
const fetchMeetings = async () => {
try {
setLoading(true);
// Fetch active meetings
const active = await api.v1RoomsListActiveMeetings({ roomName });
setActiveMeetings(active);
// Fetch upcoming calendar events (30 min ahead)
const upcoming = await api.v1RoomsListUpcomingMeetings({
roomName,
minutesAhead: 30,
});
setUpcomingEvents(upcoming);
setError(null);
} catch (err) {
console.error("Failed to fetch meetings:", err);
setError("Failed to load meetings. Please try again.");
} finally {
setLoading(false);
}
};
fetchMeetings();
// Refresh every 30 seconds
const interval = setInterval(fetchMeetings, 30000);
return () => clearInterval(interval);
}, [api, roomName]);
const handleJoinMeeting = async (meetingId: string) => {
if (!api) return;
try {
const meeting = await api.v1RoomsJoinMeeting({
roomName,
meetingId,
});
onMeetingSelect(meeting);
} catch (err) {
console.error("Failed to join meeting:", err);
setError("Failed to join meeting. Please try again.");
}
};
const handleJoinUpcoming = (event: CalendarEventResponse) => {
// Navigate to waiting page with event info
router.push(`/room/${roomName}/wait?eventId=${event.id}`);
};
if (loading) {
return (
<Box p={8} textAlign="center">
<Spinner size="lg" color="blue.500" />
<Text mt={4}>Loading meetings...</Text>
</Box>
);
}
if (error) {
return (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
return (
<VStack spacing={6} align="stretch" p={6}>
<Box>
<Text fontSize="2xl" fontWeight="bold" mb={4}>
Select a Meeting
</Text>
{/* Active Meetings */}
{activeMeetings.length > 0 && (
<>
<Text fontSize="lg" fontWeight="semibold" mb={3}>
Active Meetings
</Text>
<VStack spacing={3} mb={6}>
{activeMeetings.map((meeting) => (
<Card key={meeting.id} width="100%" variant="outline">
<CardBody>
<HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="blue.500" />
<Text fontWeight="semibold">
{meeting.calendar_metadata?.title || "Meeting"}
</Text>
</HStack>
{isOwner && meeting.calendar_metadata?.description && (
<Text fontSize="sm" color="gray.600">
{meeting.calendar_metadata.description}
</Text>
)}
<HStack spacing={4} fontSize="sm" color="gray.500">
<HStack>
<Icon as={FaUsers} />
<Text>{meeting.num_clients} participants</Text>
</HStack>
<HStack>
<Icon as={FaClock} />
<Text>
Started {formatDateTime(meeting.start_date)}
</Text>
</HStack>
</HStack>
{isOwner && meeting.calendar_metadata?.attendees && (
<HStack spacing={2} flexWrap="wrap">
{meeting.calendar_metadata.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="green"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
{meeting.calendar_metadata.attendees.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{meeting.calendar_metadata.attendees.length - 3}{" "}
more
</Badge>
)}
</HStack>
)}
</VStack>
<Button
colorScheme="blue"
size="md"
onClick={() => handleJoinMeeting(meeting.id)}
>
Join Now
</Button>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</>
)}
{/* Upcoming Meetings */}
{upcomingEvents.length > 0 && (
<>
<Text fontSize="lg" fontWeight="semibold" mb={3}>
Upcoming Meetings
</Text>
<VStack spacing={3} mb={6}>
{upcomingEvents.map((event) => (
<Card
key={event.id}
width="100%"
variant="outline"
bg="gray.50"
>
<CardBody>
<HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="semibold">
{event.title || "Scheduled Meeting"}
</Text>
<Badge colorScheme="orange" fontSize="xs">
{formatCountdown(event.start_time)}
</Badge>
</HStack>
{isOwner && event.description && (
<Text fontSize="sm" color="gray.600">
{event.description}
</Text>
)}
<HStack spacing={4} fontSize="sm" color="gray.500">
<Text>
{formatDateTime(event.start_time)} -{" "}
{formatDateTime(event.end_time)}
</Text>
</HStack>
{isOwner && event.attendees && (
<HStack spacing={2} flexWrap="wrap">
{event.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="purple"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
{event.attendees.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+{event.attendees.length - 3} more
</Badge>
)}
</HStack>
)}
</VStack>
<Button
variant="outline"
colorScheme="orange"
size="md"
onClick={() => handleJoinUpcoming(event)}
>
Join Early
</Button>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</>
)}
<Divider my={6} />
{/* Create Unscheduled Meeting */}
<Card width="100%" variant="filled" bg="gray.100">
<CardBody>
<HStack justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Text fontWeight="semibold">Start an Unscheduled Meeting</Text>
<Text fontSize="sm" color="gray.600">
Create a new meeting room that's not on the calendar
</Text>
</VStack>
<Button
leftIcon={<FaPlus />}
colorScheme="green"
onClick={onCreateUnscheduled}
>
Create Meeting
</Button>
</HStack>
</CardBody>
</Card>
</Box>
</VStack>
);
}

View File

@@ -24,8 +24,9 @@ import { notFound } from "next/navigation";
import useSessionStatus from "../lib/useSessionStatus";
import { useRecordingConsent } from "../recordingConsentContext";
import useApi from "../lib/useApi";
import { Meeting } from "../api";
import { FaBars } from "react-icons/fa6";
import { Meeting, Room } from "../api";
import { FaBars, FaInfoCircle } from "react-icons/fa6";
import MeetingInfo from "./MeetingInfo";
export type RoomDetails = {
params: {
@@ -254,7 +255,10 @@ export default function Room(details: RoomDetails) {
const roomName = details.params.roomName;
const meeting = useRoomMeeting(roomName);
const router = useRouter();
const { isLoading, isAuthenticated } = useSessionStatus();
const { isLoading, isAuthenticated, data: session } = useSessionStatus();
const [showMeetingInfo, setShowMeetingInfo] = useState(false);
const [room, setRoom] = useState<Room | null>(null);
const api = useApi();
const roomUrl = meeting?.response?.host_room_url
? meeting?.response?.host_room_url
@@ -268,6 +272,15 @@ export default function Room(details: RoomDetails) {
router.push("/browse");
}, [router]);
// Fetch room details
useEffect(() => {
if (!api || !roomName) return;
api.v1RoomsRetrieve({ roomName }).then(setRoom).catch(console.error);
}, [api, roomName]);
const isOwner = session?.user?.id === room?.user_id;
useEffect(() => {
if (
!isLoading &&
@@ -319,6 +332,25 @@ export default function Room(details: RoomDetails) {
wherebyRef={wherebyRef}
/>
)}
{meeting?.response && (
<>
<Button
position="absolute"
top="56px"
right={showMeetingInfo ? "320px" : "8px"}
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={() => setShowMeetingInfo(!showMeetingInfo)}
leftIcon={<Icon as={FaInfoCircle} />}
>
Meeting Info
</Button>
{showMeetingInfo && (
<MeetingInfo meeting={meeting.response} isOwner={isOwner} />
)}
</>
)}
</>
)}
</>

View File

@@ -40,6 +40,20 @@ const useRoomMeeting = (
useEffect(() => {
if (!roomName || !api) return;
// Check if meeting was pre-selected from meeting selection page
const storedMeeting = sessionStorage.getItem(`meeting_${roomName}`);
if (storedMeeting) {
try {
const meeting = JSON.parse(storedMeeting);
sessionStorage.removeItem(`meeting_${roomName}`); // Clean up
setResponse(meeting);
setLoading(false);
return;
} catch (e) {
console.error("Failed to parse stored meeting:", e);
}
}
if (!response) {
setLoading(true);
}

View File

@@ -30,6 +30,108 @@ export const $Body_transcript_record_upload_v1_transcripts__transcript_id__recor
"Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post",
} as const;
export const $CalendarEventResponse = {
properties: {
id: {
type: "string",
title: "Id",
},
room_id: {
type: "string",
title: "Room Id",
},
ics_uid: {
type: "string",
title: "Ics Uid",
},
title: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Title",
},
description: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Description",
},
start_time: {
type: "string",
format: "date-time",
title: "Start Time",
},
end_time: {
type: "string",
format: "date-time",
title: "End Time",
},
attendees: {
anyOf: [
{
items: {
additionalProperties: true,
type: "object",
},
type: "array",
},
{
type: "null",
},
],
title: "Attendees",
},
location: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Location",
},
last_synced: {
type: "string",
format: "date-time",
title: "Last Synced",
},
created_at: {
type: "string",
format: "date-time",
title: "Created At",
},
updated_at: {
type: "string",
format: "date-time",
title: "Updated At",
},
},
type: "object",
required: [
"id",
"room_id",
"ics_uid",
"start_time",
"end_time",
"last_synced",
"created_at",
"updated_at",
],
title: "CalendarEventResponse",
} as const;
export const $CreateParticipant = {
properties: {
speaker: {
@@ -91,6 +193,27 @@ export const $CreateRoom = {
type: "boolean",
title: "Is Shared",
},
ics_url: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Ics Url",
},
ics_fetch_interval: {
type: "integer",
title: "Ics Fetch Interval",
default: 300,
},
ics_enabled: {
type: "boolean",
title: "Ics Enabled",
default: false,
},
},
type: "object",
required: [
@@ -687,6 +810,112 @@ export const $HTTPValidationError = {
title: "HTTPValidationError",
} as const;
export const $ICSStatus = {
properties: {
status: {
type: "string",
title: "Status",
},
last_sync: {
anyOf: [
{
type: "string",
format: "date-time",
},
{
type: "null",
},
],
title: "Last Sync",
},
next_sync: {
anyOf: [
{
type: "string",
format: "date-time",
},
{
type: "null",
},
],
title: "Next Sync",
},
last_etag: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Last Etag",
},
events_count: {
type: "integer",
title: "Events Count",
default: 0,
},
},
type: "object",
required: ["status"],
title: "ICSStatus",
} as const;
export const $ICSSyncResult = {
properties: {
status: {
type: "string",
title: "Status",
},
hash: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Hash",
},
events_found: {
type: "integer",
title: "Events Found",
default: 0,
},
events_created: {
type: "integer",
title: "Events Created",
default: 0,
},
events_updated: {
type: "integer",
title: "Events Updated",
default: 0,
},
events_deleted: {
type: "integer",
title: "Events Deleted",
default: 0,
},
error: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Error",
},
},
type: "object",
required: ["status"],
title: "ICSSyncResult",
} as const;
export const $Meeting = {
properties: {
id: {
@@ -950,6 +1179,50 @@ export const $Room = {
type: "boolean",
title: "Is Shared",
},
ics_url: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Ics Url",
},
ics_fetch_interval: {
type: "integer",
title: "Ics Fetch Interval",
default: 300,
},
ics_enabled: {
type: "boolean",
title: "Ics Enabled",
default: false,
},
ics_last_sync: {
anyOf: [
{
type: "string",
format: "date-time",
},
{
type: "null",
},
],
title: "Ics Last Sync",
},
ics_last_etag: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Ics Last Etag",
},
},
type: "object",
required: [
@@ -1294,54 +1567,139 @@ export const $UpdateParticipant = {
export const $UpdateRoom = {
properties: {
name: {
type: "string",
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Name",
},
zulip_auto_post: {
type: "boolean",
anyOf: [
{
type: "boolean",
},
{
type: "null",
},
],
title: "Zulip Auto Post",
},
zulip_stream: {
type: "string",
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Zulip Stream",
},
zulip_topic: {
type: "string",
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Zulip Topic",
},
is_locked: {
type: "boolean",
anyOf: [
{
type: "boolean",
},
{
type: "null",
},
],
title: "Is Locked",
},
room_mode: {
type: "string",
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Room Mode",
},
recording_type: {
type: "string",
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Recording Type",
},
recording_trigger: {
type: "string",
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Recording Trigger",
},
is_shared: {
type: "boolean",
anyOf: [
{
type: "boolean",
},
{
type: "null",
},
],
title: "Is Shared",
},
ics_url: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Ics Url",
},
ics_fetch_interval: {
anyOf: [
{
type: "integer",
},
{
type: "null",
},
],
title: "Ics Fetch Interval",
},
ics_enabled: {
anyOf: [
{
type: "boolean",
},
{
type: "null",
},
],
title: "Ics Enabled",
},
},
type: "object",
required: [
"name",
"zulip_auto_post",
"zulip_stream",
"zulip_topic",
"is_locked",
"room_mode",
"recording_type",
"recording_trigger",
"is_shared",
],
title: "UpdateRoom",
} as const;

View File

@@ -16,6 +16,18 @@ import type {
V1RoomsDeleteResponse,
V1RoomsCreateMeetingData,
V1RoomsCreateMeetingResponse,
V1RoomsSyncIcsData,
V1RoomsSyncIcsResponse,
V1RoomsIcsStatusData,
V1RoomsIcsStatusResponse,
V1RoomsListMeetingsData,
V1RoomsListMeetingsResponse,
V1RoomsListUpcomingMeetingsData,
V1RoomsListUpcomingMeetingsResponse,
V1RoomsListActiveMeetingsData,
V1RoomsListActiveMeetingsResponse,
V1RoomsJoinMeetingData,
V1RoomsJoinMeetingResponse,
V1TranscriptsListData,
V1TranscriptsListResponse,
V1TranscriptsCreateData,
@@ -227,6 +239,146 @@ export class DefaultService {
});
}
/**
* Rooms Sync Ics
* @param data The data for the request.
* @param data.roomName
* @returns ICSSyncResult Successful Response
* @throws ApiError
*/
public v1RoomsSyncIcs(
data: V1RoomsSyncIcsData,
): CancelablePromise<V1RoomsSyncIcsResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/rooms/{room_name}/ics/sync",
path: {
room_name: data.roomName,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Ics Status
* @param data The data for the request.
* @param data.roomName
* @returns ICSStatus Successful Response
* @throws ApiError
*/
public v1RoomsIcsStatus(
data: V1RoomsIcsStatusData,
): CancelablePromise<V1RoomsIcsStatusResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/rooms/{room_name}/ics/status",
path: {
room_name: data.roomName,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms List Meetings
* @param data The data for the request.
* @param data.roomName
* @returns CalendarEventResponse Successful Response
* @throws ApiError
*/
public v1RoomsListMeetings(
data: V1RoomsListMeetingsData,
): CancelablePromise<V1RoomsListMeetingsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/rooms/{room_name}/meetings",
path: {
room_name: data.roomName,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms List Upcoming Meetings
* @param data The data for the request.
* @param data.roomName
* @param data.minutesAhead
* @returns CalendarEventResponse Successful Response
* @throws ApiError
*/
public v1RoomsListUpcomingMeetings(
data: V1RoomsListUpcomingMeetingsData,
): CancelablePromise<V1RoomsListUpcomingMeetingsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/rooms/{room_name}/meetings/upcoming",
path: {
room_name: data.roomName,
},
query: {
minutes_ahead: data.minutesAhead,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms List Active Meetings
* List all active meetings for a room (supports multiple active meetings)
* @param data The data for the request.
* @param data.roomName
* @returns Meeting Successful Response
* @throws ApiError
*/
public v1RoomsListActiveMeetings(
data: V1RoomsListActiveMeetingsData,
): CancelablePromise<V1RoomsListActiveMeetingsResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/rooms/{room_name}/meetings/active",
path: {
room_name: data.roomName,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Join Meeting
* Join a specific meeting by ID
* @param data The data for the request.
* @param data.roomName
* @param data.meetingId
* @returns Meeting Successful Response
* @throws ApiError
*/
public v1RoomsJoinMeeting(
data: V1RoomsJoinMeetingData,
): CancelablePromise<V1RoomsJoinMeetingResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/rooms/{room_name}/meetings/{meeting_id}/join",
path: {
room_name: data.roomName,
meeting_id: data.meetingId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcripts List
* @param data The data for the request.

View File

@@ -9,6 +9,23 @@ export type Body_transcript_record_upload_v1_transcripts__transcript_id__record_
chunk: Blob | File;
};
export type CalendarEventResponse = {
id: string;
room_id: string;
ics_uid: string;
title?: string | null;
description?: string | null;
start_time: string;
end_time: string;
attendees?: Array<{
[key: string]: unknown;
}> | null;
location?: string | null;
last_synced: string;
created_at: string;
updated_at: string;
};
export type CreateParticipant = {
speaker?: number | null;
name: string;
@@ -24,6 +41,9 @@ export type CreateRoom = {
recording_type: string;
recording_trigger: string;
is_shared: boolean;
ics_url?: string | null;
ics_fetch_interval?: number;
ics_enabled?: boolean;
};
export type CreateTranscript = {
@@ -123,6 +143,24 @@ export type HTTPValidationError = {
detail?: Array<ValidationError>;
};
export type ICSStatus = {
status: string;
last_sync?: string | null;
next_sync?: string | null;
last_etag?: string | null;
events_count?: number;
};
export type ICSSyncResult = {
status: string;
hash?: string | null;
events_found?: number;
events_created?: number;
events_updated?: number;
events_deleted?: number;
error?: string | null;
};
export type Meeting = {
id: string;
room_name: string;
@@ -174,6 +212,11 @@ export type Room = {
recording_type: string;
recording_trigger: string;
is_shared: boolean;
ics_url?: string | null;
ics_fetch_interval?: number;
ics_enabled?: boolean;
ics_last_sync?: string | null;
ics_last_etag?: string | null;
};
export type RtcOffer = {
@@ -266,15 +309,18 @@ export type UpdateParticipant = {
};
export type UpdateRoom = {
name: string;
zulip_auto_post: boolean;
zulip_stream: string;
zulip_topic: string;
is_locked: boolean;
room_mode: string;
recording_type: string;
recording_trigger: string;
is_shared: boolean;
name?: string | null;
zulip_auto_post?: boolean | null;
zulip_stream?: string | null;
zulip_topic?: string | null;
is_locked?: boolean | null;
room_mode?: string | null;
recording_type?: string | null;
recording_trigger?: string | null;
is_shared?: boolean | null;
ics_url?: string | null;
ics_fetch_interval?: number | null;
ics_enabled?: boolean | null;
};
export type UpdateTranscript = {
@@ -371,6 +417,44 @@ export type V1RoomsCreateMeetingData = {
export type V1RoomsCreateMeetingResponse = Meeting;
export type V1RoomsSyncIcsData = {
roomName: string;
};
export type V1RoomsSyncIcsResponse = ICSSyncResult;
export type V1RoomsIcsStatusData = {
roomName: string;
};
export type V1RoomsIcsStatusResponse = ICSStatus;
export type V1RoomsListMeetingsData = {
roomName: string;
};
export type V1RoomsListMeetingsResponse = Array<CalendarEventResponse>;
export type V1RoomsListUpcomingMeetingsData = {
minutesAhead?: number;
roomName: string;
};
export type V1RoomsListUpcomingMeetingsResponse = Array<CalendarEventResponse>;
export type V1RoomsListActiveMeetingsData = {
roomName: string;
};
export type V1RoomsListActiveMeetingsResponse = Array<Meeting>;
export type V1RoomsJoinMeetingData = {
meetingId: string;
roomName: string;
};
export type V1RoomsJoinMeetingResponse = Meeting;
export type V1TranscriptsListData = {
/**
* Page number
@@ -670,6 +754,96 @@ export type $OpenApiTs = {
};
};
};
"/v1/rooms/{room_name}/ics/sync": {
post: {
req: V1RoomsSyncIcsData;
res: {
/**
* Successful Response
*/
200: ICSSyncResult;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_name}/ics/status": {
get: {
req: V1RoomsIcsStatusData;
res: {
/**
* Successful Response
*/
200: ICSStatus;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_name}/meetings": {
get: {
req: V1RoomsListMeetingsData;
res: {
/**
* Successful Response
*/
200: Array<CalendarEventResponse>;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_name}/meetings/upcoming": {
get: {
req: V1RoomsListUpcomingMeetingsData;
res: {
/**
* Successful Response
*/
200: Array<CalendarEventResponse>;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_name}/meetings/active": {
get: {
req: V1RoomsListActiveMeetingsData;
res: {
/**
* Successful Response
*/
200: Array<Meeting>;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_name}/meetings/{meeting_id}/join": {
post: {
req: V1RoomsJoinMeetingData;
res: {
/**
* Successful Response
*/
200: Meeting;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/transcripts": {
get: {
req: V1TranscriptsListData;

View File

@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
import { Box, Spinner, VStack, Text } from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import useApi from "../../lib/useApi";
import useSessionStatus from "../../lib/useSessionStatus";
import MeetingSelection from "../../[roomName]/MeetingSelection";
import { Meeting, Room } from "../../api";
interface RoomPageProps {
params: {
roomName: string;
};
}
export default function RoomPage({ params }: RoomPageProps) {
const { roomName } = params;
const router = useRouter();
const api = useApi();
const { data: session } = useSessionStatus();
const [room, setRoom] = useState<Room | null>(null);
const [loading, setLoading] = useState(true);
const [checkingMeetings, setCheckingMeetings] = useState(false);
const isOwner = session?.user?.id === room?.user_id;
useEffect(() => {
if (!api) return;
const fetchRoom = async () => {
try {
// Get room details
const roomData = await api.v1RoomsRetrieve({ roomName });
setRoom(roomData);
// Check if we should show meeting selection
if (roomData.ics_enabled) {
setCheckingMeetings(true);
// Check for active meetings
const activeMeetings = await api.v1RoomsListActiveMeetings({
roomName,
});
// Check for upcoming meetings
const upcomingEvents = await api.v1RoomsListUpcomingMeetings({
roomName,
minutesAhead: 30,
});
// If there's only one active meeting and no upcoming, auto-join
if (activeMeetings.length === 1 && upcomingEvents.length === 0) {
handleMeetingSelect(activeMeetings[0]);
} else if (
activeMeetings.length === 0 &&
upcomingEvents.length === 0
) {
// No meetings, create unscheduled
handleCreateUnscheduled();
}
// Otherwise, show selection UI (handled by render)
} else {
// ICS not enabled, use traditional flow
handleCreateUnscheduled();
}
} catch (err) {
console.error("Failed to fetch room:", err);
// Room not found or error
router.push("/rooms");
} finally {
setLoading(false);
setCheckingMeetings(false);
}
};
fetchRoom();
}, [api, roomName]);
const handleMeetingSelect = (meeting: Meeting) => {
// Navigate to the classic room page with the meeting
// Store meeting in session storage for the classic page to use
sessionStorage.setItem(`meeting_${roomName}`, JSON.stringify(meeting));
router.push(`/${roomName}`);
};
const handleCreateUnscheduled = async () => {
if (!api) return;
try {
// Create a new unscheduled meeting
const meeting = await api.v1RoomsCreateMeeting({ roomName });
handleMeetingSelect(meeting);
} catch (err) {
console.error("Failed to create meeting:", err);
}
};
if (loading || checkingMeetings) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" />
<Text>{loading ? "Loading room..." : "Checking meetings..."}</Text>
</VStack>
</Box>
);
}
if (!room) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<Text fontSize="lg">Room not found</Text>
</Box>
);
}
// Show meeting selection if ICS is enabled and we have multiple options
if (room.ics_enabled) {
return (
<Box minH="100vh" bg="gray.50">
<MeetingSelection
roomName={roomName}
isOwner={isOwner}
onMeetingSelect={handleMeetingSelect}
onCreateUnscheduled={handleCreateUnscheduled}
/>
</Box>
);
}
// Should not reach here - redirected above
return null;
}

View File

@@ -0,0 +1,277 @@
"use client";
import {
Box,
VStack,
HStack,
Text,
Spinner,
Progress,
Card,
CardBody,
Button,
Icon,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { FaClock, FaArrowLeft } from "react-icons/fa";
import useApi from "../../../lib/useApi";
import { CalendarEventResponse } from "../../../api";
interface WaitingPageProps {
params: {
roomName: string;
};
}
export default function WaitingPage({ params }: WaitingPageProps) {
const { roomName } = params;
const router = useRouter();
const searchParams = useSearchParams();
const eventId = searchParams.get("eventId");
const [event, setEvent] = useState<CalendarEventResponse | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [checkingMeeting, setCheckingMeeting] = useState(false);
const api = useApi();
useEffect(() => {
if (!api || !eventId) return;
const fetchEvent = async () => {
try {
const events = await api.v1RoomsListUpcomingMeetings({
roomName,
minutesAhead: 60,
});
const targetEvent = events.find((e) => e.id === eventId);
if (targetEvent) {
setEvent(targetEvent);
} else {
// Event not found or already started
router.push(`/room/${roomName}`);
}
} catch (err) {
console.error("Failed to fetch event:", err);
router.push(`/room/${roomName}`);
} finally {
setLoading(false);
}
};
fetchEvent();
}, [api, eventId, roomName]);
useEffect(() => {
if (!event) return;
const updateCountdown = () => {
const now = new Date();
const start = new Date(event.start_time);
const diff = Math.max(0, start.getTime() - now.getTime());
setTimeRemaining(diff);
// Check if meeting has started
if (diff <= 0) {
checkForActiveMeeting();
}
};
const checkForActiveMeeting = async () => {
if (!api || checkingMeeting) return;
setCheckingMeeting(true);
try {
// Check for active meetings
const activeMeetings = await api.v1RoomsListActiveMeetings({
roomName,
});
// Find meeting for this calendar event
const calendarMeeting = activeMeetings.find(
(m) => m.calendar_event_id === eventId,
);
if (calendarMeeting) {
// Meeting is now active, join it
const meeting = await api.v1RoomsJoinMeeting({
roomName,
meetingId: calendarMeeting.id,
});
// Navigate to the meeting room
router.push(`/${roomName}?meetingId=${meeting.id}`);
}
} catch (err) {
console.error("Failed to check for active meeting:", err);
} finally {
setCheckingMeeting(false);
}
};
// Update countdown every second
const interval = setInterval(updateCountdown, 1000);
// Check for meeting every 10 seconds when close to start time
let checkInterval: NodeJS.Timeout | null = null;
if (timeRemaining < 60000) {
// Less than 1 minute
checkInterval = setInterval(checkForActiveMeeting, 10000);
}
updateCountdown(); // Initial update
return () => {
clearInterval(interval);
if (checkInterval) clearInterval(checkInterval);
};
}, [event, api, eventId, roomName, checkingMeeting]);
const formatTime = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const getProgressValue = () => {
if (!event) return 0;
const now = new Date();
const created = new Date(event.created_at);
const start = new Date(event.start_time);
const totalTime = start.getTime() - created.getTime();
const elapsed = now.getTime() - created.getTime();
return Math.min(100, (elapsed / totalTime) * 100);
};
if (loading) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" />
<Text>Loading meeting details...</Text>
</VStack>
</Box>
);
}
if (!event) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<Text fontSize="lg">Meeting not found</Text>
<Button
leftIcon={<FaArrowLeft />}
onClick={() => router.push(`/room/${roomName}`)}
>
Back to Room
</Button>
</VStack>
</Box>
);
}
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<Card maxW="lg" width="100%" mx={4}>
<CardBody>
<VStack spacing={6} py={4}>
<Icon as={FaClock} boxSize={16} color="blue.500" />
<VStack spacing={2}>
<Text fontSize="2xl" fontWeight="bold">
{event.title || "Scheduled Meeting"}
</Text>
<Text color="gray.600" textAlign="center">
The meeting will start automatically when it's time
</Text>
</VStack>
<Box width="100%">
<Text
fontSize="4xl"
fontWeight="bold"
textAlign="center"
color="blue.600"
>
{formatTime(timeRemaining)}
</Text>
<Progress
value={getProgressValue()}
colorScheme="blue"
size="sm"
mt={4}
borderRadius="full"
/>
</Box>
{event.description && (
<Box width="100%" p={4} bg="gray.100" borderRadius="md">
<Text fontSize="sm" fontWeight="semibold" mb={1}>
Meeting Description
</Text>
<Text fontSize="sm" color="gray.700">
{event.description}
</Text>
</Box>
)}
<VStack spacing={3} width="100%">
<Text fontSize="sm" color="gray.500">
Scheduled for {new Date(event.start_time).toLocaleString()}
</Text>
{checkingMeeting && (
<HStack spacing={2}>
<Spinner size="sm" color="blue.500" />
<Text fontSize="sm" color="blue.600">
Checking if meeting has started...
</Text>
</HStack>
)}
</VStack>
<Button
variant="outline"
leftIcon={<FaArrowLeft />}
onClick={() => router.push(`/room/${roomName}`)}
width="100%"
>
Back to Meeting Selection
</Button>
</VStack>
</CardBody>
</Card>
</Box>
);
}