From e9e1676409ea0a496bf4f9c1782730a25ef76673 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 18:41:10 -0500 Subject: [PATCH 01/11] feat: add specific error messages for Daily.co meeting disconnections Handle fatal errors from Daily.co SDK (connection-error, exp-room, ejected, etc.) with user-friendly messages and appropriate actions. Improve join failure display to show actual API error detail. --- www/app/[roomName]/components/DailyRoom.tsx | 95 ++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index d1c00254..e6eccfc4 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -8,7 +8,7 @@ import { useRef, useState, } from "react"; -import { Box, Spinner, Center, Text } from "@chakra-ui/react"; +import { Box, Spinner, Center, Text, Button, VStack } from "@chakra-ui/react"; import { useRouter, useParams } from "next/navigation"; import DailyIframe, { DailyCall, @@ -16,10 +16,12 @@ import DailyIframe, { DailyCustomTrayButton, DailyCustomTrayButtons, DailyEventObjectCustomButtonClick, + DailyEventObjectFatalError, DailyFactoryOptions, DailyParticipantsObject, } from "@daily-co/daily-js"; import type { components } from "../../reflector-api"; +import { printApiError } from "../../api/_error"; import { useAuth } from "../../lib/AuthProvider"; import { useConsentDialog } from "../../lib/consent"; import { @@ -82,6 +84,8 @@ const USE_FRAME_INIT_STATE = { joined: false as boolean, } as const; +type FatalError = { type: string; message: string }; + // Daily js and not Daily react used right now because daily-js allows for prebuild interface vs. -react is customizable but has no nice defaults const useFrame = ( container: HTMLDivElement | null, @@ -89,6 +93,7 @@ const useFrame = ( onLeftMeeting: () => void; onCustomButtonClick: (ev: DailyEventObjectCustomButtonClick) => void; onJoinMeeting: () => void; + onError: (ev: DailyEventObjectFatalError) => void; }, ) => { const [{ frame, joined }, setState] = useState(USE_FRAME_INIT_STATE); @@ -134,6 +139,7 @@ const useFrame = ( if (!frame) return; frame.on("left-meeting", cbs.onLeftMeeting); frame.on("custom-button-click", cbs.onCustomButtonClick); + frame.on("error", cbs.onError); const joinCb = () => { if (!frame) { console.error("frame is null in joined-meeting callback"); @@ -145,6 +151,7 @@ const useFrame = ( return () => { frame.off("left-meeting", cbs.onLeftMeeting); frame.off("custom-button-click", cbs.onCustomButtonClick); + frame.off("error", cbs.onError); frame.off("joined-meeting", joinCb); }; }, [frame, cbs]); @@ -188,6 +195,8 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { const joinMutation = useRoomJoinMeeting(); const startRecordingMutation = useMeetingStartRecording(); const [joinedMeeting, setJoinedMeeting] = useState(null); + const [fatalError, setFatalError] = useState(null); + const fatalErrorRef = useRef(null); // Generate deterministic instanceIds so all participants use SAME IDs const cloudInstanceId = parseNonEmptyString(meeting.id); @@ -234,9 +243,20 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { const roomUrl = joinedMeeting?.room_url; const handleLeave = useCallback(() => { + // If a fatal error occurred, don't redirect — let the error UI show + if (fatalErrorRef.current) return; router.push("/browse"); }, [router]); + const handleError = useCallback((ev: DailyEventObjectFatalError) => { + const error: FatalError = { + type: ev.error?.type ?? "unknown", + message: ev.errorMsg, + }; + fatalErrorRef.current = error; + setFatalError(error); + }, []); + const handleCustomButtonClick = useCallback( (ev: DailyEventObjectCustomButtonClick) => { if (ev.button_id === CONSENT_BUTTON_ID) { @@ -324,6 +344,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { onLeftMeeting: handleLeave, onCustomButtonClick: handleCustomButtonClick, onJoinMeeting: handleFrameJoinMeeting, + onError: handleError, }); useEffect(() => { @@ -380,9 +401,79 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { } if (joinMutation.isError) { + const apiDetail = printApiError( + joinMutation.error as { + detail?: components["schemas"]["ValidationError"][]; + } | null, + ); + return ( +
+ + + {apiDetail ?? "Failed to join meeting. Please try again."} + + + +
+ ); + } + + if (fatalError) { + const renderFatalError = () => { + switch (fatalError.type) { + case "connection-error": + return ( + + + Connection lost. Please check your network. + + + + + ); + case "exp-room": + return ( + + The meeting time has ended. + + + ); + case "ejected": + return ( + + You were removed from this meeting. + + + ); + default: + return ( + + + Something went wrong: {fatalError.message} + + + + ); + } + }; return (
- Failed to join meeting. Please try again. + {renderFatalError()}
); } From 1da687fe13aa686a7b244bb3196dceb8f554281e Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 18:41:59 -0500 Subject: [PATCH 02/11] fix: prevent duplicate meetings from aggregated ICS calendar feeds When Cal.com events appear in an aggregated ICS feed, the same event gets two different UIDs (one from Cal.com, one from Google Calendar). This caused duplicate meetings to be created for the same time slot. Add time-window dedup check in create_upcoming_meetings_for_event: after verifying no meeting exists for the calendar_event_id, also check if a meeting already exists for the same room + start_date + end_date. --- server/reflector/db/meetings.py | 21 ++++ server/reflector/worker/ics_sync.py | 17 ++- server/tests/test_ics_dedup.py | 186 ++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 server/tests/test_ics_dedup.py diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py index 02f407b2..44a23920 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -346,6 +346,27 @@ async def get_by_calendar_event( return None return Meeting(**result) + async def get_by_room_and_time_window( + self, room: Room, start_date: datetime, end_date: datetime + ) -> Meeting | None: + """Check if a meeting already exists for this room with the same time window.""" + query = ( + meetings.select() + .where( + sa.and_( + meetings.c.room_id == room.id, + meetings.c.start_date == start_date, + meetings.c.end_date == end_date, + meetings.c.is_active, + ) + ) + .limit(1) + ) + 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) diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index 6e126309..bf16a47b 100644 --- a/server/reflector/worker/ics_sync.py +++ b/server/reflector/worker/ics_sync.py @@ -94,6 +94,21 @@ async def create_upcoming_meetings_for_event(event, create_window, room: Room): if existing_meeting: return + # Prevent duplicate meetings from aggregated calendar feeds + # (e.g. same event appears with different UIDs from Cal.com and Google Calendar) + end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION) + existing_by_time = await meetings_controller.get_by_room_and_time_window( + room, event.start_time, end_date + ) + if existing_by_time: + logger.info( + "Skipping duplicate calendar event - meeting already exists for this time window", + room_id=room.id, + event_id=event.id, + existing_meeting_id=existing_by_time.id, + ) + return + logger.info( "Pre-creating meeting for calendar event", room_id=room.id, @@ -102,8 +117,6 @@ 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) - client = create_platform_client(room.platform) meeting_data = await client.create_meeting( diff --git a/server/tests/test_ics_dedup.py b/server/tests/test_ics_dedup.py new file mode 100644 index 00000000..ce7d299b --- /dev/null +++ b/server/tests/test_ics_dedup.py @@ -0,0 +1,186 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.meetings import meetings +from reflector.db.rooms import rooms_controller +from reflector.worker.ics_sync import create_upcoming_meetings_for_event + + +@pytest.mark.asyncio +async def test_duplicate_calendar_event_does_not_create_duplicate_meeting(): + """When an aggregated ICS feed contains the same event with different UIDs + (e.g. Cal.com UID + Google Calendar UUID), only one meeting should be created.""" + + room = await rooms_controller.add( + name="dedup-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/dedup.ics", + ics_enabled=True, + ) + + now = datetime.now(timezone.utc) + start_time = now + timedelta(hours=1) + end_time = now + timedelta(hours=2) + + # Create first calendar event (Cal.com UID) + event1 = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid="abc123@Cal.com", + title="Team Standup", + start_time=start_time, + end_time=end_time, + ) + ) + + # create_window must be before start_time for the function to proceed + create_window = now - timedelta(minutes=6) + + # Create meeting for event1 + with patch( + "reflector.worker.ics_sync.create_platform_client" + ) as mock_platform_factory: + mock_client = AsyncMock() + mock_client.create_meeting.return_value = AsyncMock( + meeting_id="meeting-1", + room_name="dedup-test-room-abc", + room_url="https://mock.video/dedup-test-room-abc", + host_room_url="https://mock.video/dedup-test-room-abc?host=true", + ) + mock_client.upload_logo = AsyncMock() + mock_platform_factory.return_value = mock_client + + await create_upcoming_meetings_for_event(event1, create_window, room) + + # Verify meeting was created + from reflector.db import get_database + + results = await get_database().fetch_all( + meetings.select().where(meetings.c.room_id == room.id) + ) + assert len(results) == 1, f"Expected 1 meeting, got {len(results)}" + + # Create second calendar event with different UID but same time window (Google Calendar UUID) + event2 = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid="550e8400-e29b-41d4-a716-446655440000", + title="Team Standup", + start_time=start_time, + end_time=end_time, + ) + ) + + # Try to create meeting for event2 - should be skipped due to dedup + with patch( + "reflector.worker.ics_sync.create_platform_client" + ) as mock_platform_factory: + mock_client = AsyncMock() + mock_client.create_meeting.return_value = AsyncMock( + meeting_id="meeting-2", + room_name="dedup-test-room-def", + room_url="https://mock.video/dedup-test-room-def", + host_room_url="https://mock.video/dedup-test-room-def?host=true", + ) + mock_client.upload_logo = AsyncMock() + mock_platform_factory.return_value = mock_client + + await create_upcoming_meetings_for_event(event2, create_window, room) + + # Platform client should NOT have been called for the duplicate + mock_client.create_meeting.assert_not_called() + + # Verify still only 1 meeting + results = await get_database().fetch_all( + meetings.select().where(meetings.c.room_id == room.id) + ) + assert len(results) == 1, f"Expected 1 meeting after dedup, got {len(results)}" + + +@pytest.mark.asyncio +async def test_different_time_windows_create_separate_meetings(): + """Events at different times should create separate meetings, even if titles match.""" + + room = await rooms_controller.add( + name="dedup-diff-time-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/dedup2.ics", + ics_enabled=True, + ) + + now = datetime.now(timezone.utc) + create_window = now - timedelta(minutes=6) + + # Event 1: 1-2pm + event1 = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid="event-morning@Cal.com", + title="Team Standup", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + ) + ) + + # Event 2: 3-4pm (different time) + event2 = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid="event-afternoon@Cal.com", + title="Team Standup", + start_time=now + timedelta(hours=3), + end_time=now + timedelta(hours=4), + ) + ) + + with patch( + "reflector.worker.ics_sync.create_platform_client" + ) as mock_platform_factory: + mock_client = AsyncMock() + + call_count = 0 + + async def mock_create_meeting(room_name_prefix, end_date, room): + nonlocal call_count + call_count += 1 + return AsyncMock( + meeting_id=f"meeting-{call_count}", + room_name=f"dedup-diff-time-room-{call_count}", + room_url=f"https://mock.video/dedup-diff-time-room-{call_count}", + host_room_url=f"https://mock.video/dedup-diff-time-room-{call_count}?host=true", + ) + + mock_client.create_meeting = mock_create_meeting + mock_client.upload_logo = AsyncMock() + mock_platform_factory.return_value = mock_client + + await create_upcoming_meetings_for_event(event1, create_window, room) + await create_upcoming_meetings_for_event(event2, create_window, room) + + from reflector.db import get_database + + results = await get_database().fetch_all( + meetings.select().where(meetings.c.room_id == room.id) + ) + assert ( + len(results) == 2 + ), f"Expected 2 meetings for different times, got {len(results)}" From 238d76849900784be2f1d0c9bab4552f140595d3 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 18:50:12 -0500 Subject: [PATCH 03/11] fix: address review feedback - use ApiError type, move inline imports --- server/tests/test_ics_dedup.py | 5 +---- www/app/[roomName]/components/DailyRoom.tsx | 8 ++------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/server/tests/test_ics_dedup.py b/server/tests/test_ics_dedup.py index ce7d299b..4c03822e 100644 --- a/server/tests/test_ics_dedup.py +++ b/server/tests/test_ics_dedup.py @@ -3,6 +3,7 @@ import pytest +from reflector.db import get_database from reflector.db.calendar_events import CalendarEvent, calendar_events_controller from reflector.db.meetings import meetings from reflector.db.rooms import rooms_controller @@ -64,8 +65,6 @@ async def test_duplicate_calendar_event_does_not_create_duplicate_meeting(): await create_upcoming_meetings_for_event(event1, create_window, room) # Verify meeting was created - from reflector.db import get_database - results = await get_database().fetch_all( meetings.select().where(meetings.c.room_id == room.id) ) @@ -176,8 +175,6 @@ async def mock_create_meeting(room_name_prefix, end_date, room): await create_upcoming_meetings_for_event(event1, create_window, room) await create_upcoming_meetings_for_event(event2, create_window, room) - from reflector.db import get_database - results = await get_database().fetch_all( meetings.select().where(meetings.c.room_id == room.id) ) diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index e6eccfc4..02feafbc 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -21,7 +21,7 @@ import DailyIframe, { DailyParticipantsObject, } from "@daily-co/daily-js"; import type { components } from "../../reflector-api"; -import { printApiError } from "../../api/_error"; +import { printApiError, ApiError } from "../../api/_error"; import { useAuth } from "../../lib/AuthProvider"; import { useConsentDialog } from "../../lib/consent"; import { @@ -401,11 +401,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { } if (joinMutation.isError) { - const apiDetail = printApiError( - joinMutation.error as { - detail?: components["schemas"]["ValidationError"][]; - } | null, - ); + const apiDetail = printApiError(joinMutation.error as ApiError); return (
From a2694650fdcc36f6f7c9399e9a50e5bc0592fb9a Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 19:16:58 -0500 Subject: [PATCH 04/11] chore: add REPORT.md and ref markers for ApiError cast issue --- REPORT.md | 66 +++++++++++++++++++++ www/app/[roomName]/components/DailyRoom.tsx | 4 +- www/app/lib/apiHooks.ts | 1 + 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 REPORT.md diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 00000000..b55f20c5 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,66 @@ +# Fix: Daily.co UX & ICS Meeting Dedup + +Branch: `fix-daily-ux` (worktree at `.worktrees/fix-daily-ux`) +Base: `main` (`1ce1c7a9`) + +## Context / Incident + +Feb 2, ~11:30 AM Montreal — user JLee reported being "kicked out / can't rejoin" Max's room. Screenshot showed "Failed to join meeting. try again." Two active meetings were visible with ~1s creation difference, plus one recently deactivated. + +### Root Cause Analysis + +**The kick**: Exact cause unknown (server logs lost — container recreated ~Feb 3). `eject_at_room_exp` is NOT set on Daily rooms (defaults false), so room expiry did NOT eject users. Most likely a WebRTC connection drop or Daily.co infrastructure hiccup. After disconnection, rejoin failed because the meeting had passed `end_date` → join endpoint returned 400 "Meeting has ended" → frontend showed generic error. + +**The duplicate meetings**: Max's room uses an aggregated ICS feed (user.fm) that merges Cal.com + Google Calendar. Every Cal.com booking appears twice with different `ics_uid` values — one `@Cal.com`, one UUID from Google Calendar. Reflector deduplicates by `ics_uid`, so it creates 2 `calendar_event` rows → 2 meetings → 2 Daily rooms. This is systematic and ongoing for every Cal.com booking. Proven with production DB data across Feb 2, 3, 5. + +## What's Done + +### Commit 1: `e9e16764` — Daily.co error messages +**File**: `www/app/[roomName]/components/DailyRoom.tsx` + +- Added `frame.on("error", handler)` for Daily.co `DailyEventObjectFatalError` events +- `fatalError` state + ref to distinguish forced disconnect from voluntary leave +- Specific error screens per `error.type`: + - `connection-error` → "Connection lost" + "Try Rejoining" (page reload) + "Leave" + - `exp-room` → "Meeting time has ended" + "Back to Room" + - `ejected` → "You were removed" + "Back to Room" + - fallback → shows `errorMsg` + "Back to Room" +- Join failure (`joinMutation.isError`) now shows actual API error detail via `printApiError` instead of generic message +- Added "Back to Room" button on join failure screen + +### Commit 2: `1da687fe` — ICS meeting dedup +**Files**: `server/reflector/db/meetings.py`, `server/reflector/worker/ics_sync.py`, `server/tests/test_ics_dedup.py` + +- `MeetingController.get_by_room_and_time_window(room, start_date, end_date)` — queries for existing active meeting with exact same room + start + end times +- In `create_upcoming_meetings_for_event`: after checking `get_by_calendar_event`, also checks `get_by_room_and_time_window`. If a meeting already exists for the same time slot, skips creation and logs it. +- 2 tests: dedup prevents duplicate, different times still create separate meetings. Both pass. + +### Commit 3: `238d7684` — Review fixes +- Import `ApiError` type instead of inline type literal in cast +- Move `get_database` import from inline to top-of-file in test + +## What's Left / Known Issues + +### Must fix: `joinMutation.error as ApiError` cast +In `DailyRoom.tsx:404`, the `as ApiError` cast is needed because `openapi-react-query` types the error based on the OpenAPI spec (which declares `detail: ValidationError[]` for all errors), but real 400 errors return `{ detail: "string" }`. This is a known codebase-wide issue (see `apiHooks.ts:12` XXX comment). The cast is safe at runtime (`printApiError` handles both string and array), but it's a type-level lie. Proper fix: either fix the OpenAPI error response schemas, or make `printApiError` accept `unknown` and do full runtime narrowing. Both are broader changes beyond this PR's scope. + +### Not tested (needs manual verification) +- Daily.co error event rendering — requires live Daily room in browser to trigger `error` events. Cannot be tested locally without a running meeting. +- The "Try Rejoining" button simply reloads the page. Could be improved to re-call the join endpoint directly without full reload. + +### Layer A (ICS feed config) not addressed +The dedup code fix (Layer B) prevents duplicate meetings, but the root cause is Max's aggregated calendar feed including both Cal.com and Google Calendar copies. Configuring the ICS URL to point directly at Cal.com's feed (or deduplicating at the feed level) would eliminate the duplicate `calendar_event` rows too. This is a user configuration change, not a code change. + +### Dedup edge case: shared rooms +The dedup check uses exact `(room_id, start_date, end_date)` match. For shared rooms where multiple people could legitimately book the same time slot, this could incorrectly skip a valid meeting. Currently not an issue since Max's room is personal, but worth noting if this logic is applied broadly. Could add a guard like `if not room.is_shared` if needed. + +### No DB index for dedup query +`get_by_room_and_time_window` queries on `(room_id, start_date, end_date, is_active)`. Existing `idx_meeting_room_id` index on `room_id` is sufficient for current scale. No composite index added. + +## Files Changed (total: +315/-4) +``` +www/app/[roomName]/components/DailyRoom.tsx — +93 (error event handling, error UIs) +server/reflector/db/meetings.py — +21 (get_by_room_and_time_window) +server/reflector/worker/ics_sync.py — +17/-2 (dedup check before meeting creation) +server/tests/test_ics_dedup.py — +186 (new test file, 2 tests) +``` diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 02feafbc..adf519fe 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -401,7 +401,9 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { } if (joinMutation.isError) { - const apiDetail = printApiError(joinMutation.error as ApiError); + const apiDetail = printApiError( + joinMutation.error as /*ref 095959E6-01CC-4CF0-B3A9-F65F12F046D3*/ ApiError, + ); return (
diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 788dfac6..58378f3e 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -9,6 +9,7 @@ import { MeetingId } from "./types"; import { NonEmptyString } from "./utils"; /* + * ref 095959E6-01CC-4CF0-B3A9-F65F12F046D3 * XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other * this is either a limitation or incorrect usage of Python json schema generator * or, limitation or incorrect usage of .d type generator from json schema From 4fd88b2fc16fe0edd293ae3162094843405f1c6c Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 19:35:47 -0500 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20review=20feedback=20=E2=80=94=20li?= =?UTF-8?q?teral=20error=20types,=20extract=20FatalErrorScreen,=20type=20p?= =?UTF-8?q?arams,=20fix=20mock=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- REPORT.md | 66 ---------- server/reflector/worker/ics_sync.py | 6 +- server/tests/test_ics_dedup.py | 2 +- www/app/[roomName]/components/DailyRoom.tsx | 127 +++++++++++--------- 4 files changed, 76 insertions(+), 125 deletions(-) delete mode 100644 REPORT.md diff --git a/REPORT.md b/REPORT.md deleted file mode 100644 index b55f20c5..00000000 --- a/REPORT.md +++ /dev/null @@ -1,66 +0,0 @@ -# Fix: Daily.co UX & ICS Meeting Dedup - -Branch: `fix-daily-ux` (worktree at `.worktrees/fix-daily-ux`) -Base: `main` (`1ce1c7a9`) - -## Context / Incident - -Feb 2, ~11:30 AM Montreal — user JLee reported being "kicked out / can't rejoin" Max's room. Screenshot showed "Failed to join meeting. try again." Two active meetings were visible with ~1s creation difference, plus one recently deactivated. - -### Root Cause Analysis - -**The kick**: Exact cause unknown (server logs lost — container recreated ~Feb 3). `eject_at_room_exp` is NOT set on Daily rooms (defaults false), so room expiry did NOT eject users. Most likely a WebRTC connection drop or Daily.co infrastructure hiccup. After disconnection, rejoin failed because the meeting had passed `end_date` → join endpoint returned 400 "Meeting has ended" → frontend showed generic error. - -**The duplicate meetings**: Max's room uses an aggregated ICS feed (user.fm) that merges Cal.com + Google Calendar. Every Cal.com booking appears twice with different `ics_uid` values — one `@Cal.com`, one UUID from Google Calendar. Reflector deduplicates by `ics_uid`, so it creates 2 `calendar_event` rows → 2 meetings → 2 Daily rooms. This is systematic and ongoing for every Cal.com booking. Proven with production DB data across Feb 2, 3, 5. - -## What's Done - -### Commit 1: `e9e16764` — Daily.co error messages -**File**: `www/app/[roomName]/components/DailyRoom.tsx` - -- Added `frame.on("error", handler)` for Daily.co `DailyEventObjectFatalError` events -- `fatalError` state + ref to distinguish forced disconnect from voluntary leave -- Specific error screens per `error.type`: - - `connection-error` → "Connection lost" + "Try Rejoining" (page reload) + "Leave" - - `exp-room` → "Meeting time has ended" + "Back to Room" - - `ejected` → "You were removed" + "Back to Room" - - fallback → shows `errorMsg` + "Back to Room" -- Join failure (`joinMutation.isError`) now shows actual API error detail via `printApiError` instead of generic message -- Added "Back to Room" button on join failure screen - -### Commit 2: `1da687fe` — ICS meeting dedup -**Files**: `server/reflector/db/meetings.py`, `server/reflector/worker/ics_sync.py`, `server/tests/test_ics_dedup.py` - -- `MeetingController.get_by_room_and_time_window(room, start_date, end_date)` — queries for existing active meeting with exact same room + start + end times -- In `create_upcoming_meetings_for_event`: after checking `get_by_calendar_event`, also checks `get_by_room_and_time_window`. If a meeting already exists for the same time slot, skips creation and logs it. -- 2 tests: dedup prevents duplicate, different times still create separate meetings. Both pass. - -### Commit 3: `238d7684` — Review fixes -- Import `ApiError` type instead of inline type literal in cast -- Move `get_database` import from inline to top-of-file in test - -## What's Left / Known Issues - -### Must fix: `joinMutation.error as ApiError` cast -In `DailyRoom.tsx:404`, the `as ApiError` cast is needed because `openapi-react-query` types the error based on the OpenAPI spec (which declares `detail: ValidationError[]` for all errors), but real 400 errors return `{ detail: "string" }`. This is a known codebase-wide issue (see `apiHooks.ts:12` XXX comment). The cast is safe at runtime (`printApiError` handles both string and array), but it's a type-level lie. Proper fix: either fix the OpenAPI error response schemas, or make `printApiError` accept `unknown` and do full runtime narrowing. Both are broader changes beyond this PR's scope. - -### Not tested (needs manual verification) -- Daily.co error event rendering — requires live Daily room in browser to trigger `error` events. Cannot be tested locally without a running meeting. -- The "Try Rejoining" button simply reloads the page. Could be improved to re-call the join endpoint directly without full reload. - -### Layer A (ICS feed config) not addressed -The dedup code fix (Layer B) prevents duplicate meetings, but the root cause is Max's aggregated calendar feed including both Cal.com and Google Calendar copies. Configuring the ICS URL to point directly at Cal.com's feed (or deduplicating at the feed level) would eliminate the duplicate `calendar_event` rows too. This is a user configuration change, not a code change. - -### Dedup edge case: shared rooms -The dedup check uses exact `(room_id, start_date, end_date)` match. For shared rooms where multiple people could legitimately book the same time slot, this could incorrectly skip a valid meeting. Currently not an issue since Max's room is personal, but worth noting if this logic is applied broadly. Could add a guard like `if not room.is_shared` if needed. - -### No DB index for dedup query -`get_by_room_and_time_window` queries on `(room_id, start_date, end_date, is_active)`. Existing `idx_meeting_room_id` index on `room_id` is sufficient for current scale. No composite index added. - -## Files Changed (total: +315/-4) -``` -www/app/[roomName]/components/DailyRoom.tsx — +93 (error event handling, error UIs) -server/reflector/db/meetings.py — +21 (get_by_room_and_time_window) -server/reflector/worker/ics_sync.py — +17/-2 (dedup check before meeting creation) -server/tests/test_ics_dedup.py — +186 (new test file, 2 tests) -``` diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index bf16a47b..5feb80c8 100644 --- a/server/reflector/worker/ics_sync.py +++ b/server/reflector/worker/ics_sync.py @@ -5,7 +5,7 @@ from celery.utils.log import get_task_logger from reflector.asynctask import asynctask -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 Room, rooms_controller from reflector.redis_cache import RedisAsyncLock @@ -86,7 +86,9 @@ def _should_sync(room) -> bool: MEETING_DEFAULT_DURATION = timedelta(hours=1) -async def create_upcoming_meetings_for_event(event, create_window, room: Room): +async def create_upcoming_meetings_for_event( + event: CalendarEvent, create_window: datetime, room: Room +): if event.start_time <= create_window: return existing_meeting = await meetings_controller.get_by_calendar_event(event.id, room) diff --git a/server/tests/test_ics_dedup.py b/server/tests/test_ics_dedup.py index 4c03822e..d1fa1597 100644 --- a/server/tests/test_ics_dedup.py +++ b/server/tests/test_ics_dedup.py @@ -158,7 +158,7 @@ async def test_different_time_windows_create_separate_meetings(): call_count = 0 - async def mock_create_meeting(room_name_prefix, end_date, room): + async def mock_create_meeting(room_name_prefix, *, end_date, room): nonlocal call_count call_count += 1 return AsyncMock( diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index adf519fe..0e620cb8 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -47,6 +47,70 @@ const RAW_TRACKS_NAMESPACE = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; const RECORDING_START_DELAY_MS = 2000; const RECORDING_START_MAX_RETRIES = 5; +function FatalErrorScreen({ + error, + roomName, +}: { + error: FatalError; + roomName: string; +}) { + const router = useRouter(); + switch (error.type) { + case "connection-error": + return ( +
+ + + Connection lost. Please check your network. + + + + +
+ ); + case "exp-room": + return ( +
+ + The meeting time has ended. + + +
+ ); + case "ejected": + return ( +
+ + You were removed from this meeting. + + +
+ ); + default: + return ( +
+ + Something went wrong: {error.message} + + +
+ ); + } +} + type Meeting = components["schemas"]["Meeting"]; type Room = components["schemas"]["RoomDetails"]; @@ -84,7 +148,12 @@ const USE_FRAME_INIT_STATE = { joined: false as boolean, } as const; -type FatalError = { type: string; message: string }; +type DailyFatalErrorType = + | "connection-error" + | "exp-room" + | "ejected" + | (string & {}); +type FatalError = { type: DailyFatalErrorType; message: string }; // Daily js and not Daily react used right now because daily-js allows for prebuild interface vs. -react is customizable but has no nice defaults const useFrame = ( @@ -419,61 +488,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { } if (fatalError) { - const renderFatalError = () => { - switch (fatalError.type) { - case "connection-error": - return ( - - - Connection lost. Please check your network. - - - - - ); - case "exp-room": - return ( - - The meeting time has ended. - - - ); - case "ejected": - return ( - - You were removed from this meeting. - - - ); - default: - return ( - - - Something went wrong: {fatalError.message} - - - - ); - } - }; - return ( -
- {renderFatalError()} -
- ); + return ; } if (!roomUrl) { From d3161730efbd6a07e333ddba439622e5d5a1518f Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 19:43:25 -0500 Subject: [PATCH 06/11] fix: use SDK DailyFatalErrorType, add meeting-full/not-allowed/exp-token cases, remove dead end_date fallback --- server/reflector/worker/ics_sync.py | 5 +-- www/app/[roomName]/components/DailyRoom.tsx | 43 ++++++++++++++++++--- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index 5feb80c8..f02a2ddd 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: CalendarEvent, create_window: datetime, room: Room ): @@ -98,7 +95,7 @@ async def create_upcoming_meetings_for_event( # Prevent duplicate meetings from aggregated calendar feeds # (e.g. same event appears with different UIDs from Cal.com and Google Calendar) - end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION) + end_date = event.end_time existing_by_time = await meetings_controller.get_by_room_and_time_window( room, event.start_time, end_date ) diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 0e620cb8..9790a5e0 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -17,6 +17,7 @@ import DailyIframe, { DailyCustomTrayButtons, DailyEventObjectCustomButtonClick, DailyEventObjectFatalError, + DailyFatalErrorType, DailyFactoryOptions, DailyParticipantsObject, } from "@daily-co/daily-js"; @@ -97,6 +98,41 @@ function FatalErrorScreen({
); + case "meeting-full": + return ( +
+ + This meeting is full. + + +
+ ); + case "not-allowed": + return ( +
+ + + You are not allowed to join this meeting. + + + +
+ ); + case "exp-token": + return ( +
+ + Your session has expired. + + +
+ ); default: return (
@@ -148,12 +184,7 @@ const USE_FRAME_INIT_STATE = { joined: false as boolean, } as const; -type DailyFatalErrorType = - | "connection-error" - | "exp-room" - | "ejected" - | (string & {}); -type FatalError = { type: DailyFatalErrorType; message: string }; +type FatalError = { type: DailyFatalErrorType | "unknown"; message: string }; // Daily js and not Daily react used right now because daily-js allows for prebuild interface vs. -react is customizable but has no nice defaults const useFrame = ( From 2b484aec007501a6da3dc620e9d4066c1bbe7b56 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 19:49:14 -0500 Subject: [PATCH 07/11] refactor: data-driven FatalErrorScreen, cover all DailyFatalErrorType cases --- www/app/[roomName]/components/DailyRoom.tsx | 118 +++++++------------- 1 file changed, 38 insertions(+), 80 deletions(-) diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 9790a5e0..61250e22 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -48,6 +48,24 @@ const RAW_TRACKS_NAMESPACE = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; const RECORDING_START_DELAY_MS = 2000; const RECORDING_START_MAX_RETRIES = 5; +const FATAL_ERROR_MESSAGES: Partial< + Record +> = { + "connection-error": { + message: "Connection lost. Please check your network.", + rejoinable: true, + }, + "exp-room": { message: "The meeting time has ended." }, + "exp-token": { message: "Your session has expired.", rejoinable: true }, + ejected: { message: "You were removed from this meeting." }, + "meeting-full": { message: "This meeting is full." }, + "not-allowed": { message: "You are not allowed to join this meeting." }, + "nbf-room": { message: "This meeting hasn't started yet." }, + "nbf-token": { message: "This meeting hasn't started yet." }, + "no-room": { message: "This room does not exist." }, + "end-of-life": { message: "This meeting room is no longer available." }, +}; + function FatalErrorScreen({ error, roomName, @@ -56,14 +74,17 @@ function FatalErrorScreen({ roomName: string; }) { const router = useRouter(); - switch (error.type) { - case "connection-error": - return ( -
- - - Connection lost. Please check your network. - + const info = + error.type !== "unknown" ? FATAL_ERROR_MESSAGES[error.type] : undefined; + const message = info?.message ?? `Something went wrong: ${error.message}`; + const rejoinable = info?.rejoinable ?? false; + + return ( +
+ + {message} + {rejoinable ? ( + <> @@ -73,78 +94,15 @@ function FatalErrorScreen({ > Leave - -
- ); - case "exp-room": - return ( -
- - The meeting time has ended. - - -
- ); - case "ejected": - return ( -
- - You were removed from this meeting. - - -
- ); - case "meeting-full": - return ( -
- - This meeting is full. - - -
- ); - case "not-allowed": - return ( -
- - - You are not allowed to join this meeting. - - - -
- ); - case "exp-token": - return ( -
- - Your session has expired. - - -
- ); - default: - return ( -
- - Something went wrong: {error.message} - - -
- ); - } + + ) : ( + + )} +
+
+ ); } type Meeting = components["schemas"]["Meeting"]; From f4803c0d76a5bec2d825e56a8ca51da942fd4ac5 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 19:51:27 -0500 Subject: [PATCH 08/11] fix: use function mocks with correct signature in dedup tests --- server/tests/test_ics_dedup.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/server/tests/test_ics_dedup.py b/server/tests/test_ics_dedup.py index d1fa1597..db9e2088 100644 --- a/server/tests/test_ics_dedup.py +++ b/server/tests/test_ics_dedup.py @@ -53,12 +53,16 @@ async def test_duplicate_calendar_event_does_not_create_duplicate_meeting(): "reflector.worker.ics_sync.create_platform_client" ) as mock_platform_factory: mock_client = AsyncMock() - mock_client.create_meeting.return_value = AsyncMock( - meeting_id="meeting-1", - room_name="dedup-test-room-abc", - room_url="https://mock.video/dedup-test-room-abc", - host_room_url="https://mock.video/dedup-test-room-abc?host=true", - ) + + async def mock_create_meeting_1(room_name_prefix, *, end_date, room): + return AsyncMock( + meeting_id="meeting-1", + room_name="dedup-test-room-abc", + room_url="https://mock.video/dedup-test-room-abc", + host_room_url="https://mock.video/dedup-test-room-abc?host=true", + ) + + mock_client.create_meeting = mock_create_meeting_1 mock_client.upload_logo = AsyncMock() mock_platform_factory.return_value = mock_client @@ -86,19 +90,22 @@ async def test_duplicate_calendar_event_does_not_create_duplicate_meeting(): "reflector.worker.ics_sync.create_platform_client" ) as mock_platform_factory: mock_client = AsyncMock() - mock_client.create_meeting.return_value = AsyncMock( - meeting_id="meeting-2", - room_name="dedup-test-room-def", - room_url="https://mock.video/dedup-test-room-def", - host_room_url="https://mock.video/dedup-test-room-def?host=true", - ) + create_meeting_called = False + + async def mock_create_meeting_2(room_name_prefix, *, end_date, room): + nonlocal create_meeting_called + create_meeting_called = True + + mock_client.create_meeting = mock_create_meeting_2 mock_client.upload_logo = AsyncMock() mock_platform_factory.return_value = mock_client await create_upcoming_meetings_for_event(event2, create_window, room) # Platform client should NOT have been called for the duplicate - mock_client.create_meeting.assert_not_called() + assert ( + not create_meeting_called + ), "create_meeting should not be called for duplicate" # Verify still only 1 meeting results = await get_database().fetch_all( From 6c88e054238833808a8a56f8ee8a6d337e4712f0 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 5 Feb 2026 22:06:15 -0500 Subject: [PATCH 09/11] refactor: remove redundant fatalError ref, use state directly in handleLeave --- www/app/[roomName]/components/DailyRoom.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 61250e22..c64b6d18 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -254,7 +254,6 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { const startRecordingMutation = useMeetingStartRecording(); const [joinedMeeting, setJoinedMeeting] = useState(null); const [fatalError, setFatalError] = useState(null); - const fatalErrorRef = useRef(null); // Generate deterministic instanceIds so all participants use SAME IDs const cloudInstanceId = parseNonEmptyString(meeting.id); @@ -302,16 +301,15 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { const handleLeave = useCallback(() => { // If a fatal error occurred, don't redirect — let the error UI show - if (fatalErrorRef.current) return; + if (fatalError) return; router.push("/browse"); - }, [router]); + }, [router, fatalError]); const handleError = useCallback((ev: DailyEventObjectFatalError) => { const error: FatalError = { type: ev.error?.type ?? "unknown", message: ev.errorMsg, }; - fatalErrorRef.current = error; setFatalError(error); }, []); From 8773d9fbbd5c6ce66ef62555891cc201df3b9da8 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Fri, 6 Feb 2026 11:02:20 -0500 Subject: [PATCH 10/11] test: add integration test for ICS dedup using Max's live calendar feed --- server/tests/test_ics_dedup_integration.py | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 server/tests/test_ics_dedup_integration.py diff --git a/server/tests/test_ics_dedup_integration.py b/server/tests/test_ics_dedup_integration.py new file mode 100644 index 00000000..cd90ccae --- /dev/null +++ b/server/tests/test_ics_dedup_integration.py @@ -0,0 +1,131 @@ +"""Integration test: ICS dedup against Max's real calendar feed. + +Fetches the live ICS feed, picks an upcoming event, then simulates +a duplicate (different UID, same time window) to verify dedup logic. +""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest +from icalendar import Calendar + +from reflector.db import get_database +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.meetings import meetings +from reflector.db.rooms import rooms_controller +from reflector.services.ics_sync import ICSFetchService +from reflector.worker.ics_sync import create_upcoming_meetings_for_event + +MAX_ICS_URL = ( + "https://user.fm/calendar/v1-929f6269635c3c3bebd57c32b23bf448/Max%20Calendar.ics" +) + + +def _find_upcoming_event( + fetch_service: ICSFetchService, calendar: Calendar +) -> dict | None: + """Find any upcoming event within next 48h (ignoring room matching).""" + now = datetime.now(timezone.utc) + window_end = now + timedelta(hours=48) + + for component in calendar.walk(): + if component.name != "VEVENT": + continue + status = component.get("STATUS", "").upper() + if status == "CANCELLED": + continue + event_data = fetch_service._parse_event(component) + if event_data and now < event_data["start_time"] < window_end: + return event_data + return None + + +@pytest.mark.asyncio +async def test_dedup_with_real_ics_feed(): + """Fetch Max's real ICS, pick an upcoming event, inject duplicate UID, verify dedup.""" + + # 1. Fetch and parse real ICS feed + fetch_service = ICSFetchService() + ics_content = await fetch_service.fetch_ics(MAX_ICS_URL) + calendar = fetch_service.parse_ics(ics_content) + + upcoming = _find_upcoming_event(fetch_service, calendar) + if upcoming is None: + pytest.skip("No upcoming events in Max's calendar within 48h window") + + # 2. Create a test room + room = await rooms_controller.add( + name="max-dedup-integration-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=MAX_ICS_URL, + ics_enabled=True, + ) + + start_time = upcoming["start_time"] + end_time = upcoming["end_time"] + title = upcoming["title"] + + # 3. Insert two calendar events with different UIDs but same time window + event1 = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid=upcoming["ics_uid"], # real UID from the feed + title=title, + start_time=start_time, + end_time=end_time, + ) + ) + event2 = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid=f"SYNTHETIC-DUPLICATE-{upcoming['ics_uid']}", + title=title, + start_time=start_time, + end_time=end_time, + ) + ) + + create_window = datetime.now(timezone.utc) - timedelta(minutes=6) + call_count = 0 + + # 4. Mock the platform client, run both events through meeting creation + with patch( + "reflector.worker.ics_sync.create_platform_client" + ) as mock_platform_factory: + mock_client = AsyncMock() + + async def mock_create_meeting(room_name_prefix, *, end_date, room): + nonlocal call_count + call_count += 1 + return AsyncMock( + meeting_id=f"integ-meeting-{call_count}", + room_name=f"max-dedup-integration-test-{call_count}", + room_url=f"https://mock.video/max-dedup-integration-test-{call_count}", + host_room_url=f"https://mock.video/max-dedup-integration-test-{call_count}?host=true", + ) + + mock_client.create_meeting = mock_create_meeting + mock_client.upload_logo = AsyncMock() + mock_platform_factory.return_value = mock_client + + await create_upcoming_meetings_for_event(event1, create_window, room) + await create_upcoming_meetings_for_event(event2, create_window, room) + + # 5. Verify: only 1 meeting created + results = await get_database().fetch_all( + meetings.select().where(meetings.c.room_id == room.id) + ) + assert len(results) == 1, ( + f"Expected 1 meeting (dedup should block duplicate), got {len(results)}. " + f"Event: {title} @ {start_time} - {end_time}" + ) + assert call_count == 1, f"create_meeting called {call_count} times, expected 1" From 2a9993ceeca21bbc382c90d50515ba05163300ca Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Fri, 6 Feb 2026 11:31:22 -0500 Subject: [PATCH 11/11] remove integration test with hardcoded external URL --- server/tests/test_ics_dedup_integration.py | 131 --------------------- 1 file changed, 131 deletions(-) delete mode 100644 server/tests/test_ics_dedup_integration.py diff --git a/server/tests/test_ics_dedup_integration.py b/server/tests/test_ics_dedup_integration.py deleted file mode 100644 index cd90ccae..00000000 --- a/server/tests/test_ics_dedup_integration.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Integration test: ICS dedup against Max's real calendar feed. - -Fetches the live ICS feed, picks an upcoming event, then simulates -a duplicate (different UID, same time window) to verify dedup logic. -""" - -from datetime import datetime, timedelta, timezone -from unittest.mock import AsyncMock, patch - -import pytest -from icalendar import Calendar - -from reflector.db import get_database -from reflector.db.calendar_events import CalendarEvent, calendar_events_controller -from reflector.db.meetings import meetings -from reflector.db.rooms import rooms_controller -from reflector.services.ics_sync import ICSFetchService -from reflector.worker.ics_sync import create_upcoming_meetings_for_event - -MAX_ICS_URL = ( - "https://user.fm/calendar/v1-929f6269635c3c3bebd57c32b23bf448/Max%20Calendar.ics" -) - - -def _find_upcoming_event( - fetch_service: ICSFetchService, calendar: Calendar -) -> dict | None: - """Find any upcoming event within next 48h (ignoring room matching).""" - now = datetime.now(timezone.utc) - window_end = now + timedelta(hours=48) - - for component in calendar.walk(): - if component.name != "VEVENT": - continue - status = component.get("STATUS", "").upper() - if status == "CANCELLED": - continue - event_data = fetch_service._parse_event(component) - if event_data and now < event_data["start_time"] < window_end: - return event_data - return None - - -@pytest.mark.asyncio -async def test_dedup_with_real_ics_feed(): - """Fetch Max's real ICS, pick an upcoming event, inject duplicate UID, verify dedup.""" - - # 1. Fetch and parse real ICS feed - fetch_service = ICSFetchService() - ics_content = await fetch_service.fetch_ics(MAX_ICS_URL) - calendar = fetch_service.parse_ics(ics_content) - - upcoming = _find_upcoming_event(fetch_service, calendar) - if upcoming is None: - pytest.skip("No upcoming events in Max's calendar within 48h window") - - # 2. Create a test room - room = await rooms_controller.add( - name="max-dedup-integration-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=MAX_ICS_URL, - ics_enabled=True, - ) - - start_time = upcoming["start_time"] - end_time = upcoming["end_time"] - title = upcoming["title"] - - # 3. Insert two calendar events with different UIDs but same time window - event1 = await calendar_events_controller.upsert( - CalendarEvent( - room_id=room.id, - ics_uid=upcoming["ics_uid"], # real UID from the feed - title=title, - start_time=start_time, - end_time=end_time, - ) - ) - event2 = await calendar_events_controller.upsert( - CalendarEvent( - room_id=room.id, - ics_uid=f"SYNTHETIC-DUPLICATE-{upcoming['ics_uid']}", - title=title, - start_time=start_time, - end_time=end_time, - ) - ) - - create_window = datetime.now(timezone.utc) - timedelta(minutes=6) - call_count = 0 - - # 4. Mock the platform client, run both events through meeting creation - with patch( - "reflector.worker.ics_sync.create_platform_client" - ) as mock_platform_factory: - mock_client = AsyncMock() - - async def mock_create_meeting(room_name_prefix, *, end_date, room): - nonlocal call_count - call_count += 1 - return AsyncMock( - meeting_id=f"integ-meeting-{call_count}", - room_name=f"max-dedup-integration-test-{call_count}", - room_url=f"https://mock.video/max-dedup-integration-test-{call_count}", - host_room_url=f"https://mock.video/max-dedup-integration-test-{call_count}?host=true", - ) - - mock_client.create_meeting = mock_create_meeting - mock_client.upload_logo = AsyncMock() - mock_platform_factory.return_value = mock_client - - await create_upcoming_meetings_for_event(event1, create_window, room) - await create_upcoming_meetings_for_event(event2, create_window, room) - - # 5. Verify: only 1 meeting created - results = await get_database().fetch_all( - meetings.select().where(meetings.c.room_id == room.id) - ) - assert len(results) == 1, ( - f"Expected 1 meeting (dedup should block duplicate), got {len(results)}. " - f"Event: {title} @ {start_time} - {end_time}" - ) - assert call_count == 1, f"create_meeting called {call_count} times, expected 1"