diff --git a/server/migrations/versions/a256772ef058_add_ics_uid_to_calendar_event.py b/server/migrations/versions/a256772ef058_add_ics_uid_to_calendar_event.py new file mode 100644 index 00000000..06dce024 --- /dev/null +++ b/server/migrations/versions/a256772ef058_add_ics_uid_to_calendar_event.py @@ -0,0 +1,46 @@ +"""add_ics_uid_to_calendar_event + +Revision ID: a256772ef058 +Revises: d4a1c446458c +Create Date: 2025-08-19 09:27:26.472456 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a256772ef058" +down_revision: Union[str, None] = "d4a1c446458c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("calendar_event", schema=None) as batch_op: + batch_op.add_column(sa.Column("ics_uid", sa.Text(), nullable=False)) + batch_op.drop_constraint(batch_op.f("uq_room_calendar_event"), type_="unique") + batch_op.create_unique_constraint( + "uq_room_calendar_event", ["room_id", "ics_uid"] + ) + batch_op.drop_column("external_id") + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("calendar_event", schema=None) as batch_op: + batch_op.add_column( + sa.Column("external_id", sa.TEXT(), autoincrement=False, nullable=True) + ) + batch_op.drop_constraint("uq_room_calendar_event", type_="unique") + batch_op.create_unique_constraint( + batch_op.f("uq_room_calendar_event"), ["room_id", "external_id"] + ) + batch_op.drop_column("ics_uid") + + # ### end Alembic commands ### diff --git a/server/reflector/services/ics_sync.py b/server/reflector/services/ics_sync.py index 04986096..6e34fb8c 100644 --- a/server/reflector/services/ics_sync.py +++ b/server/reflector/services/ics_sync.py @@ -36,6 +36,18 @@ class SyncStats(TypedDict): events_deleted: int +class SyncResult(TypedDict, total=False): + status: str # "success", "unchanged", "error", "skipped" + hash: str | None + events_found: int + total_events: int + events_created: int + events_updated: int + events_deleted: int + error: str | None + reason: str | None + + class ICSFetchService: def __init__(self): self.client = httpx.AsyncClient( @@ -53,8 +65,9 @@ class ICSFetchService: def extract_room_events( self, calendar: Calendar, room_name: str, room_url: str - ) -> list[EventData]: + ) -> tuple[list[EventData], int]: events = [] + total_events = 0 now = datetime.now(timezone.utc) window_start = now - timedelta(hours=1) window_end = now + timedelta(hours=24) @@ -66,18 +79,19 @@ class ICSFetchService: 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) + # Count total non-cancelled events in the time window + event_data = self._parse_event(component) + if ( + event_data + and window_start <= event_data["start_time"] <= window_end + ): + total_events += 1 - # Only include events in our time window - if ( - event_data - and window_start <= event_data["start_time"] <= window_end - ): + # Check if event matches this room + if self._event_matches_room(component, room_name, room_url): events.append(event_data) - return events + return events, total_events def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool: location = str(event.get("LOCATION", "")) @@ -160,20 +174,19 @@ class ICSFetchService: 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) + attendees = event.get("ATTENDEE", []) + if not isinstance(attendees, list): + attendees = [attendees] + for att in attendees: + 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") @@ -194,7 +207,7 @@ class ICSSyncService: def __init__(self): self.fetch_service = ICSFetchService() - async def sync_room_calendar(self, room: Room) -> dict: + async def sync_room_calendar(self, room: Room) -> SyncResult: if not room.ics_enabled or not room.ics_url: return {"status": "skipped", "reason": "ICS not configured"} @@ -210,7 +223,21 @@ class ICSSyncService: 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} + # Still parse to get event count + calendar = self.fetch_service.parse_ics(ics_content) + room_url = f"{settings.BASE_URL}/room/{room.name}" + events, total_events = self.fetch_service.extract_room_events( + calendar, room.name, room_url + ) + return { + "status": "unchanged", + "hash": content_hash, + "events_found": len(events), + "total_events": total_events, + "events_created": 0, + "events_updated": 0, + "events_deleted": 0, + } # Parse calendar calendar = self.fetch_service.parse_ics(ics_content) @@ -219,7 +246,7 @@ class ICSSyncService: room_url = f"{settings.BASE_URL}/room/{room.name}" # Extract matching events - events = self.fetch_service.extract_room_events( + events, total_events = self.fetch_service.extract_room_events( calendar, room.name, room_url ) @@ -240,6 +267,7 @@ class ICSSyncService: "status": "success", "hash": content_hash, "events_found": len(events), + "total_events": total_events, **sync_result, } diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 668d47b9..e1f6d102 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -1,6 +1,7 @@ import logging import sqlite3 from datetime import datetime, timedelta, timezone +from enum import Enum from typing import Annotated, Any, Literal, Optional import asyncpg.exceptions @@ -300,14 +301,23 @@ class ICSStatus(BaseModel): events_count: int = 0 +class SyncStatus(str, Enum): + success = "success" + unchanged = "unchanged" + error = "error" + skipped = "skipped" + + class ICSSyncResult(BaseModel): - status: str + status: SyncStatus hash: Optional[str] = None events_found: int = 0 + total_events: int = 0 events_created: int = 0 events_updated: int = 0 events_deleted: int = 0 error: Optional[str] = None + reason: Optional[str] = None @router.post("/rooms/{room_name}/ics/sync", response_model=ICSSyncResult) diff --git a/server/test.ics b/server/test.ics new file mode 100644 index 00000000..486e6e43 --- /dev/null +++ b/server/test.ics @@ -0,0 +1,29 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +PRODID:-//Fastmail/2020.5/EN +X-APPLE-CALENDAR-COLOR:#0F6A0F +X-WR-CALNAME:Test reflector +X-WR-TIMEZONE:America/Costa_Rica +BEGIN:VTIMEZONE +TZID:America/Costa_Rica +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:-0600 +TZOFFSETTO:-0600 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +ATTENDEE;CN=Mathieu Virbel;PARTSTAT=ACCEPTED:MAILTO:mathieu@monadical.com +DTEND;TZID=America/Costa_Rica:20250819T143000 +DTSTAMP:20250819T155951Z +DTSTART;TZID=America/Costa_Rica:20250819T140000 +LOCATION:http://localhost:1250/room/mathieu +ORGANIZER;CN=Mathieu Virbel:MAILTO:mathieu@monadical.com +SEQUENCE:1 +SUMMARY:Checkin +TRANSP:OPAQUE +UID:867df50d-8105-4c58-9280-2b5d26cc9cd3 +END:VEVENT +END:VCALENDAR diff --git a/server/tests/test_attendee_parsing_bug.ics b/server/tests/test_attendee_parsing_bug.ics new file mode 100644 index 00000000..0c3de527 --- /dev/null +++ b/server/tests/test_attendee_parsing_bug.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +PRODID:-//Test/1.0/EN +X-WR-CALNAME:Test Attendee Bug +BEGIN:VEVENT +ATTENDEE:MAILTO:alice@example.com,bob@example.com,charlie@example.com,diana@example.com,eve@example.com,frank@example.com,george@example.com,helen@example.com,ivan@example.com,jane@example.com,kevin@example.com,laura@example.com,mike@example.com,nina@example.com,oscar@example.com,paul@example.com,queen@example.com,robert@example.com,sarah@example.com,tom@example.com,ursula@example.com,victor@example.com,wendy@example.com,xavier@example.com,yvonne@example.com,zack@example.com,amy@example.com,bill@example.com,carol@example.com +DTEND:20250819T190000Z +DTSTAMP:20250819T174000Z +DTSTART:20250819T180000Z +LOCATION:http://localhost:1250/room/test-room +ORGANIZER;CN=Test Organizer:MAILTO:organizer@example.com +SEQUENCE:1 +SUMMARY:Test Meeting with Many Attendees +UID:test-attendee-bug-event +END:VEVENT +END:VCALENDAR diff --git a/server/tests/test_attendee_parsing_bug.py b/server/tests/test_attendee_parsing_bug.py new file mode 100644 index 00000000..de3bcb56 --- /dev/null +++ b/server/tests/test_attendee_parsing_bug.py @@ -0,0 +1,167 @@ +import os +from unittest.mock import AsyncMock, patch + +import pytest + +from reflector.db.rooms import rooms_controller +from reflector.services.ics_sync import ICSSyncService + + +@pytest.mark.asyncio +async def test_attendee_parsing_bug(): + """ + Test that reproduces the attendee parsing bug where a string with comma-separated + emails gets parsed as individual characters instead of separate email addresses. + + The bug manifests as getting 29 attendees with emails like "M", "A", "I", etc. + instead of properly parsed email addresses. + """ + # Create a test 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, + ics_url="http://test.com/test.ics", + ics_enabled=True, + ) + + # Read the test ICS file that reproduces the bug + test_ics_path = os.path.join( + os.path.dirname(__file__), "test_attendee_parsing_bug.ics" + ) + with open(test_ics_path, "r") as f: + ics_content = f.read() + + # Create sync service and mock the 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 + + # Debug: Parse the ICS content directly to examine attendee parsing + calendar = sync_service.fetch_service.parse_ics(ics_content) + from reflector.settings import settings + + room_url = f"{settings.BASE_URL}/room/{room.name}" + + print(f"Room URL being used for matching: {room_url}") + print(f"ICS content:\n{ics_content}") + + events, total_events = sync_service.fetch_service.extract_room_events( + calendar, room.name, room_url + ) + + print(f"Total events in calendar: {total_events}") + print(f"Events matching room: {len(events)}") + + # Perform the sync + result = await sync_service.sync_room_calendar(room) + + # Check that the sync succeeded + assert result.get("status") == "success" + assert result.get("events_found", 0) >= 0 # Allow for debugging + + # We already have the matching events from the debug code above + assert len(events) == 1 + event = events[0] + + # This is where the bug manifests - check the attendees + attendees = event["attendees"] + + # Print attendee info for debugging + print(f"Number of attendees found: {len(attendees)}") + for i, attendee in enumerate(attendees): + print( + f"Attendee {i}: email='{attendee.get('email')}', name='{attendee.get('name')}'" + ) + + # The bug would cause individual characters to be parsed as attendees + # Check if we have the problematic parsing (emails like "M", "A", "I", etc.) + single_char_emails = [ + att for att in attendees if att.get("email") and len(att["email"]) == 1 + ] + + if single_char_emails: + print( + f"BUG DETECTED: Found {len(single_char_emails)} single-character emails:" + ) + for att in single_char_emails: + print(f" - '{att['email']}'") + + # For now, just assert that we have attendees (the test will show the bug) + # In a fix, we would expect proper email addresses, not single characters + assert len(attendees) > 0 + + if len(attendees) > 3: + pytest.fail( + f"ATTENDEE PARSING BUG DETECTED: " + f"Found {len(attendees)} attendees with {len(single_char_emails)} single-character emails. " + f"This suggests a comma-separated string was parsed as individual characters." + ) + + +@pytest.mark.asyncio +async def test_correct_attendee_parsing(): + """ + Test what correct attendee parsing should look like. + """ + from datetime import datetime, timezone + + from icalendar import Event + + from reflector.services.ics_sync import ICSFetchService + + service = ICSFetchService() + + # Create a properly formatted event with multiple attendees + event = Event() + event.add("uid", "test-correct-attendees") + event.add("summary", "Test Meeting") + event.add("location", "http://test.com/test") + event.add("dtstart", datetime.now(timezone.utc)) + event.add("dtend", datetime.now(timezone.utc)) + + # Add attendees the correct way (separate ATTENDEE lines) + event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"}) + event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"}) + event.add("attendee", "mailto:charlie@example.com", parameters={"CN": "Charlie"}) + event.add( + "organizer", "mailto:organizer@example.com", parameters={"CN": "Organizer"} + ) + + # Parse the event + result = service._parse_event(event) + + assert result is not None + attendees = result["attendees"] + + # Should have 4 attendees (3 attendees + 1 organizer) + assert len(attendees) == 4 + + # Check that all emails are valid email addresses + emails = [att["email"] for att in attendees if att.get("email")] + expected_emails = [ + "alice@example.com", + "bob@example.com", + "charlie@example.com", + "organizer@example.com", + ] + + for email in emails: + assert "@" in email, f"Invalid email format: {email}" + assert len(email) > 5, f"Email too short: {email}" + + # Check that we have the expected emails + assert "alice@example.com" in emails + assert "bob@example.com" in emails + assert "charlie@example.com" in emails + assert "organizer@example.com" in emails diff --git a/www/app/(app)/rooms/[roomName]/calendar/page.tsx b/www/app/(app)/rooms/[roomName]/calendar/page.tsx new file mode 100644 index 00000000..af3fc0fd --- /dev/null +++ b/www/app/(app)/rooms/[roomName]/calendar/page.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { + Box, + VStack, + Heading, + Text, + Card, + HStack, + Badge, + Spinner, + Flex, + Link, + Button, + Alert, + IconButton, + Tooltip, + Wrap, +} from "@chakra-ui/react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { FaSync, FaClock, FaUsers, FaEnvelope } from "react-icons/fa"; +import { LuArrowLeft } from "react-icons/lu"; +import useApi from "../../../../lib/useApi"; +import { CalendarEventResponse } from "../../../../api"; + +export default function RoomCalendarPage() { + const params = useParams(); + const router = useRouter(); + const roomName = params.roomName as string; + const api = useApi(); + + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [syncing, setSyncing] = useState(false); + + const fetchEvents = async () => { + if (!api) return; + + try { + setLoading(true); + setError(null); + const response = await api.v1RoomsListMeetings({ roomName }); + setEvents(response); + } catch (err: any) { + setError(err.body?.detail || "Failed to load calendar events"); + } finally { + setLoading(false); + } + }; + + const handleSync = async () => { + if (!api) return; + + try { + setSyncing(true); + await api.v1RoomsSyncIcs({ roomName }); + await fetchEvents(); // Refresh events after sync + } catch (err: any) { + setError(err.body?.detail || "Failed to sync calendar"); + } finally { + setSyncing(false); + } + }; + + useEffect(() => { + fetchEvents(); + }, [api, roomName]); + + const formatEventTime = (start: string, end: string) => { + const startDate = new Date(start); + const endDate = new Date(end); + const options: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", + }; + + const dateOptions: Intl.DateTimeFormatOptions = { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }; + + const isSameDay = startDate.toDateString() === endDate.toDateString(); + + if (isSameDay) { + return `${startDate.toLocaleDateString(undefined, dateOptions)} • ${startDate.toLocaleTimeString(undefined, options)} - ${endDate.toLocaleTimeString(undefined, options)}`; + } else { + return `${startDate.toLocaleDateString(undefined, dateOptions)} ${startDate.toLocaleTimeString(undefined, options)} - ${endDate.toLocaleDateString(undefined, dateOptions)} ${endDate.toLocaleTimeString(undefined, options)}`; + } + }; + + const isEventActive = (start: string, end: string) => { + const now = new Date(); + const startDate = new Date(start); + const endDate = new Date(end); + return now >= startDate && now <= endDate; + }; + + const isEventUpcoming = (start: string) => { + const now = new Date(); + const startDate = new Date(start); + const hourFromNow = new Date(now.getTime() + 60 * 60 * 1000); + return startDate > now && startDate <= hourFromNow; + }; + + const getAttendeeDisplay = (attendee: any) => { + // Use name if available, otherwise use email + const displayName = attendee.name || attendee.email || "Unknown"; + // Extract just the name part if it's in "Name " format + const cleanName = displayName.replace(/<.*>/, "").trim(); + return cleanName; + }; + + const getAttendeeEmail = (attendee: any) => { + return attendee.email || ""; + }; + + const renderAttendees = (attendees: any[]) => { + if (!attendees || attendees.length === 0) return null; + + return ( + + + Attendees: + + {attendees.map((attendee, index) => { + const email = getAttendeeEmail(attendee); + const display = getAttendeeDisplay(attendee); + + if (email && email !== display) { + return ( + + + {email} + + } + > + + {display} + + + ); + } else { + return ( + + {display} + + ); + } + })} + + + ); + }; + + const sortedEvents = [...events].sort( + (a, b) => + new Date(a.start_time).getTime() - new Date(b.start_time).getTime(), + ); + + // Separate events by status + const now = new Date(); + const activeEvents = sortedEvents.filter((e) => + isEventActive(e.start_time, e.end_time), + ); + const upcomingEvents = sortedEvents.filter( + (e) => new Date(e.start_time) > now, + ); + const pastEvents = sortedEvents + .filter((e) => new Date(e.end_time) < now) + .reverse(); + + return ( + + + + + router.push("/rooms")} + > + + + Calendar for {roomName} + + + + + {error && ( + + + {error} + + )} + + {loading ? ( + + + + ) : events.length === 0 ? ( + + + + No calendar events found. Make sure your calendar is configured + and synced. + + + + ) : ( + + {/* Active Events */} + {activeEvents.length > 0 && ( + + + Active Now + + + {activeEvents.map((event) => ( + + + + + + + {event.title || "Untitled Event"} + + Active + + + + + {formatEventTime( + event.start_time, + event.end_time, + )} + + + {event.description && ( + + {event.description} + + )} + {renderAttendees(event.attendees)} + + + + + + + + ))} + + + )} + + {/* Upcoming Events */} + {upcomingEvents.length > 0 && ( + + + Upcoming Events + + + {upcomingEvents.map((event) => ( + + + + + + {event.title || "Untitled Event"} + + {isEventUpcoming(event.start_time) && ( + Starting Soon + )} + + + + + {formatEventTime( + event.start_time, + event.end_time, + )} + + + {event.description && ( + + {event.description} + + )} + {renderAttendees(event.attendees)} + + + + ))} + + + )} + + {/* Past Events */} + {pastEvents.length > 0 && ( + + + Past Events + + + {pastEvents.slice(0, 5).map((event) => ( + + + + + {event.title || "Untitled Event"} + + + + + {formatEventTime( + event.start_time, + event.end_time, + )} + + + {renderAttendees(event.attendees)} + + + + ))} + {pastEvents.length > 5 && ( + + And {pastEvents.length - 5} more past events... + + )} + + + )} + + )} + + + ); +}