diff --git a/__tests__/boot.ts b/__tests__/boot.ts index 64ded3c0cd..e657fb2b71 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -93,7 +93,10 @@ import { UserExperienceEducation } from '../src/entity/user/experiences/UserExpe import * as betterAuthModule from '../src/betterAuth'; import { remoteConfig } from '../src/remoteConfig'; import { LiveRoom } from '../src/entity/LiveRoom'; -import { LiveRoomStatus } from '../src/common/schema/liveRooms'; +import { LiveRoomMode, LiveRoomStatus } from '../src/common/schema/liveRooms'; + +const flytingOrigin = 'http://flyting.test'; +const flytingInternalKey = 'flyting-internal-key'; let app: FastifyInstance; let con: DataSource; @@ -220,6 +223,9 @@ jest.mock('../src/growthbook', () => ({ })); beforeAll(async () => { + process.env.FLYTING_INTERNAL_KEY = flytingInternalKey; + process.env.FLYTING_ORIGIN = flytingOrigin; + con = await createOrGetConnection(); state = await initializeGraphQLTesting(() => new MockContext(con)); app = state.app; @@ -274,6 +280,38 @@ describe('anonymous boot', () => { expect(res.body.liveRooms).toEqual({ hasLive: true }); }); + it('should indicate when community-moderated room activity is live', async () => { + await con.getRepository(LiveRoom).save({ + id: '40ad3407-0d6a-4d95-98c2-bc5a3d7cf4d1', + hostId: '1', + topic: 'Community boot hint', + mode: LiveRoomMode.CommunityModerated, + status: LiveRoomStatus.Created, + }); + const scope = nock(flytingOrigin) + .post('/internal/live-rooms/counts', { + roomIds: ['40ad3407-0d6a-4d95-98c2-bc5a3d7cf4d1'], + }) + .matchHeader('x-flyting-internal-key', flytingInternalKey) + .reply(200, { + rooms: [ + { + activityStatus: 'live', + roomId: '40ad3407-0d6a-4d95-98c2-bc5a3d7cf4d1', + participantCount: 3, + }, + ], + }); + + const res = await request(app.server) + .get(BASE_PATH) + .set('User-Agent', TEST_UA) + .expect(200); + + expect(res.body.liveRooms).toEqual({ hasLive: true }); + expect(scope.isDone()).toBe(true); + }); + it('should reuse the cached live rooms boot hint', async () => { const first = await request(app.server) .get(BASE_PATH) diff --git a/__tests__/liveRooms.ts b/__tests__/liveRooms.ts index 461821b310..154ee0b966 100644 --- a/__tests__/liveRooms.ts +++ b/__tests__/liveRooms.ts @@ -28,10 +28,11 @@ import { import { usersFixture } from './fixture/user'; import { User } from '../src/entity/user/User'; import { LiveRoomStatus } from '../src/common/schema/liveRooms'; -import { deleteKeysByPattern } from '../src/redis'; +import { deleteKeysByPattern, ioRedisPool } from '../src/redis'; import { postsFixture } from './fixture/post'; import { sourcesFixture } from './fixture/source'; import { LIVE_ROOM_POST_PROMOTION_SECONDS } from '../src/schema/liveRooms'; +import { liveRoomParticipantCountCacheKey } from '../src/common/liveRoom/participantCount'; const { mock: temporalMock, @@ -125,6 +126,7 @@ describe('live rooms', () => { topic mode status + activityStatus participantCount scheduledStart description @@ -151,6 +153,7 @@ describe('live rooms', () => { topic mode status + activityStatus participantCount host { id @@ -167,6 +170,7 @@ describe('live rooms', () => { topic mode status + activityStatus participantCount host { id @@ -195,6 +199,7 @@ describe('live rooms', () => { topic mode status + activityStatus participantCount host { id @@ -405,6 +410,20 @@ describe('live rooms', () => { }) .matchHeader('x-flyting-internal-key', flytingInternalKey) .reply(200, { room: { roomId: 'ignored' } }); + const countsScope = nock(flytingOrigin) + .post('/internal/live-rooms/counts', (body) => { + return Array.isArray(body.roomIds) && body.roomIds.length === 1; + }) + .matchHeader('x-flyting-internal-key', flytingInternalKey) + .reply(200, (_uri, body: { roomIds: string[] }) => ({ + rooms: [ + { + activityStatus: 'pending', + roomId: body.roomIds[0], + participantCount: 0, + }, + ], + })); const res = await client.mutate(CREATE_MUTATION, { variables: { @@ -422,7 +441,8 @@ describe('live rooms', () => { topic: 'Community floor', mode: 'community_moderated', status: 'created', - participantCount: null, + activityStatus: 'pending', + participantCount: 0, }); const room = await con.getRepository(LiveRoom).findOneByOrFail({ @@ -431,6 +451,7 @@ describe('live rooms', () => { expect(room.mode).toBe('community_moderated'); expect(scope.isDone()).toBe(true); + expect(countsScope.isDone()).toBe(true); }); it('rejects community-moderated live rooms without a minimum participant count', async () => { @@ -720,6 +741,7 @@ describe('live rooms', () => { topic: 'Live room', mode: 'moderated', status: 'live', + activityStatus: 'live', participantCount: 7, host: { id: '2', @@ -731,6 +753,77 @@ describe('live rooms', () => { expect(scope.isDone()).toBe(true); }); + it('returns community-moderated rooms only when their activity is live', async () => { + await saveFixtures(con, LiveRoom, [ + { + id: '33333333-b26e-44fb-b9f8-4c977b28a123', + hostId: '1', + topic: 'Pending community room', + mode: 'community_moderated', + status: LiveRoomStatus.Created, + createdAt: new Date('2026-04-23T10:00:00.000Z'), + }, + { + id: '44444444-b26e-44fb-b9f8-4c977b28a123', + hostId: '2', + topic: 'Live community room', + mode: 'community_moderated', + status: LiveRoomStatus.Created, + createdAt: new Date('2026-04-23T11:00:00.000Z'), + }, + { + id: '55555555-b26e-44fb-b9f8-4c977b28a123', + hostId: '1', + topic: 'Ended community room', + mode: 'community_moderated', + status: LiveRoomStatus.Ended, + createdAt: new Date('2026-04-23T12:00:00.000Z'), + }, + ]); + + const scope = nock(flytingOrigin) + .post('/internal/live-rooms/counts', { + roomIds: [ + '44444444-b26e-44fb-b9f8-4c977b28a123', + '33333333-b26e-44fb-b9f8-4c977b28a123', + ], + }) + .matchHeader('x-flyting-internal-key', flytingInternalKey) + .reply(200, { + rooms: [ + { + activityStatus: 'live', + roomId: '44444444-b26e-44fb-b9f8-4c977b28a123', + participantCount: 3, + }, + { + activityStatus: 'pending', + roomId: '33333333-b26e-44fb-b9f8-4c977b28a123', + participantCount: 1, + }, + ], + }); + + const res = await client.query(ACTIVE_QUERY); + + expect(res.errors).toBeFalsy(); + expect(res.data.activeLiveRooms).toEqual([ + { + id: '44444444-b26e-44fb-b9f8-4c977b28a123', + topic: 'Live community room', + mode: 'community_moderated', + status: 'created', + activityStatus: 'live', + participantCount: 3, + host: { + id: '2', + username: 'tsahidaily', + }, + }, + ]); + expect(scope.isDone()).toBe(true); + }); + it('batches participant counts for active live rooms through the field resolver', async () => { await saveFixtures(con, LiveRoom, [ { @@ -783,6 +876,7 @@ describe('live rooms', () => { topic: 'Live room two', mode: 'moderated', status: 'live', + activityStatus: 'live', participantCount: 11, host: { id: '2', @@ -794,6 +888,7 @@ describe('live rooms', () => { topic: 'Live room one', mode: 'moderated', status: 'live', + activityStatus: 'live', participantCount: 7, host: { id: '1', @@ -874,6 +969,7 @@ describe('live rooms', () => { topic: 'Live room without count', mode: 'moderated', status: 'live', + activityStatus: 'live', participantCount: null, host: { id: '2', @@ -1430,6 +1526,7 @@ describe('live rooms', () => { topic: 'Readable room', mode: 'moderated', status: 'live', + activityStatus: 'live', participantCount: 5, host: { id: '2', @@ -1486,6 +1583,31 @@ describe('live rooms', () => { expect(scope.isDone()).toBe(true); }); + it('reads legacy numeric participant count cache values', async () => { + loggedUser = '1'; + const roomId = '62e45613-c9f8-4823-96cb-ebd6dcbbf4fe'; + + await saveFixtures(con, LiveRoom, [ + { + id: roomId, + hostId: '2', + topic: 'Cached room', + mode: 'moderated', + status: LiveRoomStatus.Live, + }, + ]); + await ioRedisPool.execute((client) => + client.set(liveRoomParticipantCountCacheKey(roomId), '13'), + ); + + const res = await client.query(GET_QUERY, { + variables: { id: roomId }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.liveRoom.participantCount).toBe(13); + }); + it('returns a live room by id for an anonymous caller', async () => { loggedTrackingId = 'tracking-anon-1'; @@ -1526,6 +1648,7 @@ describe('live rooms', () => { topic: 'Public live room', mode: 'moderated', status: 'live', + activityStatus: 'live', participantCount: 4, host: { id: '2', @@ -1563,6 +1686,7 @@ describe('live rooms', () => { topic: 'Public scheduled lobby', mode: 'moderated', status: 'created', + activityStatus: 'pending', participantCount: null, host: { id: '2', @@ -1600,6 +1724,7 @@ describe('live rooms', () => { topic: 'Public ended room', mode: 'moderated', status: 'ended', + activityStatus: null, participantCount: null, host: { id: '2', diff --git a/src/common/liveRoom/participantCount.ts b/src/common/liveRoom/participantCount.ts index 0a9a41f962..257b1edc80 100644 --- a/src/common/liveRoom/participantCount.ts +++ b/src/common/liveRoom/participantCount.ts @@ -1,10 +1,28 @@ +import { z } from 'zod'; import type { Context } from '../../Context'; import { StorageKey, StorageTopic, generateStorageKey } from '../../config'; import { getFlytingClient } from '../../integrations/flyting/client'; import { ioRedisPool } from '../../redis'; -import { ONE_MINUTE_IN_SECONDS } from '../constants'; +import { liveRoomActivityStatusSchema } from '../schema/liveRooms'; -const LIVE_ROOM_PARTICIPANT_COUNT_CACHE_TTL_SECONDS = 2 * ONE_MINUTE_IN_SECONDS; +const LIVE_ROOM_RUNTIME_STATE_CACHE_TTL_SECONDS = 30; + +const liveRoomRuntimeStateSchema = z.object({ + activityStatus: liveRoomActivityStatusSchema.nullable(), + participantCount: z.number().nullable().catch(null), +}); + +export type LiveRoomRuntimeState = z.infer; + +const cachedLiveRoomRuntimeStateSchema = z.union([ + liveRoomRuntimeStateSchema, + z.number().transform( + (participantCount): LiveRoomRuntimeState => ({ + activityStatus: null, + participantCount, + }), + ), +]); const getParticipantCountCacheKey = (roomId: string): string => generateStorageKey( @@ -13,22 +31,29 @@ const getParticipantCountCacheKey = (roomId: string): string => roomId, ); -const parseCachedParticipantCount = (value: string | null): number | null => { +const parseCachedRuntimeState = ( + value: string | null, +): LiveRoomRuntimeState | null => { if (value === null) { return null; } - const parsed = Number.parseInt(value, 10); - return Number.isNaN(parsed) ? null : parsed; + try { + const parsed = JSON.parse(value); + const result = cachedLiveRoomRuntimeStateSchema.safeParse(parsed); + return result.success ? result.data : null; + } catch { + return null; + } }; -export const getLiveRoomParticipantCounts = async ({ +export const getLiveRoomRuntimeStates = async ({ ctx, roomIds, }: { ctx: Context; roomIds: string[]; -}): Promise> => { +}): Promise> => { if (roomIds.length === 0) { return new Map(); } @@ -37,32 +62,35 @@ export const getLiveRoomParticipantCounts = async ({ const cachedValues = await ioRedisPool.execute((client) => client.mget(cacheKeys), ); - const countsByRoomId = new Map(); + const statesByRoomId = new Map(); const missingRoomIds: string[] = []; roomIds.forEach((roomId, index) => { - const cachedCount = parseCachedParticipantCount(cachedValues[index]); + const cachedState = parseCachedRuntimeState(cachedValues[index]); - if (cachedCount === null && cachedValues[index] === null) { + if (cachedState === null) { missingRoomIds.push(roomId); return; } - countsByRoomId.set(roomId, cachedCount); + statesByRoomId.set(roomId, cachedState); }); if (missingRoomIds.length === 0) { - return countsByRoomId; + return statesByRoomId; } try { const response = await getFlytingClient().getParticipantCounts({ roomIds: missingRoomIds, }); - const fetchedCountsByRoomId = new Map( - response.rooms.map(({ roomId, participantCount }) => [ + const fetchedStatesByRoomId = new Map( + response.rooms.map(({ activityStatus, roomId, participantCount }) => [ roomId, - participantCount, + { + activityStatus: activityStatus ?? null, + participantCount, + }, ]), ); @@ -70,15 +98,21 @@ export const getLiveRoomParticipantCounts = async ({ const multi = client.multi(); for (const roomId of missingRoomIds) { - const participantCount = fetchedCountsByRoomId.get(roomId) ?? null; - countsByRoomId.set(roomId, participantCount); - - if (typeof participantCount === 'number') { + const state = fetchedStatesByRoomId.get(roomId) ?? { + activityStatus: null, + participantCount: null, + }; + statesByRoomId.set(roomId, state); + + if ( + typeof state.participantCount === 'number' || + state.activityStatus !== null + ) { multi.set( getParticipantCountCacheKey(roomId), - participantCount.toString(), + JSON.stringify(state), 'EX', - LIVE_ROOM_PARTICIPANT_COUNT_CACHE_TTL_SECONDS, + LIVE_ROOM_RUNTIME_STATE_CACHE_TTL_SECONDS, ); } } @@ -92,11 +126,26 @@ export const getLiveRoomParticipantCounts = async ({ ); for (const roomId of missingRoomIds) { - countsByRoomId.set(roomId, null); + statesByRoomId.set(roomId, { + activityStatus: null, + participantCount: null, + }); } } - return countsByRoomId; + return statesByRoomId; +}; + +export const getLiveRoomParticipantCounts = async ( + input: Parameters[0], +): Promise> => { + const statesByRoomId = await getLiveRoomRuntimeStates(input); + return new Map( + [...statesByRoomId.entries()].map(([roomId, state]) => [ + roomId, + state.participantCount, + ]), + ); }; export const liveRoomParticipantCountCacheKey = getParticipantCountCacheKey; diff --git a/src/common/schema/liveRooms.ts b/src/common/schema/liveRooms.ts index c02dcd76c9..c9c2afbd94 100644 --- a/src/common/schema/liveRooms.ts +++ b/src/common/schema/liveRooms.ts @@ -13,6 +13,11 @@ export enum LiveRoomStatus { Ended = 'ended', } +export enum LiveRoomActivityStatus { + Pending = 'pending', + Live = 'live', +} + export enum LiveRoomParticipantRole { Host = 'host', Audience = 'audience', @@ -40,6 +45,13 @@ export const liveRoomStatusSchema = z.enum(enumValues(LiveRoomStatus), { error: 'Invalid live room status', }); +export const liveRoomActivityStatusSchema = z.enum( + enumValues(LiveRoomActivityStatus), + { + error: 'Invalid live room activity status', + }, +); + export const liveRoomParticipantRoleSchema = z.enum( enumValues(LiveRoomParticipantRole), { diff --git a/src/dataLoaderService.ts b/src/dataLoaderService.ts index 64890b2703..c5b7ef248a 100644 --- a/src/dataLoaderService.ts +++ b/src/dataLoaderService.ts @@ -9,7 +9,11 @@ import { queryReadReplica } from './common/queryReadReplica'; import type { FindOneOptions } from 'typeorm'; import { getRedisObject } from './redis'; import { generateStorageKey, StorageKey, StorageTopic } from './config'; -import { getLiveRoomParticipantCounts } from './common/liveRoom/participantCount'; +import { + getLiveRoomParticipantCounts, + getLiveRoomRuntimeStates, + type LiveRoomRuntimeState, +} from './common/liveRoom/participantCount'; export const defaultCacheKeyFn = (key: K) => { if (typeof key === 'object') { @@ -253,4 +257,26 @@ export class DataLoaderService { maxBatchSize: 100, }); } + + get liveRoomRuntimeState() { + return this.getBatchLoader({ + type: 'liveRoomRuntimeState', + batchLoadFn: async (roomIds) => { + const statesByRoomId = await getLiveRoomRuntimeStates({ + ctx: this.ctx, + roomIds: [...roomIds], + }); + + return roomIds.map( + (roomId) => + statesByRoomId.get(roomId) ?? { + activityStatus: null, + participantCount: null, + }, + ); + }, + cacheKeyFn: defaultCacheKeyFn, + maxBatchSize: 100, + }); + } } diff --git a/src/integrations/flyting/client.ts b/src/integrations/flyting/client.ts index 3ed9f3d5f2..37a6347c6b 100644 --- a/src/integrations/flyting/client.ts +++ b/src/integrations/flyting/client.ts @@ -2,7 +2,10 @@ import type { RequestInit } from 'node-fetch'; import { GarmrNoopService, GarmrService, type IGarmrService } from '../garmr'; import { fetchOptions as globalFetchOptions } from '../../http'; import { AbortError, HttpError, retryFetch } from '../retry'; -import type { LiveRoomMode } from '../../common/schema/liveRooms'; +import type { + LiveRoomActivityStatus, + LiveRoomMode, +} from '../../common/schema/liveRooms'; export class FlytingClient { private readonly fetchOptions: RequestInit; @@ -168,6 +171,7 @@ export class FlytingClient { async getParticipantCounts(input: { roomIds: string[] }): Promise<{ rooms: { + activityStatus?: LiveRoomActivityStatus | null; roomId: string; participantCount: number | null; }[]; diff --git a/src/routes/boot.ts b/src/routes/boot.ts index cab4d4174d..4ac97865e0 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -106,7 +106,12 @@ import { type EngagementCreative, } from '../integrations/skadi'; import { LiveRoom } from '../entity/LiveRoom'; -import { LiveRoomStatus } from '../common/schema/liveRooms'; +import { + LiveRoomActivityStatus, + LiveRoomMode, + LiveRoomStatus, +} from '../common/schema/liveRooms'; +import { getFlytingClient } from '../integrations/flyting/client'; export type BootSquadSource = Omit & { permalink: string; @@ -251,14 +256,48 @@ const getLiveRoomsBoot = async ( where: { status: LiveRoomStatus.Live }, }), ); + const communityRooms = hasLive + ? [] + : await queryReadReplica(con, async ({ queryRunner }) => + queryRunner.manager + .getRepository(LiveRoom) + .createQueryBuilder('room') + .select(['room.id']) + .where('room.mode = :communityMode', { + communityMode: LiveRoomMode.CommunityModerated, + }) + .andWhere('room.status != :endedStatus', { + endedStatus: LiveRoomStatus.Ended, + }) + .orderBy('room.createdAt', 'DESC') + .limit(50) + .getMany(), + ); + let hasCommunityLive = hasLive; + if (!hasCommunityLive && communityRooms.length > 0) { + try { + const response = await getFlytingClient().getParticipantCounts({ + roomIds: communityRooms.map((room) => room.id), + }); + + hasCommunityLive = response.rooms.some( + (room) => room.activityStatus === LiveRoomActivityStatus.Live, + ); + } catch (error) { + logger.warn( + { err: error }, + 'Unable to load community live room activity for boot', + ); + } + } await setRedisObjectWithExpiry( cacheKey, - hasLive ? '1' : '0', + hasCommunityLive ? '1' : '0', LIVE_ROOMS_BOOT_CACHE_TTL_SECONDS, ); - return { hasLive }; + return { hasLive: hasCommunityLive }; }; const getLastExtensionUseRedisKey = (userId: string): string => diff --git a/src/schema/liveRooms.ts b/src/schema/liveRooms.ts index 67c5af1ce7..9ae973c0b3 100644 --- a/src/schema/liveRooms.ts +++ b/src/schema/liveRooms.ts @@ -4,6 +4,7 @@ import type { GraphQLResolveInfo } from 'graphql'; import type { EntityManager } from 'typeorm'; import type { Context } from '../Context'; import { ONE_HOUR_IN_SECONDS, toGQLEnum } from '../common'; +import { getLiveRoomRuntimeStates } from '../common/liveRoom/participantCount'; import { GQLEmptyResponse } from './common'; import { createLiveRoomJoinToken } from '../common/liveRoom/token'; import { @@ -15,11 +16,13 @@ import { renderMarkdown } from '../common/markdown'; import { activeLiveRoomsQuerySchema, createLiveRoomSchema, + LiveRoomActivityStatus, LiveRoomMode, LiveRoomParticipantRole, liveRoomIdInputSchema, LiveRoomStatus, } from '../common/schema/liveRooms'; +import { queryReadReplica } from '../common/queryReadReplica'; import { ContentEmbedParentType } from '../entity/ContentEmbed'; import { NotFoundError } from '../errors'; import { Feature, FeatureType, FeatureValue } from '../entity/Feature'; @@ -49,6 +52,7 @@ type GQLLiveRoomJoinToken = { export const typeDefs = /* GraphQL */ ` ${toGQLEnum(LiveRoomMode, 'LiveRoomMode')} ${toGQLEnum(LiveRoomStatus, 'LiveRoomStatus')} + ${toGQLEnum(LiveRoomActivityStatus, 'LiveRoomActivityStatus')} ${toGQLEnum(LiveRoomParticipantRole, 'LiveRoomParticipantRole')} type LiveRoom { @@ -58,6 +62,7 @@ export const typeDefs = /* GraphQL */ ` topic: String! mode: LiveRoomMode! status: LiveRoomStatus! + activityStatus: LiveRoomActivityStatus startedAt: DateTime endedAt: DateTime scheduledStart: DateTime @@ -288,6 +293,82 @@ const isAnonymousJoinableRoom = (room: LiveRoom): boolean => room.status === LiveRoomStatus.Live || (room.status === LiveRoomStatus.Created && !!room.scheduledStart); +const getStoredRoomActivityStatus = ( + room: Pick, +): LiveRoomActivityStatus | null => { + if (room.status === LiveRoomStatus.Ended) { + return null; + } + + return room.status === LiveRoomStatus.Live + ? LiveRoomActivityStatus.Live + : LiveRoomActivityStatus.Pending; +}; + +const getActiveLiveRoomIds = async ({ + ctx, + limit, +}: { + ctx: Context; + limit: number; +}): Promise => { + const candidateLimit = Math.max(limit * 5, 50); + const [liveRooms, communityRooms] = await queryReadReplica( + ctx.con, + async ({ queryRunner }) => { + const roomRepo = queryRunner.manager.getRepository(LiveRoom); + const liveRoomCandidates = await roomRepo + .createQueryBuilder('room') + .select(['room.id', 'room.mode', 'room.status', 'room.createdAt']) + .where('room.status = :liveStatus', { + liveStatus: LiveRoomStatus.Live, + }) + .orderBy('room.createdAt', 'DESC') + .limit(limit) + .getMany(); + const communityRoomCandidates = await roomRepo + .createQueryBuilder('room') + .select(['room.id', 'room.mode', 'room.status', 'room.createdAt']) + .where('room.mode = :communityMode', { + communityMode: LiveRoomMode.CommunityModerated, + }) + .andWhere('room.status != :endedStatus', { + endedStatus: LiveRoomStatus.Ended, + }) + .orderBy('room.createdAt', 'DESC') + .limit(candidateLimit) + .getMany(); + + return [liveRoomCandidates, communityRoomCandidates]; + }, + ); + const candidateRooms = [ + ...new Map( + [...liveRooms, ...communityRooms].map((room) => [room.id, room]), + ).values(), + ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + const communityCandidateIds = communityRooms.map((room) => room.id); + const communityRuntimeStates = await getLiveRoomRuntimeStates({ + ctx, + roomIds: communityCandidateIds, + }); + + return candidateRooms + .filter((room) => { + if (room.status === LiveRoomStatus.Live) { + return true; + } + + return ( + room.mode === LiveRoomMode.CommunityModerated && + communityRuntimeStates.get(room.id)?.activityStatus === + LiveRoomActivityStatus.Live + ); + }) + .slice(0, limit) + .map((room) => room.id); +}; + const queryLiveRoomById = ( ctx: Context, info: GraphQLResolveInfo, @@ -310,15 +391,39 @@ export const resolvers: IResolvers = { ): Promise => queryLiveRoomById(ctx, info, payload.room.id), }, LiveRoom: { + activityStatus: async ( + room: GQLLiveRoom, + _, + ctx: Context, + ): Promise => { + if (room.mode !== LiveRoomMode.CommunityModerated) { + return getStoredRoomActivityStatus(room); + } + + const runtimeState = await ctx.dataLoader.liveRoomRuntimeState.load( + room.id, + ); + return runtimeState.activityStatus ?? getStoredRoomActivityStatus(room); + }, participantCount: async ( room: GQLLiveRoom, _, ctx: Context, ): Promise => { - if (room.status !== LiveRoomStatus.Live) { + if ( + room.status !== LiveRoomStatus.Live && + room.mode !== LiveRoomMode.CommunityModerated + ) { return null; } + if (room.mode === LiveRoomMode.CommunityModerated) { + const runtimeState = await ctx.dataLoader.liveRoomRuntimeState.load( + room.id, + ); + return runtimeState.participantCount; + } + return ctx.dataLoader.liveRoomParticipantCount.load(room.id); }, }, @@ -357,14 +462,22 @@ export const resolvers: IResolvers = { info, ): Promise => { const input = activeLiveRoomsQuerySchema.parse(args); + const roomIds = await getActiveLiveRoomIds({ + ctx, + limit: input.limit, + }); + + if (roomIds.length === 0) { + return []; + } return graphorm.query( ctx, info, (builder) => { builder.queryBuilder - .where(`"${builder.alias}"."status" = :status`, { - status: LiveRoomStatus.Live, + .where(`"${builder.alias}"."id" IN (:...roomIds)`, { + roomIds, }) .orderBy(`"${builder.alias}"."createdAt"`, 'DESC') .limit(input.limit);