diff --git a/server/reflector/db/calendar_events.py b/server/reflector/db/calendar_events.py index 3eddc3f1..07cc6499 100644 --- a/server/reflector/db/calendar_events.py +++ b/server/reflector/db/calendar_events.py @@ -104,6 +104,26 @@ class CalendarEventController: results = await get_database().fetch_all(query) return [CalendarEvent(**result) for result in results] + async def get_upcoming_for_rooms( + self, room_ids: list[str], minutes_ahead: int = 120 + ) -> list[CalendarEvent]: + now = datetime.now(timezone.utc) + future_time = now + timedelta(minutes=minutes_ahead) + query = ( + calendar_events.select() + .where( + sa.and_( + calendar_events.c.room_id.in_(room_ids), + 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_id(self, event_id: str) -> CalendarEvent | None: query = calendar_events.select().where(calendar_events.c.id == event_id) result = await get_database().fetch_one(query) diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py index 02f407b2..89fe3d74 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -301,6 +301,23 @@ class MeetingController: results = await get_database().fetch_all(query) return [Meeting(**result) for result in results] + async def get_all_active_for_rooms( + self, room_ids: list[str], current_time: datetime + ) -> list[Meeting]: + query = ( + meetings.select() + .where( + sa.and_( + meetings.c.room_id.in_(room_ids), + meetings.c.end_date > current_time, + meetings.c.is_active, + ) + ) + .order_by(meetings.c.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: diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index 8228144c..308817f0 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -245,6 +245,11 @@ class RoomController: return room + async def get_by_names(self, names: list[str]) -> list[Room]: + query = rooms.select().where(rooms.c.name.in_(names)) + results = await get_database().fetch_all(query) + return [Room(**r) for r in results] + async def get_ics_enabled(self) -> list[Room]: query = rooms.select().where( rooms.c.ics_enabled == True, rooms.c.ics_url != None diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 11e668c0..59369b44 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -1,4 +1,6 @@ +import asyncio import logging +from collections import defaultdict from datetime import datetime, timedelta, timezone from enum import Enum from typing import Annotated, Any, Literal, Optional @@ -195,6 +197,63 @@ async def rooms_list( return paginated +class BulkStatusRequest(BaseModel): + room_names: list[str] + + +class RoomMeetingStatus(BaseModel): + active_meetings: list[Meeting] + upcoming_events: list[CalendarEventResponse] + + +@router.post("/rooms/meetings/bulk-status", response_model=dict[str, RoomMeetingStatus]) +async def rooms_bulk_meeting_status( + request: BulkStatusRequest, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + + rooms = await rooms_controller.get_by_names(request.room_names) + room_by_id: dict[str, Any] = {r.id: r for r in rooms} + room_ids = list(room_by_id.keys()) + + current_time = datetime.now(timezone.utc) + active_meetings, upcoming_events = await asyncio.gather( + meetings_controller.get_all_active_for_rooms(room_ids, current_time), + calendar_events_controller.get_upcoming_for_rooms(room_ids), + ) + + # Group by room name + active_by_room: dict[str, list[Meeting]] = defaultdict(list) + for m in active_meetings: + room = room_by_id.get(m.room_id) + if not room: + continue + m.platform = room.platform + if user_id != room.user_id and m.platform == "whereby": + m.host_room_url = "" + active_by_room[room.name].append(m) + + upcoming_by_room: dict[str, list[CalendarEventResponse]] = defaultdict(list) + for e in upcoming_events: + room = room_by_id.get(e.room_id) + if not room: + continue + if user_id != room.user_id: + e.description = None + e.attendees = None + upcoming_by_room[room.name].append(e) + + result: dict[str, RoomMeetingStatus] = {} + for name in request.room_names: + result[name] = RoomMeetingStatus( + active_meetings=active_by_room.get(name, []), + upcoming_events=upcoming_by_room.get(name, []), + ) + + return result + + @router.get("/rooms/{room_id}", response_model=RoomDetails) async def rooms_get( room_id: str, diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 788dfac6..8c8e0a95 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -2,9 +2,10 @@ import { $api } from "./apiClient"; import { useError } from "../(errors)/errorContext"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import type { components } from "../reflector-api"; import { useAuth } from "./AuthProvider"; +import { meetingStatusBatcher } from "./meetingStatusBatcher"; import { MeetingId } from "./types"; import { NonEmptyString } from "./utils"; @@ -697,15 +698,7 @@ export function useRoomsCreateMeeting() { 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, + queryKey: meetingStatusKeys.active(roomName), }), ]); }, @@ -734,18 +727,14 @@ export function useRoomGetByName(roomName: string | null) { 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! }, - }, + return useQuery({ + queryKey: meetingStatusKeys.upcoming(roomName!), + queryFn: async () => { + const result = await meetingStatusBatcher.fetch(roomName!); + return result.upcoming_events; }, - { - enabled: !!roomName && isAuthenticated, - }, - ); + enabled: !!roomName && isAuthenticated, + }); } const MEETINGS_PATH_PARTIAL = "meetings" as const; @@ -757,19 +746,22 @@ const MEETING_LIST_PATH_PARTIALS = [ MEETINGS_UPCOMING_PATH_PARTIAL, ]; +export const meetingStatusKeys = { + active: (roomName: string) => + ["rooms", roomName, MEETINGS_ACTIVE_PATH_PARTIAL] as const, + upcoming: (roomName: string) => + ["rooms", roomName, MEETINGS_UPCOMING_PATH_PARTIAL] as const, +}; + 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! }, - }, + return useQuery({ + queryKey: meetingStatusKeys.active(roomName!), + queryFn: async () => { + const result = await meetingStatusBatcher.fetch(roomName!); + return result.active_meetings; }, - { - enabled: !!roomName, - }, - ); + enabled: !!roomName, + }); } export function useRoomGetMeeting( diff --git a/www/app/lib/meetingStatusBatcher.ts b/www/app/lib/meetingStatusBatcher.ts new file mode 100644 index 00000000..4f2625ae --- /dev/null +++ b/www/app/lib/meetingStatusBatcher.ts @@ -0,0 +1,25 @@ +import { create, keyResolver, windowScheduler } from "@yornaath/batshit"; +import { client } from "./apiClient"; +import type { components } from "../reflector-api"; + +type MeetingStatusResult = { + roomName: string; + active_meetings: components["schemas"]["Meeting"][]; + upcoming_events: components["schemas"]["CalendarEventResponse"][]; +}; + +export const meetingStatusBatcher = create({ + fetcher: async (roomNames: string[]): Promise => { + const unique = [...new Set(roomNames)]; + const { data } = await client.POST("/v1/rooms/meetings/bulk-status", { + body: { room_names: unique }, + }); + return roomNames.map((name) => ({ + roomName: name, + active_meetings: data?.[name]?.active_meetings ?? [], + upcoming_events: data?.[name]?.upcoming_events ?? [], + })); + }, + resolver: keyResolver("roomName"), + scheduler: windowScheduler(10), +}); diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index 12a7085c..8e88f2ad 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -118,6 +118,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/rooms/meetings/bulk-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Bulk Meeting Status */ + post: operations["v1_rooms_bulk_meeting_status"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/rooms/{room_id}": { parameters: { query?: never; @@ -799,6 +816,11 @@ export interface components { */ chunk: string; }; + /** BulkStatusRequest */ + BulkStatusRequest: { + /** Room Names */ + room_names: string[]; + }; /** CalendarEventResponse */ CalendarEventResponse: { /** Id */ @@ -1735,6 +1757,13 @@ export interface components { /** Webhook Secret */ webhook_secret: string | null; }; + /** RoomMeetingStatus */ + RoomMeetingStatus: { + /** Active Meetings */ + active_meetings: components["schemas"]["Meeting"][]; + /** Upcoming Events */ + upcoming_events: components["schemas"]["CalendarEventResponse"][]; + }; /** RtcOffer */ RtcOffer: { /** Sdp */ @@ -2272,6 +2301,41 @@ export interface operations { }; }; }; + v1_rooms_bulk_meeting_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkStatusRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: components["schemas"]["RoomMeetingStatus"]; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; v1_rooms_get: { parameters: { query?: never; diff --git a/www/package.json b/www/package.json index ceefbf55..6bc17a6a 100644 --- a/www/package.json +++ b/www/package.json @@ -23,6 +23,7 @@ "@tanstack/react-query": "^5.85.9", "@types/ioredis": "^5.0.0", "@whereby.com/browser-sdk": "^3.3.4", + "@yornaath/batshit": "^0.14.0", "autoprefixer": "10.4.20", "axios": "^1.8.2", "eslint": "^9.33.0", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index cd65de55..59d3c8c7 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -37,6 +37,9 @@ importers: "@whereby.com/browser-sdk": specifier: ^3.3.4 version: 3.13.1(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@yornaath/batshit": + specifier: ^0.14.0 + version: 0.14.0 autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.31) @@ -3315,6 +3318,18 @@ packages: integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==, } + "@yornaath/batshit-devtools@1.7.1": + resolution: + { + integrity: sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==, + } + + "@yornaath/batshit@0.14.0": + resolution: + { + integrity: sha512-0I+xMi5JoRs3+qVXXhk2AmsEl43MwrG+L+VW+nqw/qQqMFtgRPszLaxhJCfsBKnjfJ0gJzTI1Q9Q9+y903HyHQ==, + } + "@zag-js/accordion@1.21.0": resolution: { @@ -11349,6 +11364,12 @@ snapshots: "@xtuc/long@4.2.2": {} + "@yornaath/batshit-devtools@1.7.1": {} + + "@yornaath/batshit@0.14.0": + dependencies: + "@yornaath/batshit-devtools": 1.7.1 + "@zag-js/accordion@1.21.0": dependencies: "@zag-js/anatomy": 1.21.0