Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions __tests__/liveRooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,73 @@ 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/, {
minParticipantsToGoLive: 3,
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',
minParticipantsToGoLive: 3,
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 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)
Expand Down Expand Up @@ -1129,6 +1196,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';

Expand Down
45 changes: 44 additions & 1 deletion src/common/schema/liveRooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { enumValues } from './utils';
export enum LiveRoomMode {
Moderated = 'moderated',
FreeForAll = 'free_for_all',
CommunityModerated = 'community_moderated',
}

export enum LiveRoomStatus {
Expand All @@ -30,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',
Expand All @@ -53,19 +58,57 @@ 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(),
})
.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',
});
}
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',
});
}
});
Expand Down
2 changes: 2 additions & 0 deletions src/integrations/flyting/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class FlytingClient {
}

async prepareRoom(input: {
minParticipantsToGoLive?: number;
mode: LiveRoomMode;
roomId: string;
speakerLimit?: number;
Expand All @@ -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,
}),
Expand Down
11 changes: 11 additions & 0 deletions src/schema/liveRooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const typeDefs = /* GraphQL */ `
topic: String!
mode: LiveRoomMode = moderated
speakerLimit: Int
minParticipantsToGoLive: Int
scheduledStart: DateTime
description: String
}
Expand Down Expand Up @@ -379,6 +380,7 @@ export const resolvers: IResolvers = {
args: {
input: {
description?: string | null;
minParticipantsToGoLive?: number;
mode: LiveRoomMode;
speakerLimit?: number;
scheduledStart?: string | null;
Expand Down Expand Up @@ -430,6 +432,7 @@ export const resolvers: IResolvers = {

try {
await getFlytingClient().prepareRoom({
minParticipantsToGoLive: input.minParticipantsToGoLive,
mode: room.mode,
roomId: room.id,
speakerLimit: input.speakerLimit,
Expand Down Expand Up @@ -527,6 +530,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',
Expand Down
Loading