mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-02-06 18:56:48 +00:00
feat: batch room meeting status queries into single bulk endpoint
Reduces rooms list page from 2N+2 HTTP requests to 1 POST request. Backend: POST /v1/rooms/meetings/bulk-status with 3 DB queries total. Frontend: @yornaath/batshit DataLoader-style batcher with 10ms window.
This commit is contained in:
@@ -104,6 +104,26 @@ class CalendarEventController:
|
|||||||
results = await get_database().fetch_all(query)
|
results = await get_database().fetch_all(query)
|
||||||
return [CalendarEvent(**result) for result in results]
|
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:
|
async def get_by_id(self, event_id: str) -> CalendarEvent | None:
|
||||||
query = calendar_events.select().where(calendar_events.c.id == event_id)
|
query = calendar_events.select().where(calendar_events.c.id == event_id)
|
||||||
result = await get_database().fetch_one(query)
|
result = await get_database().fetch_one(query)
|
||||||
|
|||||||
@@ -301,6 +301,23 @@ class MeetingController:
|
|||||||
results = await get_database().fetch_all(query)
|
results = await get_database().fetch_all(query)
|
||||||
return [Meeting(**result) for result in results]
|
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(
|
async def get_active_by_calendar_event(
|
||||||
self, room: Room, calendar_event_id: str, current_time: datetime
|
self, room: Room, calendar_event_id: str, current_time: datetime
|
||||||
) -> Meeting | None:
|
) -> Meeting | None:
|
||||||
|
|||||||
@@ -245,6 +245,11 @@ class RoomController:
|
|||||||
|
|
||||||
return room
|
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]:
|
async def get_ics_enabled(self) -> list[Room]:
|
||||||
query = rooms.select().where(
|
query = rooms.select().where(
|
||||||
rooms.c.ics_enabled == True, rooms.c.ics_url != None
|
rooms.c.ics_enabled == True, rooms.c.ics_url != None
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Any, Literal, Optional
|
from typing import Annotated, Any, Literal, Optional
|
||||||
@@ -195,6 +197,63 @@ async def rooms_list(
|
|||||||
return paginated
|
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)
|
@router.get("/rooms/{room_id}", response_model=RoomDetails)
|
||||||
async def rooms_get(
|
async def rooms_get(
|
||||||
room_id: str,
|
room_id: str,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { $api } from "./apiClient";
|
import { $api } from "./apiClient";
|
||||||
import { useError } from "../(errors)/errorContext";
|
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 type { components } from "../reflector-api";
|
||||||
import { useAuth } from "./AuthProvider";
|
import { useAuth } from "./AuthProvider";
|
||||||
|
import { meetingStatusBatcher } from "./meetingStatusBatcher";
|
||||||
import { MeetingId } from "./types";
|
import { MeetingId } from "./types";
|
||||||
import { NonEmptyString } from "./utils";
|
import { NonEmptyString } from "./utils";
|
||||||
|
|
||||||
@@ -697,15 +698,7 @@ export function useRoomsCreateMeeting() {
|
|||||||
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
|
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
|
||||||
}),
|
}),
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: $api.queryOptions(
|
queryKey: meetingStatusKeys.active(roomName),
|
||||||
"get",
|
|
||||||
"/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
path: { room_name: roomName },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
).queryKey,
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
@@ -734,18 +727,14 @@ export function useRoomGetByName(roomName: string | null) {
|
|||||||
export function useRoomUpcomingMeetings(roomName: string | null) {
|
export function useRoomUpcomingMeetings(roomName: string | null) {
|
||||||
const { isAuthenticated } = useAuthReady();
|
const { isAuthenticated } = useAuthReady();
|
||||||
|
|
||||||
return $api.useQuery(
|
return useQuery({
|
||||||
"get",
|
queryKey: meetingStatusKeys.upcoming(roomName!),
|
||||||
"/v1/rooms/{room_name}/meetings/upcoming" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_UPCOMING_PATH_PARTIAL}`,
|
queryFn: async () => {
|
||||||
{
|
const result = await meetingStatusBatcher.fetch(roomName!);
|
||||||
params: {
|
return result.upcoming_events;
|
||||||
path: { room_name: roomName! },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
enabled: !!roomName && isAuthenticated,
|
||||||
enabled: !!roomName && isAuthenticated,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEETINGS_PATH_PARTIAL = "meetings" as const;
|
const MEETINGS_PATH_PARTIAL = "meetings" as const;
|
||||||
@@ -757,19 +746,22 @@ const MEETING_LIST_PATH_PARTIALS = [
|
|||||||
MEETINGS_UPCOMING_PATH_PARTIAL,
|
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) {
|
export function useRoomActiveMeetings(roomName: string | null) {
|
||||||
return $api.useQuery(
|
return useQuery({
|
||||||
"get",
|
queryKey: meetingStatusKeys.active(roomName!),
|
||||||
"/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`,
|
queryFn: async () => {
|
||||||
{
|
const result = await meetingStatusBatcher.fetch(roomName!);
|
||||||
params: {
|
return result.active_meetings;
|
||||||
path: { room_name: roomName! },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
enabled: !!roomName,
|
||||||
enabled: !!roomName,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRoomGetMeeting(
|
export function useRoomGetMeeting(
|
||||||
|
|||||||
25
www/app/lib/meetingStatusBatcher.ts
Normal file
25
www/app/lib/meetingStatusBatcher.ts
Normal file
@@ -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<MeetingStatusResult[]> => {
|
||||||
|
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),
|
||||||
|
});
|
||||||
64
www/app/reflector-api.d.ts
vendored
64
www/app/reflector-api.d.ts
vendored
@@ -118,6 +118,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/v1/rooms/{room_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -799,6 +816,11 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
chunk: string;
|
chunk: string;
|
||||||
};
|
};
|
||||||
|
/** BulkStatusRequest */
|
||||||
|
BulkStatusRequest: {
|
||||||
|
/** Room Names */
|
||||||
|
room_names: string[];
|
||||||
|
};
|
||||||
/** CalendarEventResponse */
|
/** CalendarEventResponse */
|
||||||
CalendarEventResponse: {
|
CalendarEventResponse: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1735,6 +1757,13 @@ export interface components {
|
|||||||
/** Webhook Secret */
|
/** Webhook Secret */
|
||||||
webhook_secret: string | null;
|
webhook_secret: string | null;
|
||||||
};
|
};
|
||||||
|
/** RoomMeetingStatus */
|
||||||
|
RoomMeetingStatus: {
|
||||||
|
/** Active Meetings */
|
||||||
|
active_meetings: components["schemas"]["Meeting"][];
|
||||||
|
/** Upcoming Events */
|
||||||
|
upcoming_events: components["schemas"]["CalendarEventResponse"][];
|
||||||
|
};
|
||||||
/** RtcOffer */
|
/** RtcOffer */
|
||||||
RtcOffer: {
|
RtcOffer: {
|
||||||
/** Sdp */
|
/** 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: {
|
v1_rooms_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@tanstack/react-query": "^5.85.9",
|
"@tanstack/react-query": "^5.85.9",
|
||||||
"@types/ioredis": "^5.0.0",
|
"@types/ioredis": "^5.0.0",
|
||||||
"@whereby.com/browser-sdk": "^3.3.4",
|
"@whereby.com/browser-sdk": "^3.3.4",
|
||||||
|
"@yornaath/batshit": "^0.14.0",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
|
|||||||
21
www/pnpm-lock.yaml
generated
21
www/pnpm-lock.yaml
generated
@@ -37,6 +37,9 @@ importers:
|
|||||||
"@whereby.com/browser-sdk":
|
"@whereby.com/browser-sdk":
|
||||||
specifier: ^3.3.4
|
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)
|
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:
|
autoprefixer:
|
||||||
specifier: 10.4.20
|
specifier: 10.4.20
|
||||||
version: 10.4.20(postcss@8.4.31)
|
version: 10.4.20(postcss@8.4.31)
|
||||||
@@ -3315,6 +3318,18 @@ packages:
|
|||||||
integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==,
|
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":
|
"@zag-js/accordion@1.21.0":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -11349,6 +11364,12 @@ snapshots:
|
|||||||
|
|
||||||
"@xtuc/long@4.2.2": {}
|
"@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":
|
"@zag-js/accordion@1.21.0":
|
||||||
dependencies:
|
dependencies:
|
||||||
"@zag-js/anatomy": 1.21.0
|
"@zag-js/anatomy": 1.21.0
|
||||||
|
|||||||
Reference in New Issue
Block a user