backend: implement participant role management and update signals

This commit is contained in:
Carlos Santos 2025-08-07 18:34:09 +02:00
parent 9d9cb3d5b4
commit b6acebfa18
8 changed files with 191 additions and 19 deletions

View File

@ -132,3 +132,18 @@ export const deleteParticipant = async (req: Request, res: Response) => {
handleError(res, error, `deleting participant '${participantName}' from room '${roomId}'`);
}
};
export const changeParticipantRole = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const participantService = container.get(ParticipantService);
const { roomId, participantName } = req.params;
const { role } = req.body;
try {
logger.verbose(`Changing role of participant '${participantName}' in room '${roomId}' to '${role}'`);
await participantService.changeParticipantRole(roomId, participantName, role);
res.status(200).json({ message: `Participant '${participantName}' role updated to ${role}` });
} catch (error) {
handleError(res, error, `changing role for participant '${participantName}' in room '${roomId}'`);
}
};

View File

@ -12,6 +12,27 @@ const enum OpenViduComponentsDataTopic {
ROOM_STATUS = 'roomStatus'
}
interface RecordingSignalPayload {
id: string;
roomName: string;
roomId: string;
status: string;
filename?: string;
startedAt?: number;
endedAt?: number;
duration?: number;
size?: number;
location?: string;
error?: string;
}
interface RoomStatusSignalPayload {
isRecordingStarted: boolean;
recordingList: RecordingSignalPayload[];
}
export type OpenViduComponentsSignalPayload = RecordingSignalPayload | RoomStatusSignalPayload;
export class OpenViduComponentsAdapterHelper {
private constructor() {
// Prevent instantiation of this utility class
@ -29,7 +50,7 @@ export class OpenViduComponentsAdapterHelper {
static generateRoomStatusSignal(recordingInfo: MeetRecordingInfo[], participantSid?: string) {
const isRecordingActive = recordingInfo.some((rec) => rec.status === MeetRecordingStatus.ACTIVE);
const payload = {
const payload: RoomStatusSignalPayload = {
isRecordingStarted: isRecordingActive,
recordingList: recordingInfo.map((rec) =>
OpenViduComponentsAdapterHelper.parseRecordingInfoToOpenViduComponents(rec)
@ -46,7 +67,7 @@ export class OpenViduComponentsAdapterHelper {
};
}
private static parseRecordingInfoToOpenViduComponents(info: MeetRecordingInfo) {
private static parseRecordingInfoToOpenViduComponents(info: MeetRecordingInfo): RecordingSignalPayload {
return {
id: info.recordingId,
roomName: info.roomId,

View File

@ -1,7 +1,12 @@
import { AuthMode, ParticipantOptions, ParticipantRole, UserRole } from '@typings-ce';
import { NextFunction, Request, Response } from 'express';
import { container } from '../config/index.js';
import { errorInsufficientPermissions, handleError, rejectRequestFromMeetError } from '../models/error.model.js';
import {
errorInsufficientPermissions,
errorInvalidParticipantRole,
handleError,
rejectRequestFromMeetError
} from '../models/error.model.js';
import { MeetStorageService, RoomService } from '../services/index.js';
import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js';
@ -91,3 +96,22 @@ export const checkParticipantFromSameRoom = async (req: Request, res: Response,
return next();
};
export const withValidParticipantRole = async (req: Request, res: Response, next: NextFunction) => {
const { role } = req.body;
if (!role) {
const error = errorInvalidParticipantRole();
return rejectRequestFromMeetError(res, error);
}
// Validate the role against the ParticipantRole enum
const isRoleValid = role === ParticipantRole.MODERATOR || role === ParticipantRole.PUBLISHER;
if (!isRoleValid) {
const error = errorInvalidParticipantRole();
return rejectRequestFromMeetError(res, error);
}
return next();
};

View File

@ -2,7 +2,7 @@ import bodyParser from 'body-parser';
import { Router } from 'express';
import * as meetingCtrl from '../controllers/meeting.controller.js';
import * as participantCtrl from '../controllers/participant.controller.js';
import { participantTokenValidator, withAuth, withModeratorPermissions } from '../middlewares/index.js';
import { participantTokenValidator, withAuth, withModeratorPermissions, withValidParticipantRole, withValidRoomId } from '../middlewares/index.js';
export const internalMeetingRouter = Router();
internalMeetingRouter.use(bodyParser.urlencoded({ extended: true }));
@ -13,11 +13,22 @@ internalMeetingRouter.delete(
'/:roomId',
withAuth(participantTokenValidator),
withModeratorPermissions,
withValidRoomId,
meetingCtrl.endMeeting
);
internalMeetingRouter.delete(
'/:roomId/participants/:participantName',
withAuth(participantTokenValidator),
withModeratorPermissions,
withValidRoomId,
participantCtrl.deleteParticipant
);
internalMeetingRouter.patch(
'/:roomId/participants/:participantName',
withAuth(participantTokenValidator),
withModeratorPermissions,
withValidRoomId,
withValidParticipantRole,
participantCtrl.changeParticipantRole
);

View File

@ -1,9 +1,14 @@
import { MeetRoom, MeetRecordingInfo } from '@typings-ce';
import { MeetRoom, MeetRecordingInfo, ParticipantRole } from '@typings-ce';
import { inject, injectable } from 'inversify';
import { SendDataOptions } from 'livekit-server-sdk';
import { OpenViduComponentsAdapterHelper } from '../helpers/index.js';
import { OpenViduComponentsAdapterHelper, OpenViduComponentsSignalPayload } from '../helpers/index.js';
import { LiveKitService, LoggerService } from './index.js';
import { MeetSignalType } from '../typings/ce/event.model.js';
import {
MeetParticipantRoleUpdatedPayload,
MeetRoomPreferencesUpdatedPayload,
MeetSignalPayload,
MeetSignalType
} from '../typings/ce/event.model.js';
/**
* Service responsible for all communication with the frontend
@ -68,9 +73,10 @@ export class FrontendEventService {
this.logger.debug(`Sending room preferences updated signal for room ${roomId}`);
try {
const payload = {
const payload: MeetRoomPreferencesUpdatedPayload = {
roomId,
preferences: updatedRoom.preferences
preferences: updatedRoom.preferences!,
timestamp: Date.now()
};
const options: SendDataOptions = {
@ -83,13 +89,37 @@ export class FrontendEventService {
}
}
async sendParticipantRoleUpdatedSignal(
roomId: string,
participantName: string,
newRole: ParticipantRole,
secret: string
): Promise<void> {
this.logger.debug(
`Sending participant role updated signal for participant ${participantName} in room ${roomId}`
);
const payload: MeetParticipantRoleUpdatedPayload = {
participantName,
roomId,
newRole,
secret,
timestamp: Date.now()
};
const options: SendDataOptions = {
topic: MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED
};
await this.sendSignal(roomId, payload, options);
}
/**
* Generic method to send signals to the frontend
*/
protected async sendSignal(
roomId: string,
rawData: Record<string, unknown>,
rawData: MeetSignalPayload | OpenViduComponentsSignalPayload,
options: SendDataOptions
): Promise<void> {
this.logger.verbose(`Notifying participants in room ${roomId}: "${options.topic}".`);

View File

@ -184,6 +184,29 @@ export class LiveKitService {
}
}
/**
* Updates the metadata of a participant in a LiveKit room.
*
* @param roomName - The name of the room where the participant is located
* @param participantName - The name of the participant whose metadata will be updated
* @param metadata - The new metadata to set for the participant
* @returns A Promise that resolves when the metadata has been successfully updated
* @throws An internal error if there is an issue updating the metadata
*/
async updateParticipantMetadata(
roomName: string,
participantName: string,
metadata: string
): Promise<void> {
try {
await this.roomClient.updateParticipant(roomName, participantName, metadata);
this.logger.verbose(`Updated metadata for participant ${participantName} in room ${roomName}`);
} catch (error) {
this.logger.error(`Error updating metadata for participant ${participantName} in room ${roomName}: ${error}`);
throw internalError(`updating metadata for participant '${participantName}' in room '${roomName}'`);
}
}
async deleteParticipant(participantName: string, roomName: string): Promise<void> {
const participantExists = await this.participantExists(roomName, participantName);

View File

@ -1,8 +1,15 @@
import { OpenViduMeetPermissions, ParticipantOptions, ParticipantPermissions, ParticipantRole } from '@typings-ce';
import {
MeetTokenMetadata,
OpenViduMeetPermissions,
ParticipantOptions,
ParticipantPermissions,
ParticipantRole
} from '@typings-ce';
import { inject, injectable } from 'inversify';
import { ParticipantInfo } from 'livekit-server-sdk';
import { errorParticipantAlreadyExists, errorParticipantNotFound } from '../models/error.model.js';
import { LiveKitService, LoggerService, RoomService, TokenService } from './index.js';
import { FrontendEventService, LiveKitService, LoggerService, RoomService, TokenService } from './index.js';
import { MeetRoomHelper } from '../helpers/room.helper.js';
@injectable()
export class ParticipantService {
@ -10,6 +17,7 @@ export class ParticipantService {
@inject(LoggerService) protected logger: LoggerService,
@inject(RoomService) protected roomService: RoomService,
@inject(LiveKitService) protected livekitService: LiveKitService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@inject(TokenService) protected tokenService: TokenService
) {}
@ -53,7 +61,7 @@ export class ParticipantService {
currentRoles.push({ role, permissions: permissions.openvidu });
}
return this.tokenService.generateParticipantToken(participantOptions, permissions.livekit, currentRoles);
return this.tokenService.generateParticipantToken(participantOptions, permissions.livekit, currentRoles, role);
}
async getParticipant(roomId: string, participantName: string): Promise<ParticipantInfo | null> {
@ -89,7 +97,43 @@ export class ParticipantService {
}
}
protected generateModeratorPermissions(roomId: string, addJoinPermission = true): ParticipantPermissions {
async changeParticipantRole(roomId: string, participantName: string, newRole: ParticipantRole): Promise<void> {
try {
const meetRoom = await this.roomService.getMeetRoom(roomId);
const participant = await this.getParticipant(roomId, participantName);
const metadata: MeetTokenMetadata = this.parseMetadata(participant!.metadata);
if (!metadata || typeof metadata !== 'object') {
throw new Error(`Invalid metadata for participant ${participantName}`);
}
// TODO: Should we update the roles array as well?
metadata.selectedRole = newRole;
await this.livekitService.updateParticipantMetadata(roomId, participantName, JSON.stringify(metadata));
const { publisherSecret, moderatorSecret } = MeetRoomHelper.extractSecretsFromRoom(meetRoom);
const secret = newRole === ParticipantRole.MODERATOR ? moderatorSecret : publisherSecret;
await this.frontendEventService.sendParticipantRoleUpdatedSignal(roomId, participantName, newRole, secret);
} catch (error) {
this.logger.error('Error changing participant role:', error);
throw error;
}
}
protected parseMetadata(metadata: string): MeetTokenMetadata {
try {
return JSON.parse(metadata);
} catch (error) {
this.logger.error('Failed to parse participant metadata:', error);
throw new Error('Invalid participant metadata format');
}
}
protected generateModeratorPermissions(roomId: string): ParticipantPermissions {
return {
livekit: {
roomJoin: addJoinPermission,

View File

@ -1,5 +1,6 @@
import {
LiveKitPermissions,
MeetTokenMetadata,
OpenViduMeetPermissions,
ParticipantOptions,
ParticipantRole,
@ -42,19 +43,22 @@ export class TokenService {
async generateParticipantToken(
participantOptions: ParticipantOptions,
lkPermissions: LiveKitPermissions,
roles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[]
roles: { role: ParticipantRole; permissions: OpenViduMeetPermissions }[],
selectedRole: ParticipantRole
): Promise<string> {
const { roomId, participantName } = participantOptions;
this.logger.info(`Generating token for room '${roomId}'`);
const metadata: MeetTokenMetadata = {
livekitUrl: LIVEKIT_URL,
roles,
selectedRole
};
const tokenOptions: AccessTokenOptions = {
identity: participantName,
name: participantName,
ttl: INTERNAL_CONFIG.PARTICIPANT_TOKEN_EXPIRATION,
metadata: JSON.stringify({
livekitUrl: LIVEKIT_URL,
roles
})
metadata: JSON.stringify(metadata)
};
return await this.generateJwtToken(tokenOptions, lkPermissions as VideoGrant);
}