diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index 6e126309..eb220a0c 100644 --- a/server/reflector/worker/ics_sync.py +++ b/server/reflector/worker/ics_sync.py @@ -83,9 +83,6 @@ def _should_sync(room) -> bool: return time_since_sync.total_seconds() >= room.ics_fetch_interval -MEETING_DEFAULT_DURATION = timedelta(hours=1) - - async def create_upcoming_meetings_for_event(event, create_window, room: Room): if event.start_time <= create_window: return @@ -102,7 +99,9 @@ async def create_upcoming_meetings_for_event(event, create_window, room: Room): ) try: - end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION) + # 8h rejoin window matches manual on-the-fly meetings; the scheduled + # DTEND stays in calendar_events.end_time for reference. + end_date = event.start_time + timedelta(hours=8) client = create_platform_client(room.platform) diff --git a/server/tests/test_ics_background_tasks.py b/server/tests/test_ics_background_tasks.py index c2bf5c87..bac80563 100644 --- a/server/tests/test_ics_background_tasks.py +++ b/server/tests/test_ics_background_tasks.py @@ -5,11 +5,14 @@ import pytest from icalendar import Calendar, Event from reflector.db import get_database -from reflector.db.calendar_events import calendar_events_controller +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +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.video_platforms.models import MeetingData from reflector.worker.ics_sync import ( _should_sync, + create_upcoming_meetings_for_event, sync_room_ics, ) @@ -225,6 +228,68 @@ async def test_sync_respects_fetch_interval(): assert mock_delay.call_args[0][0] == room2.id +@pytest.mark.asyncio +async def test_create_upcoming_meeting_uses_8h_end_date(): + # ICS-pre-created meetings get an 8h rejoin window anchored to the + # scheduled start, ignoring the calendar event's DTEND. Regression + # guard for the "Meeting has ended" bug when participants run over a + # short scheduled window. + room = await rooms_controller.add( + name="ics-8h-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/ics-8h.ics", + ics_enabled=True, + ) + + now = datetime.now(timezone.utc) + event_start = now + timedelta(minutes=1) + event_end = event_start + timedelta(minutes=30) + + event = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid="ics-8h-evt", + title="Short meeting that runs over", + start_time=event_start, + end_time=event_end, + ) + ) + + create_window = now - timedelta(minutes=6) + + fake_client = MagicMock() + fake_client.create_meeting = AsyncMock( + return_value=MeetingData( + meeting_id="ics-8h-meeting", + room_name=room.name, + room_url="https://daily.example/ics-8h", + host_room_url="https://daily.example/ics-8h", + platform=room.platform, + extra_data={}, + ) + ) + fake_client.upload_logo = AsyncMock(return_value=True) + + with patch( + "reflector.worker.ics_sync.create_platform_client", + return_value=fake_client, + ): + await create_upcoming_meetings_for_event(event, create_window, room) + + meeting = await meetings_controller.get_by_calendar_event(event.id, room) + assert meeting is not None + assert meeting.start_date == event_start + assert meeting.end_date == event_start + timedelta(hours=8) + + @pytest.mark.asyncio async def test_sync_handles_errors_gracefully(): room = await rooms_controller.add( diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx index e2356810..7295757d 100644 --- a/www/app/[roomName]/MeetingSelection.tsx +++ b/www/app/[roomName]/MeetingSelection.tsx @@ -24,6 +24,7 @@ import { } from "../lib/apiHooks"; import { useRouter } from "next/navigation"; import { formatDateTime, formatStartedAgo } from "../lib/timeUtils"; +import { formatJoinError } from "../lib/errorUtils"; import MeetingMinimalHeader from "../components/MeetingMinimalHeader"; import { NonEmptyString } from "../lib/utils"; import { MeetingId, assertMeetingId } from "../lib/types"; @@ -188,6 +189,19 @@ export default function MeetingSelection({ flex="1" gap={{ base: 4, md: 6 }} > + {joinMeetingMutation.isError && ( + + + {formatJoinError(joinMeetingMutation.error)} + + + )} {/* Current Ongoing Meetings - BIG DISPLAY */} {currentMeetings.length > 0 ? ( diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 5498f0ac..f02f6687 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -28,6 +28,7 @@ import { useRoomJoinMeeting, useMeetingStartRecording, } from "../../lib/apiHooks"; +import { formatJoinError } from "../../lib/errorUtils"; import { omit } from "remeda"; import { assertExists, @@ -428,7 +429,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { if (joinMutation.isError) { return (
- Failed to join meeting. Please try again. + {formatJoinError(joinMutation.error)}
); } diff --git a/www/app/[roomName]/components/LiveKitRoom.tsx b/www/app/[roomName]/components/LiveKitRoom.tsx index e6d419fa..5ab130ce 100644 --- a/www/app/[roomName]/components/LiveKitRoom.tsx +++ b/www/app/[roomName]/components/LiveKitRoom.tsx @@ -13,6 +13,7 @@ import { import type { components } from "../../reflector-api"; import { useAuth } from "../../lib/AuthProvider"; import { useRoomJoinMeeting } from "../../lib/apiHooks"; +import { formatJoinError } from "../../lib/errorUtils"; import { assertMeetingId } from "../../lib/types"; import { ConsentDialogButton, @@ -66,7 +67,6 @@ export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) { const joinMutation = useRoomJoinMeeting(); const [joinedMeeting, setJoinedMeeting] = useState(null); - const [connectionError, setConnectionError] = useState(false); const [userChoices, setUserChoices] = useState(null); // ── Consent dialog (same hooks as Daily/Whereby) ────────── @@ -99,7 +99,7 @@ export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) { } return ""; })(); - const isJoining = !!userChoices && !joinedMeeting && !connectionError; + const isJoining = !!userChoices && !joinedMeeting && !joinMutation.isError; // ── Join meeting via backend API after PreJoin submit ───── useEffect(() => { @@ -123,7 +123,6 @@ export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) { if (!cancelled) setJoinedMeeting(result); } catch (err) { console.error("Failed to join LiveKit meeting:", err); - if (!cancelled) setConnectionError(true); } } @@ -182,10 +181,10 @@ export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) { ); } - if (connectionError) { + if (joinMutation.isError) { return (
- Failed to connect to meeting + {formatJoinError(joinMutation.error)}
); } diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 4ece77e2..c9a1a822 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -863,16 +863,9 @@ export function useRoomGetMeeting( } export function useRoomJoinMeeting() { - const { setError } = useError(); - return $api.useMutation( "post", "/v1/rooms/{room_name}/meetings/{meeting_id}/join", - { - onError: (error) => { - setError(error as Error, "There was an error joining the meeting"); - }, - }, ); } diff --git a/www/app/lib/errorUtils.ts b/www/app/lib/errorUtils.ts index 1512230c..d0cc73fa 100644 --- a/www/app/lib/errorUtils.ts +++ b/www/app/lib/errorUtils.ts @@ -1,5 +1,35 @@ import { isNonEmptyArray, NonEmptyArray } from "./array"; +export function getErrorDetail(error: unknown, fallback: string): string { + if (!error) return fallback; + if (typeof error === "object" && error !== null) { + const detail = (error as { detail?: unknown }).detail; + if (typeof detail === "string" && detail.length > 0) return detail; + const response = (error as { response?: { data?: { detail?: unknown } } }) + .response; + const nestedDetail = response?.data?.detail; + if (typeof nestedDetail === "string" && nestedDetail.length > 0) + return nestedDetail; + } + return fallback; +} + +export function formatJoinError(error: unknown): string { + const detail = getErrorDetail(error, ""); + switch (detail) { + case "Meeting has ended": + return "This meeting has ended. The organizer can start a new one."; + case "Meeting is not active": + return "This meeting is no longer active. Ask the organizer to start it again."; + case "Meeting not found": + return "This meeting no longer exists. Check the link or ask the organizer for a new one."; + case "Room not found": + return "This room doesn't exist."; + default: + return detail || "We couldn't join the meeting. Please try again."; + } +} + export function shouldShowError(error: Error | null | undefined) { if ( error?.name == "ResponseError" &&