mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Compare commits
5 Commits
v0.12.1
...
mathieu/ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 311d453e41 | |||
| f286f0882c | |||
| ffcafb3bf2 | |||
| 27075d840c | |||
| 30b5cd45e3 |
497
ICS_IMPLEMENTATION.md
Normal file
497
ICS_IMPLEMENTATION.md
Normal 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
337
PLAN.md
Normal 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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
193
server/reflector/db/calendar_events.py
Normal file
193
server/reflector/db/calendar_events.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
296
server/reflector/services/ics_sync.py
Normal file
296
server/reflector/services/ics_sync.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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:
|
||||
|
||||
209
server/reflector/worker/ics_sync.py
Normal file
209
server/reflector/worker/ics_sync.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
351
server/tests/test_calendar_event.py
Normal file
351
server/tests/test_calendar_event.py
Normal 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
|
||||
230
server/tests/test_ics_background_tasks.py
Normal file
230
server/tests/test_ics_background_tasks.py
Normal 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
|
||||
289
server/tests/test_ics_sync.py
Normal file
289
server/tests/test_ics_sync.py
Normal 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"]
|
||||
283
server/tests/test_multiple_active_meetings.py
Normal file
283
server/tests/test_multiple_active_meetings.py
Normal 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
|
||||
225
server/tests/test_room_ics.py
Normal file
225
server/tests/test_room_ics.py
Normal 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
|
||||
385
server/tests/test_room_ics_api.py
Normal file
385
server/tests/test_room_ics_api.py
Normal 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
17
server/uv.lock
generated
@@ -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" },
|
||||
|
||||
258
www/app/(app)/rooms/_components/ICSSettings.tsx
Normal file
258
www/app/(app)/rooms/_components/ICSSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
203
www/app/[roomName]/MeetingInfo.tsx
Normal file
203
www/app/[roomName]/MeetingInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
www/app/[roomName]/MeetingSelection.tsx
Normal file
335
www/app/[roomName]/MeetingSelection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
147
www/app/room/[roomName]/page.tsx
Normal file
147
www/app/room/[roomName]/page.tsx
Normal 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;
|
||||
}
|
||||
277
www/app/room/[roomName]/wait/page.tsx
Normal file
277
www/app/room/[roomName]/wait/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user