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:
Igor Loskutov
2026-02-05 17:47:58 -05:00
parent 1ce1c7a910
commit 7e072219bf
9 changed files with 236 additions and 32 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -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(

View 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),
});

View File

@@ -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;

View File

@@ -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",

21
www/pnpm-lock.yaml generated
View File

@@ -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