From 6f680b57954c688882c4ed49f40f161c52a00a24 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 17 Sep 2025 16:43:20 -0600 Subject: [PATCH] feat: calendar integration (#608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: calendar integration * feat: add ICS calendar API endpoints for room configuration and sync * feat: add Celery background tasks for ICS sync * 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. * feat: implement frontend for calendar integration (Phase 3 & 4) - Created MeetingSelection component for choosing between multiple active meetings - Shows both active meetings and upcoming calendar events (30 min ahead) - Displays meeting metadata with privacy controls (owner-only details) - Supports creation of unscheduled meetings alongside calendar meetings - Added waiting page for users joining before scheduled start time - Shows countdown timer until meeting begins - Auto-transitions to meeting when calendar event becomes active - Handles early joining with proper routing - Created collapsible info panel showing meeting details - Displays calendar metadata (title, description, attendees) - Shows participant count and duration - Privacy-aware: sensitive info only visible to room owners - Integrated ICS settings into room configuration dialog - Test connection functionality with immediate feedback - Manual sync trigger with detailed results - Shows last sync time and ETag for monitoring - Configurable sync intervals (1 min to 1 hour) - New /room/{roomName} route for meeting selection - Waiting room at /room/{roomName}/wait?eventId={id} - Classic room page at /{roomName} with meeting info - Uses sessionStorage to pass selected meeting between pages - Added new endpoints for active/upcoming meetings - Regenerated TypeScript client with latest OpenAPI spec - Proper error handling and loading states - Auto-refresh every 30 seconds for live updates - Color-coded badges for meeting status - Attendee status indicators (accepted/declined/tentative) - Responsive design with Chakra UI components - Clear visual hierarchy between active and upcoming meetings - Smart truncation for long attendee lists This completes the frontend implementation for calendar integration, enabling users to seamlessly join scheduled meetings from their calendar applications. * WIP: Migrate calendar integration frontend to React Query - Migrate all calendar components from useApi to React Query hooks - Fix Chakra UI v3 compatibility issues (Card, Progress, spacing props, leftIcon) - Update backend Meeting model to include calendar fields - Replace imperative API calls with declarative React Query patterns - Remove old OpenAPI generated files that conflict with new structure * fix: alembic migrations * feat: add calendar migration * feat: update ics, first version working * feat: implement tabbed interface for room edit dialog - Add General, Calendar, and Share tabs to organize room settings - Move ICS settings to dedicated Calendar tab - Move Zulip configuration to Share tab - Keep basic room settings and webhooks in General tab - Remove redundant migration file - Fix Chakra UI v3 compatibility issues in calendar components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: infinite loop * feat: improve ICS calendar sync UX and fix room URL matching - Replace "Test Connection" button with "Force Sync" button (Edit Room only) - Show detailed sync results: total events downloaded vs room matches - Remove emoticons and auto-hide timeout for cleaner UX - Fix room URL matching to use UI_BASE_URL instead of BASE_URL - Replace FaSync icon with LuRefreshCw for consistency - Clear sync results when dialog closes or Force Sync pressed - Update tests to reflect UI_BASE_URL change and exact URL matching 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: reorganize room edit dialog and fix Force Sync button - Move WebHook configuration from General to dedicated WebHook tab - Add WebHook tab after Share tab in room edit dialog - Fix Force Sync button not appearing by adding missing isEditing prop - Fix indentation issues in MeetingSelection component 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: complete calendar integration with UI improvements and code cleanup Calendar Integration Tasks: - Update upcoming meetings window from 30 to 120 minutes - Include currently happening events in upcoming meetings API - Create shared time utility functions (formatDateTime, formatCountdown, formatStartedAgo) - Improve ongoing meetings UI logic with proper time detection - Fix backend code organization and remove excessive documentation UI/UX Improvements: - Restructure room page layout using MinimalHeader pattern - Remove borders from header and footer elements - Change button text from "Leave Meeting" to "Leave Room" - Remove "Back to Reflector" footer for cleaner design - Extract WaitPageClient component for better separation Backend Changes: - calendar_events.py: Fix import organization and extend timing window - rooms.py: Update API default from 30 to 120 minutes - Enhanced test coverage for ongoing meeting scenarios Frontend Changes: - MinimalHeader: Add onLeave prop for custom navigation - MeetingSelection: Complete layout restructure with shared utilities - timeUtils: New shared utility file for consistent time formatting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: remove wait page and simplify Join button with 5-minute disable logic - Remove entire wait page directory and associated files - Update handleJoinUpcoming to create unscheduled meeting directly - Simplify Join button to single state: - Always shows "Join" text - Blue when meeting can be joined (ongoing or within 5 minutes) - Gray/disabled when more than 5 minutes away - Remove confusing "Join Now", "Join Early" text variations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: improve calendar integration and meeting UI - Refactor ICS sync tasks to use @asynctask decorator for cleaner async handling - Extract meeting creation logic into reusable function - Improve meeting selection UI with distinct current/upcoming sections - Add early join functionality for upcoming meetings within 5-minute window - Simplify non-ICS room workflow with direct Whereby embed - Fix import paths and component organization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * feat: restore original recording consent functionality - Remove custom ConsentDialogButton and WherebyEmbed components - Merge RoomClient logic back into main room page - Restore original consent UI: blue button with toast modal - Maintain calendar integration features for ICS-enabled rooms - Add consent-handler.md documentation of original functionality - Preserve focus management and accessibility features * fix: redirect Join Now button to local meeting page - Change handleJoinDirect to use onMeetingSelect instead of opening external URL - Join Now button now navigates to /{roomName}/{meetingId} instead of whereby.com - Maintains proper routing within the application * feat: remove restrictive message for non-owners in private rooms - Remove confusing message about room owner permissions - Cleaner UI for all users regardless of ownership status - Users will only see available meetings and join options * feat: improve meeting selection UI for better readability - Limit page content to max 800px width for better 4K display readability - Remove LIVE tag badge for cleaner interface - Remove shadow from main live meeting box - Remove blue border and hover effects for minimal design - Change background to neutral gray for less visual noise * feat: add room by name endpoint for non-authenticated access - Add GET /rooms/name/{room_name} backend endpoint - Endpoint supports non-authenticated access for public rooms - Returns RoomDetails with webhook fields hidden for non-owners - Update useRoomGetByName hook to use new direct endpoint - Remove authentication requirement from frontend hook - Regenerate API client types Fixes: Non-authenticated users can now access room lobbies * feat: add friendly message when no meetings are ongoing - Show centered message with calendar icon when no meetings are active - Message text: 'No meetings right now' with helpful description - Contextual text for owners/shared rooms mentioning quick meeting option - Consistent gray styling matching the rest of the interface - Only displays when both currentMeetings and upcomingMeetings are empty * style: center no meetings message and remove background - Change from Box to Flex with flex=1 for vertical centering - Remove gray background, border radius, and padding - Message now appears cleanly centered in available space - Maintains horizontal and vertical centering * feat: move Create Meeting button to header - Remove 'Start a Quick Meeting' box from main content area - Add showCreateButton and onCreateMeeting props to MinimalHeader - Create Meeting button now appears in header left of Leave Room - Only shows for room owners or shared room users - Update no meetings message to remove reference to quick meeting below - Cleaner, more accessible UI with actions in the header * style: change room title and no meetings text to pure black - Update room title in MinimalHeader from gray.700 to black - Update 'No meetings right now' text from gray.700 to black - Improves visual hierarchy and readability - Consistent with other pages' styling * style: linting * fix: remove plan files * fix: alembic migration with named foreign keys * feat: add SyncStatus enum and refactor ICS sync to use rooms controller - Add SyncStatus enum to replace string literals in ICS sync status - Replace direct SQL queries in worker with rooms_controller.get_ics_enabled() - Improve type safety and maintainability of ICS sync code - Enum values: SUCCESS, UNCHANGED, ERROR, SKIPPED maintain backward compatibility * refactor: remove unnecessary docstring from get_ics_enabled method The function name is self-explanatory * fix: import top level * feat: use Literal type for ICSStatus.status field - Changed ICSStatus.status from str to Literal['enabled', 'disabled'] - Improves type safety and API documentation * feat: update TypeScript definitions for ICSStatus Literal type - OpenAPI generation now properly reflects Literal['enabled', 'disabled'] type - Improves type safety for frontend consumers of the API - Applied automatic formatting via pre-commit hooks * refactor: replace loguru with structlog in ics_sync service - Replace loguru import with structlog in services/ics_sync.py - Update logging calls to use structlog's structured format with keyword args - Maintains consistency with other services using structlog - Changes: logger.info(f'...') -> logger.info('...', key=value) format * chore: remove loguru dependency and improve type annotations - Remove loguru from dependencies in pyproject.toml (replaced with structlog) - Update meeting controller methods to properly return Optional types - Update dependency lock file after loguru removal * fix: resolve pyflakes warnings in ics_sync and meetings modules Remove unused imports and variables to clean up code quality * Remove grace period logic and improve meeting deactivation - Removed grace_period_minutes and last_participant_left_at fields - Simplified deactivation logic based on actual usage patterns: * Active sessions: Keep meeting active regardless of scheduled time * Calendar meetings: Wait until scheduled end if unused, deactivate immediately once used and empty * On-the-fly meetings: Deactivate immediately when empty - Created migration to drop unused database columns - Updated tests to remove grace period test cases * Update test to match new deactivation logic for calendar meetings * fix: remove unwanted file * fix: incompleted changes from EVENT_WINDOW* * fix: update room ICS API tests to include required webhook fields and correct URL - Add webhook_url and webhook_secret fields to room creation tests - Fix room URL matching in ICS sync test to use UI_BASE_URL instead of BASE_URL - Aligns test with actual API requirements and ICS sync service implementation * fix: add Redis distributed locking to prevent race conditions in process_meetings - Implement per-meeting locks using Redis to prevent concurrent processing - Add lock extension after slow API calls (Whereby) to handle long-running operations - Use redis-py's built-in lock.extend() with replace_ttl=True for simple TTL refresh - Track and log skipped meetings when locked by other workers - Document SSRF analysis showing it's low-risk due to async worker isolation This prevents multiple workers from processing the same meeting simultaneously, which could cause state corruption or duplicate deactivations. * refactor: rename MinimalHeader to MeetingMinimalHeader for clarity * fix: minor code quality improvements - add emoji constants, fix type safety, cleanup comments * fix: database migration * self-pr review * self-pr review * self-pr review treeshake * fix: local fixes * fix: creation of meeting * fix: meeting selection create button * compile fix * fix: meeting selection responsive * fix: rework process logic for meeting * fix: meeting useEffect frontend-only dedupe (#647) * meeting useEffect frontend-only dedupe * format * also get room by name backend fix --------- Co-authored-by: Igor Loskutov * invalidate meeting list on new meeting * test fix * room url copy button for ics * calendar refresh quick action icon * remove work.md * meeting page frontend fixes * hide number of meeting participants * Revert "hide number of meeting participants" This reverts commit 38906c5d1a20bf5938d73ca7133fbd4a51438ce6. * ui bits * ui bits * remove log * room name typing stricten * feat: protect atomic operation involving external service with redlock --------- Co-authored-by: Claude Co-authored-by: Igor Monadical Co-authored-by: Igor Loskutov --- ...ef2_remove_one_active_meeting_per_room_.py | 53 ++ ...458c_add_grace_period_fields_to_meeting.py | 34 + .../versions/d8e204bbf615_add_calendar.py | 129 +++ ...dc035ff72fd5_remove_grace_period_fields.py | 43 + server/pyproject.toml | 2 +- server/reflector/auth/auth_jwt.py | 3 +- server/reflector/db/__init__.py | 1 + server/reflector/db/calendar_events.py | 182 ++++ server/reflector/db/meetings.py | 89 +- server/reflector/db/rooms.py | 26 + server/reflector/redis_cache.py | 97 +++ server/reflector/services/ics_sync.py | 408 +++++++++ server/reflector/views/meetings.py | 32 + server/reflector/views/rooms.py | 422 ++++++++-- server/reflector/views/whereby.py | 5 +- server/reflector/worker/app.py | 9 + server/reflector/worker/ics_sync.py | 175 ++++ server/reflector/worker/process.py | 92 +- server/test.ics | 29 + server/tests/test_attendee_parsing_bug.ics | 18 + server/tests/test_attendee_parsing_bug.py | 192 +++++ server/tests/test_calendar_event.py | 424 ++++++++++ server/tests/test_ics_background_tasks.py | 255 ++++++ server/tests/test_ics_sync.py | 290 +++++++ server/tests/test_multiple_active_meetings.py | 167 ++++ server/tests/test_room_ics.py | 225 +++++ server/tests/test_room_ics_api.py | 390 +++++++++ server/uv.lock | 181 ++-- .../(app)/rooms/_components/ICSSettings.tsx | 343 ++++++++ www/app/(app)/rooms/_components/RoomList.tsx | 3 +- www/app/(app)/rooms/_components/RoomTable.tsx | 177 +++- www/app/(app)/rooms/page.tsx | 783 ++++++++++-------- www/app/[roomName]/MeetingSelection.tsx | 569 +++++++++++++ www/app/[roomName]/[meetingId]/constants.ts | 1 + www/app/[roomName]/[meetingId]/page.tsx | 3 + www/app/[roomName]/page.tsx | 337 +------- www/app/[roomName]/room.tsx | 437 ++++++++++ ...mMeeting.tsx => useRoomDefaultMeeting.tsx} | 29 +- www/app/api/_error.ts | 26 + www/app/api/schemas.gen.ts | 0 www/app/api/services.gen.ts | 0 www/app/api/types.gen.ts | 0 www/app/components/MeetingMinimalHeader.tsx | 101 +++ www/app/lib/WherebyWebinarEmbed.tsx | 6 +- www/app/lib/apiHooks.ts | 300 +++++-- www/app/lib/routes.ts | 7 + www/app/lib/routesClient.ts | 5 + www/app/lib/timeUtils.ts | 25 + www/app/lib/wherebyClient.ts | 22 + www/app/reflector-api.d.ts | 671 ++++++++++++++- www/app/webinars/[title]/page.tsx | 4 +- www/package.json | 1 + www/pnpm-lock.yaml | 13 + 53 files changed, 6876 insertions(+), 960 deletions(-) create mode 100644 server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py create mode 100644 server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py create mode 100644 server/migrations/versions/d8e204bbf615_add_calendar.py create mode 100644 server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py create mode 100644 server/reflector/db/calendar_events.py create mode 100644 server/reflector/services/ics_sync.py create mode 100644 server/reflector/worker/ics_sync.py create mode 100644 server/test.ics create mode 100644 server/tests/test_attendee_parsing_bug.ics create mode 100644 server/tests/test_attendee_parsing_bug.py create mode 100644 server/tests/test_calendar_event.py create mode 100644 server/tests/test_ics_background_tasks.py create mode 100644 server/tests/test_ics_sync.py create mode 100644 server/tests/test_multiple_active_meetings.py create mode 100644 server/tests/test_room_ics.py create mode 100644 server/tests/test_room_ics_api.py create mode 100644 www/app/(app)/rooms/_components/ICSSettings.tsx create mode 100644 www/app/[roomName]/MeetingSelection.tsx create mode 100644 www/app/[roomName]/[meetingId]/constants.ts create mode 100644 www/app/[roomName]/[meetingId]/page.tsx create mode 100644 www/app/[roomName]/room.tsx rename www/app/[roomName]/{useRoomMeeting.tsx => useRoomDefaultMeeting.tsx} (75%) create mode 100644 www/app/api/_error.ts delete mode 100644 www/app/api/schemas.gen.ts delete mode 100644 www/app/api/services.gen.ts delete mode 100644 www/app/api/types.gen.ts create mode 100644 www/app/components/MeetingMinimalHeader.tsx create mode 100644 www/app/lib/routes.ts create mode 100644 www/app/lib/routesClient.ts create mode 100644 www/app/lib/timeUtils.ts create mode 100644 www/app/lib/wherebyClient.ts diff --git a/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py b/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py new file mode 100644 index 00000000..4c6e2f7b --- /dev/null +++ b/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py @@ -0,0 +1,53 @@ +"""remove_one_active_meeting_per_room_constraint + +Revision ID: 6025e9b2bef2 +Revises: 2ae3db106d4e +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] = "2ae3db106d4e" +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"), + ) diff --git a/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py b/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py new file mode 100644 index 00000000..868e3479 --- /dev/null +++ b/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py @@ -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") diff --git a/server/migrations/versions/d8e204bbf615_add_calendar.py b/server/migrations/versions/d8e204bbf615_add_calendar.py new file mode 100644 index 00000000..a134989d --- /dev/null +++ b/server/migrations/versions/d8e204bbf615_add_calendar.py @@ -0,0 +1,129 @@ +"""add calendar + +Revision ID: d8e204bbf615 +Revises: d4a1c446458c +Create Date: 2025-09-10 19:56:22.295756 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "d8e204bbf615" +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! ### + op.create_table( + "calendar_event", + sa.Column("id", sa.String(), nullable=False), + sa.Column("room_id", sa.String(), nullable=False), + sa.Column("ics_uid", sa.Text(), nullable=False), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("attendees", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("location", sa.Text(), nullable=True), + sa.Column("ics_raw_data", sa.Text(), nullable=True), + sa.Column("last_synced", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "is_deleted", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["room_id"], + ["room.id"], + name="fk_calendar_event_room_id", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("room_id", "ics_uid", name="uq_room_calendar_event"), + ) + with op.batch_alter_table("calendar_event", schema=None) as batch_op: + batch_op.create_index( + "idx_calendar_event_deleted", + ["is_deleted"], + unique=False, + postgresql_where=sa.text("NOT is_deleted"), + ) + batch_op.create_index( + "idx_calendar_event_room_start", ["room_id", "start_time"], unique=False + ) + + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.add_column(sa.Column("calendar_event_id", sa.String(), nullable=True)) + batch_op.add_column( + sa.Column( + "calendar_metadata", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ) + ) + batch_op.create_index( + "idx_meeting_calendar_event", ["calendar_event_id"], unique=False + ) + batch_op.create_foreign_key( + "fk_meeting_calendar_event_id", + "calendar_event", + ["calendar_event_id"], + ["id"], + ondelete="SET NULL", + ) + + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column(sa.Column("ics_url", sa.Text(), nullable=True)) + batch_op.add_column( + sa.Column( + "ics_fetch_interval", sa.Integer(), server_default="300", nullable=True + ) + ) + batch_op.add_column( + sa.Column( + "ics_enabled", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + ) + ) + batch_op.add_column( + sa.Column("ics_last_sync", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("ics_last_etag", sa.Text(), nullable=True)) + batch_op.create_index("idx_room_ics_enabled", ["ics_enabled"], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_index("idx_room_ics_enabled") + batch_op.drop_column("ics_last_etag") + batch_op.drop_column("ics_last_sync") + batch_op.drop_column("ics_enabled") + batch_op.drop_column("ics_fetch_interval") + batch_op.drop_column("ics_url") + + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.drop_constraint("fk_meeting_calendar_event_id", type_="foreignkey") + batch_op.drop_index("idx_meeting_calendar_event") + batch_op.drop_column("calendar_metadata") + batch_op.drop_column("calendar_event_id") + + with op.batch_alter_table("calendar_event", schema=None) as batch_op: + batch_op.drop_index("idx_calendar_event_room_start") + batch_op.drop_index( + "idx_calendar_event_deleted", postgresql_where=sa.text("NOT is_deleted") + ) + + op.drop_table("calendar_event") + # ### end Alembic commands ### diff --git a/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py b/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py new file mode 100644 index 00000000..c38a0227 --- /dev/null +++ b/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py @@ -0,0 +1,43 @@ +"""remove_grace_period_fields + +Revision ID: dc035ff72fd5 +Revises: d8e204bbf615 +Create Date: 2025-09-11 10:36:45.197588 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "dc035ff72fd5" +down_revision: Union[str, None] = "d8e204bbf615" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Remove grace period columns from meeting table + op.drop_column("meeting", "last_participant_left_at") + op.drop_column("meeting", "grace_period_minutes") + + +def downgrade() -> None: + # Add back grace period columns to meeting table + op.add_column( + "meeting", + sa.Column( + "last_participant_left_at", sa.DateTime(timezone=True), nullable=True + ), + ) + op.add_column( + "meeting", + sa.Column( + "grace_period_minutes", + sa.Integer(), + server_default=sa.text("15"), + nullable=True, + ), + ) diff --git a/server/pyproject.toml b/server/pyproject.toml index 1609afe0..f63947c8 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -12,7 +12,6 @@ dependencies = [ "requests>=2.31.0", "aiortc>=1.5.0", "sortedcontainers>=2.4.0", - "loguru>=0.7.0", "pydantic-settings>=2.0.2", "structlog>=23.1.0", "uvicorn[standard]>=0.23.1", @@ -39,6 +38,7 @@ dependencies = [ "llama-index-llms-openai-like>=0.4.0", "pytest-env>=1.1.5", "webvtt-py>=0.5.0", + "icalendar>=6.0.0", ] [dependency-groups] diff --git a/server/reflector/auth/auth_jwt.py b/server/reflector/auth/auth_jwt.py index 4cc8ba03..309ab3f7 100644 --- a/server/reflector/auth/auth_jwt.py +++ b/server/reflector/auth/auth_jwt.py @@ -67,7 +67,8 @@ def current_user( try: payload = jwtauth.verify_token(token) sub = payload["sub"] - return UserInfo(sub=sub) + email = payload["email"] + return UserInfo(sub=sub, email=email) except JWTError as e: logger.error(f"JWT error: {e}") raise HTTPException(status_code=401, detail="Invalid authentication") diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py index da488a51..f79a2573 100644 --- a/server/reflector/db/__init__.py +++ b/server/reflector/db/__init__.py @@ -24,6 +24,7 @@ def get_database() -> databases.Database: # import models +import reflector.db.calendar_events # noqa import reflector.db.meetings # noqa import reflector.db.recordings # noqa import reflector.db.rooms # noqa diff --git a/server/reflector/db/calendar_events.py b/server/reflector/db/calendar_events.py new file mode 100644 index 00000000..4a88d126 --- /dev/null +++ b/server/reflector/db/calendar_events.py @@ -0,0 +1,182 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +import sqlalchemy as sa +from pydantic import BaseModel, Field +from sqlalchemy.dialects.postgresql import JSONB + +from reflector.db import get_database, metadata +from reflector.utils import generate_uuid4 + +calendar_events = sa.Table( + "calendar_event", + metadata, + sa.Column("id", sa.String, primary_key=True), + sa.Column( + "room_id", + sa.String, + sa.ForeignKey("room.id", ondelete="CASCADE", name="fk_calendar_event_room_id"), + nullable=False, + ), + sa.Column("ics_uid", sa.Text, nullable=False), + sa.Column("title", sa.Text), + sa.Column("description", sa.Text), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("attendees", JSONB), + sa.Column("location", sa.Text), + sa.Column("ics_raw_data", sa.Text), + sa.Column("last_synced", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_deleted", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("room_id", "ics_uid", name="uq_room_calendar_event"), + sa.Index("idx_calendar_event_room_start", "room_id", "start_time"), + sa.Index( + "idx_calendar_event_deleted", + "is_deleted", + postgresql_where=sa.text("NOT is_deleted"), + ), +) + + +class CalendarEvent(BaseModel): + id: str = Field(default_factory=generate_uuid4) + room_id: str + ics_uid: str + title: str | None = None + description: str | None = None + start_time: datetime + end_time: datetime + attendees: list[dict[str, Any]] | None = None + location: str | None = None + ics_raw_data: str | None = None + last_synced: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + is_deleted: bool = False + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class CalendarEventController: + async def get_by_room( + self, + room_id: str, + include_deleted: bool = False, + start_after: datetime | None = None, + end_before: datetime | None = None, + ) -> list[CalendarEvent]: + query = calendar_events.select().where(calendar_events.c.room_id == room_id) + + if not include_deleted: + query = query.where(calendar_events.c.is_deleted == False) + + if start_after: + query = query.where(calendar_events.c.start_time >= start_after) + + if end_before: + query = query.where(calendar_events.c.end_time <= end_before) + + query = query.order_by(calendar_events.c.start_time.asc()) + + results = await get_database().fetch_all(query) + return [CalendarEvent(**result) for result in results] + + async def get_upcoming( + self, room_id: str, minutes_ahead: int = 120 + ) -> list[CalendarEvent]: + """Get upcoming events for a room within the specified minutes, including currently happening events.""" + now = datetime.now(timezone.utc) + future_time = now + timedelta(minutes=minutes_ahead) + + query = ( + calendar_events.select() + .where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.is_deleted == False, + calendar_events.c.start_time <= future_time, + calendar_events.c.end_time >= now, + ) + ) + .order_by(calendar_events.c.start_time.asc()) + ) + + results = await get_database().fetch_all(query) + return [CalendarEvent(**result) for result in results] + + async def get_by_ics_uid(self, room_id: str, ics_uid: str) -> CalendarEvent | None: + query = calendar_events.select().where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.ics_uid == ics_uid, + ) + ) + result = await get_database().fetch_one(query) + return CalendarEvent(**result) if result else None + + async def upsert(self, event: CalendarEvent) -> CalendarEvent: + existing = await self.get_by_ics_uid(event.room_id, event.ics_uid) + + if existing: + event.id = existing.id + event.created_at = existing.created_at + event.updated_at = datetime.now(timezone.utc) + + query = ( + calendar_events.update() + .where(calendar_events.c.id == existing.id) + .values(**event.model_dump()) + ) + else: + query = calendar_events.insert().values(**event.model_dump()) + + await get_database().execute(query) + return event + + async def soft_delete_missing( + self, room_id: str, current_ics_uids: list[str] + ) -> int: + """Soft delete future events that are no longer in the calendar.""" + now = datetime.now(timezone.utc) + + select_query = calendar_events.select().where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.start_time > now, + calendar_events.c.is_deleted == False, + calendar_events.c.ics_uid.notin_(current_ics_uids) + if current_ics_uids + else True, + ) + ) + + to_delete = await get_database().fetch_all(select_query) + delete_count = len(to_delete) + + if delete_count > 0: + update_query = ( + calendar_events.update() + .where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.start_time > now, + calendar_events.c.is_deleted == False, + calendar_events.c.ics_uid.notin_(current_ics_uids) + if current_ics_uids + else True, + ) + ) + .values(is_deleted=True, updated_at=now) + ) + + await get_database().execute(update_query) + + return delete_count + + async def delete_by_room(self, room_id: str) -> int: + query = calendar_events.delete().where(calendar_events.c.room_id == room_id) + result = await get_database().execute(query) + return result.rowcount + + +calendar_events_controller = CalendarEventController() diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py index c3821241..12a0c187 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -1,8 +1,9 @@ from datetime import datetime -from typing import Literal +from typing import Any, Literal import sqlalchemy as sa from pydantic import BaseModel, Field +from sqlalchemy.dialects.postgresql import JSONB from reflector.db import get_database, metadata from reflector.db.rooms import Room @@ -44,13 +45,18 @@ meetings = sa.Table( nullable=False, server_default=sa.true(), ), - sa.Index("idx_meeting_room_id", "room_id"), - sa.Index( - "idx_one_active_meeting_per_room", - "room_id", - unique=True, - postgresql_where=sa.text("is_active = true"), + sa.Column( + "calendar_event_id", + sa.String, + sa.ForeignKey( + "calendar_event.id", + ondelete="SET NULL", + name="fk_meeting_calendar_event_id", + ), ), + sa.Column("calendar_metadata", JSONB), + sa.Index("idx_meeting_room_id", "room_id"), + sa.Index("idx_meeting_calendar_event", "calendar_event_id"), ) meeting_consent = sa.Table( @@ -92,6 +98,9 @@ class Meeting(BaseModel): "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" num_clients: int = 0 + is_active: bool = True + calendar_event_id: str | None = None + calendar_metadata: dict[str, Any] | None = None class MeetingController: @@ -104,6 +113,8 @@ class MeetingController: start_date: datetime, end_date: datetime, room: Room, + calendar_event_id: str | None = None, + calendar_metadata: dict[str, Any] | None = None, ): meeting = Meeting( id=id, @@ -117,6 +128,8 @@ class MeetingController: room_mode=room.room_mode, recording_type=room.recording_type, recording_trigger=room.recording_trigger, + calendar_event_id=calendar_event_id, + calendar_metadata=calendar_metadata, ) query = meetings.insert().values(**meeting.model_dump()) await get_database().execute(query) @@ -130,7 +143,16 @@ class MeetingController: self, room_name: str, ) -> Meeting | None: - query = meetings.select().where(meetings.c.room_name == room_name) + """ + Get a meeting by room name. + For backward compatibility, returns the most recent meeting. + """ + end_date = getattr(meetings.c, "end_date") + query = ( + meetings.select() + .where(meetings.c.room_name == room_name) + .order_by(end_date.desc()) + ) result = await get_database().fetch_one(query) if not result: return None @@ -138,6 +160,10 @@ class MeetingController: return Meeting(**result) async def get_active(self, room: Room, current_time: datetime) -> Meeting | None: + """ + Get latest active meeting for a room. + For backward compatibility, returns the most recent active meeting. + """ end_date = getattr(meetings.c, "end_date") query = ( meetings.select() @@ -156,6 +182,43 @@ class MeetingController: return Meeting(**result) + async def get_all_active_for_room( + self, room: Room, current_time: datetime + ) -> list[Meeting]: + 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: query = meetings.select().where(meetings.c.id == meeting_id) result = await get_database().fetch_one(query) @@ -163,6 +226,15 @@ class MeetingController: return None return Meeting(**result) + async def get_by_calendar_event(self, calendar_event_id: str) -> Meeting | None: + query = meetings.select().where( + meetings.c.calendar_event_id == calendar_event_id + ) + result = await get_database().fetch_one(query) + if not result: + return None + return Meeting(**result) + async def update_meeting(self, meeting_id: str, **kwargs): query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs) await get_database().execute(query) @@ -190,7 +262,6 @@ class MeetingConsentController: return MeetingConsent(**result) async def upsert(self, consent: MeetingConsent) -> MeetingConsent: - """Create new consent or update existing one for authenticated users""" if consent.user_id: # For authenticated users, check if consent already exists # not transactional but we're ok with that; the consents ain't deleted anyways diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index abc45e61..396c818a 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -43,7 +43,15 @@ rooms = sqlalchemy.Table( ), sqlalchemy.Column("webhook_url", sqlalchemy.String, nullable=True), sqlalchemy.Column("webhook_secret", sqlalchemy.String, nullable=True), + sqlalchemy.Column("ics_url", sqlalchemy.Text), + sqlalchemy.Column("ics_fetch_interval", sqlalchemy.Integer, server_default="300"), + sqlalchemy.Column( + "ics_enabled", sqlalchemy.Boolean, nullable=False, server_default=false() + ), + sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)), + sqlalchemy.Column("ics_last_etag", sqlalchemy.Text), sqlalchemy.Index("idx_room_is_shared", "is_shared"), + sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"), ) @@ -64,6 +72,11 @@ class Room(BaseModel): is_shared: bool = False webhook_url: str | None = None webhook_secret: str | None = None + ics_url: str | None = None + ics_fetch_interval: int = 300 + ics_enabled: bool = False + ics_last_sync: datetime | None = None + ics_last_etag: str | None = None class RoomController: @@ -114,6 +127,9 @@ class RoomController: is_shared: bool, webhook_url: str = "", webhook_secret: str = "", + ics_url: str | None = None, + ics_fetch_interval: int = 300, + ics_enabled: bool = False, ): """ Add a new room @@ -134,6 +150,9 @@ class RoomController: is_shared=is_shared, webhook_url=webhook_url, webhook_secret=webhook_secret, + ics_url=ics_url, + ics_fetch_interval=ics_fetch_interval, + ics_enabled=ics_enabled, ) query = rooms.insert().values(**room.model_dump()) try: @@ -198,6 +217,13 @@ class RoomController: return room + async def get_ics_enabled(self) -> list[Room]: + query = rooms.select().where( + rooms.c.ics_enabled == True, rooms.c.ics_url != None + ) + results = await get_database().fetch_all(query) + return [Room(**result) for result in results] + async def remove_by_id( self, room_id: str, diff --git a/server/reflector/redis_cache.py b/server/reflector/redis_cache.py index 2215149e..cb7ac3b8 100644 --- a/server/reflector/redis_cache.py +++ b/server/reflector/redis_cache.py @@ -1,10 +1,17 @@ +import asyncio import functools import json +from typing import Optional import redis +import redis.asyncio as redis_async +import structlog +from redis.exceptions import LockError from reflector.settings import settings +logger = structlog.get_logger(__name__) + redis_clients = {} @@ -21,6 +28,12 @@ def get_redis_client(db=0): return redis_clients[db] +async def get_async_redis_client(db: int = 0): + return await redis_async.from_url( + f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}/{db}" + ) + + def redis_cache(prefix="cache", duration=3600, db=settings.REDIS_CACHE_DB, argidx=1): """ Cache the result of a function in Redis. @@ -49,3 +62,87 @@ def redis_cache(prefix="cache", duration=3600, db=settings.REDIS_CACHE_DB, argid return wrapper return decorator + + +class RedisAsyncLock: + def __init__( + self, + key: str, + timeout: int = 120, + extend_interval: int = 30, + skip_if_locked: bool = False, + blocking: bool = True, + blocking_timeout: Optional[float] = None, + ): + self.key = f"async_lock:{key}" + self.timeout = timeout + self.extend_interval = extend_interval + self.skip_if_locked = skip_if_locked + self.blocking = blocking + self.blocking_timeout = blocking_timeout + self._lock = None + self._redis = None + self._extend_task = None + self._acquired = False + + async def _extend_lock_periodically(self): + while True: + try: + await asyncio.sleep(self.extend_interval) + if self._lock: + await self._lock.extend(self.timeout, replace_ttl=True) + logger.debug("Extended lock", key=self.key) + except LockError: + logger.warning("Failed to extend lock", key=self.key) + break + except asyncio.CancelledError: + break + except Exception as e: + logger.error("Error extending lock", key=self.key, error=str(e)) + break + + async def __aenter__(self): + self._redis = await get_async_redis_client() + self._lock = self._redis.lock( + self.key, + timeout=self.timeout, + blocking=self.blocking, + blocking_timeout=self.blocking_timeout, + ) + + self._acquired = await self._lock.acquire() + + if not self._acquired: + if self.skip_if_locked: + logger.warning( + "Lock already acquired by another process, skipping", key=self.key + ) + return self + else: + raise LockError(f"Failed to acquire lock: {self.key}") + + self._extend_task = asyncio.create_task(self._extend_lock_periodically()) + logger.info("Acquired lock", key=self.key) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._extend_task: + self._extend_task.cancel() + try: + await self._extend_task + except asyncio.CancelledError: + pass + + if self._acquired and self._lock: + try: + await self._lock.release() + logger.info("Released lock", key=self.key) + except LockError: + logger.debug("Lock already released or expired", key=self.key) + + if self._redis: + await self._redis.aclose() + + @property + def acquired(self) -> bool: + return self._acquired diff --git a/server/reflector/services/ics_sync.py b/server/reflector/services/ics_sync.py new file mode 100644 index 00000000..2a4855cb --- /dev/null +++ b/server/reflector/services/ics_sync.py @@ -0,0 +1,408 @@ +""" +ICS Calendar Synchronization Service + +This module provides services for fetching, parsing, and synchronizing ICS (iCalendar) +calendar feeds with room booking data in the database. + +Key Components: +- ICSFetchService: Handles HTTP fetching and parsing of ICS calendar data +- ICSSyncService: Manages the synchronization process between ICS feeds and database + +Example Usage: + # Sync a room's calendar + room = Room(id="room1", name="conference-room", ics_url="https://cal.example.com/room.ics") + result = await ics_sync_service.sync_room_calendar(room) + + # Result structure: + { + "status": "success", # success|unchanged|error|skipped + "hash": "abc123...", # MD5 hash of ICS content + "events_found": 5, # Events matching this room + "total_events": 12, # Total events in calendar within time window + "events_created": 2, # New events added to database + "events_updated": 3, # Existing events modified + "events_deleted": 1 # Events soft-deleted (no longer in calendar) + } + +Event Matching: + Events are matched to rooms by checking if the room's full URL appears in the + event's LOCATION or DESCRIPTION fields. Only events within a 25-hour window + (1 hour ago to 24 hours from now) are processed. + +Input: ICS calendar URL (e.g., "https://calendar.google.com/calendar/ical/...") +Output: EventData objects with structured calendar information: + { + "ics_uid": "event123@google.com", + "title": "Team Meeting", + "description": "Weekly sync meeting", + "location": "https://meet.company.com/conference-room", + "start_time": datetime(2024, 1, 15, 14, 0, tzinfo=UTC), + "end_time": datetime(2024, 1, 15, 15, 0, tzinfo=UTC), + "attendees": [ + {"email": "user@company.com", "name": "John Doe", "role": "ORGANIZER"}, + {"email": "attendee@company.com", "name": "Jane Smith", "status": "ACCEPTED"} + ], + "ics_raw_data": "BEGIN:VEVENT\nUID:event123@google.com\n..." + } +""" + +import hashlib +from datetime import date, datetime, timedelta, timezone +from enum import Enum +from typing import TypedDict + +import httpx +import pytz +import structlog +from icalendar import Calendar, Event + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.rooms import Room, rooms_controller +from reflector.redis_cache import RedisAsyncLock +from reflector.settings import settings + +logger = structlog.get_logger() + +EVENT_WINDOW_DELTA_START = timedelta(hours=-1) +EVENT_WINDOW_DELTA_END = timedelta(hours=24) + + +class SyncStatus(str, Enum): + SUCCESS = "success" + UNCHANGED = "unchanged" + ERROR = "error" + SKIPPED = "skipped" + + +class AttendeeData(TypedDict, total=False): + email: str | None + name: str | None + status: str | None + role: str | None + + +class EventData(TypedDict): + ics_uid: str + title: str | None + description: str | None + location: str | None + start_time: datetime + end_time: datetime + attendees: list[AttendeeData] + ics_raw_data: str + + +class SyncStats(TypedDict): + events_created: int + events_updated: int + events_deleted: int + + +class SyncResultBase(TypedDict): + status: SyncStatus + + +class SyncResult(SyncResultBase, total=False): + 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( + timeout=30.0, headers={"User-Agent": "Reflector/1.0"} + ) + + async def fetch_ics(self, url: str) -> str: + response = await self.client.get(url) + response.raise_for_status() + + return response.text + + def parse_ics(self, ics_content: str) -> Calendar: + return Calendar.from_ical(ics_content) + + def extract_room_events( + self, calendar: Calendar, room_name: str, room_url: str + ) -> tuple[list[EventData], int]: + events = [] + total_events = 0 + now = datetime.now(timezone.utc) + window_start = now + EVENT_WINDOW_DELTA_START + window_end = now + EVENT_WINDOW_DELTA_END + + for component in calendar.walk(): + if component.name != "VEVENT": + continue + + status = component.get("STATUS", "").upper() + if status == "CANCELLED": + continue + + # 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 + + # Check if event matches this room + if self._event_matches_room(component, room_name, room_url): + events.append(event_data) + + return events, total_events + + def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool: + location = str(event.get("LOCATION", "")) + description = str(event.get("DESCRIPTION", "")) + + # Only match full room URL + # XXX leaved here as a patterns, to later be extended with tinyurl or such too + patterns = [ + room_url, + ] + + # Check location and description for patterns + text_to_check = f"{location} {description}".lower() + for pattern in patterns: + if pattern.lower() in text_to_check: + return True + + return False + + def _parse_event(self, event: Event) -> EventData | None: + uid = str(event.get("UID", "")) + summary = str(event.get("SUMMARY", "")) + description = str(event.get("DESCRIPTION", "")) + location = str(event.get("LOCATION", "")) + dtstart = event.get("DTSTART") + dtend = event.get("DTEND") + + if not dtstart: + return None + + # Convert fields + start_time = self._normalize_datetime( + dtstart.dt if hasattr(dtstart, "dt") else dtstart + ) + end_time = ( + self._normalize_datetime(dtend.dt if hasattr(dtend, "dt") else dtend) + if dtend + else start_time + timedelta(hours=1) + ) + attendees = self._parse_attendees(event) + + # Get raw event data for storage + raw_data = event.to_ical().decode("utf-8") + + return { + "ics_uid": uid, + "title": summary, + "description": description, + "location": location, + "start_time": start_time, + "end_time": end_time, + "attendees": attendees, + "ics_raw_data": raw_data, + } + + def _normalize_datetime(self, dt) -> datetime: + # Ensure datetime is with timezone, if not, assume UTC + if isinstance(dt, date) and not isinstance(dt, datetime): + dt = datetime.combine(dt, datetime.min.time()) + dt = pytz.UTC.localize(dt) + elif isinstance(dt, datetime): + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + else: + dt = dt.astimezone(pytz.UTC) + + return dt + + def _parse_attendees(self, event: Event) -> list[AttendeeData]: + # Extracts attendee information from both ATTENDEE and ORGANIZER properties. + # Handles malformed comma-separated email addresses in single ATTENDEE fields + # by splitting them into separate attendee entries. Returns a list of attendee + # data including email, name, status, and role information. + final_attendees = [] + + attendees = event.get("ATTENDEE", []) + if not isinstance(attendees, list): + attendees = [attendees] + for att in attendees: + email_str = str(att).replace("mailto:", "") if att else None + + # Handle malformed comma-separated email addresses in a single ATTENDEE field + if email_str and "," in email_str: + # Split comma-separated emails and create separate attendee entries + email_parts = [email.strip() for email in email_str.split(",")] + for email in email_parts: + if email and "@" in email: + clean_email = email.replace("MAILTO:", "").replace( + "mailto:", "" + ) + att_data: AttendeeData = { + "email": clean_email, + "name": att.params.get("CN") + if hasattr(att, "params") and email == email_parts[0] + else None, + "status": att.params.get("PARTSTAT") + if hasattr(att, "params") and email == email_parts[0] + else None, + "role": att.params.get("ROLE") + if hasattr(att, "params") and email == email_parts[0] + else None, + } + final_attendees.append(att_data) + else: + # Normal single attendee + att_data: AttendeeData = { + "email": email_str, + "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, + } + final_attendees.append(att_data) + + # Add organizer + organizer = event.get("ORGANIZER") + if organizer: + org_email = ( + str(organizer).replace("mailto:", "").replace("MAILTO:", "") + if organizer + else None + ) + org_data: AttendeeData = { + "email": org_email, + "name": organizer.params.get("CN") + if hasattr(organizer, "params") + else None, + "role": "ORGANIZER", + } + final_attendees.append(org_data) + + return final_attendees + + +class ICSSyncService: + def __init__(self): + self.fetch_service = ICSFetchService() + + async def sync_room_calendar(self, room: Room) -> SyncResult: + async with RedisAsyncLock( + f"ics_sync_room:{room.id}", skip_if_locked=True + ) as lock: + if not lock.acquired: + logger.warning("ICS sync already in progress for room", room_id=room.id) + return { + "status": SyncStatus.SKIPPED, + "reason": "Sync already in progress", + } + + return await self._sync_room_calendar(room) + + async def _sync_room_calendar(self, room: Room) -> SyncResult: + if not room.ics_enabled or not room.ics_url: + return {"status": SyncStatus.SKIPPED, "reason": "ICS not configured"} + + try: + if not self._should_sync(room): + return {"status": SyncStatus.SKIPPED, "reason": "Not time to sync yet"} + + ics_content = await self.fetch_service.fetch_ics(room.ics_url) + calendar = self.fetch_service.parse_ics(ics_content) + + content_hash = hashlib.md5(ics_content.encode()).hexdigest() + if room.ics_last_etag == content_hash: + logger.info("No changes in ICS for room", room_id=room.id) + room_url = f"{settings.UI_BASE_URL}/{room.name}" + events, total_events = self.fetch_service.extract_room_events( + calendar, room.name, room_url + ) + return { + "status": SyncStatus.UNCHANGED, + "hash": content_hash, + "events_found": len(events), + "total_events": total_events, + "events_created": 0, + "events_updated": 0, + "events_deleted": 0, + } + + # Extract matching events + room_url = f"{settings.UI_BASE_URL}/{room.name}" + events, total_events = self.fetch_service.extract_room_events( + calendar, room.name, room_url + ) + sync_result = await self._sync_events_to_database(room.id, events) + + # Update room sync metadata + await rooms_controller.update( + room, + { + "ics_last_sync": datetime.now(timezone.utc), + "ics_last_etag": content_hash, + }, + mutate=False, + ) + + return { + "status": SyncStatus.SUCCESS, + "hash": content_hash, + "events_found": len(events), + "total_events": total_events, + **sync_result, + } + + except Exception as e: + logger.error("Failed to sync ICS for room", room_id=room.id, error=str(e)) + return {"status": SyncStatus.ERROR, "error": str(e)} + + def _should_sync(self, room: Room) -> bool: + if not room.ics_last_sync: + return True + + time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync + return time_since_sync.total_seconds() >= room.ics_fetch_interval + + async def _sync_events_to_database( + self, room_id: str, events: list[EventData] + ) -> SyncStats: + created = 0 + updated = 0 + + current_ics_uids = [] + + for event_data in events: + calendar_event = CalendarEvent(room_id=room_id, **event_data) + existing = await calendar_events_controller.get_by_ics_uid( + room_id, event_data["ics_uid"] + ) + + if existing: + updated += 1 + else: + created += 1 + + await calendar_events_controller.upsert(calendar_event) + current_ics_uids.append(event_data["ics_uid"]) + + # Soft delete events that are no longer in calendar + deleted = await calendar_events_controller.soft_delete_missing( + room_id, current_ics_uids + ) + + return { + "events_created": created, + "events_updated": updated, + "events_deleted": deleted, + } + + +ics_sync_service = ICSSyncService() diff --git a/server/reflector/views/meetings.py b/server/reflector/views/meetings.py index 2603d875..25987e47 100644 --- a/server/reflector/views/meetings.py +++ b/server/reflector/views/meetings.py @@ -10,6 +10,7 @@ from reflector.db.meetings import ( meeting_consent_controller, meetings_controller, ) +from reflector.db.rooms import rooms_controller router = APIRouter() @@ -41,3 +42,34 @@ async def meeting_audio_consent( updated_consent = await meeting_consent_controller.upsert(consent) return {"status": "success", "consent_id": updated_consent.id} + + +@router.patch("/meetings/{meeting_id}/deactivate") +async def meeting_deactivate( + meeting_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user)], +): + user_id = user["sub"] if user else None + if not user_id: + raise HTTPException(status_code=401, detail="Authentication required") + + meeting = await meetings_controller.get_by_id(meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + if not meeting.is_active: + return {"status": "success", "meeting_id": meeting_id} + + # Only room owner or meeting creator can deactivate + room = await rooms_controller.get_by_id(meeting.room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + if user_id != room.user_id and user_id != meeting.user_id: + raise HTTPException( + status_code=403, detail="Only the room owner can deactivate meetings" + ) + + await meetings_controller.update_meeting(meeting_id, is_active=False) + + return {"status": "success", "meeting_id": meeting_id} diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 546c1dd3..b849ae3d 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -1,34 +1,27 @@ import logging -import sqlite3 from datetime import datetime, timedelta, timezone -from typing import Annotated, Literal, Optional +from enum import Enum +from typing import Annotated, Any, Literal, Optional -import asyncpg.exceptions from fastapi import APIRouter, Depends, HTTPException from fastapi_pagination import Page from fastapi_pagination.ext.databases import apaginate from pydantic import BaseModel +from redis.exceptions import LockError import reflector.auth as auth from reflector.db import get_database +from reflector.db.calendar_events import calendar_events_controller from reflector.db.meetings import meetings_controller from reflector.db.rooms import rooms_controller +from reflector.redis_cache import RedisAsyncLock +from reflector.services.ics_sync import ics_sync_service from reflector.settings import settings from reflector.whereby import create_meeting, upload_logo from reflector.worker.webhook import test_webhook logger = logging.getLogger(__name__) -router = APIRouter() - - -def parse_datetime_with_timezone(iso_string: str) -> datetime: - """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive).""" - dt = datetime.fromisoformat(iso_string) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - class Room(BaseModel): id: str @@ -43,6 +36,11 @@ class Room(BaseModel): recording_type: str recording_trigger: str is_shared: bool + ics_url: Optional[str] = None + ics_fetch_interval: int = 300 + ics_enabled: bool = False + ics_last_sync: Optional[datetime] = None + ics_last_etag: Optional[str] = None class RoomDetails(Room): @@ -54,10 +52,22 @@ class Meeting(BaseModel): id: str room_name: str room_url: str + # TODO it's not always present, | None host_room_url: str start_date: datetime end_date: datetime + user_id: str | None = None + room_id: str | None = None + is_locked: bool = False + room_mode: Literal["normal", "group"] = "normal" recording_type: Literal["none", "local", "cloud"] = "cloud" + recording_trigger: Literal[ + "none", "prompt", "automatic", "automatic-2nd-participant" + ] = "automatic-2nd-participant" + num_clients: int = 0 + is_active: bool = True + calendar_event_id: str | None = None + calendar_metadata: dict[str, Any] | None = None class CreateRoom(BaseModel): @@ -72,20 +82,30 @@ class CreateRoom(BaseModel): is_shared: bool webhook_url: str webhook_secret: str + ics_url: Optional[str] = None + ics_fetch_interval: int = 300 + ics_enabled: bool = False class UpdateRoom(BaseModel): - name: str - zulip_auto_post: bool - zulip_stream: str - zulip_topic: str - is_locked: bool - room_mode: str - recording_type: str - recording_trigger: str - is_shared: bool - webhook_url: str - webhook_secret: str + name: Optional[str] = None + zulip_auto_post: Optional[bool] = None + zulip_stream: Optional[str] = None + zulip_topic: Optional[str] = None + is_locked: Optional[bool] = None + room_mode: Optional[str] = None + recording_type: Optional[str] = None + recording_trigger: Optional[str] = None + is_shared: Optional[bool] = None + webhook_url: Optional[str] = None + webhook_secret: Optional[str] = None + ics_url: Optional[str] = None + ics_fetch_interval: Optional[int] = None + ics_enabled: Optional[bool] = None + + +class CreateRoomMeeting(BaseModel): + allow_duplicated: Optional[bool] = False class DeletionStatus(BaseModel): @@ -100,6 +120,59 @@ class WebhookTestResult(BaseModel): response_preview: str | None = None +class ICSStatus(BaseModel): + status: Literal["enabled", "disabled"] + last_sync: Optional[datetime] = None + next_sync: Optional[datetime] = None + last_etag: Optional[str] = None + events_count: int = 0 + + +class SyncStatus(str, Enum): + success = "success" + unchanged = "unchanged" + error = "error" + skipped = "skipped" + + +class ICSSyncResult(BaseModel): + 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 + + +class CalendarEventResponse(BaseModel): + id: str + room_id: str + ics_uid: str + title: Optional[str] = None + description: Optional[str] = None + start_time: datetime + end_time: datetime + attendees: Optional[list[dict]] = None + location: Optional[str] = None + last_synced: datetime + created_at: datetime + updated_at: datetime + + +router = APIRouter() + + +def parse_datetime_with_timezone(iso_string: str) -> datetime: + """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive).""" + dt = datetime.fromisoformat(iso_string) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + @router.get("/rooms", response_model=Page[RoomDetails]) async def rooms_list( user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], @@ -129,6 +202,30 @@ async def rooms_get( return room +@router.get("/rooms/name/{room_name}", response_model=RoomDetails) +async def rooms_get_by_name( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + # Convert to RoomDetails format (add webhook fields if user is owner) + room_dict = room.__dict__.copy() + if user_id == room.user_id: + # User is owner, include webhook details if available + room_dict["webhook_url"] = getattr(room, "webhook_url", None) + room_dict["webhook_secret"] = getattr(room, "webhook_secret", None) + else: + # Non-owner, hide webhook details + room_dict["webhook_url"] = None + room_dict["webhook_secret"] = None + + return RoomDetails(**room_dict) + + @router.post("/rooms", response_model=Room) async def rooms_create( room: CreateRoom, @@ -149,6 +246,9 @@ async def rooms_create( is_shared=room.is_shared, webhook_url=room.webhook_url, webhook_secret=room.webhook_secret, + ics_url=room.ics_url, + ics_fetch_interval=room.ics_fetch_interval, + ics_enabled=room.ics_enabled, ) @@ -183,6 +283,7 @@ async def rooms_delete( @router.post("/rooms/{room_name}/meeting", response_model=Meeting) async def rooms_create_meeting( room_name: str, + info: CreateRoomMeeting, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): user_id = user["sub"] if user else None @@ -190,50 +291,44 @@ async def rooms_create_meeting( if not room: raise HTTPException(status_code=404, detail="Room not found") - current_time = datetime.now(timezone.utc) - meeting = await meetings_controller.get_active(room=room, current_time=current_time) + try: + async with RedisAsyncLock( + f"create_meeting:{room_name}", + timeout=30, + extend_interval=10, + blocking_timeout=5.0, + ) as lock: + current_time = datetime.now(timezone.utc) - if meeting is None: - end_date = current_time + timedelta(hours=8) + meeting = None + if not info.allow_duplicated: + meeting = await meetings_controller.get_active( + room=room, current_time=current_time + ) - whereby_meeting = await create_meeting("", end_date=end_date, room=room) - - await upload_logo(whereby_meeting["roomName"], "./images/logo.png") - - # Now try to save to database - try: - meeting = await meetings_controller.create( - id=whereby_meeting["meetingId"], - room_name=whereby_meeting["roomName"], - room_url=whereby_meeting["roomUrl"], - host_room_url=whereby_meeting["hostRoomUrl"], - start_date=parse_datetime_with_timezone(whereby_meeting["startDate"]), - end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]), - room=room, - ) - except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError): - # Another request already created a meeting for this room - # Log this race condition occurrence - logger.warning( - "Race condition detected for room %s and meeting %s - fetching existing meeting", - room.name, - whereby_meeting["meetingId"], - ) - - # Fetch the meeting that was created by the other request - meeting = await meetings_controller.get_active( - room=room, current_time=current_time - ) if meeting is None: - # Edge case: meeting was created but expired/deleted between checks - logger.error( - "Meeting disappeared after race condition for room %s", - room.name, - exc_info=True, - ) - raise HTTPException( - status_code=503, detail="Unable to join meeting - please try again" + end_date = current_time + timedelta(hours=8) + + whereby_meeting = await create_meeting("", end_date=end_date, room=room) + + await upload_logo(whereby_meeting["roomName"], "./images/logo.png") + + meeting = await meetings_controller.create( + id=whereby_meeting["meetingId"], + room_name=whereby_meeting["roomName"], + room_url=whereby_meeting["roomUrl"], + host_room_url=whereby_meeting["hostRoomUrl"], + start_date=parse_datetime_with_timezone( + whereby_meeting["startDate"] + ), + end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]), + room=room, ) + except LockError: + logger.warning("Failed to acquire lock for room %s within timeout", room_name) + raise HTTPException( + status_code=503, detail="Meeting creation in progress, please try again" + ) if user_id != room.user_id: meeting.host_room_url = "" @@ -260,3 +355,202 @@ async def rooms_test_webhook( result = await test_webhook(room_id) return WebhookTestResult(**result) + + +@router.post("/rooms/{room_name}/ics/sync", response_model=ICSSyncResult) +async def rooms_sync_ics( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + if user_id != room.user_id: + raise HTTPException( + status_code=403, detail="Only room owner can trigger ICS sync" + ) + + if not room.ics_enabled or not room.ics_url: + raise HTTPException(status_code=400, detail="ICS not configured for this room") + + result = await ics_sync_service.sync_room_calendar(room) + + if result["status"] == "error": + raise HTTPException( + status_code=500, detail=result.get("error", "Unknown error") + ) + + return ICSSyncResult(**result) + + +@router.get("/rooms/{room_name}/ics/status", response_model=ICSStatus) +async def rooms_ics_status( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + if user_id != room.user_id: + raise HTTPException( + status_code=403, detail="Only room owner can view ICS status" + ) + + next_sync = None + if room.ics_enabled and room.ics_last_sync: + next_sync = room.ics_last_sync + timedelta(seconds=room.ics_fetch_interval) + + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + + return ICSStatus( + status="enabled" if room.ics_enabled else "disabled", + last_sync=room.ics_last_sync, + next_sync=next_sync, + last_etag=room.ics_last_etag, + events_count=len(events), + ) + + +@router.get("/rooms/{room_name}/meetings", response_model=list[CalendarEventResponse]) +async def rooms_list_meetings( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + + if user_id != room.user_id: + for event in events: + event.description = None + event.attendees = None + + return events + + +@router.get( + "/rooms/{room_name}/meetings/upcoming", response_model=list[CalendarEventResponse] +) +async def rooms_list_upcoming_meetings( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + minutes_ahead: int = 120, +): + 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") + + events = await calendar_events_controller.get_upcoming( + room.id, minutes_ahead=minutes_ahead + ) + + if user_id != room.user_id: + for event in events: + event.description = None + event.attendees = None + + return events + + +@router.get("/rooms/{room_name}/meetings/active", response_model=list[Meeting]) +async def rooms_list_active_meetings( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + 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.get("/rooms/{room_name}/meetings/{meeting_id}", response_model=Meeting) +async def rooms_get_meeting( + room_name: str, + meeting_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + """Get a single meeting by ID within a specific room.""" + 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 user_id != room.user_id and not room.is_shared: + meeting.host_room_url = "" + + return meeting + + +@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)], +): + 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 diff --git a/server/reflector/views/whereby.py b/server/reflector/views/whereby.py index c1682621..d12b0a9f 100644 --- a/server/reflector/views/whereby.py +++ b/server/reflector/views/whereby.py @@ -68,8 +68,7 @@ async def whereby_webhook(event: WherebyWebhookEvent, request: Request): raise HTTPException(status_code=404, detail="Meeting not found") if event.type in ["room.client.joined", "room.client.left"]: - await meetings_controller.update_meeting( - meeting.id, num_clients=event.data["numClients"] - ) + update_data = {"num_clients": event.data["numClients"]} + await meetings_controller.update_meeting(meeting.id, **update_data) return {"status": "ok"} diff --git a/server/reflector/worker/app.py b/server/reflector/worker/app.py index e9468bd2..3c7795a2 100644 --- a/server/reflector/worker/app.py +++ b/server/reflector/worker/app.py @@ -20,6 +20,7 @@ else: "reflector.worker.healthcheck", "reflector.worker.process", "reflector.worker.cleanup", + "reflector.worker.ics_sync", ] ) @@ -37,6 +38,14 @@ else: "task": "reflector.worker.process.reprocess_failed_recordings", "schedule": crontab(hour=5, minute=0), # Midnight EST }, + "sync_all_ics_calendars": { + "task": "reflector.worker.ics_sync.sync_all_ics_calendars", + "schedule": 60.0, # Run every minute to check which rooms need sync + }, + "create_upcoming_meetings": { + "task": "reflector.worker.ics_sync.create_upcoming_meetings", + "schedule": 30.0, # Run every 30 seconds to create upcoming meetings + }, } if settings.PUBLIC_MODE: diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py new file mode 100644 index 00000000..faf62f4a --- /dev/null +++ b/server/reflector/worker/ics_sync.py @@ -0,0 +1,175 @@ +from datetime import datetime, timedelta, timezone + +import structlog +from celery import shared_task +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.meetings import meetings_controller +from reflector.db.rooms import rooms_controller +from reflector.redis_cache import RedisAsyncLock +from reflector.services.ics_sync import SyncStatus, ics_sync_service +from reflector.whereby import create_meeting, upload_logo + +logger = structlog.wrap_logger(get_task_logger(__name__)) + + +@shared_task +@asynctask +async def sync_room_ics(room_id: str): + try: + room = await rooms_controller.get_by_id(room_id) + if not room: + logger.warning("Room not found for ICS sync", room_id=room_id) + return + + if not room.ics_enabled or not room.ics_url: + logger.debug("ICS not enabled for room", room_id=room_id) + return + + logger.info("Starting ICS sync for room", room_id=room_id, room_name=room.name) + result = await ics_sync_service.sync_room_calendar(room) + + if result["status"] == SyncStatus.SUCCESS: + logger.info( + "ICS sync completed successfully", + room_id=room_id, + events_found=result.get("events_found", 0), + events_created=result.get("events_created", 0), + events_updated=result.get("events_updated", 0), + events_deleted=result.get("events_deleted", 0), + ) + elif result["status"] == SyncStatus.UNCHANGED: + logger.debug("ICS content unchanged", room_id=room_id) + elif result["status"] == SyncStatus.ERROR: + logger.error("ICS sync failed", room_id=room_id, error=result.get("error")) + else: + logger.debug( + "ICS sync skipped", room_id=room_id, reason=result.get("reason") + ) + + except Exception as e: + logger.error("Unexpected error during ICS sync", room_id=room_id, error=str(e)) + + +@shared_task +@asynctask +async def sync_all_ics_calendars(): + try: + logger.info("Starting sync for all ICS-enabled rooms") + + ics_enabled_rooms = await rooms_controller.get_ics_enabled() + logger.info(f"Found {len(ics_enabled_rooms)} rooms with ICS enabled") + + for room in ics_enabled_rooms: + if not _should_sync(room): + logger.debug("Skipping room, not time to sync yet", room_id=room.id) + continue + + sync_room_ics.delay(room.id) + + logger.info("Queued sync tasks for all eligible rooms") + + except Exception as e: + logger.error("Error in sync_all_ics_calendars", error=str(e)) + + +def _should_sync(room) -> bool: + if not room.ics_last_sync: + return True + + time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync + return time_since_sync.total_seconds() >= room.ics_fetch_interval + + +MEETING_DEFAULT_DURATION = timedelta(hours=1) + + +async def create_upcoming_meetings_for_event(event, create_window, room_id, room): + if event.start_time <= create_window: + return + existing_meeting = await meetings_controller.get_by_calendar_event(event.id) + + if existing_meeting: + return + + logger.info( + "Pre-creating meeting for calendar event", + room_id=room_id, + event_id=event.id, + event_title=event.title, + ) + + try: + end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION) + + whereby_meeting = await create_meeting( + "", + end_date=end_date, + room=room, + ) + await upload_logo(whereby_meeting["roomName"], "./images/logo.png") + + meeting = await meetings_controller.create( + id=whereby_meeting["meetingId"], + room_name=whereby_meeting["roomName"], + room_url=whereby_meeting["roomUrl"], + host_room_url=whereby_meeting["hostRoomUrl"], + start_date=datetime.fromisoformat(whereby_meeting["startDate"]), + end_date=datetime.fromisoformat(whereby_meeting["endDate"]), + room=room, + calendar_event_id=event.id, + calendar_metadata={ + "title": event.title, + "description": event.description, + "attendees": event.attendees, + }, + ) + + logger.info( + "Meeting pre-created successfully", + meeting_id=meeting.id, + event_id=event.id, + ) + + except Exception as e: + logger.error( + "Failed to pre-create meeting", + room_id=room_id, + event_id=event.id, + error=str(e), + ) + + +@shared_task +@asynctask +async def create_upcoming_meetings(): + async with RedisAsyncLock("create_upcoming_meetings", skip_if_locked=True) as lock: + if not lock.acquired: + logger.warning( + "Another worker is already creating upcoming meetings, skipping" + ) + return + + try: + logger.info("Starting creation of upcoming meetings") + + ics_enabled_rooms = await rooms_controller.get_ics_enabled() + now = datetime.now(timezone.utc) + create_window = now - timedelta(minutes=6) + + for room in ics_enabled_rooms: + events = await calendar_events_controller.get_upcoming( + room.id, + minutes_ahead=7, + ) + + for event in events: + await create_upcoming_meetings_for_event( + event, create_window, room.id, room + ) + logger.info("Completed pre-creation check for upcoming meetings") + + except Exception as e: + logger.error("Error in create_upcoming_meetings", error=str(e)) diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index 00126514..8c885d14 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -9,6 +9,7 @@ import structlog from celery import shared_task from celery.utils.log import get_task_logger from pydantic import ValidationError +from redis.exceptions import LockError from reflector.db.meetings import meetings_controller from reflector.db.recordings import Recording, recordings_controller @@ -16,6 +17,7 @@ from reflector.db.rooms import rooms_controller from reflector.db.transcripts import SourceKind, transcripts_controller from reflector.pipelines.main_file_pipeline import task_pipeline_file_process from reflector.pipelines.main_live_pipeline import asynctask +from reflector.redis_cache import get_redis_client from reflector.settings import settings from reflector.whereby import get_room_sessions @@ -147,24 +149,94 @@ async def process_recording(bucket_name: str, object_key: str): @shared_task @asynctask async def process_meetings(): + """ + Checks which meetings are still active and deactivates those that have ended. + + Deactivation logic: + - Active sessions: Keep meeting active regardless of scheduled time + - No active sessions: + * Calendar meetings: + - If previously used (had sessions): Deactivate immediately + - If never used: Keep active until scheduled end time, then deactivate + * On-the-fly meetings: Deactivate immediately (created when someone joins, + so no sessions means everyone left) + + Uses distributed locking to prevent race conditions when multiple workers + process the same meeting simultaneously. + """ logger.info("Processing meetings") meetings = await meetings_controller.get_all_active() + current_time = datetime.now(timezone.utc) + redis_client = get_redis_client() + processed_count = 0 + skipped_count = 0 + for meeting in meetings: - is_active = False - end_date = meeting.end_date - if end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=timezone.utc) - if end_date > datetime.now(timezone.utc): + logger_ = logger.bind(meeting_id=meeting.id, room_name=meeting.room_name) + lock_key = f"meeting_process_lock:{meeting.id}" + lock = redis_client.lock(lock_key, timeout=120) + + try: + if not lock.acquire(blocking=False): + logger_.debug("Meeting is being processed by another worker, skipping") + skipped_count += 1 + continue + + # Process the meeting + should_deactivate = False + end_date = meeting.end_date + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + + # This API call could be slow, extend lock if needed response = await get_room_sessions(meeting.room_name) + + try: + # Extend lock after slow operation to ensure we still hold it + lock.extend(120, replace_ttl=True) + except LockError: + logger_.warning("Lost lock for meeting, skipping") + continue + room_sessions = response.get("results", []) - is_active = not room_sessions or any( + has_active_sessions = room_sessions and any( rs["endedAt"] is None for rs in room_sessions ) - if not is_active: - await meetings_controller.update_meeting(meeting.id, is_active=False) - logger.info("Meeting %s is deactivated", meeting.id) + has_had_sessions = bool(room_sessions) - logger.info("Processed meetings") + if has_active_sessions: + logger_.debug("Meeting still has active sessions, keep it") + elif has_had_sessions: + should_deactivate = True + logger_.info("Meeting ended - all participants left") + elif current_time > end_date: + should_deactivate = True + logger_.info( + "Meeting deactivated - scheduled time ended with no participants", + meeting.id, + ) + else: + logger_.debug("Meeting not yet started, keep it") + + if should_deactivate: + await meetings_controller.update_meeting(meeting.id, is_active=False) + logger_.info("Meeting is deactivated") + + processed_count += 1 + + except Exception as e: + logger_.error(f"Error processing meeting", exc_info=True) + finally: + try: + lock.release() + except LockError: + pass # Lock already released or expired + + logger.info( + f"Processed meetings finished", + processed_count=processed_count, + skipped_count=skipped_count, + ) @shared_task diff --git a/server/test.ics b/server/test.ics new file mode 100644 index 00000000..8d0b6653 --- /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/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..1adc99fe --- /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:20250910T190000Z +DTSTAMP:20250910T174000Z +DTSTART:20250910T180000Z +LOCATION:http://localhost:3000/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..5e038761 --- /dev/null +++ b/server/tests/test_attendee_parsing_bug.py @@ -0,0 +1,192 @@ +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 and update it with current time + from datetime import datetime, timedelta, timezone + + 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() + + # Replace the dates with current time + 1 hour to ensure it's within the 24h window + now = datetime.now(timezone.utc) + future_time = now + timedelta(hours=1) + end_time = future_time + timedelta(hours=1) + + # Format dates for ICS format + dtstart = future_time.strftime("%Y%m%dT%H%M%SZ") + dtend = end_time.strftime("%Y%m%dT%H%M%SZ") + dtstamp = now.strftime("%Y%m%dT%H%M%SZ") + + # Update the ICS content with current dates + ics_content = ics_content.replace("20250910T180000Z", dtstart) + ics_content = ics_content.replace("20250910T190000Z", dtend) + ics_content = ics_content.replace("20250910T174000Z", dtstamp) + + # 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.UI_BASE_URL}/{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')}'" + ) + + # With the fix, we should now get properly parsed email addresses + # Check that no single characters are parsed as emails + 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']}'") + + # Should have attendees but not single-character emails + assert len(attendees) > 0 + assert ( + len(single_char_emails) == 0 + ), f"Found {len(single_char_emails)} single-character emails, parsing is still buggy" + + # Check that all emails are valid (contain @ symbol) + valid_emails = [ + att for att in attendees if att.get("email") and "@" in att["email"] + ] + assert len(valid_emails) == len( + attendees + ), "Some attendees don't have valid email addresses" + + # We expect around 29 attendees (28 from the comma-separated list + 1 organizer) + assert ( + len(attendees) >= 25 + ), f"Expected around 29 attendees, got {len(attendees)}" + + +@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/server/tests/test_calendar_event.py b/server/tests/test_calendar_event.py new file mode 100644 index 00000000..ece5f56a --- /dev/null +++ b/server/tests/test_calendar_event.py @@ -0,0 +1,424 @@ +""" +Tests for CalendarEvent model. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.rooms import rooms_controller + + +@pytest.mark.asyncio +async def test_calendar_event_create(): + """Test creating a calendar event.""" + # Create a room first + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + # Create calendar event + now = datetime.now(timezone.utc) + event = CalendarEvent( + room_id=room.id, + ics_uid="test-event-123", + title="Team Meeting", + description="Weekly team sync", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + location=f"https://example.com/{room.name}", + attendees=[ + {"email": "alice@example.com", "name": "Alice", "status": "ACCEPTED"}, + {"email": "bob@example.com", "name": "Bob", "status": "TENTATIVE"}, + ], + ) + + # Save event + saved_event = await calendar_events_controller.upsert(event) + + assert saved_event.ics_uid == "test-event-123" + assert saved_event.title == "Team Meeting" + assert saved_event.room_id == room.id + assert len(saved_event.attendees) == 2 + + +@pytest.mark.asyncio +async def test_calendar_event_get_by_room(): + """Test getting calendar events for a room.""" + # Create room + room = await rooms_controller.add( + name="events-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create multiple events + for i in range(3): + event = CalendarEvent( + room_id=room.id, + ics_uid=f"event-{i}", + title=f"Meeting {i}", + start_time=now + timedelta(hours=i), + end_time=now + timedelta(hours=i + 1), + ) + await calendar_events_controller.upsert(event) + + # Get events for room + events = await calendar_events_controller.get_by_room(room.id) + + assert len(events) == 3 + assert all(e.room_id == room.id for e in events) + assert events[0].title == "Meeting 0" + assert events[1].title == "Meeting 1" + assert events[2].title == "Meeting 2" + + +@pytest.mark.asyncio +async def test_calendar_event_get_upcoming(): + """Test getting upcoming events within time window.""" + # Create room + room = await rooms_controller.add( + name="upcoming-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create events at different times + # Past event (should not be included) + past_event = CalendarEvent( + room_id=room.id, + ics_uid="past-event", + title="Past Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + ) + await calendar_events_controller.upsert(past_event) + + # Upcoming event within 30 minutes + upcoming_event = CalendarEvent( + room_id=room.id, + ics_uid="upcoming-event", + title="Upcoming Meeting", + start_time=now + timedelta(minutes=15), + end_time=now + timedelta(minutes=45), + ) + await calendar_events_controller.upsert(upcoming_event) + + # Currently happening event (started 10 minutes ago, ends in 20 minutes) + current_event = CalendarEvent( + room_id=room.id, + ics_uid="current-event", + title="Current Meeting", + start_time=now - timedelta(minutes=10), + end_time=now + timedelta(minutes=20), + ) + await calendar_events_controller.upsert(current_event) + + # Future event beyond 30 minutes + future_event = CalendarEvent( + room_id=room.id, + ics_uid="future-event", + title="Future Meeting", + start_time=now + timedelta(hours=2), + end_time=now + timedelta(hours=3), + ) + await calendar_events_controller.upsert(future_event) + + # Get upcoming events (default 120 minutes) - should include current, upcoming, and future + upcoming = await calendar_events_controller.get_upcoming(room.id) + + assert len(upcoming) == 3 + # Events should be sorted by start_time (current event first, then upcoming, then future) + assert upcoming[0].ics_uid == "current-event" + assert upcoming[1].ics_uid == "upcoming-event" + assert upcoming[2].ics_uid == "future-event" + + # Get upcoming with custom window + upcoming_extended = await calendar_events_controller.get_upcoming( + room.id, minutes_ahead=180 + ) + + assert len(upcoming_extended) == 3 + # Events should be sorted by start_time + assert upcoming_extended[0].ics_uid == "current-event" + assert upcoming_extended[1].ics_uid == "upcoming-event" + assert upcoming_extended[2].ics_uid == "future-event" + + +@pytest.mark.asyncio +async def test_calendar_event_get_upcoming_includes_currently_happening(): + """Test that get_upcoming includes currently happening events but excludes ended events.""" + # Create room + room = await rooms_controller.add( + name="current-happening-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Event that ended in the past (should NOT be included) + past_ended_event = CalendarEvent( + room_id=room.id, + ics_uid="past-ended-event", + title="Past Ended Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(minutes=30), + ) + await calendar_events_controller.upsert(past_ended_event) + + # Event currently happening (started 10 minutes ago, ends in 20 minutes) - SHOULD be included + currently_happening_event = CalendarEvent( + room_id=room.id, + ics_uid="currently-happening", + title="Currently Happening Meeting", + start_time=now - timedelta(minutes=10), + end_time=now + timedelta(minutes=20), + ) + await calendar_events_controller.upsert(currently_happening_event) + + # Event starting soon (in 5 minutes) - SHOULD be included + upcoming_soon_event = CalendarEvent( + room_id=room.id, + ics_uid="upcoming-soon", + title="Upcoming Soon Meeting", + start_time=now + timedelta(minutes=5), + end_time=now + timedelta(minutes=35), + ) + await calendar_events_controller.upsert(upcoming_soon_event) + + # Get upcoming events + upcoming = await calendar_events_controller.get_upcoming(room.id, minutes_ahead=30) + + # Should only include currently happening and upcoming soon events + assert len(upcoming) == 2 + assert upcoming[0].ics_uid == "currently-happening" + assert upcoming[1].ics_uid == "upcoming-soon" + + +@pytest.mark.asyncio +async def test_calendar_event_upsert(): + """Test upserting (create/update) calendar events.""" + # Create room + room = await rooms_controller.add( + name="upsert-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create new event + event = CalendarEvent( + room_id=room.id, + ics_uid="upsert-test", + title="Original Title", + start_time=now, + end_time=now + timedelta(hours=1), + ) + + created = await calendar_events_controller.upsert(event) + assert created.title == "Original Title" + + # Update existing event + event.title = "Updated Title" + event.description = "Added description" + + updated = await calendar_events_controller.upsert(event) + assert updated.title == "Updated Title" + assert updated.description == "Added description" + assert updated.ics_uid == "upsert-test" + + # Verify only one event exists + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].title == "Updated Title" + + +@pytest.mark.asyncio +async def test_calendar_event_soft_delete(): + """Test soft deleting events no longer in calendar.""" + # Create room + room = await rooms_controller.add( + name="delete-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create multiple events + for i in range(4): + event = CalendarEvent( + room_id=room.id, + ics_uid=f"event-{i}", + title=f"Meeting {i}", + start_time=now + timedelta(hours=i), + end_time=now + timedelta(hours=i + 1), + ) + await calendar_events_controller.upsert(event) + + # Soft delete events not in current list + current_ids = ["event-0", "event-2"] # Keep events 0 and 2 + deleted_count = await calendar_events_controller.soft_delete_missing( + room.id, current_ids + ) + + assert deleted_count == 2 # Should delete events 1 and 3 + + # Get non-deleted events + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + assert len(events) == 2 + assert {e.ics_uid for e in events} == {"event-0", "event-2"} + + # Get all events including deleted + all_events = await calendar_events_controller.get_by_room( + room.id, include_deleted=True + ) + assert len(all_events) == 4 + + +@pytest.mark.asyncio +async def test_calendar_event_past_events_not_deleted(): + """Test that past events are not soft deleted.""" + # Create room + room = await rooms_controller.add( + name="past-events-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create past event + past_event = CalendarEvent( + room_id=room.id, + ics_uid="past-event", + title="Past Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + ) + await calendar_events_controller.upsert(past_event) + + # Create future event + future_event = CalendarEvent( + room_id=room.id, + ics_uid="future-event", + title="Future Meeting", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + ) + await calendar_events_controller.upsert(future_event) + + # Try to soft delete all events (only future should be deleted) + deleted_count = await calendar_events_controller.soft_delete_missing(room.id, []) + + assert deleted_count == 1 # Only future event deleted + + # Verify past event still exists + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + assert len(events) == 1 + assert events[0].ics_uid == "past-event" + + +@pytest.mark.asyncio +async def test_calendar_event_with_raw_ics_data(): + """Test storing raw ICS data with calendar event.""" + # Create room + room = await rooms_controller.add( + name="raw-ics-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + raw_ics = """BEGIN:VEVENT +UID:test-raw-123 +SUMMARY:Test Event +DTSTART:20240101T100000Z +DTEND:20240101T110000Z +END:VEVENT""" + + event = CalendarEvent( + room_id=room.id, + ics_uid="test-raw-123", + title="Test Event", + start_time=datetime.now(timezone.utc), + end_time=datetime.now(timezone.utc) + timedelta(hours=1), + ics_raw_data=raw_ics, + ) + + saved = await calendar_events_controller.upsert(event) + + assert saved.ics_raw_data == raw_ics + + # Retrieve and verify + retrieved = await calendar_events_controller.get_by_ics_uid(room.id, "test-raw-123") + assert retrieved is not None + assert retrieved.ics_raw_data == raw_ics diff --git a/server/tests/test_ics_background_tasks.py b/server/tests/test_ics_background_tasks.py new file mode 100644 index 00000000..c2bf5c87 --- /dev/null +++ b/server/tests/test_ics_background_tasks.py @@ -0,0 +1,255 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +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.rooms import rooms, rooms_controller +from reflector.services.ics_sync import ics_sync_service +from reflector.worker.ics_sync import ( + _should_sync, + sync_room_ics, +) + + +@pytest.mark.asyncio +async def test_sync_room_ics_task(): + room = await rooms_controller.add( + name="task-test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/task.ics", + ics_enabled=True, + ) + + cal = Calendar() + event = Event() + event.add("uid", "task-event-1") + event.add("summary", "Task Test Meeting") + from reflector.settings import settings + + event.add("location", f"{settings.UI_BASE_URL}/{room.name}") + now = datetime.now(timezone.utc) + event.add("dtstart", now + timedelta(hours=1)) + event.add("dtend", now + timedelta(hours=2)) + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + + with patch( + "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content + + # Call the service directly instead of the Celery task to avoid event loop issues + await ics_sync_service.sync_room_calendar(room) + + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].ics_uid == "task-event-1" + + +@pytest.mark.asyncio +async def test_sync_room_ics_disabled(): + room = await rooms_controller.add( + name="disabled-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_enabled=False, + ) + + # Test that disabled rooms are skipped by the service + result = await ics_sync_service.sync_room_calendar(room) + + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 0 + + +@pytest.mark.asyncio +async def test_sync_all_ics_calendars(): + room1 = await rooms_controller.add( + name="sync-all-1", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/1.ics", + ics_enabled=True, + ) + + room2 = await rooms_controller.add( + name="sync-all-2", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/2.ics", + ics_enabled=True, + ) + + room3 = await rooms_controller.add( + name="sync-all-3", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_enabled=False, + ) + + with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay: + # Directly call the sync_all logic without the Celery wrapper + query = rooms.select().where( + rooms.c.ics_enabled == True, rooms.c.ics_url != None + ) + all_rooms = await get_database().fetch_all(query) + + for room_data in all_rooms: + room_id = room_data["id"] + room = await rooms_controller.get_by_id(room_id) + if room and _should_sync(room): + sync_room_ics.delay(room_id) + + assert mock_delay.call_count == 2 + called_room_ids = [call.args[0] for call in mock_delay.call_args_list] + assert room1.id in called_room_ids + assert room2.id in called_room_ids + assert room3.id not in called_room_ids + + +@pytest.mark.asyncio +async def test_should_sync_logic(): + room = MagicMock() + + room.ics_last_sync = None + assert _should_sync(room) is True + + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100) + room.ics_fetch_interval = 300 + assert _should_sync(room) is False + + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400) + room.ics_fetch_interval = 300 + assert _should_sync(room) is True + + +@pytest.mark.asyncio +async def test_sync_respects_fetch_interval(): + now = datetime.now(timezone.utc) + + room1 = await rooms_controller.add( + name="interval-test-1", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/interval.ics", + ics_enabled=True, + ics_fetch_interval=300, + ) + + await rooms_controller.update( + room1, + {"ics_last_sync": now - timedelta(seconds=100)}, + ) + + room2 = await rooms_controller.add( + name="interval-test-2", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/interval2.ics", + ics_enabled=True, + ics_fetch_interval=60, + ) + + await rooms_controller.update( + room2, + {"ics_last_sync": now - timedelta(seconds=100)}, + ) + + with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay: + # Test the sync logic without the Celery wrapper + query = rooms.select().where( + rooms.c.ics_enabled == True, rooms.c.ics_url != None + ) + all_rooms = await get_database().fetch_all(query) + + for room_data in all_rooms: + room_id = room_data["id"] + room = await rooms_controller.get_by_id(room_id) + if room and _should_sync(room): + sync_room_ics.delay(room_id) + + assert mock_delay.call_count == 1 + assert mock_delay.call_args[0][0] == room2.id + + +@pytest.mark.asyncio +async def test_sync_handles_errors_gracefully(): + room = await rooms_controller.add( + name="error-task-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/error.ics", + ics_enabled=True, + ) + + with patch( + "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.side_effect = Exception("Network error") + + # Call the service directly to test error handling + result = await ics_sync_service.sync_room_calendar(room) + assert result["status"] == "error" + + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 0 diff --git a/server/tests/test_ics_sync.py b/server/tests/test_ics_sync.py new file mode 100644 index 00000000..e448dd7d --- /dev/null +++ b/server/tests/test_ics_sync.py @@ -0,0 +1,290 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from icalendar import Calendar, Event + +from reflector.db.calendar_events import calendar_events_controller +from reflector.db.rooms import rooms_controller +from reflector.services.ics_sync import ICSFetchService, ICSSyncService + + +@pytest.mark.asyncio +async def test_ics_fetch_service_event_matching(): + service = ICSFetchService() + room_name = "test-room" + room_url = "https://example.com/test-room" + + # Create test event + event = Event() + event.add("uid", "test-123") + event.add("summary", "Test Meeting") + + # Test matching with full URL in location + event.add("location", "https://example.com/test-room") + assert service._event_matches_room(event, room_name, room_url) is True + + # Test non-matching with URL without protocol (exact matching only now) + event["location"] = "example.com/test-room" + assert service._event_matches_room(event, room_name, room_url) is False + + # Test matching in description + event["location"] = "Conference Room A" + event.add("description", f"Join at {room_url}") + assert service._event_matches_room(event, room_name, room_url) is True + + # Test non-matching + event["location"] = "Different Room" + event["description"] = "No room URL here" + assert service._event_matches_room(event, room_name, room_url) is False + + # Test partial paths should NOT match anymore + event["location"] = "/test-room" + assert service._event_matches_room(event, room_name, room_url) is False + + event["location"] = f"Room: {room_name}" + assert service._event_matches_room(event, room_name, room_url) is False + + +@pytest.mark.asyncio +async def test_ics_fetch_service_parse_event(): + service = ICSFetchService() + + # Create test event + event = Event() + event.add("uid", "test-456") + event.add("summary", "Team Standup") + event.add("description", "Daily team sync") + event.add("location", "https://example.com/standup") + + now = datetime.now(timezone.utc) + event.add("dtstart", now) + event.add("dtend", now + timedelta(hours=1)) + + # Add attendees + event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"}) + event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"}) + event.add("organizer", "mailto:carol@example.com", parameters={"CN": "Carol"}) + + # Parse event + result = service._parse_event(event) + + assert result is not None + assert result["ics_uid"] == "test-456" + assert result["title"] == "Team Standup" + assert result["description"] == "Daily team sync" + assert result["location"] == "https://example.com/standup" + assert len(result["attendees"]) == 3 # 2 attendees + 1 organizer + + +@pytest.mark.asyncio +async def test_ics_fetch_service_extract_room_events(): + service = ICSFetchService() + room_name = "meeting" + room_url = "https://example.com/meeting" + + # Create calendar with multiple events + cal = Calendar() + + # Event 1: Matches room + event1 = Event() + event1.add("uid", "match-1") + event1.add("summary", "Planning Meeting") + event1.add("location", room_url) + now = datetime.now(timezone.utc) + event1.add("dtstart", now + timedelta(hours=2)) + event1.add("dtend", now + timedelta(hours=3)) + cal.add_component(event1) + + # Event 2: Doesn't match room + event2 = Event() + event2.add("uid", "no-match") + event2.add("summary", "Other Meeting") + event2.add("location", "https://example.com/other") + event2.add("dtstart", now + timedelta(hours=4)) + event2.add("dtend", now + timedelta(hours=5)) + cal.add_component(event2) + + # Event 3: Matches room in description + event3 = Event() + event3.add("uid", "match-2") + event3.add("summary", "Review Session") + event3.add("description", f"Meeting link: {room_url}") + event3.add("dtstart", now + timedelta(hours=6)) + event3.add("dtend", now + timedelta(hours=7)) + cal.add_component(event3) + + # Event 4: Cancelled event (should be skipped) + event4 = Event() + event4.add("uid", "cancelled") + event4.add("summary", "Cancelled Meeting") + event4.add("location", room_url) + event4.add("status", "CANCELLED") + event4.add("dtstart", now + timedelta(hours=8)) + event4.add("dtend", now + timedelta(hours=9)) + cal.add_component(event4) + + # Extract events + events, total_events = service.extract_room_events(cal, room_name, room_url) + + assert len(events) == 2 + assert total_events == 3 # 3 events in time window (excluding cancelled) + assert events[0]["ics_uid"] == "match-1" + assert events[1]["ics_uid"] == "match-2" + + +@pytest.mark.asyncio +async def test_ics_sync_service_sync_room_calendar(): + # Create room + room = await rooms_controller.add( + name="sync-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/test.ics", + ics_enabled=True, + ) + + # Mock ICS content + cal = Calendar() + event = Event() + event.add("uid", "sync-event-1") + event.add("summary", "Sync Test Meeting") + # Use the actual UI_BASE_URL from settings + from reflector.settings import settings + + event.add("location", f"{settings.UI_BASE_URL}/{room.name}") + now = datetime.now(timezone.utc) + event.add("dtstart", now + timedelta(hours=1)) + event.add("dtend", now + timedelta(hours=2)) + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + + # Create sync service and mock fetch + sync_service = ICSSyncService() + + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content + + # First sync + result = await sync_service.sync_room_calendar(room) + + assert result["status"] == "success" + assert result["events_found"] == 1 + assert result["events_created"] == 1 + assert result["events_updated"] == 0 + assert result["events_deleted"] == 0 + + # Verify event was created + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].ics_uid == "sync-event-1" + assert events[0].title == "Sync Test Meeting" + + # Second sync with same content (should be unchanged) + # Refresh room to get updated etag and force sync by setting old sync time + room = await rooms_controller.get_by_id(room.id) + await rooms_controller.update( + room, {"ics_last_sync": datetime.now(timezone.utc) - timedelta(minutes=10)} + ) + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "unchanged" + + # Third sync with updated event + event["summary"] = "Updated Meeting Title" + cal = Calendar() + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + mock_fetch.return_value = ics_content + + # Force sync by clearing etag + await rooms_controller.update(room, {"ics_last_etag": None}) + + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "success" + assert result["events_created"] == 0 + assert result["events_updated"] == 1 + + # Verify event was updated + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].title == "Updated Meeting Title" + + +@pytest.mark.asyncio +async def test_ics_sync_service_should_sync(): + service = ICSSyncService() + + # Room never synced + room = MagicMock() + room.ics_last_sync = None + room.ics_fetch_interval = 300 + assert service._should_sync(room) is True + + # Room synced recently + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100) + assert service._should_sync(room) is False + + # Room sync due + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400) + assert service._should_sync(room) is True + + +@pytest.mark.asyncio +async def test_ics_sync_service_skip_disabled(): + service = ICSSyncService() + + # Room with ICS disabled + room = MagicMock() + room.ics_enabled = False + room.ics_url = "https://calendar.example.com/test.ics" + + result = await service.sync_room_calendar(room) + assert result["status"] == "skipped" + assert result["reason"] == "ICS not configured" + + # Room without URL + room.ics_enabled = True + room.ics_url = None + + result = await service.sync_room_calendar(room) + assert result["status"] == "skipped" + assert result["reason"] == "ICS not configured" + + +@pytest.mark.asyncio +async def test_ics_sync_service_error_handling(): + # Create room + room = await rooms_controller.add( + name="error-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/error.ics", + ics_enabled=True, + ) + + sync_service = ICSSyncService() + + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.side_effect = Exception("Network error") + + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "error" + assert "Network error" in result["error"] diff --git a/server/tests/test_multiple_active_meetings.py b/server/tests/test_multiple_active_meetings.py new file mode 100644 index 00000000..61bce0e0 --- /dev/null +++ b/server/tests/test_multiple_active_meetings.py @@ -0,0 +1,167 @@ +"""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, + 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, + 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, + 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_calendar_meeting_deactivates_after_scheduled_end(): + """Test that unused calendar meetings deactivate after scheduled end time.""" + # 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 that ended 35 minutes ago + event = CalendarEvent( + room_id=room.id, + ics_uid="test-event-unused", + title="Test Meeting Unused", + start_time=datetime.now(timezone.utc) - timedelta(hours=2), + end_time=datetime.now(timezone.utc) - timedelta(minutes=35), + ) + 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-unused", + room_name="test-meeting-unused", + room_url="https://whereby.com/test-unused", + host_room_url="https://whereby.com/test-unused-host", + start_date=event.start_time, + end_date=event.end_time, + room=room, + calendar_event_id=event.id, + ) + + # Test the new logic: unused calendar meetings deactivate after scheduled end + # The meeting ended 35 minutes ago and was never used, so it should be deactivated + + # Simulate process_meetings logic for unused calendar meeting past end time + if meeting.calendar_event_id and current_time > meeting.end_date: + # In real code, we'd check has_had_sessions = False here + 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 # Deactivated after scheduled end diff --git a/server/tests/test_room_ics.py b/server/tests/test_room_ics.py new file mode 100644 index 00000000..7a3c4d74 --- /dev/null +++ b/server/tests/test_room_ics.py @@ -0,0 +1,225 @@ +""" +Tests for Room model ICS calendar integration fields. +""" + +from datetime import datetime, timezone + +import pytest + +from reflector.db.rooms import rooms_controller + + +@pytest.mark.asyncio +async def test_room_create_with_ics_fields(): + """Test creating a room with ICS calendar fields.""" + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.google.com/calendar/ical/test/private-token/basic.ics", + ics_fetch_interval=600, + ics_enabled=True, + ) + + assert room.name == "test-room" + assert ( + room.ics_url + == "https://calendar.google.com/calendar/ical/test/private-token/basic.ics" + ) + assert room.ics_fetch_interval == 600 + assert room.ics_enabled is True + assert room.ics_last_sync is None + assert room.ics_last_etag is None + + +@pytest.mark.asyncio +async def test_room_update_ics_configuration(): + """Test updating room ICS configuration.""" + # Create room without ICS + room = await rooms_controller.add( + name="update-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + assert room.ics_enabled is False + assert room.ics_url is None + + # Update with ICS configuration + await rooms_controller.update( + room, + { + "ics_url": "https://outlook.office365.com/owa/calendar/test/calendar.ics", + "ics_fetch_interval": 300, + "ics_enabled": True, + }, + ) + + assert ( + room.ics_url == "https://outlook.office365.com/owa/calendar/test/calendar.ics" + ) + assert room.ics_fetch_interval == 300 + assert room.ics_enabled is True + + +@pytest.mark.asyncio +async def test_room_ics_sync_metadata(): + """Test updating room ICS sync metadata.""" + room = await rooms_controller.add( + name="sync-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://example.com/calendar.ics", + ics_enabled=True, + ) + + # Update sync metadata + sync_time = datetime.now(timezone.utc) + await rooms_controller.update( + room, + { + "ics_last_sync": sync_time, + "ics_last_etag": "abc123hash", + }, + ) + + assert room.ics_last_sync == sync_time + assert room.ics_last_etag == "abc123hash" + + +@pytest.mark.asyncio +async def test_room_get_with_ics_fields(): + """Test retrieving room with ICS fields.""" + # Create room + created_room = await rooms_controller.add( + name="get-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="webcal://calendar.example.com/feed.ics", + ics_fetch_interval=900, + ics_enabled=True, + ) + + # Get by ID + room = await rooms_controller.get_by_id(created_room.id) + assert room is not None + assert room.ics_url == "webcal://calendar.example.com/feed.ics" + assert room.ics_fetch_interval == 900 + assert room.ics_enabled is True + + # Get by name + room = await rooms_controller.get_by_name("get-test") + assert room is not None + assert room.ics_url == "webcal://calendar.example.com/feed.ics" + assert room.ics_fetch_interval == 900 + assert room.ics_enabled is True + + +@pytest.mark.asyncio +async def test_room_list_with_ics_enabled_filter(): + """Test listing rooms filtered by ICS enabled status.""" + # Create rooms with and without ICS + room1 = await rooms_controller.add( + name="ics-enabled-1", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=True, + ics_url="https://calendar1.example.com/feed.ics", + ) + + room2 = await rooms_controller.add( + name="ics-disabled", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=False, + ) + + room3 = await rooms_controller.add( + name="ics-enabled-2", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=True, + ics_url="https://calendar2.example.com/feed.ics", + ) + + # Get all rooms + all_rooms = await rooms_controller.get_all() + assert len(all_rooms) == 3 + + # Filter for ICS-enabled rooms (would need to implement this in controller) + ics_rooms = [r for r in all_rooms if r["ics_enabled"]] + assert len(ics_rooms) == 2 + assert all(r["ics_enabled"] for r in ics_rooms) + + +@pytest.mark.asyncio +async def test_room_default_ics_values(): + """Test that ICS fields have correct default values.""" + room = await rooms_controller.add( + name="default-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + # Don't specify ICS fields + ) + + assert room.ics_url is None + assert room.ics_fetch_interval == 300 # Default 5 minutes + assert room.ics_enabled is False + assert room.ics_last_sync is None + assert room.ics_last_etag is None diff --git a/server/tests/test_room_ics_api.py b/server/tests/test_room_ics_api.py new file mode 100644 index 00000000..27a784d7 --- /dev/null +++ b/server/tests/test_room_ics_api.py @@ -0,0 +1,390 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest +from icalendar import Calendar, Event + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.rooms import rooms_controller + + +@pytest.fixture +async def authenticated_client(client): + from reflector.app import app + from reflector.auth import current_user_optional + + app.dependency_overrides[current_user_optional] = lambda: { + "sub": "test-user", + "email": "test@example.com", + } + yield client + del app.dependency_overrides[current_user_optional] + + +@pytest.mark.asyncio +async def test_create_room_with_ics_fields(authenticated_client): + client = authenticated_client + response = await client.post( + "/rooms", + json={ + "name": "test-ics-room", + "zulip_auto_post": False, + "zulip_stream": "", + "zulip_topic": "", + "is_locked": False, + "room_mode": "normal", + "recording_type": "cloud", + "recording_trigger": "automatic-2nd-participant", + "is_shared": False, + "webhook_url": "", + "webhook_secret": "", + "ics_url": "https://calendar.example.com/test.ics", + "ics_fetch_interval": 600, + "ics_enabled": True, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "test-ics-room" + assert data["ics_url"] == "https://calendar.example.com/test.ics" + assert data["ics_fetch_interval"] == 600 + assert data["ics_enabled"] is True + + +@pytest.mark.asyncio +async def test_update_room_ics_configuration(authenticated_client): + client = authenticated_client + response = await client.post( + "/rooms", + json={ + "name": "update-ics-room", + "zulip_auto_post": False, + "zulip_stream": "", + "zulip_topic": "", + "is_locked": False, + "room_mode": "normal", + "recording_type": "cloud", + "recording_trigger": "automatic-2nd-participant", + "is_shared": False, + "webhook_url": "", + "webhook_secret": "", + }, + ) + assert response.status_code == 200 + room_id = response.json()["id"] + + response = await client.patch( + f"/rooms/{room_id}", + json={ + "ics_url": "https://calendar.google.com/updated.ics", + "ics_fetch_interval": 300, + "ics_enabled": True, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ics_url"] == "https://calendar.google.com/updated.ics" + assert data["ics_fetch_interval"] == 300 + assert data["ics_enabled"] is True + + +@pytest.mark.asyncio +async def test_trigger_ics_sync(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="sync-api-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/api.ics", + ics_enabled=True, + ) + + cal = Calendar() + event = Event() + event.add("uid", "api-test-event") + event.add("summary", "API Test Meeting") + from reflector.settings import settings + + event.add("location", f"{settings.UI_BASE_URL}/{room.name}") + now = datetime.now(timezone.utc) + event.add("dtstart", now + timedelta(hours=1)) + event.add("dtend", now + timedelta(hours=2)) + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + + with patch( + "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content + + response = await client.post(f"/rooms/{room.name}/ics/sync") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["events_found"] == 1 + assert data["events_created"] == 1 + + +@pytest.mark.asyncio +async def test_trigger_ics_sync_unauthorized(client): + room = await rooms_controller.add( + name="sync-unauth-room", + user_id="owner-123", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/api.ics", + ics_enabled=True, + ) + + response = await client.post(f"/rooms/{room.name}/ics/sync") + assert response.status_code == 403 + assert "Only room owner can trigger ICS sync" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_trigger_ics_sync_not_configured(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="sync-not-configured", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_enabled=False, + ) + + response = await client.post(f"/rooms/{room.name}/ics/sync") + assert response.status_code == 400 + assert "ICS not configured" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_get_ics_status(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="status-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/status.ics", + ics_enabled=True, + ics_fetch_interval=300, + ) + + now = datetime.now(timezone.utc) + await rooms_controller.update( + room, + {"ics_last_sync": now, "ics_last_etag": "test-etag"}, + ) + + response = await client.get(f"/rooms/{room.name}/ics/status") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "enabled" + assert data["last_etag"] == "test-etag" + assert data["events_count"] == 0 + + +@pytest.mark.asyncio +async def test_get_ics_status_unauthorized(client): + room = await rooms_controller.add( + name="status-unauth", + user_id="owner-456", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/status.ics", + ics_enabled=True, + ) + + response = await client.get(f"/rooms/{room.name}/ics/status") + assert response.status_code == 403 + assert "Only room owner can view ICS status" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_list_room_meetings(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="meetings-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + event1 = CalendarEvent( + room_id=room.id, + ics_uid="meeting-1", + title="Past Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + ) + await calendar_events_controller.upsert(event1) + + event2 = CalendarEvent( + room_id=room.id, + ics_uid="meeting-2", + title="Future Meeting", + description="Team sync", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + attendees=[{"email": "test@example.com"}], + ) + await calendar_events_controller.upsert(event2) + + response = await client.get(f"/rooms/{room.name}/meetings") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "Past Meeting" + assert data[1]["title"] == "Future Meeting" + assert data[1]["description"] == "Team sync" + assert data[1]["attendees"] == [{"email": "test@example.com"}] + + +@pytest.mark.asyncio +async def test_list_room_meetings_non_owner(client): + room = await rooms_controller.add( + name="meetings-privacy", + user_id="owner-789", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + event = CalendarEvent( + room_id=room.id, + ics_uid="private-meeting", + title="Meeting Title", + description="Sensitive info", + start_time=datetime.now(timezone.utc) + timedelta(hours=1), + end_time=datetime.now(timezone.utc) + timedelta(hours=2), + attendees=[{"email": "private@example.com"}], + ) + await calendar_events_controller.upsert(event) + + response = await client.get(f"/rooms/{room.name}/meetings") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["title"] == "Meeting Title" + assert data[0]["description"] is None + assert data[0]["attendees"] is None + + +@pytest.mark.asyncio +async def test_list_upcoming_meetings(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="upcoming-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + past_event = CalendarEvent( + room_id=room.id, + ics_uid="past", + title="Past", + start_time=now - timedelta(hours=1), + end_time=now - timedelta(minutes=30), + ) + await calendar_events_controller.upsert(past_event) + + soon_event = CalendarEvent( + room_id=room.id, + ics_uid="soon", + title="Soon", + start_time=now + timedelta(minutes=15), + end_time=now + timedelta(minutes=45), + ) + await calendar_events_controller.upsert(soon_event) + + later_event = CalendarEvent( + room_id=room.id, + ics_uid="later", + title="Later", + start_time=now + timedelta(hours=2), + end_time=now + timedelta(hours=3), + ) + await calendar_events_controller.upsert(later_event) + + response = await client.get(f"/rooms/{room.name}/meetings/upcoming") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "Soon" + assert data[1]["title"] == "Later" + + response = await client.get( + f"/rooms/{room.name}/meetings/upcoming", params={"minutes_ahead": 180} + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "Soon" + assert data[1]["title"] == "Later" + + +@pytest.mark.asyncio +async def test_room_not_found_endpoints(client): + response = await client.post("/rooms/nonexistent/ics/sync") + assert response.status_code == 404 + + response = await client.get("/rooms/nonexistent/ics/status") + assert response.status_code == 404 + + response = await client.get("/rooms/nonexistent/meetings") + assert response.status_code == 404 + + response = await client.get("/rooms/nonexistent/meetings/upcoming") + assert response.status_code == 404 diff --git a/server/uv.lock b/server/uv.lock index b93d0ac3..2c28f61b 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -2,13 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.13" resolution-markers = [ - "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'", - "python_full_version < '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", "python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'", ] @@ -1035,27 +1037,27 @@ wheels = [ [[package]] name = "fonttools" -version = "4.59.1" +version = "4.59.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/7f/29c9c3fe4246f6ad96fee52b88d0dc3a863c7563b0afc959e36d78b965dc/fonttools-4.59.1.tar.gz", hash = "sha256:74995b402ad09822a4c8002438e54940d9f1ecda898d2bb057729d7da983e4cb", size = 3534394, upload-time = "2025-08-14T16:28:14.266Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/a5/fba25f9fbdab96e26dedcaeeba125e5f05a09043bf888e0305326e55685b/fonttools-4.59.2.tar.gz", hash = "sha256:e72c0749b06113f50bcb80332364c6be83a9582d6e3db3fe0b280f996dc2ef22", size = 3540889, upload-time = "2025-08-27T16:40:30.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/62/9667599561f623d4a523cc9eb4f66f3b94b6155464110fa9aebbf90bbec7/fonttools-4.59.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4909cce2e35706f3d18c54d3dcce0414ba5e0fb436a454dffec459c61653b513", size = 2778815, upload-time = "2025-08-14T16:26:28.484Z" }, - { url = "https://files.pythonhosted.org/packages/8f/78/cc25bcb2ce86033a9df243418d175e58f1956a35047c685ef553acae67d6/fonttools-4.59.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:efbec204fa9f877641747f2d9612b2b656071390d7a7ef07a9dbf0ecf9c7195c", size = 2341631, upload-time = "2025-08-14T16:26:30.396Z" }, - { url = "https://files.pythonhosted.org/packages/a4/cc/fcbb606dd6871f457ac32f281c20bcd6cc77d9fce77b5a4e2b2afab1f500/fonttools-4.59.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39dfd42cc2dc647b2c5469bc7a5b234d9a49e72565b96dd14ae6f11c2c59ef15", size = 5022222, upload-time = "2025-08-14T16:26:32.447Z" }, - { url = "https://files.pythonhosted.org/packages/61/96/c0b1cf2b74d08eb616a80dbf5564351fe4686147291a25f7dce8ace51eb3/fonttools-4.59.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b11bc177a0d428b37890825d7d025040d591aa833f85f8d8878ed183354f47df", size = 4966512, upload-time = "2025-08-14T16:26:34.621Z" }, - { url = "https://files.pythonhosted.org/packages/a4/26/51ce2e3e0835ffc2562b1b11d1fb9dafd0aca89c9041b64a9e903790a761/fonttools-4.59.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b9b4c35b3be45e5bc774d3fc9608bbf4f9a8d371103b858c80edbeed31dd5aa", size = 5001645, upload-time = "2025-08-14T16:26:36.876Z" }, - { url = "https://files.pythonhosted.org/packages/36/11/ef0b23f4266349b6d5ccbd1a07b7adc998d5bce925792aa5d1ec33f593e3/fonttools-4.59.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:01158376b8a418a0bae9625c476cebfcfcb5e6761e9d243b219cd58341e7afbb", size = 5113777, upload-time = "2025-08-14T16:26:39.002Z" }, - { url = "https://files.pythonhosted.org/packages/d0/da/b398fe61ef433da0a0472cdb5d4399124f7581ffe1a31b6242c91477d802/fonttools-4.59.1-cp311-cp311-win32.whl", hash = "sha256:cf7c5089d37787387123f1cb8f1793a47c5e1e3d1e4e7bfbc1cc96e0f925eabe", size = 2215076, upload-time = "2025-08-14T16:26:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/94/bd/e2624d06ab94e41c7c77727b2941f1baed7edb647e63503953e6888020c9/fonttools-4.59.1-cp311-cp311-win_amd64.whl", hash = "sha256:c866eef7a0ba320486ade6c32bfc12813d1a5db8567e6904fb56d3d40acc5116", size = 2262779, upload-time = "2025-08-14T16:26:43.483Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fe/6e069cc4cb8881d164a9bd956e9df555bc62d3eb36f6282e43440200009c/fonttools-4.59.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:43ab814bbba5f02a93a152ee61a04182bb5809bd2bc3609f7822e12c53ae2c91", size = 2769172, upload-time = "2025-08-14T16:26:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/b9/98/ec4e03f748fefa0dd72d9d95235aff6fef16601267f4a2340f0e16b9330f/fonttools-4.59.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4f04c3ffbfa0baafcbc550657cf83657034eb63304d27b05cff1653b448ccff6", size = 2337281, upload-time = "2025-08-14T16:26:47.921Z" }, - { url = "https://files.pythonhosted.org/packages/8b/b1/890360a7e3d04a30ba50b267aca2783f4c1364363797e892e78a4f036076/fonttools-4.59.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d601b153e51a5a6221f0d4ec077b6bfc6ac35bfe6c19aeaa233d8990b2b71726", size = 4909215, upload-time = "2025-08-14T16:26:49.682Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/2490599550d6c9c97a44c1e36ef4de52d6acf742359eaa385735e30c05c4/fonttools-4.59.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c735e385e30278c54f43a0d056736942023c9043f84ee1021eff9fd616d17693", size = 4951958, upload-time = "2025-08-14T16:26:51.616Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/bd053f6f7634234a9b9805ff8ae4f32df4f2168bee23cafd1271ba9915a9/fonttools-4.59.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1017413cdc8555dce7ee23720da490282ab7ec1cf022af90a241f33f9a49afc4", size = 4894738, upload-time = "2025-08-14T16:26:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a1/3cd12a010d288325a7cfcf298a84825f0f9c29b01dee1baba64edfe89257/fonttools-4.59.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5c6d8d773470a5107052874341ed3c487c16ecd179976d81afed89dea5cd7406", size = 5045983, upload-time = "2025-08-14T16:26:56.153Z" }, - { url = "https://files.pythonhosted.org/packages/a2/af/8a2c3f6619cc43cf87951405337cc8460d08a4e717bb05eaa94b335d11dc/fonttools-4.59.1-cp312-cp312-win32.whl", hash = "sha256:2a2d0d33307f6ad3a2086a95dd607c202ea8852fa9fb52af9b48811154d1428a", size = 2203407, upload-time = "2025-08-14T16:26:58.165Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f2/a19b874ddbd3ebcf11d7e25188ef9ac3f68b9219c62263acb34aca8cde05/fonttools-4.59.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b9e4fa7eaf046ed6ac470f6033d52c052481ff7a6e0a92373d14f556f298dc0", size = 2251561, upload-time = "2025-08-14T16:27:00.646Z" }, - { url = "https://files.pythonhosted.org/packages/0f/64/9d606e66d498917cd7a2ff24f558010d42d6fd4576d9dd57f0bd98333f5a/fonttools-4.59.1-py3-none-any.whl", hash = "sha256:647db657073672a8330608970a984d51573557f328030566521bc03415535042", size = 1130094, upload-time = "2025-08-14T16:28:12.048Z" }, + { url = "https://files.pythonhosted.org/packages/f8/53/742fcd750ae0bdc74de4c0ff923111199cc2f90a4ee87aaddad505b6f477/fonttools-4.59.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:511946e8d7ea5c0d6c7a53c4cb3ee48eda9ab9797cd9bf5d95829a398400354f", size = 2774961, upload-time = "2025-08-27T16:38:47.536Z" }, + { url = "https://files.pythonhosted.org/packages/57/2a/976f5f9fa3b4dd911dc58d07358467bec20e813d933bc5d3db1a955dd456/fonttools-4.59.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5e2682cf7be766d84f462ba8828d01e00c8751a8e8e7ce12d7784ccb69a30d", size = 2344690, upload-time = "2025-08-27T16:38:49.723Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b7eefc274fcf370911e292e95565c8253b0b87c82a53919ab3c795a4f50e/fonttools-4.59.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5729e12a982dba3eeae650de48b06f3b9ddb51e9aee2fcaf195b7d09a96250e2", size = 5026910, upload-time = "2025-08-27T16:38:51.904Z" }, + { url = "https://files.pythonhosted.org/packages/69/95/864726eaa8f9d4e053d0c462e64d5830ec7c599cbdf1db9e40f25ca3972e/fonttools-4.59.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c52694eae5d652361d59ecdb5a2246bff7cff13b6367a12da8499e9df56d148d", size = 4971031, upload-time = "2025-08-27T16:38:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/b8c4735ebdea20696277c70c79e0de615dbe477834e5a7c2569aa1db4033/fonttools-4.59.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f1bbc23ba1312bd8959896f46f667753b90216852d2a8cfa2d07e0cb234144", size = 5006112, upload-time = "2025-08-27T16:38:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/3b/23/f9ea29c292aa2fc1ea381b2e5621ac436d5e3e0a5dee24ffe5404e58eae8/fonttools-4.59.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a1bfe5378962825dabe741720885e8b9ae9745ec7ecc4a5ec1f1ce59a6062bf", size = 5117671, upload-time = "2025-08-27T16:38:58.984Z" }, + { url = "https://files.pythonhosted.org/packages/ba/07/cfea304c555bf06e86071ff2a3916bc90f7c07ec85b23bab758d4908c33d/fonttools-4.59.2-cp311-cp311-win32.whl", hash = "sha256:e937790f3c2c18a1cbc7da101550a84319eb48023a715914477d2e7faeaba570", size = 2218157, upload-time = "2025-08-27T16:39:00.75Z" }, + { url = "https://files.pythonhosted.org/packages/d7/de/35d839aa69db737a3f9f3a45000ca24721834d40118652a5775d5eca8ebb/fonttools-4.59.2-cp311-cp311-win_amd64.whl", hash = "sha256:9836394e2f4ce5f9c0a7690ee93bd90aa1adc6b054f1a57b562c5d242c903104", size = 2265846, upload-time = "2025-08-27T16:39:02.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3d/1f45db2df51e7bfa55492e8f23f383d372200be3a0ded4bf56a92753dd1f/fonttools-4.59.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82906d002c349cad647a7634b004825a7335f8159d0d035ae89253b4abf6f3ea", size = 2769711, upload-time = "2025-08-27T16:39:04.423Z" }, + { url = "https://files.pythonhosted.org/packages/29/df/cd236ab32a8abfd11558f296e064424258db5edefd1279ffdbcfd4fd8b76/fonttools-4.59.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a10c1bd7644dc58f8862d8ba0cf9fb7fef0af01ea184ba6ce3f50ab7dfe74d5a", size = 2340225, upload-time = "2025-08-27T16:39:06.143Z" }, + { url = "https://files.pythonhosted.org/packages/98/12/b6f9f964fe6d4b4dd4406bcbd3328821c3de1f909ffc3ffa558fe72af48c/fonttools-4.59.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:738f31f23e0339785fd67652a94bc69ea49e413dfdb14dcb8c8ff383d249464e", size = 4912766, upload-time = "2025-08-27T16:39:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/82bde2f2d2c306ef3909b927363170b83df96171f74e0ccb47ad344563cd/fonttools-4.59.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ec99f9bdfee9cdb4a9172f9e8fd578cce5feb231f598909e0aecf5418da4f25", size = 4955178, upload-time = "2025-08-27T16:39:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/92/77/7de766afe2d31dda8ee46d7e479f35c7d48747e558961489a2d6e3a02bd4/fonttools-4.59.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0476ea74161322e08c7a982f83558a2b81b491509984523a1a540baf8611cc31", size = 4897898, upload-time = "2025-08-27T16:39:12.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/77/ce0e0b905d62a06415fda9f2b2e109a24a5db54a59502b769e9e297d2242/fonttools-4.59.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95922a922daa1f77cc72611747c156cfb38030ead72436a2c551d30ecef519b9", size = 5049144, upload-time = "2025-08-27T16:39:13.84Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/870d93aefd23fff2e07cbeebdc332527868422a433c64062c09d4d5e7fe6/fonttools-4.59.2-cp312-cp312-win32.whl", hash = "sha256:39ad9612c6a622726a6a130e8ab15794558591f999673f1ee7d2f3d30f6a3e1c", size = 2206473, upload-time = "2025-08-27T16:39:15.854Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/e44bad000c4a4bb2e9ca11491d266e857df98ab6d7428441b173f0fe2517/fonttools-4.59.2-cp312-cp312-win_amd64.whl", hash = "sha256:980fd7388e461b19a881d35013fec32c713ffea1fc37aef2f77d11f332dfd7da", size = 2254706, upload-time = "2025-08-27T16:39:17.893Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/d2f7be3c86708912c02571db0b550121caab8cd88a3c0aacb9cfa15ea66e/fonttools-4.59.2-py3-none-any.whl", hash = "sha256:8bd0f759020e87bb5d323e6283914d9bf4ae35a7307dafb2cbd1e379e720ad37", size = 1132315, upload-time = "2025-08-27T16:40:28.984Z" }, ] [[package]] @@ -1307,6 +1309,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/c9/751b6401887f4b50f9307cc1e53d287b3dc77c375c126aeb6335aff73ccb/HyperPyYAML-1.2.2-py3-none-any.whl", hash = "sha256:3c5864bdc8864b2f0fbd7bc495e7e8fdf2dfd5dd80116f72da27ca96a128bdeb", size = 16118, upload-time = "2023-09-21T14:45:25.101Z" }, ] +[[package]] +name = "icalendar" +version = "6.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/13/e5899c916dcf1343ea65823eb7278d3e1a1d679f383f6409380594b5f322/icalendar-6.3.1.tar.gz", hash = "sha256:a697ce7b678072941e519f2745704fc29d78ef92a2dc53d9108ba6a04aeba466", size = 177169, upload-time = "2025-05-20T07:42:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/25/b5fc00e85d2dfaf5c806ac8b5f1de072fa11630c5b15b4ae5bbc228abd51/icalendar-6.3.1-py3-none-any.whl", hash = "sha256:7ea1d1b212df685353f74cdc6ec9646bf42fa557d1746ea645ce8779fdfbecdd", size = 242349, upload-time = "2025-05-20T07:42:48.589Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -1549,7 +1564,7 @@ wheels = [ [[package]] name = "lightning" -version = "2.5.3" +version = "2.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec", extra = ["http"] }, @@ -1563,9 +1578,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/80/dddb5a382aa0ff18045aee6491f81e40371102cb05da2ad5a8436a51c475/lightning-2.5.3.tar.gz", hash = "sha256:4ed3e12369a1e0f928beecf5c9f5efdabda60a9216057954851e2d89f1abecde", size = 636577, upload-time = "2025-08-13T20:29:32.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/dd/86bb3bebadcdbc6e6e5a63657f0a03f74cd065b5ea965896679f76fec0b4/lightning-2.5.5.tar.gz", hash = "sha256:4d3d66c5b1481364a7e6a1ce8ddde1777a04fa740a3145ec218a9941aed7dd30", size = 640770, upload-time = "2025-09-05T16:01:21.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/6b/00e9c2b03a449c21d7a4d73a7104ac94f56c37a1e6eae77b1c702d8dddf0/lightning-2.5.3-py3-none-any.whl", hash = "sha256:c551111fda0db0bce267791f9a90cd4f9cf94bc327d36348af0ef79ec752d666", size = 824181, upload-time = "2025-08-13T20:29:30.244Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d0/4b4fbafc3b18df91207a6e46782d9fd1905f9f45cb2c3b8dfbb239aef781/lightning-2.5.5-py3-none-any.whl", hash = "sha256:69eb248beadd7b600bf48eff00a0ec8af171ec7a678d23787c4aedf12e225e8f", size = 828490, upload-time = "2025-09-05T16:01:17.845Z" }, ] [[package]] @@ -1865,19 +1880,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/24/8497595be04a8a0209536e9ce70d4132f8f8e001986f4c700414b3777758/llama_parse-0.6.43-py3-none-any.whl", hash = "sha256:fe435309638c4fdec4fec31f97c5031b743c92268962d03b99bd76704f566c32", size = 4944, upload-time = "2025-07-08T18:20:57.089Z" }, ] -[[package]] -name = "loguru" -version = "0.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, -] - [[package]] name = "mako" version = "1.3.10" @@ -1944,7 +1946,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.10.5" +version = "3.10.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -1957,25 +1959,25 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/91/f2939bb60b7ebf12478b030e0d7f340247390f402b3b189616aad790c366/matplotlib-3.10.5.tar.gz", hash = "sha256:352ed6ccfb7998a00881692f38b4ca083c691d3e275b4145423704c34c909076", size = 34804044, upload-time = "2025-07-31T18:09:33.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/c7/1f2db90a1d43710478bb1e9b57b162852f79234d28e4f48a28cc415aa583/matplotlib-3.10.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dcfc39c452c6a9f9028d3e44d2d721484f665304857188124b505b2c95e1eecf", size = 8239216, upload-time = "2025-07-31T18:07:51.947Z" }, - { url = "https://files.pythonhosted.org/packages/82/6d/ca6844c77a4f89b1c9e4d481c412e1d1dbabf2aae2cbc5aa2da4a1d6683e/matplotlib-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:903352681b59f3efbf4546985142a9686ea1d616bb054b09a537a06e4b892ccf", size = 8102130, upload-time = "2025-07-31T18:07:53.65Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1e/5e187a30cc673a3e384f3723e5f3c416033c1d8d5da414f82e4e731128ea/matplotlib-3.10.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:080c3676a56b8ee1c762bcf8fca3fe709daa1ee23e6ef06ad9f3fc17332f2d2a", size = 8666471, upload-time = "2025-07-31T18:07:55.304Z" }, - { url = "https://files.pythonhosted.org/packages/03/c0/95540d584d7d645324db99a845ac194e915ef75011a0d5e19e1b5cee7e69/matplotlib-3.10.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b4984d5064a35b6f66d2c11d668565f4389b1119cc64db7a4c1725bc11adffc", size = 9500518, upload-time = "2025-07-31T18:07:57.199Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2e/e019352099ea58b4169adb9c6e1a2ad0c568c6377c2b677ee1f06de2adc7/matplotlib-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3967424121d3a46705c9fa9bdb0931de3228f13f73d7bb03c999c88343a89d89", size = 9552372, upload-time = "2025-07-31T18:07:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b7/81/3200b792a5e8b354f31f4101ad7834743ad07b6d620259f2059317b25e4d/matplotlib-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:33775bbeb75528555a15ac29396940128ef5613cf9a2d31fb1bfd18b3c0c0903", size = 8100634, upload-time = "2025-07-31T18:08:01.801Z" }, - { url = "https://files.pythonhosted.org/packages/52/46/a944f6f0c1f5476a0adfa501969d229ce5ae60cf9a663be0e70361381f89/matplotlib-3.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:c61333a8e5e6240e73769d5826b9a31d8b22df76c0778f8480baf1b4b01c9420", size = 7978880, upload-time = "2025-07-31T18:08:03.407Z" }, - { url = "https://files.pythonhosted.org/packages/66/1e/c6f6bcd882d589410b475ca1fc22e34e34c82adff519caf18f3e6dd9d682/matplotlib-3.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:00b6feadc28a08bd3c65b2894f56cf3c94fc8f7adcbc6ab4516ae1e8ed8f62e2", size = 8253056, upload-time = "2025-07-31T18:08:05.385Z" }, - { url = "https://files.pythonhosted.org/packages/53/e6/d6f7d1b59413f233793dda14419776f5f443bcccb2dfc84b09f09fe05dbe/matplotlib-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee98a5c5344dc7f48dc261b6ba5d9900c008fc12beb3fa6ebda81273602cc389", size = 8110131, upload-time = "2025-07-31T18:08:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/66/2b/bed8a45e74957549197a2ac2e1259671cd80b55ed9e1fe2b5c94d88a9202/matplotlib-3.10.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a17e57e33de901d221a07af32c08870ed4528db0b6059dce7d7e65c1122d4bea", size = 8669603, upload-time = "2025-07-31T18:08:09.064Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a7/315e9435b10d057f5e52dfc603cd353167ae28bb1a4e033d41540c0067a4/matplotlib-3.10.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97b9d6443419085950ee4a5b1ee08c363e5c43d7176e55513479e53669e88468", size = 9508127, upload-time = "2025-07-31T18:08:10.845Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d9/edcbb1f02ca99165365d2768d517898c22c6040187e2ae2ce7294437c413/matplotlib-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ceefe5d40807d29a66ae916c6a3915d60ef9f028ce1927b84e727be91d884369", size = 9566926, upload-time = "2025-07-31T18:08:13.186Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/6dd924ad5616c97b7308e6320cf392c466237a82a2040381163b7500510a/matplotlib-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:c04cba0f93d40e45b3c187c6c52c17f24535b27d545f757a2fffebc06c12b98b", size = 8107599, upload-time = "2025-07-31T18:08:15.116Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f3/522dc319a50f7b0279fbe74f86f7a3506ce414bc23172098e8d2bdf21894/matplotlib-3.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:a41bcb6e2c8e79dc99c5511ae6f7787d2fb52efd3d805fff06d5d4f667db16b2", size = 7978173, upload-time = "2025-07-31T18:08:21.518Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/e921be4e1a5f7aca5194e1f016cb67ec294548e530013251f630713e456d/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:160e125da27a749481eaddc0627962990f6029811dbeae23881833a011a0907f", size = 8233224, upload-time = "2025-07-31T18:09:27.512Z" }, - { url = "https://files.pythonhosted.org/packages/ec/74/a2b9b04824b9c349c8f1b2d21d5af43fa7010039427f2b133a034cb09e59/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac3d50760394d78a3c9be6b28318fe22b494c4fcf6407e8fd4794b538251899b", size = 8098539, upload-time = "2025-07-31T18:09:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/fc/66/cd29ebc7f6c0d2a15d216fb572573e8fc38bd5d6dec3bd9d7d904c0949f7/matplotlib-3.10.5-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c49465bf689c4d59d174d0c7795fb42a21d4244d11d70e52b8011987367ac61", size = 8672192, upload-time = "2025-07-31T18:09:31.407Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/5d3665aa44c49005aaacaa68ddea6fcb27345961cd538a98bb0177934ede/matplotlib-3.10.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:905b60d1cb0ee604ce65b297b61cf8be9f4e6cfecf95a3fe1c388b5266bc8f4f", size = 8257527, upload-time = "2025-08-30T00:12:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/8c/af/30ddefe19ca67eebd70047dabf50f899eaff6f3c5e6a1a7edaecaf63f794/matplotlib-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bac38d816637343e53d7185d0c66677ff30ffb131044a81898b5792c956ba76", size = 8119583, upload-time = "2025-08-30T00:12:47.236Z" }, + { url = "https://files.pythonhosted.org/packages/d3/29/4a8650a3dcae97fa4f375d46efcb25920d67b512186f8a6788b896062a81/matplotlib-3.10.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:942a8de2b5bfff1de31d95722f702e2966b8a7e31f4e68f7cd963c7cd8861cf6", size = 8692682, upload-time = "2025-08-30T00:12:48.781Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/b793b9cb061cfd5d42ff0f69d1822f8d5dbc94e004618e48a97a8373179a/matplotlib-3.10.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3276c85370bc0dfca051ec65c5817d1e0f8f5ce1b7787528ec8ed2d524bbc2f", size = 9521065, upload-time = "2025-08-30T00:12:50.602Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c5/53de5629f223c1c66668d46ac2621961970d21916a4bc3862b174eb2a88f/matplotlib-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9df5851b219225731f564e4b9e7f2ac1e13c9e6481f941b5631a0f8e2d9387ce", size = 9576888, upload-time = "2025-08-30T00:12:52.92Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/0a18d6d7d2d0a2e66585032a760d13662e5250c784d53ad50434e9560991/matplotlib-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:abb5d9478625dd9c9eb51a06d39aae71eda749ae9b3138afb23eb38824026c7e", size = 8115158, upload-time = "2025-08-30T00:12:54.863Z" }, + { url = "https://files.pythonhosted.org/packages/07/b3/1a5107bb66c261e23b9338070702597a2d374e5aa7004b7adfc754fbed02/matplotlib-3.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:886f989ccfae63659183173bb3fced7fd65e9eb793c3cc21c273add368536951", size = 7992444, upload-time = "2025-08-30T00:12:57.067Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1a/7042f7430055d567cc3257ac409fcf608599ab27459457f13772c2d9778b/matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347", size = 8272404, upload-time = "2025-08-30T00:12:59.112Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5d/1d5f33f5b43f4f9e69e6a5fe1fb9090936ae7bc8e2ff6158e7a76542633b/matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75", size = 8128262, upload-time = "2025-08-30T00:13:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/67/c3/135fdbbbf84e0979712df58e5e22b4f257b3f5e52a3c4aacf1b8abec0d09/matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95", size = 8697008, upload-time = "2025-08-30T00:13:03.24Z" }, + { url = "https://files.pythonhosted.org/packages/9c/be/c443ea428fb2488a3ea7608714b1bd85a82738c45da21b447dc49e2f8e5d/matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb", size = 9530166, upload-time = "2025-08-30T00:13:05.951Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/48441422b044d74034aea2a3e0d1a49023f12150ebc58f16600132b9bbaf/matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07", size = 9593105, upload-time = "2025-08-30T00:13:08.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/c3/994ef20eb4154ab84cc08d033834555319e4af970165e6c8894050af0b3c/matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b", size = 8122784, upload-time = "2025-08-30T00:13:10.367Z" }, + { url = "https://files.pythonhosted.org/packages/57/b8/5c85d9ae0e40f04e71bedb053aada5d6bab1f9b5399a0937afb5d6b02d98/matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa", size = 7992823, upload-time = "2025-08-30T00:13:12.24Z" }, + { url = "https://files.pythonhosted.org/packages/12/bb/02c35a51484aae5f49bd29f091286e7af5f3f677a9736c58a92b3c78baeb/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f2d684c3204fa62421bbf770ddfebc6b50130f9cad65531eeba19236d73bb488", size = 8252296, upload-time = "2025-08-30T00:14:19.49Z" }, + { url = "https://files.pythonhosted.org/packages/7d/85/41701e3092005aee9a2445f5ee3904d9dbd4a7df7a45905ffef29b7ef098/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6f4a69196e663a41d12a728fab8751177215357906436804217d6d9cf0d4d6cf", size = 8116749, upload-time = "2025-08-30T00:14:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/16/53/8d8fa0ea32a8c8239e04d022f6c059ee5e1b77517769feccd50f1df43d6d/matplotlib-3.10.6-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d6ca6ef03dfd269f4ead566ec6f3fb9becf8dab146fb999022ed85ee9f6b3eb", size = 8693933, upload-time = "2025-08-30T00:14:22.942Z" }, ] [[package]] @@ -2176,7 +2178,7 @@ wheels = [ [[package]] name = "optuna" -version = "4.4.0" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alembic" }, @@ -2187,9 +2189,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/e0/b303190ae8032d12f320a24c42af04038bacb1f3b17ede354dd1044a5642/optuna-4.4.0.tar.gz", hash = "sha256:a9029f6a92a1d6c8494a94e45abd8057823b535c2570819072dbcdc06f1c1da4", size = 467708, upload-time = "2025-06-16T05:13:00.024Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/bcd1e5500de6ec794c085a277e5b624e60b4fac1790681d7cdbde25b93a2/optuna-4.5.0.tar.gz", hash = "sha256:264844da16dad744dea295057d8bc218646129c47567d52c35a201d9f99942ba", size = 472338, upload-time = "2025-08-18T06:49:22.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/5e/068798a8c7087863e7772e9363a880ab13fe55a5a7ede8ec42fab8a1acbb/optuna-4.4.0-py3-none-any.whl", hash = "sha256:fad8d9c5d5af993ae1280d6ce140aecc031c514a44c3b639d8c8658a8b7920ea", size = 395949, upload-time = "2025-06-16T05:12:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/7f/12/cba81286cbaf0f0c3f0473846cfd992cb240bdcea816bf2ef7de8ed0f744/optuna-4.5.0-py3-none-any.whl", hash = "sha256:5b8a783e84e448b0742501bc27195344a28d2c77bd2feef5b558544d954851b0", size = 400872, upload-time = "2025-08-18T06:49:20.697Z" }, ] [[package]] @@ -2937,7 +2939,7 @@ wheels = [ [[package]] name = "pytorch-lightning" -version = "2.5.3" +version = "2.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec", extra = ["http"] }, @@ -2950,14 +2952,14 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/a8/31fe79bf96dab33cee5537ed6f08230ed6f032834bb4ff529cc487fb40e8/pytorch_lightning-2.5.3.tar.gz", hash = "sha256:65f4eee774ee1adba181aacacffb9f677fe5c5f9fd3d01a95f603403f940be6a", size = 639897, upload-time = "2025-08-13T20:29:39.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/78/bce84aab9a5b3b2e9d087d4f1a6be9b481adbfaac4903bc9daaaf09d49a3/pytorch_lightning-2.5.5.tar.gz", hash = "sha256:d6fc8173d1d6e49abfd16855ea05d2eb2415e68593f33d43e59028ecb4e64087", size = 643703, upload-time = "2025-09-05T16:01:18.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/a2/5f2b7b40ec5213db5282e98dd32fd419fe5b73b5b53895dfff56fe12fed0/pytorch_lightning-2.5.3-py3-none-any.whl", hash = "sha256:7476bd36282d9253dda175b9263b07942489d70ad90bbd1bc0a59c46e012f353", size = 828186, upload-time = "2025-08-13T20:29:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/f6/99a5c66478f469598dee25b0e29b302b5bddd4e03ed0da79608ac964056e/pytorch_lightning-2.5.5-py3-none-any.whl", hash = "sha256:0b533991df2353c0c6ea9ca10a7d0728b73631fd61f5a15511b19bee2aef8af0", size = 832431, upload-time = "2025-09-05T16:01:16.234Z" }, ] [[package]] name = "pytorch-metric-learning" -version = "2.8.1" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -2966,9 +2968,9 @@ dependencies = [ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/94/1bfb2c3eaf195b2d72912b65b3d417f2d9ac22491563eca360d453512c59/pytorch-metric-learning-2.8.1.tar.gz", hash = "sha256:fcc4d3b4a805e5fce25fb2e67505c47ba6fea0563fc09c5655ea1f08d1e8ed93", size = 83117, upload-time = "2024-12-11T19:21:15.982Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/80/6e61b1a91debf4c1b47d441f9a9d7fe2aabcdd9575ed70b2811474eb95c3/pytorch-metric-learning-2.9.0.tar.gz", hash = "sha256:27a626caf5e2876a0fd666605a78cb67ef7597e25d7a68c18053dd503830701f", size = 84530, upload-time = "2025-08-17T17:11:19.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/15/eee4e24c3f5a63b3e73692ff79766a66cab8844e24f5912be29350937592/pytorch_metric_learning-2.8.1-py3-none-any.whl", hash = "sha256:aba6da0508d29ee9661a67fbfee911cdf62e65fc07e404b167d82871ca7e3e88", size = 125923, upload-time = "2024-12-11T19:21:13.448Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/73ef5052f57b7720cad00e16598db3592a5ef4826745ffca67a2f085d4dc/pytorch_metric_learning-2.9.0-py3-none-any.whl", hash = "sha256:d51646006dc87168f00cf954785db133a4c5aac81253877248737aa42ef6432a", size = 127801, upload-time = "2025-08-17T17:11:18.185Z" }, ] [[package]] @@ -3104,10 +3106,10 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "fastapi-pagination" }, { name = "httpx" }, + { name = "icalendar" }, { name = "jsonschema" }, { name = "llama-index" }, { name = "llama-index-llms-openai-like" }, - { name = "loguru" }, { name = "nltk" }, { name = "openai" }, { name = "prometheus-fastapi-instrumentator" }, @@ -3180,10 +3182,10 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.100.1" }, { name = "fastapi-pagination", specifier = ">=0.12.6" }, { name = "httpx", specifier = ">=0.24.1" }, + { name = "icalendar", specifier = ">=6.0.0" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "llama-index", specifier = ">=0.12.52" }, { name = "llama-index-llms-openai-like", specifier = ">=0.4.0" }, - { name = "loguru", specifier = ">=0.7.0" }, { name = "nltk", specifier = ">=3.8.1" }, { name = "openai", specifier = ">=1.59.7" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=6.1.0" }, @@ -3427,14 +3429,14 @@ wheels = [ [[package]] name = "ruamel-yaml" -version = "0.18.14" +version = "0.18.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511, upload-time = "2025-06-09T08:51:09.828Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570, upload-time = "2025-06-09T08:51:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" }, ] [[package]] @@ -3621,7 +3623,7 @@ wheels = [ [[package]] name = "silero-vad" -version = "5.1.2" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "onnxruntime" }, @@ -3630,9 +3632,9 @@ dependencies = [ { name = "torchaudio", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'darwin')" }, { name = "torchaudio", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/b4/d0311b2e6220a11f8f4699f4a278cb088131573286cdfe804c87c7eb5123/silero_vad-5.1.2.tar.gz", hash = "sha256:c442971160026d2d7aa0ad83f0c7ee86c89797a65289fe625c8ea59fc6fb828d", size = 5098526, upload-time = "2024-10-09T09:50:47.019Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/79/ff5b13ca491a2eef2a43cd989ac9a87fa2131c246d467d909f2568c56955/silero_vad-6.0.0.tar.gz", hash = "sha256:4d202cb662112d9cba0e3fbc9f2c67e2e265c853f319adf20e348d108c797b76", size = 14567206, upload-time = "2025-08-26T07:10:02.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/f7/5ae11d13fbb733cd3bfd7ff1c3a3902e6f55437df4b72307c1f168146268/silero_vad-5.1.2-py3-none-any.whl", hash = "sha256:93b41953d7774b165407fda6b533c119c5803864e367d5034dc626c82cfdf661", size = 5026737, upload-time = "2024-10-09T09:50:44.355Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6a/a0a024878a1933a2326c42a3ce24fff6c0bf4882655f156c960ba50c2ed4/silero_vad-6.0.0-py3-none-any.whl", hash = "sha256:37d29be8944d2a2e6f1cc38a066076f13e78e6fc1b567a1beddcca72096f077f", size = 6119146, upload-time = "2025-08-26T07:10:00.637Z" }, ] [[package]] @@ -3940,12 +3942,14 @@ name = "torch" version = "2.8.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", - "python_full_version < '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", ] dependencies = [ { name = "filelock", marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" }, @@ -4029,10 +4033,12 @@ name = "torchaudio" version = "2.8.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version < '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", ] dependencies = [ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, @@ -4046,7 +4052,7 @@ wheels = [ [[package]] name = "torchmetrics" -version = "1.8.1" +version = "1.8.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lightning-utilities" }, @@ -4055,9 +4061,9 @@ dependencies = [ { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" }, { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/1f/2cd9eb8f3390c3ec4693ac0871913d4b468964b3833638e4091a70817e0a/torchmetrics-1.8.1.tar.gz", hash = "sha256:04ca021105871637c5d34d0a286b3ab665a1e3d2b395e561f14188a96e862fdb", size = 580373, upload-time = "2025-08-07T20:44:44.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679, upload-time = "2025-09-03T14:00:54.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/59/5c1c1cb08c494621901cf549a543f87143019fac1e6dd191eb4630bbc8fb/torchmetrics-1.8.1-py3-none-any.whl", hash = "sha256:2437501351e0da3d294c71210ce8139b9c762b5e20604f7a051a725443db8f4b", size = 982961, upload-time = "2025-08-07T20:44:42.608Z" }, + { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161, upload-time = "2025-09-03T14:00:51.921Z" }, ] [[package]] @@ -4209,8 +4215,10 @@ name = "vcrpy" version = "5.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'", - "python_full_version < '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", ] dependencies = [ { name = "pyyaml", marker = "platform_python_implementation == 'PyPy'" }, @@ -4344,15 +4352,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/ed/aad7e0f5a462d679f7b4d2e0d8502c3096740c883b5bbed5103146480937/webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7", size = 19802, upload-time = "2024-05-30T13:40:14.661Z" }, ] -[[package]] -name = "win32-setctime" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, -] - [[package]] name = "wrapt" version = "1.17.2" diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx new file mode 100644 index 00000000..1fa97692 --- /dev/null +++ b/www/app/(app)/rooms/_components/ICSSettings.tsx @@ -0,0 +1,343 @@ +import { + VStack, + HStack, + Field, + Input, + Select, + Checkbox, + Button, + Text, + Badge, + createListCollection, + Spinner, + Box, + IconButton, +} from "@chakra-ui/react"; +import { useState, useEffect, useRef } from "react"; +import { LuRefreshCw, LuCopy, LuCheck } from "react-icons/lu"; +import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa"; +import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks"; +import { toaster } from "../../../components/ui/toaster"; +import { roomAbsoluteUrl } from "../../../lib/routesClient"; +import { + assertExists, + assertExistsAndNonEmptyString, + NonEmptyString, + parseNonEmptyString, +} from "../../../lib/utils"; + +interface ICSSettingsProps { + roomName: NonEmptyString; + icsUrl?: string; + icsEnabled?: boolean; + icsFetchInterval?: number; + icsLastSync?: string; + icsLastEtag?: string; + onChange: (settings: Partial) => void; + isOwner?: boolean; + isEditing?: boolean; +} + +export interface ICSSettingsData { + ics_url: string; + ics_enabled: boolean; + ics_fetch_interval: number; +} + +const fetchIntervalOptions = [ + { label: "1 minute", value: "1" }, + { label: "5 minutes", value: "5" }, + { label: "10 minutes", value: "10" }, + { label: "30 minutes", value: "30" }, + { label: "1 hour", value: "60" }, +]; + +export default function ICSSettings({ + roomName, + icsUrl = "", + icsEnabled = false, + icsFetchInterval = 5, + icsLastSync, + icsLastEtag, + onChange, + isOwner = true, + isEditing = false, +}: ICSSettingsProps) { + const [syncStatus, setSyncStatus] = useState< + "idle" | "syncing" | "success" | "error" + >("idle"); + const [syncMessage, setSyncMessage] = useState(""); + const [syncResult, setSyncResult] = useState<{ + eventsFound: number; + totalEvents: number; + eventsCreated: number; + eventsUpdated: number; + } | null>(null); + const [justCopied, setJustCopied] = useState(false); + const roomUrlInputRef = useRef(null); + + const syncMutation = useRoomIcsSync(); + + const fetchIntervalCollection = createListCollection({ + items: fetchIntervalOptions, + }); + + const handleCopyRoomUrl = async () => { + try { + await navigator.clipboard.writeText( + roomAbsoluteUrl(assertExistsAndNonEmptyString(roomName)), + ); + setJustCopied(true); + + toaster + .create({ + placement: "top", + duration: 3000, + render: ({ dismiss }) => ( + + + Room URL copied to clipboard! + + ), + }) + .then(() => {}); + + setTimeout(() => { + setJustCopied(false); + }, 2000); + } catch (err) { + console.error("Failed to copy room url:", err); + } + }; + + const handleRoomUrlClick = () => { + if (roomUrlInputRef.current) { + roomUrlInputRef.current.select(); + handleCopyRoomUrl(); + } + }; + + // Clear sync results when dialog closes + useEffect(() => { + if (!isEditing) { + setSyncStatus("idle"); + setSyncResult(null); + setSyncMessage(""); + } + }, [isEditing]); + + const handleForceSync = async () => { + if (!roomName || !isEditing) return; + + // Clear previous results + setSyncStatus("syncing"); + setSyncResult(null); + setSyncMessage(""); + + try { + const result = await syncMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + }); + + if (result.status === "success" || result.status === "unchanged") { + setSyncStatus("success"); + setSyncResult({ + eventsFound: result.events_found || 0, + totalEvents: result.total_events || 0, + eventsCreated: result.events_created || 0, + eventsUpdated: result.events_updated || 0, + }); + } else { + setSyncStatus("error"); + setSyncMessage(result.error || "Sync failed"); + } + } catch (err: any) { + setSyncStatus("error"); + setSyncMessage(err.body?.detail || "Failed to force sync calendar"); + } + }; + + if (!isOwner) { + return null; // ICS settings only visible to room owner + } + + return ( + + + onChange({ ics_enabled: !!e.checked })} + > + + + + + Enable ICS calendar sync + + + + {icsEnabled && ( + <> + + Room URL + + To enable Reflector to recognize your calendar events as meetings, + add this URL as the location in your calendar events + + + + + + {justCopied ? : } + + + + + + + ICS Calendar URL + onChange({ ics_url: e.target.value })} + /> + + Enter the ICS URL from Google Calendar, Outlook, or other calendar + services + + + + + Sync Interval + { + const value = parseInt(details.value[0]); + onChange({ ics_fetch_interval: value }); + }} + > + + + + + {fetchIntervalOptions.map((option) => ( + + {option.label} + + ))} + + + + How often to check for calendar updates + + + + {icsUrl && isEditing && roomName && ( + + + + )} + + {syncResult && syncStatus === "success" && ( + + + + Sync completed + + + {syncResult.totalEvents} events downloaded,{" "} + {syncResult.eventsFound} match this room + + {(syncResult.eventsCreated > 0 || + syncResult.eventsUpdated > 0) && ( + + {syncResult.eventsCreated} created,{" "} + {syncResult.eventsUpdated} updated + + )} + + + )} + + {syncMessage && ( + + + {syncMessage} + + + )} + + {icsLastSync && ( + + + + Last sync: {new Date(icsLastSync).toLocaleString()} + + {icsLastEtag && ( + + ETag: {icsLastEtag.slice(0, 8)}... + + )} + + )} + + )} + + ); +} diff --git a/www/app/(app)/rooms/_components/RoomList.tsx b/www/app/(app)/rooms/_components/RoomList.tsx index 218c890c..8cd83277 100644 --- a/www/app/(app)/rooms/_components/RoomList.tsx +++ b/www/app/(app)/rooms/_components/RoomList.tsx @@ -4,12 +4,13 @@ import type { components } from "../../../reflector-api"; type Room = components["schemas"]["Room"]; import { RoomTable } from "./RoomTable"; import { RoomCards } from "./RoomCards"; +import { NonEmptyString } from "../../../lib/utils"; interface RoomListProps { title: string; rooms: Room[]; linkCopied: string; - onCopyUrl: (roomName: string) => void; + onCopyUrl: (roomName: NonEmptyString) => void; onEdit: (roomId: string, roomData: any) => void; onDelete: (roomId: string) => void; emptyMessage?: string; diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx index 113eca7f..ca6c2214 100644 --- a/www/app/(app)/rooms/_components/RoomTable.tsx +++ b/www/app/(app)/rooms/_components/RoomTable.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Box, Table, @@ -7,17 +7,58 @@ import { IconButton, Text, Spinner, + Badge, + VStack, + Icon, } from "@chakra-ui/react"; -import { LuLink } from "react-icons/lu"; +import { LuLink, LuRefreshCw } from "react-icons/lu"; +import { FaCalendarAlt } from "react-icons/fa"; import type { components } from "../../../reflector-api"; +import { + useRoomActiveMeetings, + useRoomUpcomingMeetings, + useRoomIcsSync, +} from "../../../lib/apiHooks"; type Room = components["schemas"]["Room"]; +type Meeting = components["schemas"]["Meeting"]; +type CalendarEventResponse = components["schemas"]["CalendarEventResponse"]; import { RoomActionsMenu } from "./RoomActionsMenu"; +import { MEETING_DEFAULT_TIME_MINUTES } from "../../../[roomName]/[meetingId]/constants"; +import { NonEmptyString, parseNonEmptyString } from "../../../lib/utils"; + +// Custom icon component that combines calendar and refresh icons +const CalendarSyncIcon = () => ( + + + + + + +); interface RoomTableProps { rooms: Room[]; linkCopied: string; - onCopyUrl: (roomName: string) => void; + onCopyUrl: (roomName: NonEmptyString) => void; onEdit: (roomId: string, roomData: any) => void; onDelete: (roomId: string) => void; loading?: boolean; @@ -63,6 +104,71 @@ const getZulipDisplay = ( return "Enabled"; }; +function MeetingStatus({ roomName }: { roomName: string }) { + const activeMeetingsQuery = useRoomActiveMeetings(roomName); + const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName); + + const activeMeetings = activeMeetingsQuery.data || []; + const upcomingMeetings = upcomingMeetingsQuery.data || []; + + if (activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading) { + return ; + } + + if (activeMeetings.length > 0) { + const meeting = activeMeetings[0]; + const title = String( + meeting.calendar_metadata?.["title"] || "Active Meeting", + ); + return ( + + + {title} + + + {meeting.num_clients} participants + + + ); + } + + if (upcomingMeetings.length > 0) { + const event = upcomingMeetings[0]; + const startTime = new Date(event.start_time); + const now = new Date(); + const diffMinutes = Math.floor( + (startTime.getTime() - now.getTime()) / 60000, + ); + + return ( + + + {diffMinutes < MEETING_DEFAULT_TIME_MINUTES + ? `In ${diffMinutes}m` + : "Upcoming"} + + + {event.title || "Scheduled Meeting"} + + + {startTime.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "numeric", + })} + + + ); + } + + return ( + + No meetings + + ); +} + export function RoomTable({ rooms, linkCopied, @@ -71,6 +177,30 @@ export function RoomTable({ onDelete, loading, }: RoomTableProps) { + const [syncingRooms, setSyncingRooms] = useState>( + new Set(), + ); + const syncMutation = useRoomIcsSync(); + + const handleForceSync = async (roomName: NonEmptyString) => { + setSyncingRooms((prev) => new Set(prev).add(roomName)); + try { + await syncMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + }); + } catch (err) { + console.error("Failed to sync calendar:", err); + } finally { + setSyncingRooms((prev) => { + const next = new Set(prev); + next.delete(roomName); + return next; + }); + } + }; + return ( {loading && ( @@ -97,13 +227,16 @@ export function RoomTable({ Room Name - - Zulip - - - Room Size + + Current Meeting + Zulip + + + Room Size + + Recording {room.name} + + + {getZulipDisplay( room.zulip_auto_post, @@ -133,7 +269,26 @@ export function RoomTable({ )} - + + {room.ics_enabled && ( + + handleForceSync(parseNonEmptyString(room.name)) + } + size="sm" + variant="ghost" + disabled={syncingRooms.has( + parseNonEmptyString(room.name), + )} + > + {syncingRooms.has(parseNonEmptyString(room.name)) ? ( + + ) : ( + + )} + + )} {linkCopied === room.name ? ( Copied! @@ -141,7 +296,9 @@ export function RoomTable({ ) : ( onCopyUrl(room.name)} + onClick={() => + onCopyUrl(parseNonEmptyString(room.name)) + } size="sm" variant="ghost" > diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 8b1378df..88e66720 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -14,6 +14,7 @@ import { IconButton, createListCollection, useDisclosure, + Tabs, } from "@chakra-ui/react"; import { useEffect, useMemo, useState } from "react"; import { LuEye, LuEyeOff } from "react-icons/lu"; @@ -30,7 +31,13 @@ import { } from "../../lib/apiHooks"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; -import { assertExists } from "../../lib/utils"; +import { + assertExists, + NonEmptyString, + parseNonEmptyString, +} from "../../lib/utils"; +import ICSSettings from "./_components/ICSSettings"; +import { roomAbsoluteUrl } from "../../lib/routesClient"; type Room = components["schemas"]["Room"]; @@ -40,6 +47,8 @@ interface SelectOption { } const RESERVED_PATHS = ["browse", "rooms", "transcripts"]; +const SUCCESS_EMOJI = "✅"; +const ERROR_EMOJI = "❌"; const roomModeOptions: SelectOption[] = [ { label: "2-4 people", value: "normal" }, @@ -70,6 +79,9 @@ const roomInitialState = { isShared: false, webhookUrl: "", webhookSecret: "", + icsUrl: "", + icsEnabled: false, + icsFetchInterval: 5, }; export default function RoomsList() { @@ -137,6 +149,9 @@ export default function RoomsList() { isShared: detailedEditedRoom.is_shared, webhookUrl: detailedEditedRoom.webhook_url || "", webhookSecret: detailedEditedRoom.webhook_secret || "", + icsUrl: detailedEditedRoom.ics_url || "", + icsEnabled: detailedEditedRoom.ics_enabled || false, + icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5, } : null, [detailedEditedRoom], @@ -176,14 +191,13 @@ export default function RoomsList() { items: topicOptions, }); - const handleCopyUrl = (roomName: string) => { - const roomUrl = `${window.location.origin}/${roomName}`; - navigator.clipboard.writeText(roomUrl); - setLinkCopied(roomName); - - setTimeout(() => { - setLinkCopied(""); - }, 2000); + const handleCopyUrl = (roomName: NonEmptyString) => { + navigator.clipboard.writeText(roomAbsoluteUrl(roomName)).then(() => { + setLinkCopied(roomName); + setTimeout(() => { + setLinkCopied(""); + }, 2000); + }); }; const handleCloseDialog = () => { @@ -217,10 +231,10 @@ export default function RoomsList() { if (response.success) { setWebhookTestResult( - `✅ Webhook test successful! Status: ${response.status_code}`, + `${SUCCESS_EMOJI} Webhook test successful! Status: ${response.status_code}`, ); } else { - let errorMsg = `❌ Webhook test failed`; + let errorMsg = `${ERROR_EMOJI} Webhook test failed`; errorMsg += ` (Status: ${response.status_code})`; if (response.error) { errorMsg += `: ${response.error}`; @@ -275,6 +289,9 @@ export default function RoomsList() { is_shared: room.isShared, webhook_url: room.webhookUrl, webhook_secret: room.webhookSecret, + ics_url: room.icsUrl, + ics_enabled: room.icsEnabled, + ics_fetch_interval: room.icsFetchInterval, }; if (isEditing) { @@ -316,6 +333,22 @@ export default function RoomsList() { setShowWebhookSecret(false); setWebhookTestResult(null); + setRoomInput({ + name: roomData.name, + zulipAutoPost: roomData.zulip_auto_post, + zulipStream: roomData.zulip_stream, + zulipTopic: roomData.zulip_topic, + isLocked: roomData.is_locked, + roomMode: roomData.room_mode, + recordingType: roomData.recording_type, + recordingTrigger: roomData.recording_trigger, + isShared: roomData.is_shared, + webhookUrl: roomData.webhook_url || "", + webhookSecret: roomData.webhook_secret || "", + icsUrl: roomData.ics_url || "", + icsEnabled: roomData.ics_enabled || false, + icsFetchInterval: roomData.ics_fetch_interval || 5, + }); setEditRoomId(roomId); setIsEditing(true); setNameError(""); @@ -416,353 +449,407 @@ export default function RoomsList() { - - Room name - - - No spaces or special characters allowed - - {nameError && {nameError}} - + + + General + Calendar + Share + WebHook + - - { - const syntheticEvent = { - target: { - name: "isLocked", - type: "checkbox", - checked: e.checked, - }, - }; - handleRoomChange(syntheticEvent); - }} - > - - - - - Locked room - - - - Room size - - setRoomInput({ ...room, roomMode: e.value[0] }) - } - collection={roomModeCollection} - > - - - - - - - - - - - - {roomModeOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - Recording type - - setRoomInput({ - ...room, - recordingType: e.value[0], - recordingTrigger: - e.value[0] !== "cloud" ? "none" : room.recordingTrigger, - }) - } - collection={recordingTypeCollection} - > - - - - - - - - - - - - {recordingTypeOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - Cloud recording start trigger - - setRoomInput({ ...room, recordingTrigger: e.value[0] }) - } - collection={recordingTriggerCollection} - disabled={room.recordingType !== "cloud"} - > - - - - - - - - - - - - {recordingTriggerOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - { - const syntheticEvent = { - target: { - name: "zulipAutoPost", - type: "checkbox", - checked: e.checked, - }, - }; - handleRoomChange(syntheticEvent); - }} - > - - - - - - Automatically post transcription to Zulip - - - - - Zulip stream - - setRoomInput({ - ...room, - zulipStream: e.value[0], - zulipTopic: "", - }) - } - collection={streamCollection} - disabled={!room.zulipAutoPost} - > - - - - - - - - - - - - {streamOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - Zulip topic - - setRoomInput({ ...room, zulipTopic: e.value[0] }) - } - collection={topicCollection} - disabled={!room.zulipAutoPost} - > - - - - - - - - - - - - {topicOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - {/* Webhook Configuration Section */} - - Webhook URL - - - Optional: URL to receive notifications when transcripts are - ready - - - - {room.webhookUrl && ( - <> - - Webhook Secret - - - {isEditing && room.webhookSecret && ( - - setShowWebhookSecret(!showWebhookSecret) - } - > - {showWebhookSecret ? : } - - )} - + + + Room name + - Used for HMAC signature verification (auto-generated if - left empty) + No spaces or special characters allowed + + {nameError && ( + {nameError} + )} + + + + { + const syntheticEvent = { + target: { + name: "isLocked", + type: "checkbox", + checked: e.checked, + }, + }; + handleRoomChange(syntheticEvent); + }} + > + + + + + Locked room + + + + + Room size + + setRoomInput({ ...room, roomMode: e.value[0] }) + } + collection={roomModeCollection} + > + + + + + + + + + + + + {roomModeOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + + Recording type + + setRoomInput({ + ...room, + recordingType: e.value[0], + recordingTrigger: + e.value[0] !== "cloud" + ? "none" + : room.recordingTrigger, + }) + } + collection={recordingTypeCollection} + > + + + + + + + + + + + + {recordingTypeOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + + Cloud recording start trigger + + setRoomInput({ ...room, recordingTrigger: e.value[0] }) + } + collection={recordingTriggerCollection} + disabled={room.recordingType !== "cloud"} + > + + + + + + + + + + + + {recordingTriggerOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + + { + const syntheticEvent = { + target: { + name: "isShared", + type: "checkbox", + checked: e.checked, + }, + }; + handleRoomChange(syntheticEvent); + }} + > + + + + + Shared room + + + + + + { + setRoomInput({ + ...room, + icsUrl: + settings.ics_url !== undefined + ? settings.ics_url + : room.icsUrl, + icsEnabled: + settings.ics_enabled !== undefined + ? settings.ics_enabled + : room.icsEnabled, + icsFetchInterval: + settings.ics_fetch_interval !== undefined + ? settings.ics_fetch_interval + : room.icsFetchInterval, + }); + }} + isOwner={true} + isEditing={isEditing} + /> + + + + + { + const syntheticEvent = { + target: { + name: "zulipAutoPost", + type: "checkbox", + checked: e.checked, + }, + }; + handleRoomChange(syntheticEvent); + }} + > + + + + + + Automatically post transcription to Zulip + + + + + + Zulip stream + + setRoomInput({ + ...room, + zulipStream: e.value[0], + zulipTopic: "", + }) + } + collection={streamCollection} + disabled={!room.zulipAutoPost} + > + + + + + + + + + + + + {streamOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + + Zulip topic + + setRoomInput({ ...room, zulipTopic: e.value[0] }) + } + collection={topicCollection} + disabled={!room.zulipAutoPost} + > + + + + + + + + + + + + {topicOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + + + + Webhook URL + + + Optional: URL to receive notifications when transcripts + are ready - {isEditing && ( + {room.webhookUrl && ( <> - - - {webhookTestResult && ( -
+ + Used for HMAC signature verification (auto-generated + if left empty) + + + + {isEditing && ( + <> + - {webhookTestResult} -
- )} -
+ + {webhookTestResult && ( +
+ {webhookTestResult} +
+ )} +
+ + )} )} - - )} - - - { - const syntheticEvent = { - target: { - name: "isShared", - type: "checkbox", - checked: e.checked, - }, - }; - handleRoomChange(syntheticEvent); - }} - > - - - - - Shared room - - + + + {isOwner && ( + + )} + +
+
+ ))} + + ) : upcomingMeetings.length > 0 ? ( + /* Upcoming Meetings - BIG DISPLAY when no ongoing meetings */ + + + Upcoming Meeting{upcomingMeetings.length > 1 ? "s" : ""} + + {upcomingMeetings.map((meeting) => { + const now = new Date(); + const startTime = new Date(meeting.start_date); + const minutesUntilStart = Math.floor( + (startTime.getTime() - now.getTime()) / (1000 * 60), + ); + + return ( + + + + + + + {(meeting.calendar_metadata as any)?.title || + "Upcoming Meeting"} + + + + {isOwner && + (meeting.calendar_metadata as any)?.description && ( + + {(meeting.calendar_metadata as any).description} + + )} + + + + Starts in {minutesUntilStart} minute + {minutesUntilStart !== 1 ? "s" : ""} + + + {formatDateTime(new Date(meeting.start_date))} + + + + {isOwner && + (meeting.calendar_metadata as any)?.attendees && ( + + {(meeting.calendar_metadata as any).attendees + .slice(0, 4) + .map((attendee: any, idx: number) => ( + + {attendee.name || attendee.email} + + ))} + {(meeting.calendar_metadata as any).attendees + .length > 4 && ( + + + + {(meeting.calendar_metadata as any).attendees + .length - 4}{" "} + more + + )} + + )} + + + + + {isOwner && ( + + )} + + + + ); + })} + + ) : null} + + {/* Upcoming Meetings - SMALLER ASIDE DISPLAY when there are ongoing meetings */} + {currentMeetings.length > 0 && upcomingMeetings.length > 0 && ( + + + Starting Soon + + + {upcomingMeetings.map((meeting) => { + const now = new Date(); + const startTime = new Date(meeting.start_date); + const minutesUntilStart = Math.floor( + (startTime.getTime() - now.getTime()) / (1000 * 60), + ); + + return ( + + + + + + {(meeting.calendar_metadata as any)?.title || + "Upcoming Meeting"} + + + + + in {minutesUntilStart} minute + {minutesUntilStart !== 1 ? "s" : ""} + + + + Starts: {formatDateTime(new Date(meeting.start_date))} + + + + + + ); + })} + + + )} + + {/* No meetings message - show when no ongoing or upcoming meetings */} + {currentMeetings.length === 0 && upcomingMeetings.length === 0 && ( + + + + + + No meetings right now + + + There are no ongoing or upcoming meetings in this room at the + moment. + + + + + )} + + + ); +} diff --git a/www/app/[roomName]/[meetingId]/constants.ts b/www/app/[roomName]/[meetingId]/constants.ts new file mode 100644 index 00000000..6978da36 --- /dev/null +++ b/www/app/[roomName]/[meetingId]/constants.ts @@ -0,0 +1 @@ +export const MEETING_DEFAULT_TIME_MINUTES = 60; diff --git a/www/app/[roomName]/[meetingId]/page.tsx b/www/app/[roomName]/[meetingId]/page.tsx new file mode 100644 index 00000000..8ce405ba --- /dev/null +++ b/www/app/[roomName]/[meetingId]/page.tsx @@ -0,0 +1,3 @@ +import Room from "../room"; + +export default Room; diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index 867aeb3e..1aaca4c7 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -1,336 +1,3 @@ -"use client"; +import Room from "./room"; -import { - useCallback, - useEffect, - useRef, - useState, - useContext, - RefObject, - use, -} from "react"; -import { - Box, - Button, - Text, - VStack, - HStack, - Spinner, - Icon, -} from "@chakra-ui/react"; -import { toaster } from "../components/ui/toaster"; -import useRoomMeeting from "./useRoomMeeting"; -import { useRouter } from "next/navigation"; -import { notFound } from "next/navigation"; -import { useRecordingConsent } from "../recordingConsentContext"; -import { useMeetingAudioConsent } from "../lib/apiHooks"; -import type { components } from "../reflector-api"; - -type Meeting = components["schemas"]["Meeting"]; -import { FaBars } from "react-icons/fa6"; -import { useAuth } from "../lib/AuthProvider"; - -export type RoomDetails = { - params: Promise<{ - roomName: string; - }>; -}; - -// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially -const useConsentWherebyFocusManagement = ( - acceptButtonRef: RefObject, - wherebyRef: RefObject, -) => { - const currentFocusRef = useRef(null); - useEffect(() => { - if (acceptButtonRef.current) { - acceptButtonRef.current.focus(); - } else { - console.error( - "accept button ref not available yet for focus management - seems to be illegal state", - ); - } - - const handleWherebyReady = () => { - console.log("whereby ready - refocusing consent button"); - currentFocusRef.current = document.activeElement as HTMLElement; - if (acceptButtonRef.current) { - acceptButtonRef.current.focus(); - } - }; - - if (wherebyRef.current) { - wherebyRef.current.addEventListener("ready", handleWherebyReady); - } else { - console.warn( - "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.", - ); - } - - return () => { - wherebyRef.current?.removeEventListener("ready", handleWherebyReady); - currentFocusRef.current?.focus(); - }; - }, []); -}; - -const useConsentDialog = ( - meetingId: string, - wherebyRef: RefObject /*accessibility*/, -) => { - const { state: consentState, touch, hasConsent } = useRecordingConsent(); - // toast would open duplicates, even with using "id=" prop - const [modalOpen, setModalOpen] = useState(false); - const audioConsentMutation = useMeetingAudioConsent(); - - const handleConsent = useCallback( - async (meetingId: string, given: boolean) => { - try { - await audioConsentMutation.mutateAsync({ - params: { - path: { - meeting_id: meetingId, - }, - }, - body: { - consent_given: given, - }, - }); - - touch(meetingId); - } catch (error) { - console.error("Error submitting consent:", error); - } - }, - [audioConsentMutation, touch], - ); - - const showConsentModal = useCallback(() => { - if (modalOpen) return; - - setModalOpen(true); - - const toastId = toaster.create({ - placement: "top", - duration: null, - render: ({ dismiss }) => { - const AcceptButton = () => { - const buttonRef = useRef(null); - useConsentWherebyFocusManagement(buttonRef, wherebyRef); - return ( - - ); - }; - - return ( - - - - Can we have your permission to store this meeting's audio - recording on our servers? - - - - - - - - ); - }, - }); - - // Set modal state when toast is dismissed - toastId.then((id) => { - const checkToastStatus = setInterval(() => { - if (!toaster.isActive(id)) { - setModalOpen(false); - clearInterval(checkToastStatus); - } - }, 100); - }); - - // Handle escape key to close the toast - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - toastId.then((id) => toaster.dismiss(id)); - } - }; - - document.addEventListener("keydown", handleKeyDown); - - const cleanup = () => { - toastId.then((id) => toaster.dismiss(id)); - document.removeEventListener("keydown", handleKeyDown); - }; - - return cleanup; - }, [meetingId, handleConsent, wherebyRef, modalOpen]); - - return { - showConsentModal, - consentState, - hasConsent, - consentLoading: audioConsentMutation.isPending, - }; -}; - -function ConsentDialogButton({ - meetingId, - wherebyRef, -}: { - meetingId: string; - wherebyRef: React.RefObject; -}) { - const { showConsentModal, consentState, hasConsent, consentLoading } = - useConsentDialog(meetingId, wherebyRef); - - if (!consentState.ready || hasConsent(meetingId) || consentLoading) { - return null; - } - - return ( - - ); -} - -const recordingTypeRequiresConsent = ( - recordingType: NonNullable, -) => { - return recordingType === "cloud"; -}; - -// next throws even with "use client" -const useWhereby = () => { - const [wherebyLoaded, setWherebyLoaded] = useState(false); - useEffect(() => { - if (typeof window !== "undefined") { - import("@whereby.com/browser-sdk/embed") - .then(() => { - setWherebyLoaded(true); - }) - .catch(console.error.bind(console)); - } - }, []); - return wherebyLoaded; -}; - -export default function Room(details: RoomDetails) { - const params = use(details.params); - const wherebyLoaded = useWhereby(); - const wherebyRef = useRef(null); - const roomName = params.roomName; - const meeting = useRoomMeeting(roomName); - const router = useRouter(); - const status = useAuth().status; - const isAuthenticated = status === "authenticated"; - const isLoading = status === "loading" || meeting.loading; - - const roomUrl = meeting?.response?.host_room_url - ? meeting?.response?.host_room_url - : meeting?.response?.room_url; - - const meetingId = meeting?.response?.id; - - const recordingType = meeting?.response?.recording_type; - - const handleLeave = useCallback(() => { - router.push("/browse"); - }, [router]); - - useEffect(() => { - if ( - !isLoading && - meeting?.error && - "status" in meeting.error && - meeting.error.status === 404 - ) { - notFound(); - } - }, [isLoading, meeting?.error]); - - useEffect(() => { - if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return; - - wherebyRef.current?.addEventListener("leave", handleLeave); - - return () => { - wherebyRef.current?.removeEventListener("leave", handleLeave); - }; - }, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]); - - if (isLoading) { - return ( - - - - ); - } - - return ( - <> - {roomUrl && meetingId && wherebyLoaded && ( - <> - - {recordingType && recordingTypeRequiresConsent(recordingType) && ( - - )} - - )} - - ); -} +export default Room; diff --git a/www/app/[roomName]/room.tsx b/www/app/[roomName]/room.tsx new file mode 100644 index 00000000..780851e2 --- /dev/null +++ b/www/app/[roomName]/room.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { roomMeetingUrl, roomUrl as getRoomUrl } from "../lib/routes"; +import { + useCallback, + useEffect, + useRef, + useState, + useContext, + RefObject, + use, +} from "react"; +import { + Box, + Button, + Text, + VStack, + HStack, + Spinner, + Icon, +} from "@chakra-ui/react"; +import { toaster } from "../components/ui/toaster"; +import { useRouter } from "next/navigation"; +import { useRecordingConsent } from "../recordingConsentContext"; +import { + useMeetingAudioConsent, + useRoomGetByName, + useRoomActiveMeetings, + useRoomUpcomingMeetings, + useRoomsCreateMeeting, + useRoomGetMeeting, +} from "../lib/apiHooks"; +import type { components } from "../reflector-api"; +import MeetingSelection from "./MeetingSelection"; +import useRoomDefaultMeeting from "./useRoomDefaultMeeting"; + +type Meeting = components["schemas"]["Meeting"]; +import { FaBars } from "react-icons/fa6"; +import { useAuth } from "../lib/AuthProvider"; +import { getWherebyUrl, useWhereby } from "../lib/wherebyClient"; +import { useError } from "../(errors)/errorContext"; +import { + assertExistsAndNonEmptyString, + NonEmptyString, + parseNonEmptyString, +} from "../lib/utils"; +import { printApiError } from "../api/_error"; + +export type RoomDetails = { + params: Promise<{ + roomName: string; + meetingId?: string; + }>; +}; + +// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially +const useConsentWherebyFocusManagement = ( + acceptButtonRef: RefObject, + wherebyRef: RefObject, +) => { + const currentFocusRef = useRef(null); + useEffect(() => { + if (acceptButtonRef.current) { + acceptButtonRef.current.focus(); + } else { + console.error( + "accept button ref not available yet for focus management - seems to be illegal state", + ); + } + + const handleWherebyReady = () => { + console.log("whereby ready - refocusing consent button"); + currentFocusRef.current = document.activeElement as HTMLElement; + if (acceptButtonRef.current) { + acceptButtonRef.current.focus(); + } + }; + + if (wherebyRef.current) { + wherebyRef.current.addEventListener("ready", handleWherebyReady); + } else { + console.warn( + "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.", + ); + } + + return () => { + wherebyRef.current?.removeEventListener("ready", handleWherebyReady); + currentFocusRef.current?.focus(); + }; + }, []); +}; + +const useConsentDialog = ( + meetingId: string, + wherebyRef: RefObject /*accessibility*/, +) => { + const { state: consentState, touch, hasConsent } = useRecordingConsent(); + // toast would open duplicates, even with using "id=" prop + const [modalOpen, setModalOpen] = useState(false); + const audioConsentMutation = useMeetingAudioConsent(); + + const handleConsent = useCallback( + async (meetingId: string, given: boolean) => { + try { + await audioConsentMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + body: { + consent_given: given, + }, + }); + + touch(meetingId); + } catch (error) { + console.error("Error submitting consent:", error); + } + }, + [audioConsentMutation, touch], + ); + + const showConsentModal = useCallback(() => { + if (modalOpen) return; + + setModalOpen(true); + + const toastId = toaster.create({ + placement: "top", + duration: null, + render: ({ dismiss }) => { + const AcceptButton = () => { + const buttonRef = useRef(null); + useConsentWherebyFocusManagement(buttonRef, wherebyRef); + return ( + + ); + }; + + return ( + + + + Can we have your permission to store this meeting's audio + recording on our servers? + + + + + + + + ); + }, + }); + + // Set modal state when toast is dismissed + toastId.then((id) => { + const checkToastStatus = setInterval(() => { + if (!toaster.isActive(id)) { + setModalOpen(false); + clearInterval(checkToastStatus); + } + }, 100); + }); + + // Handle escape key to close the toast + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + toastId.then((id) => toaster.dismiss(id)); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + const cleanup = () => { + toastId.then((id) => toaster.dismiss(id)); + document.removeEventListener("keydown", handleKeyDown); + }; + + return cleanup; + }, [meetingId, handleConsent, wherebyRef, modalOpen]); + + return { + showConsentModal, + consentState, + hasConsent, + consentLoading: audioConsentMutation.isPending, + }; +}; + +function ConsentDialogButton({ + meetingId, + wherebyRef, +}: { + meetingId: NonEmptyString; + wherebyRef: React.RefObject; +}) { + const { showConsentModal, consentState, hasConsent, consentLoading } = + useConsentDialog(meetingId, wherebyRef); + + if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + return null; + } + + return ( + + ); +} + +const recordingTypeRequiresConsent = ( + recordingType: NonNullable, +) => { + return recordingType === "cloud"; +}; + +export default function Room(details: RoomDetails) { + const params = use(details.params); + const wherebyLoaded = useWhereby(); + const wherebyRef = useRef(null); + const roomName = parseNonEmptyString(params.roomName); + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + const { setError } = useError(); + + const roomQuery = useRoomGetByName(roomName); + const createMeetingMutation = useRoomsCreateMeeting(); + + const room = roomQuery.data; + + const pageMeetingId = params.meetingId; + + // this one is called on room page + const defaultMeeting = useRoomDefaultMeeting( + room && !room.ics_enabled && !pageMeetingId ? roomName : null, + ); + + const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null); + const wherebyRoomUrl = explicitMeeting.data + ? getWherebyUrl(explicitMeeting.data) + : defaultMeeting.response + ? getWherebyUrl(defaultMeeting.response) + : null; + const recordingType = (explicitMeeting.data || defaultMeeting.response) + ?.recording_type; + const meetingId = (explicitMeeting.data || defaultMeeting.response)?.id; + + const isLoading = + status === "loading" || + roomQuery.isLoading || + defaultMeeting?.loading || + explicitMeeting.isLoading; + + const errors = [ + explicitMeeting.error, + defaultMeeting.error, + roomQuery.error, + createMeetingMutation.error, + ].filter(Boolean); + + const isOwner = + isAuthenticated && room ? auth.user?.id === room.user_id : false; + + const handleMeetingSelect = (selectedMeeting: Meeting) => { + router.push( + roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)), + ); + }; + + const handleCreateUnscheduled = async () => { + try { + // Create a new unscheduled meeting + const newMeeting = await createMeetingMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + body: { + allow_duplicated: room ? room.ics_enabled : false, + }, + }); + handleMeetingSelect(newMeeting); + } catch (err) { + console.error("Failed to create meeting:", err); + } + }; + + const handleLeave = useCallback(() => { + router.push("/browse"); + }, [router]); + + useEffect(() => { + if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded) + return; + + wherebyRef.current?.addEventListener("leave", handleLeave); + + return () => { + wherebyRef.current?.removeEventListener("leave", handleLeave); + }; + }, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]); + + useEffect(() => { + if (!isLoading && !wherebyRoomUrl) { + setError(new Error("Whereby room URL not found")); + } + }, [isLoading, wherebyRoomUrl]); + + if (isLoading) { + return ( + + + + ); + } + + if (!room) { + return ( + + Room not found + + ); + } + + if (room.ics_enabled && !params.meetingId) { + return ( + + ); + } + + if (errors.length > 0) { + return ( + + {errors.map((error, i) => ( + + {printApiError(error)} + + ))} + + ); + } + + return ( + <> + {wherebyRoomUrl && wherebyLoaded && ( + <> + + {recordingType && + recordingTypeRequiresConsent(recordingType) && + meetingId && ( + + )} + + )} + + ); +} diff --git a/www/app/[roomName]/useRoomMeeting.tsx b/www/app/[roomName]/useRoomDefaultMeeting.tsx similarity index 75% rename from www/app/[roomName]/useRoomMeeting.tsx rename to www/app/[roomName]/useRoomDefaultMeeting.tsx index 93491a05..724e692f 100644 --- a/www/app/[roomName]/useRoomMeeting.tsx +++ b/www/app/[roomName]/useRoomDefaultMeeting.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useError } from "../(errors)/errorContext"; import type { components } from "../reflector-api"; import { shouldShowError } from "../lib/errorUtils"; @@ -6,30 +6,31 @@ import { shouldShowError } from "../lib/errorUtils"; type Meeting = components["schemas"]["Meeting"]; import { useRoomsCreateMeeting } from "../lib/apiHooks"; import { notFound } from "next/navigation"; +import { ApiError } from "../api/_error"; type ErrorMeeting = { - error: Error; + error: ApiError; loading: false; response: null; reload: () => void; }; type LoadingMeeting = { + error: null; response: null; loading: true; - error: false; reload: () => void; }; type SuccessMeeting = { + error: null; response: Meeting; loading: false; - error: null; reload: () => void; }; -const useRoomMeeting = ( - roomName: string | null | undefined, +const useRoomDefaultMeeting = ( + roomName: string | null, ): ErrorMeeting | LoadingMeeting | SuccessMeeting => { const [response, setResponse] = useState(null); const [reload, setReload] = useState(0); @@ -37,10 +38,15 @@ const useRoomMeeting = ( const createMeetingMutation = useRoomsCreateMeeting(); const reloadHandler = () => setReload((prev) => prev + 1); + // this is to undupe dev mode room creation + const creatingRef = useRef(false); + useEffect(() => { if (!roomName) return; + if (creatingRef.current) return; const createMeeting = async () => { + creatingRef.current = true; try { const result = await createMeetingMutation.mutateAsync({ params: { @@ -48,6 +54,9 @@ const useRoomMeeting = ( room_name: roomName, }, }, + body: { + allow_duplicated: false, + }, }); setResponse(result); } catch (error: any) { @@ -60,14 +69,16 @@ const useRoomMeeting = ( } else { setError(error); } + } finally { + creatingRef.current = false; } }; - createMeeting(); + createMeeting().then(() => {}); }, [roomName, reload]); const loading = createMeetingMutation.isPending && !response; - const error = createMeetingMutation.error as Error | null; + const error = createMeetingMutation.error; return { response, loading, error, reload: reloadHandler } as | ErrorMeeting @@ -75,4 +86,4 @@ const useRoomMeeting = ( | SuccessMeeting; }; -export default useRoomMeeting; +export default useRoomDefaultMeeting; diff --git a/www/app/api/_error.ts b/www/app/api/_error.ts new file mode 100644 index 00000000..9603b8e8 --- /dev/null +++ b/www/app/api/_error.ts @@ -0,0 +1,26 @@ +import { components } from "../reflector-api"; +import { isArray } from "remeda"; + +export type ApiError = { + detail?: components["schemas"]["ValidationError"][]; +} | null; + +// errors as declared on api types is not != as they in reality e.g. detail may be a string +export const printApiError = (error: ApiError) => { + if (!error || !error.detail) { + return null; + } + const detail = error.detail as unknown; + if (isArray(error.detail)) { + return error.detail.map((e) => e.msg).join(", "); + } + if (typeof detail === "string") { + if (detail.length > 0) { + return detail; + } + console.error("Error detail is empty"); + return null; + } + console.error("Error detail is not a string or array"); + return null; +}; diff --git a/www/app/api/schemas.gen.ts b/www/app/api/schemas.gen.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/www/app/api/services.gen.ts b/www/app/api/services.gen.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/www/app/components/MeetingMinimalHeader.tsx b/www/app/components/MeetingMinimalHeader.tsx new file mode 100644 index 00000000..fe08c9d6 --- /dev/null +++ b/www/app/components/MeetingMinimalHeader.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Flex, Link, Button, Text, HStack } from "@chakra-ui/react"; +import NextLink from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { roomUrl } from "../lib/routes"; +import { NonEmptyString } from "../lib/utils"; + +interface MeetingMinimalHeaderProps { + roomName: NonEmptyString; + displayName?: string; + showLeaveButton?: boolean; + onLeave?: () => void; + showCreateButton?: boolean; + onCreateMeeting?: () => void; + isCreatingMeeting?: boolean; +} + +export default function MeetingMinimalHeader({ + roomName, + displayName, + showLeaveButton = true, + onLeave, + showCreateButton = false, + onCreateMeeting, + isCreatingMeeting = false, +}: MeetingMinimalHeaderProps) { + const router = useRouter(); + + const handleLeaveMeeting = () => { + if (onLeave) { + onLeave(); + } else { + router.push(roomUrl(roomName)); + } + }; + + const roomTitle = displayName + ? displayName.endsWith("'s") || displayName.endsWith("s") + ? `${displayName} Room` + : `${displayName}'s Room` + : `${roomName} Room`; + + return ( + + {/* Logo and Room Context */} + + + Reflector + + + {roomTitle} + + + + {/* Action Buttons */} + + {showCreateButton && onCreateMeeting && ( + + )} + {showLeaveButton && ( + + )} + + + ); +} diff --git a/www/app/lib/WherebyWebinarEmbed.tsx b/www/app/lib/WherebyWebinarEmbed.tsx index 5bfef554..5526cca2 100644 --- a/www/app/lib/WherebyWebinarEmbed.tsx +++ b/www/app/lib/WherebyWebinarEmbed.tsx @@ -4,16 +4,16 @@ import "@whereby.com/browser-sdk/embed"; import { Box, Button, HStack, Text, Link } from "@chakra-ui/react"; import { toaster } from "../components/ui/toaster"; -interface WherebyEmbedProps { +interface WherebyWebinarEmbedProps { roomUrl: string; onLeave?: () => void; } -// currently used for webinars only +// used for webinars only export default function WherebyWebinarEmbed({ roomUrl, onLeave, -}: WherebyEmbedProps) { +}: WherebyWebinarEmbedProps) { const wherebyRef = useRef(null); // TODO extract common toast logic / styles to be used by consent toast on normal rooms diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 3b5eed2b..c5b4f9b9 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -12,7 +12,7 @@ import { useAuth } from "./AuthProvider"; * or, limitation or incorrect usage of .d type generator from json schema * */ -const useAuthReady = () => { +export const useAuthReady = () => { const auth = useAuth(); return { @@ -75,7 +75,7 @@ export function useTranscriptDelete() { return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", { onSuccess: () => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: ["get", "/v1/transcripts/search"], }); }, @@ -102,7 +102,7 @@ export function useTranscriptGet(transcriptId: string | null) { { params: { path: { - transcript_id: transcriptId || "", + transcript_id: transcriptId!, }, }, }, @@ -120,7 +120,7 @@ export function useRoomGet(roomId: string | null) { "/v1/rooms/{room_id}", { params: { - path: { room_id: roomId || "" }, + path: { room_id: roomId! }, }, }, { @@ -145,7 +145,7 @@ export function useRoomCreate() { return $api.useMutation("post", "/v1/rooms", { onSuccess: () => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, }); }, @@ -188,7 +188,7 @@ export function useRoomDelete() { return $api.useMutation("delete", "/v1/rooms/{room_id}", { onSuccess: () => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, }); }, @@ -236,7 +236,7 @@ export function useTranscriptUpdate() { return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", { onSuccess: (data, variables) => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", { params: { path: { transcript_id: variables.params.path.transcript_id }, @@ -270,7 +270,7 @@ export function useTranscriptUploadAudio() { "/v1/transcripts/{transcript_id}/record/upload", { onSuccess: (data, variables) => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: $api.queryOptions( "get", "/v1/transcripts/{transcript_id}", @@ -327,7 +327,7 @@ export function useTranscriptTopics(transcriptId: string | null) { "/v1/transcripts/{transcript_id}/topics", { params: { - path: { transcript_id: transcriptId || "" }, + path: { transcript_id: transcriptId! }, }, }, { @@ -344,7 +344,7 @@ export function useTranscriptTopicsWithWords(transcriptId: string | null) { "/v1/transcripts/{transcript_id}/topics/with-words", { params: { - path: { transcript_id: transcriptId || "" }, + path: { transcript_id: transcriptId! }, }, }, { @@ -365,8 +365,8 @@ export function useTranscriptTopicsWithWordsPerSpeaker( { params: { path: { - transcript_id: transcriptId || "", - topic_id: topicId || "", + transcript_id: transcriptId!, + topic_id: topicId!, }, }, }, @@ -384,7 +384,7 @@ export function useTranscriptParticipants(transcriptId: string | null) { "/v1/transcripts/{transcript_id}/participants", { params: { - path: { transcript_id: transcriptId || "" }, + path: { transcript_id: transcriptId! }, }, }, { @@ -402,7 +402,7 @@ export function useTranscriptParticipantUpdate() { "/v1/transcripts/{transcript_id}/participants/{participant_id}", { onSuccess: (data, variables) => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: $api.queryOptions( "get", "/v1/transcripts/{transcript_id}/participants", @@ -430,7 +430,7 @@ export function useTranscriptParticipantCreate() { "/v1/transcripts/{transcript_id}/participants", { onSuccess: (data, variables) => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: $api.queryOptions( "get", "/v1/transcripts/{transcript_id}/participants", @@ -458,7 +458,7 @@ export function useTranscriptParticipantDelete() { "/v1/transcripts/{transcript_id}/participants/{participant_id}", { onSuccess: (data, variables) => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: $api.queryOptions( "get", "/v1/transcripts/{transcript_id}/participants", @@ -486,28 +486,30 @@ export function useTranscriptSpeakerAssign() { "/v1/transcripts/{transcript_id}/speaker/assign", { onSuccess: (data, variables) => { - queryClient.invalidateQueries({ - queryKey: $api.queryOptions( - "get", - "/v1/transcripts/{transcript_id}", - { - params: { - path: { transcript_id: variables.params.path.transcript_id }, + return Promise.all([ + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, }, - }, - ).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: $api.queryOptions( - "get", - "/v1/transcripts/{transcript_id}/participants", - { - params: { - path: { transcript_id: variables.params.path.transcript_id }, + ).queryKey, + }), + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, }, - }, - ).queryKey, - }); + ).queryKey, + }), + ]); }, onError: (error) => { setError(error as Error, "There was an error assigning the speaker"); @@ -525,28 +527,30 @@ export function useTranscriptSpeakerMerge() { "/v1/transcripts/{transcript_id}/speaker/merge", { onSuccess: (data, variables) => { - queryClient.invalidateQueries({ - queryKey: $api.queryOptions( - "get", - "/v1/transcripts/{transcript_id}", - { - params: { - path: { transcript_id: variables.params.path.transcript_id }, + return Promise.all([ + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, }, - }, - ).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: $api.queryOptions( - "get", - "/v1/transcripts/{transcript_id}/participants", - { - params: { - path: { transcript_id: variables.params.path.transcript_id }, + ).queryKey, + }), + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, }, - }, - ).queryKey, - }); + ).queryKey, + }), + ]); }, onError: (error) => { setError(error as Error, "There was an error merging speakers"); @@ -565,6 +569,29 @@ export function useMeetingAudioConsent() { }); } +export function useMeetingDeactivate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("patch", `/v1/meetings/{meeting_id}/deactivate`, { + onError: (error) => { + setError(error as Error, "Failed to end meeting"); + }, + onSuccess: () => { + return queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return key.some( + (k) => + typeof k === "string" && + !!MEETING_LIST_PATH_PARTIALS.find((e) => k.includes(e)), + ); + }, + }); + }, + }); +} + export function useTranscriptWebRTC() { const { setError } = useError(); @@ -585,7 +612,7 @@ export function useTranscriptCreate() { return $api.useMutation("post", "/v1/transcripts", { onSuccess: () => { - queryClient.invalidateQueries({ + return queryClient.invalidateQueries({ queryKey: ["get", "/v1/transcripts/search"], }); }, @@ -600,13 +627,164 @@ export function useRoomsCreateMeeting() { const queryClient = useQueryClient(); return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, - }); + onSuccess: async (data, variables) => { + const roomName = variables.params.path.room_name; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }), + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`, + { + params: { + path: { room_name: roomName }, + }, + }, + ).queryKey, + }), + ]); }, onError: (error) => { setError(error as Error, "There was an error creating the meeting"); }, }); } + +// Calendar integration hooks +export function useRoomGetByName(roomName: string | null) { + return $api.useQuery( + "get", + "/v1/rooms/name/{room_name}", + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName, + }, + ); +} + +export function useRoomUpcomingMeetings(roomName: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings/upcoming" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_UPCOMING_PATH_PARTIAL}`, + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName && isAuthenticated, + }, + ); +} + +const MEETINGS_PATH_PARTIAL = "meetings" as const; +const MEETINGS_ACTIVE_PATH_PARTIAL = `${MEETINGS_PATH_PARTIAL}/active` as const; +const MEETINGS_UPCOMING_PATH_PARTIAL = + `${MEETINGS_PATH_PARTIAL}/upcoming` as const; +const MEETING_LIST_PATH_PARTIALS = [ + MEETINGS_ACTIVE_PATH_PARTIAL, + MEETINGS_UPCOMING_PATH_PARTIAL, +]; + +export function useRoomActiveMeetings(roomName: string | null) { + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`, + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName, + }, + ); +} + +export function useRoomGetMeeting( + roomName: string | null, + meetingId: string | null, +) { + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings/{meeting_id}", + { + params: { + path: { + room_name: roomName!, + meeting_id: meetingId!, + }, + }, + }, + { + enabled: !!roomName && !!meetingId, + }, + ); +} + +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"); + }, + }, + ); +} + +export function useRoomIcsSync() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/rooms/{room_name}/ics/sync", { + onError: (error) => { + setError(error as Error, "There was an error syncing the calendar"); + }, + }); +} + +export function useRoomIcsStatus(roomName: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/ics/status", + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName && isAuthenticated, + }, + ); +} + +export function useRoomCalendarEvents(roomName: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings", + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName && isAuthenticated, + }, + ); +} +// End of Calendar integration hooks diff --git a/www/app/lib/routes.ts b/www/app/lib/routes.ts new file mode 100644 index 00000000..480082d0 --- /dev/null +++ b/www/app/lib/routes.ts @@ -0,0 +1,7 @@ +import { NonEmptyString } from "./utils"; + +export const roomUrl = (roomName: NonEmptyString) => `/${roomName}`; +export const roomMeetingUrl = ( + roomName: NonEmptyString, + meetingId: NonEmptyString, +) => `${roomUrl(roomName)}/${meetingId}`; diff --git a/www/app/lib/routesClient.ts b/www/app/lib/routesClient.ts new file mode 100644 index 00000000..9522bc74 --- /dev/null +++ b/www/app/lib/routesClient.ts @@ -0,0 +1,5 @@ +import { roomUrl } from "./routes"; +import { NonEmptyString } from "./utils"; + +export const roomAbsoluteUrl = (roomName: NonEmptyString) => + `${window.location.origin}${roomUrl(roomName)}`; diff --git a/www/app/lib/timeUtils.ts b/www/app/lib/timeUtils.ts new file mode 100644 index 00000000..db8a8152 --- /dev/null +++ b/www/app/lib/timeUtils.ts @@ -0,0 +1,25 @@ +export const formatDateTime = (d: Date): string => { + return d.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export const formatStartedAgo = ( + startTime: Date, + now: Date = new Date(), +): string => { + const diff = now.getTime() - startTime.getTime(); + + if (diff <= 0) return "Starting now"; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `Started ${days}d ${hours % 24}h ${minutes % 60}m ago`; + if (hours > 0) return `Started ${hours}h ${minutes % 60}m ago`; + return `Started ${minutes} minutes ago`; +}; diff --git a/www/app/lib/wherebyClient.ts b/www/app/lib/wherebyClient.ts new file mode 100644 index 00000000..2345bd7b --- /dev/null +++ b/www/app/lib/wherebyClient.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import { components } from "../reflector-api"; + +export const useWhereby = () => { + const [wherebyLoaded, setWherebyLoaded] = useState(false); + useEffect(() => { + if (typeof window !== "undefined") { + import("@whereby.com/browser-sdk/embed") + .then(() => { + setWherebyLoaded(true); + }) + .catch(console.error.bind(console)); + } + }, []); + return wherebyLoaded; +}; + +export const getWherebyUrl = ( + meeting: Pick, +) => + // host_room_url possible '' atm + meeting.host_room_url || meeting.room_url; diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index 2b92f4d4..e1709d69 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -41,6 +41,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/meetings/{meeting_id}/deactivate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Meeting Deactivate */ + patch: operations["v1_meeting_deactivate"]; + trace?: never; + }; "/v1/rooms": { parameters: { query?: never; @@ -78,6 +95,23 @@ export interface paths { patch: operations["v1_rooms_update"]; trace?: never; }; + "/v1/rooms/name/{room_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Get By Name */ + get: operations["v1_rooms_get_by_name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/rooms/{room_name}/meeting": { parameters: { query?: never; @@ -115,6 +149,128 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/rooms/{room_name}/ics/sync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Sync Ics */ + post: operations["v1_rooms_sync_ics"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/ics/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Ics Status */ + get: operations["v1_rooms_ics_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Meetings */ + get: operations["v1_rooms_list_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/upcoming": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Upcoming Meetings */ + get: operations["v1_rooms_list_upcoming_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Active Meetings */ + get: operations["v1_rooms_list_active_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Rooms Get Meeting + * @description Get a single meeting by ID within a specific room. + */ + get: operations["v1_rooms_get_meeting"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}/join": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Join Meeting */ + post: operations["v1_rooms_join_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/transcripts": { parameters: { query?: never; @@ -505,6 +661,52 @@ export interface components { */ chunk: string; }; + /** CalendarEventResponse */ + CalendarEventResponse: { + /** Id */ + id: string; + /** Room Id */ + room_id: string; + /** Ics Uid */ + ics_uid: string; + /** Title */ + title?: string | null; + /** Description */ + description?: string | null; + /** + * Start Time + * Format: date-time + */ + start_time: string; + /** + * End Time + * Format: date-time + */ + end_time: string; + /** Attendees */ + attendees?: + | { + [key: string]: unknown; + }[] + | null; + /** Location */ + location?: string | null; + /** + * Last Synced + * Format: date-time + */ + last_synced: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; /** CreateParticipant */ CreateParticipant: { /** Speaker */ @@ -536,6 +738,26 @@ export interface components { webhook_url: string; /** Webhook Secret */ webhook_secret: string; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + }; + /** CreateRoomMeeting */ + CreateRoomMeeting: { + /** + * Allow Duplicated + * @default false + */ + allow_duplicated: boolean | null; }; /** CreateTranscript */ CreateTranscript: { @@ -748,6 +970,60 @@ export interface components { /** Detail */ detail?: components["schemas"]["ValidationError"][]; }; + /** ICSStatus */ + ICSStatus: { + /** + * Status + * @enum {string} + */ + status: "enabled" | "disabled"; + /** Last Sync */ + last_sync?: string | null; + /** Next Sync */ + next_sync?: string | null; + /** Last Etag */ + last_etag?: string | null; + /** + * Events Count + * @default 0 + */ + events_count: number; + }; + /** ICSSyncResult */ + ICSSyncResult: { + status: components["schemas"]["SyncStatus"]; + /** Hash */ + hash?: string | null; + /** + * Events Found + * @default 0 + */ + events_found: number; + /** + * Total Events + * @default 0 + */ + total_events: number; + /** + * Events Created + * @default 0 + */ + events_created: number; + /** + * Events Updated + * @default 0 + */ + events_updated: number; + /** + * Events Deleted + * @default 0 + */ + events_deleted: number; + /** Error */ + error?: string | null; + /** Reason */ + reason?: string | null; + }; /** Meeting */ Meeting: { /** Id */ @@ -768,12 +1044,53 @@ export interface components { * Format: date-time */ end_date: string; + /** User Id */ + user_id?: string | null; + /** Room Id */ + room_id?: string | null; + /** + * Is Locked + * @default false + */ + is_locked: boolean; + /** + * Room Mode + * @default normal + * @enum {string} + */ + room_mode: "normal" | "group"; /** * Recording Type * @default cloud * @enum {string} */ recording_type: "none" | "local" | "cloud"; + /** + * Recording Trigger + * @default automatic-2nd-participant + * @enum {string} + */ + recording_trigger: + | "none" + | "prompt" + | "automatic" + | "automatic-2nd-participant"; + /** + * Num Clients + * @default 0 + */ + num_clients: number; + /** + * Is Active + * @default true + */ + is_active: boolean; + /** Calendar Event Id */ + calendar_event_id?: string | null; + /** Calendar Metadata */ + calendar_metadata?: { + [key: string]: unknown; + } | null; }; /** MeetingConsentRequest */ MeetingConsentRequest: { @@ -844,6 +1161,22 @@ export interface components { recording_trigger: string; /** Is Shared */ is_shared: boolean; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + /** Ics Last Sync */ + ics_last_sync?: string | null; + /** Ics Last Etag */ + ics_last_etag?: string | null; }; /** RoomDetails */ RoomDetails: { @@ -874,6 +1207,22 @@ export interface components { recording_trigger: string; /** Is Shared */ is_shared: boolean; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + /** Ics Last Sync */ + ics_last_sync?: string | null; + /** Ics Last Etag */ + ics_last_etag?: string | null; /** Webhook Url */ webhook_url: string | null; /** Webhook Secret */ @@ -998,6 +1347,11 @@ export interface components { /** Name */ name: string; }; + /** + * SyncStatus + * @enum {string} + */ + SyncStatus: "success" | "unchanged" | "error" | "skipped"; /** Topic */ Topic: { /** Name */ @@ -1022,27 +1376,33 @@ export interface components { /** UpdateRoom */ UpdateRoom: { /** Name */ - name: string; + name?: string | null; /** Zulip Auto Post */ - zulip_auto_post: boolean; + zulip_auto_post?: boolean | null; /** Zulip Stream */ - zulip_stream: string; + zulip_stream?: string | null; /** Zulip Topic */ - zulip_topic: string; + zulip_topic?: string | null; /** Is Locked */ - is_locked: boolean; + is_locked?: boolean | null; /** Room Mode */ - room_mode: string; + room_mode?: string | null; /** Recording Type */ - recording_type: string; + recording_type?: string | null; /** Recording Trigger */ - recording_trigger: string; + recording_trigger?: string | null; /** Is Shared */ - is_shared: boolean; + is_shared?: boolean | null; /** Webhook Url */ - webhook_url: string; + webhook_url?: string | null; /** Webhook Secret */ - webhook_secret: string; + webhook_secret?: string | null; + /** Ics Url */ + ics_url?: string | null; + /** Ics Fetch Interval */ + ics_fetch_interval?: number | null; + /** Ics Enabled */ + ics_enabled?: boolean | null; }; /** UpdateTranscript */ UpdateTranscript: { @@ -1204,6 +1564,37 @@ export interface operations { }; }; }; + v1_meeting_deactivate: { + parameters: { + query?: never; + header?: never; + path: { + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; v1_rooms_list: { parameters: { query?: { @@ -1368,7 +1759,7 @@ export interface operations { }; }; }; - v1_rooms_create_meeting: { + v1_rooms_get_by_name: { parameters: { query?: never; header?: never; @@ -1378,6 +1769,41 @@ export interface operations { cookie?: never; }; requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_create_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRoomMeeting"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -1430,6 +1856,227 @@ export interface operations { }; }; }; + v1_rooms_sync_ics: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ICSSyncResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_ics_status: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ICSStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_meetings: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CalendarEventResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_upcoming_meetings: { + parameters: { + query?: { + minutes_ahead?: number; + }; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CalendarEventResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_active_meetings: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_get_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_join_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; v1_transcripts_list: { parameters: { query?: { diff --git a/www/app/webinars/[title]/page.tsx b/www/app/webinars/[title]/page.tsx index 51583a2a..ff21af1e 100644 --- a/www/app/webinars/[title]/page.tsx +++ b/www/app/webinars/[title]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, use } from "react"; import Link from "next/link"; import Image from "next/image"; import { notFound } from "next/navigation"; -import useRoomMeeting from "../../[roomName]/useRoomMeeting"; +import useRoomDefaultMeeting from "../../[roomName]/useRoomDefaultMeeting"; import dynamic from "next/dynamic"; const WherebyEmbed = dynamic(() => import("../../lib/WherebyWebinarEmbed"), { ssr: false, @@ -72,7 +72,7 @@ export default function WebinarPage(details: WebinarDetails) { const startDate = new Date(Date.parse(webinar.startsAt)); const endDate = new Date(Date.parse(webinar.endsAt)); - const meeting = useRoomMeeting(ROOM_NAME); + const meeting = useRoomDefaultMeeting(ROOM_NAME); const roomUrl = meeting?.response?.host_room_url ? meeting?.response?.host_room_url : meeting?.response?.room_url; diff --git a/www/package.json b/www/package.json index d53c1536..c93a9554 100644 --- a/www/package.json +++ b/www/package.json @@ -45,6 +45,7 @@ "react-qr-code": "^2.0.12", "react-select-search": "^4.1.7", "redlock": "5.0.0-beta.2", + "remeda": "^2.31.1", "sass": "^1.63.6", "simple-peer": "^9.11.1", "tailwindcss": "^3.3.2", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index a4e78972..6c0a3d83 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: redlock: specifier: 5.0.0-beta.2 version: 5.0.0-beta.2 + remeda: + specifier: ^2.31.1 + version: 2.31.1 sass: specifier: ^1.63.6 version: 1.90.0 @@ -7645,6 +7648,12 @@ packages: integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==, } + remeda@2.31.1: + resolution: + { + integrity: sha512-FRZefcuXbmCoYt8hAITAzW4t8i/RERaGk/+GtRN90eV3NHxsnRKCDIOJVrwrQ6zz77TG/Xyi9mGRfiJWT7DK1g==, + } + require-directory@2.1.1: resolution: { @@ -14510,6 +14519,10 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remeda@2.31.1: + dependencies: + type-fest: 4.41.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {}