feat: implement Phase 2 - Multiple active meetings per room with grace period

This commit adds support for multiple concurrent meetings per room, implementing
grace period logic and improved meeting lifecycle management for calendar integration.

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

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

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

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

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

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

This enables proper calendar integration with overlapping meetings while
preventing accidental meeting closures through the grace period mechanism.
This commit is contained in:
2025-08-18 19:03:41 -06:00
parent 8b2ad23288
commit 29725ee72d
7 changed files with 544 additions and 9 deletions

View File

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

View File

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

View File

@@ -48,6 +48,8 @@ meetings = sa.Table(
sa.ForeignKey("calendar_event.id", ondelete="SET NULL"), sa.ForeignKey("calendar_event.id", ondelete="SET NULL"),
), ),
sa.Column("calendar_metadata", JSONB), 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_room_id", "room_id"),
sa.Index("idx_meeting_calendar_event", "calendar_event_id"), sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
) )
@@ -95,6 +97,8 @@ class Meeting(BaseModel):
is_active: bool = True is_active: bool = True
calendar_event_id: str | None = None calendar_event_id: str | None = None
calendar_metadata: dict[str, Any] | None = None calendar_metadata: dict[str, Any] | None = None
last_participant_left_at: datetime | None = None
grace_period_minutes: int = 15
class MeetingController: class MeetingController:
@@ -158,6 +162,7 @@ class MeetingController:
async def get_active(self, room: Room, current_time: datetime) -> Meeting: async def get_active(self, room: Room, current_time: datetime) -> Meeting:
""" """
Get latest active meeting for a room. Get latest active meeting for a room.
For backward compatibility, returns the most recent active meeting.
""" """
end_date = getattr(meetings.c, "end_date") end_date = getattr(meetings.c, "end_date")
query = ( query = (
@@ -177,6 +182,47 @@ class MeetingController:
return Meeting(**result) 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: async def get_by_id(self, meeting_id: str, **kwargs) -> Meeting | None:
""" """
Get a meeting by id Get a meeting by id

View File

@@ -429,3 +429,65 @@ async def rooms_list_upcoming_meetings(
event.attendees = None event.attendees = None
return events return events
@router.get("/rooms/{room_name}/meetings/active", response_model=list[Meeting])
async def rooms_list_active_meetings(
room_name: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
"""List all active meetings for a room (supports multiple active meetings)"""
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
current_time = datetime.now(timezone.utc)
meetings = await meetings_controller.get_all_active_for_room(
room=room, current_time=current_time
)
# Hide host URLs from non-owners
if user_id != room.user_id:
for meeting in meetings:
meeting.host_room_url = ""
return meetings
@router.post("/rooms/{room_name}/meetings/{meeting_id}/join", response_model=Meeting)
async def rooms_join_meeting(
room_name: str,
meeting_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
"""Join a specific meeting by ID"""
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
meeting = await meetings_controller.get_by_id(meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
if meeting.room_id != room.id:
raise HTTPException(
status_code=403, detail="Meeting does not belong to this room"
)
if not meeting.is_active:
raise HTTPException(status_code=400, detail="Meeting is not active")
current_time = datetime.now(timezone.utc)
if meeting.end_date <= current_time:
raise HTTPException(status_code=400, detail="Meeting has ended")
# Hide host URL from non-owners
if user_id != room.user_id:
meeting.host_room_url = ""
return meeting

View File

@@ -68,8 +68,13 @@ async def whereby_webhook(event: WherebyWebhookEvent, request: Request):
raise HTTPException(status_code=404, detail="Meeting not found") raise HTTPException(status_code=404, detail="Meeting not found")
if event.type in ["room.client.joined", "room.client.left"]: if event.type in ["room.client.joined", "room.client.left"]:
await meetings_controller.update_meeting( update_data = {"num_clients": event.data["numClients"]}
meeting.id, 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"} return {"status": "ok"}

View File

@@ -1,6 +1,6 @@
import json import json
import os import os
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from urllib.parse import unquote from urllib.parse import unquote
import av import av
@@ -147,24 +147,76 @@ async def process_recording(bucket_name: str, object_key: str):
@shared_task @shared_task
@asynctask @asynctask
async def process_meetings(): 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") logger.info("Processing meetings")
meetings = await meetings_controller.get_all_active() meetings = await meetings_controller.get_all_active()
current_time = datetime.now(timezone.utc)
for meeting in meetings: for meeting in meetings:
is_active = False should_deactivate = False
end_date = meeting.end_date end_date = meeting.end_date
if end_date.tzinfo is None: if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc) 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) response = await get_room_sessions(meeting.room_name)
room_sessions = response.get("results", []) 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 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) await meetings_controller.update_meeting(meeting.id, is_active=False)
logger.info("Meeting %s is deactivated", meeting.id) logger.info("Meeting %s is deactivated", meeting.id)
logger.info("Processed meetings") logger.info("Processed %d meetings", len(meetings))
@shared_task @shared_task

View File

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