From 9c25eedf788bf0a9fd50cce83d4bda0c7d521453 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Sun, 24 May 2026 21:52:05 +0300 Subject: [PATCH 1/2] feat: support community moderated live rooms --- __tests__/liveRooms.ts | 65 ++++++++++++++++++++++++++++++++++ src/common/schema/liveRooms.ts | 5 ++- src/schema/liveRooms.ts | 8 +++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/__tests__/liveRooms.ts b/__tests__/liveRooms.ts index cc20e0ed06..0ebcd5a17d 100644 --- a/__tests__/liveRooms.ts +++ b/__tests__/liveRooms.ts @@ -393,6 +393,44 @@ describe('live rooms', () => { expect(scope.isDone()).toBe(true); }); + it('creates a community-moderated live room and forwards its speaker limit to flyting', async () => { + loggedUser = '1'; + await grantStandupAccess(loggedUser); + + const scope = nock(flytingOrigin) + .post(/\/internal\/live-rooms\/[^/]+\/prepare/, { + mode: 'community_moderated', + speakerLimit: 6, + }) + .matchHeader('x-flyting-internal-key', flytingInternalKey) + .reply(200, { room: { roomId: 'ignored' } }); + + const res = await client.mutate(CREATE_MUTATION, { + variables: { + input: { + topic: 'Community floor', + mode: 'community_moderated', + speakerLimit: 6, + }, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.createLiveRoom.room).toMatchObject({ + topic: 'Community floor', + mode: 'community_moderated', + status: 'created', + participantCount: null, + }); + + const room = await con.getRepository(LiveRoom).findOneByOrFail({ + id: res.data.createLiveRoom.room.id, + }); + + expect(room.mode).toBe('community_moderated'); + expect(scope.isDone()).toBe(true); + }); + it('rejects speaker limits for moderated live rooms', async () => { loggedUser = '1'; const scope = nock(flytingOrigin) @@ -1129,6 +1167,33 @@ describe('live rooms', () => { ); }); + it('rejects anonymous join tokens for community-moderated rooms', async () => { + loggedTrackingId = 'tracking-anon-1'; + + await saveFixtures(con, LiveRoom, [ + { + id: 'ca4bb4ae-a0af-4310-b1ff-7d6345cb5253', + hostId: '1', + topic: 'Community room', + mode: 'community_moderated', + status: LiveRoomStatus.Live, + startedAt: new Date('2026-04-23T11:00:00.000Z'), + }, + ]); + + await testMutationErrorCode( + client, + { + mutation: JOIN_TOKEN_MUTATION, + variables: { + roomId: 'ca4bb4ae-a0af-4310-b1ff-7d6345cb5253', + }, + }, + 'GRAPHQL_VALIDATION_FAILED', + 'Community-moderated rooms require authenticated participants', + ); + }); + it('rejects join tokens when flyting reports that the participant was kicked from the room', async () => { loggedTrackingId = 'tracking-kicked-1'; diff --git a/src/common/schema/liveRooms.ts b/src/common/schema/liveRooms.ts index fa15cb639b..5c86db2e83 100644 --- a/src/common/schema/liveRooms.ts +++ b/src/common/schema/liveRooms.ts @@ -4,6 +4,7 @@ import { enumValues } from './utils'; export enum LiveRoomMode { Moderated = 'moderated', FreeForAll = 'free_for_all', + CommunityModerated = 'community_moderated', } export enum LiveRoomStatus { @@ -60,12 +61,14 @@ export const createLiveRoomSchema = z .superRefine((input, ctx) => { if ( input.mode !== LiveRoomMode.FreeForAll && + input.mode !== LiveRoomMode.CommunityModerated && input.speakerLimit !== undefined ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['speakerLimit'], - message: 'Speaker limit can only be set for free-for-all rooms', + message: + 'Speaker limit can only be set for free-for-all or community-moderated rooms', }); } }); diff --git a/src/schema/liveRooms.ts b/src/schema/liveRooms.ts index 86e133dc51..7e681894ec 100644 --- a/src/schema/liveRooms.ts +++ b/src/schema/liveRooms.ts @@ -527,6 +527,14 @@ export const resolvers: IResolvers = { } const authKind = ctx.userId ? 'authenticated' : 'anonymous'; + if ( + authKind === 'anonymous' && + room.mode === LiveRoomMode.CommunityModerated + ) { + throw new ValidationError( + 'Community-moderated rooms require authenticated participants', + ); + } if (authKind === 'anonymous' && !isAnonymousJoinableRoom(room)) { throw new ValidationError( 'Anonymous viewers can only join live rooms or scheduled lobbies', From 7b846b02b7c4934370ea157c676e7ebb86ba877e Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Mon, 25 May 2026 11:28:02 +0300 Subject: [PATCH 2/2] feat: add community room quorum config --- __tests__/liveRooms.ts | 29 ++++++++++++++++++++++ src/common/schema/liveRooms.ts | 40 ++++++++++++++++++++++++++++++ src/integrations/flyting/client.ts | 2 ++ src/schema/liveRooms.ts | 3 +++ 4 files changed, 74 insertions(+) diff --git a/__tests__/liveRooms.ts b/__tests__/liveRooms.ts index 0ebcd5a17d..461821b310 100644 --- a/__tests__/liveRooms.ts +++ b/__tests__/liveRooms.ts @@ -399,6 +399,7 @@ describe('live rooms', () => { const scope = nock(flytingOrigin) .post(/\/internal\/live-rooms\/[^/]+\/prepare/, { + minParticipantsToGoLive: 3, mode: 'community_moderated', speakerLimit: 6, }) @@ -409,6 +410,7 @@ describe('live rooms', () => { variables: { input: { topic: 'Community floor', + minParticipantsToGoLive: 3, mode: 'community_moderated', speakerLimit: 6, }, @@ -431,6 +433,33 @@ describe('live rooms', () => { expect(scope.isDone()).toBe(true); }); + it('rejects community-moderated live rooms without a minimum participant count', async () => { + loggedUser = '1'; + await grantStandupAccess(loggedUser); + + const scope = nock(flytingOrigin) + .post(/\/internal\/live-rooms\/[^/]+\/prepare/) + .reply(200, { room: { roomId: 'ignored' } }); + + const res = await client.mutate(CREATE_MUTATION, { + variables: { + input: { + topic: 'Community without minimum', + mode: 'community_moderated', + speakerLimit: 6, + }, + }, + }); + + expect(res.errors?.[0]?.message).toBe('Validation error'); + expect(scope.isDone()).toBe(false); + await expect( + con + .getRepository(LiveRoom) + .findOneBy({ topic: 'Community without minimum' }), + ).resolves.toBeNull(); + }); + it('rejects speaker limits for moderated live rooms', async () => { loggedUser = '1'; const scope = nock(flytingOrigin) diff --git a/src/common/schema/liveRooms.ts b/src/common/schema/liveRooms.ts index 5c86db2e83..c02dcd76c9 100644 --- a/src/common/schema/liveRooms.ts +++ b/src/common/schema/liveRooms.ts @@ -31,6 +31,10 @@ export const liveRoomSpeakerLimitSchema = z .number() .int() .positive('Speaker limit must be at least 1'); +export const liveRoomMinParticipantsToGoLiveSchema = z + .number() + .int() + .min(2, 'Minimum participants to go live must be at least 2'); export const liveRoomStatusSchema = z.enum(enumValues(LiveRoomStatus), { error: 'Invalid live room status', @@ -54,6 +58,7 @@ export const createLiveRoomSchema = z .object({ topic: z.string().trim().min(1).max(280), description: z.string().trim().max(20_000).optional().nullable(), + minParticipantsToGoLive: liveRoomMinParticipantsToGoLiveSchema.optional(), mode: liveRoomModeSchema.default(LiveRoomMode.Moderated), speakerLimit: liveRoomSpeakerLimitSchema.optional(), scheduledStart: z.coerce.date().optional().nullable(), @@ -71,6 +76,41 @@ export const createLiveRoomSchema = z 'Speaker limit can only be set for free-for-all or community-moderated rooms', }); } + if ( + input.mode !== LiveRoomMode.CommunityModerated && + input.minParticipantsToGoLive !== undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minParticipantsToGoLive'], + message: + 'Minimum participants can only be set for community-moderated rooms', + }); + } + if ( + input.mode === LiveRoomMode.CommunityModerated && + input.minParticipantsToGoLive === undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minParticipantsToGoLive'], + message: + 'Community-moderated rooms require a minimum participant count', + }); + } + if ( + input.mode === LiveRoomMode.CommunityModerated && + input.minParticipantsToGoLive !== undefined && + input.speakerLimit !== undefined && + input.minParticipantsToGoLive > input.speakerLimit + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minParticipantsToGoLive'], + message: + 'Minimum participants cannot exceed the community-moderated room speaker limit', + }); + } }); export const activeLiveRoomsQuerySchema = z.object({ diff --git a/src/integrations/flyting/client.ts b/src/integrations/flyting/client.ts index 0354b05c28..3ed9f3d5f2 100644 --- a/src/integrations/flyting/client.ts +++ b/src/integrations/flyting/client.ts @@ -26,6 +26,7 @@ export class FlytingClient { } async prepareRoom(input: { + minParticipantsToGoLive?: number; mode: LiveRoomMode; roomId: string; speakerLimit?: number; @@ -41,6 +42,7 @@ export class FlytingClient { 'x-flyting-internal-key': this.internalApiKey, }, body: JSON.stringify({ + minParticipantsToGoLive: input.minParticipantsToGoLive, mode: input.mode, speakerLimit: input.speakerLimit, }), diff --git a/src/schema/liveRooms.ts b/src/schema/liveRooms.ts index 7e681894ec..67c5af1ce7 100644 --- a/src/schema/liveRooms.ts +++ b/src/schema/liveRooms.ts @@ -79,6 +79,7 @@ export const typeDefs = /* GraphQL */ ` topic: String! mode: LiveRoomMode = moderated speakerLimit: Int + minParticipantsToGoLive: Int scheduledStart: DateTime description: String } @@ -379,6 +380,7 @@ export const resolvers: IResolvers = { args: { input: { description?: string | null; + minParticipantsToGoLive?: number; mode: LiveRoomMode; speakerLimit?: number; scheduledStart?: string | null; @@ -430,6 +432,7 @@ export const resolvers: IResolvers = { try { await getFlytingClient().prepareRoom({ + minParticipantsToGoLive: input.minParticipantsToGoLive, mode: room.mode, roomId: room.id, speakerLimit: input.speakerLimit,