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}'`); 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' 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 { export class OpenViduComponentsAdapterHelper {
private constructor() { private constructor() {
// Prevent instantiation of this utility class // Prevent instantiation of this utility class
@ -29,7 +50,7 @@ export class OpenViduComponentsAdapterHelper {
static generateRoomStatusSignal(recordingInfo: MeetRecordingInfo[], participantSid?: string) { static generateRoomStatusSignal(recordingInfo: MeetRecordingInfo[], participantSid?: string) {
const isRecordingActive = recordingInfo.some((rec) => rec.status === MeetRecordingStatus.ACTIVE); const isRecordingActive = recordingInfo.some((rec) => rec.status === MeetRecordingStatus.ACTIVE);
const payload = { const payload: RoomStatusSignalPayload = {
isRecordingStarted: isRecordingActive, isRecordingStarted: isRecordingActive,
recordingList: recordingInfo.map((rec) => recordingList: recordingInfo.map((rec) =>
OpenViduComponentsAdapterHelper.parseRecordingInfoToOpenViduComponents(rec) OpenViduComponentsAdapterHelper.parseRecordingInfoToOpenViduComponents(rec)
@ -46,7 +67,7 @@ export class OpenViduComponentsAdapterHelper {
}; };
} }
private static parseRecordingInfoToOpenViduComponents(info: MeetRecordingInfo) { private static parseRecordingInfoToOpenViduComponents(info: MeetRecordingInfo): RecordingSignalPayload {
return { return {
id: info.recordingId, id: info.recordingId,
roomName: info.roomId, roomName: info.roomId,

View File

@ -1,7 +1,12 @@
import { AuthMode, ParticipantOptions, ParticipantRole, UserRole } from '@typings-ce'; import { AuthMode, ParticipantOptions, ParticipantRole, UserRole } from '@typings-ce';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { container } from '../config/index.js'; 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 { MeetStorageService, RoomService } from '../services/index.js';
import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js'; import { allowAnonymous, tokenAndRoleValidator, withAuth } from './auth.middleware.js';
@ -91,3 +96,22 @@ export const checkParticipantFromSameRoom = async (req: Request, res: Response,
return next(); 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 { Router } from 'express';
import * as meetingCtrl from '../controllers/meeting.controller.js'; import * as meetingCtrl from '../controllers/meeting.controller.js';
import * as participantCtrl from '../controllers/participant.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(); export const internalMeetingRouter = Router();
internalMeetingRouter.use(bodyParser.urlencoded({ extended: true })); internalMeetingRouter.use(bodyParser.urlencoded({ extended: true }));
@ -13,11 +13,22 @@ internalMeetingRouter.delete(
'/:roomId', '/:roomId',
withAuth(participantTokenValidator), withAuth(participantTokenValidator),
withModeratorPermissions, withModeratorPermissions,
withValidRoomId,
meetingCtrl.endMeeting meetingCtrl.endMeeting
); );
internalMeetingRouter.delete( internalMeetingRouter.delete(
'/:roomId/participants/:participantName', '/:roomId/participants/:participantName',
withAuth(participantTokenValidator), withAuth(participantTokenValidator),
withModeratorPermissions, withModeratorPermissions,
withValidRoomId,
participantCtrl.deleteParticipant 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 { inject, injectable } from 'inversify';
import { SendDataOptions } from 'livekit-server-sdk'; 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 { 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 * 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}`); this.logger.debug(`Sending room preferences updated signal for room ${roomId}`);
try { try {
const payload = { const payload: MeetRoomPreferencesUpdatedPayload = {
roomId, roomId,
preferences: updatedRoom.preferences preferences: updatedRoom.preferences!,
timestamp: Date.now()
}; };
const options: SendDataOptions = { 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 * Generic method to send signals to the frontend
*/ */
protected async sendSignal( protected async sendSignal(
roomId: string, roomId: string,
rawData: Record<string, unknown>, rawData: MeetSignalPayload | OpenViduComponentsSignalPayload,
options: SendDataOptions options: SendDataOptions
): Promise<void> { ): Promise<void> {
this.logger.verbose(`Notifying participants in room ${roomId}: "${options.topic}".`); 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> { async deleteParticipant(participantName: string, roomName: string): Promise<void> {
const participantExists = await this.participantExists(roomName, participantName); 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 { inject, injectable } from 'inversify';
import { ParticipantInfo } from 'livekit-server-sdk'; import { ParticipantInfo } from 'livekit-server-sdk';
import { errorParticipantAlreadyExists, errorParticipantNotFound } from '../models/error.model.js'; 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() @injectable()
export class ParticipantService { export class ParticipantService {
@ -10,6 +17,7 @@ export class ParticipantService {
@inject(LoggerService) protected logger: LoggerService, @inject(LoggerService) protected logger: LoggerService,
@inject(RoomService) protected roomService: RoomService, @inject(RoomService) protected roomService: RoomService,
@inject(LiveKitService) protected livekitService: LiveKitService, @inject(LiveKitService) protected livekitService: LiveKitService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@inject(TokenService) protected tokenService: TokenService @inject(TokenService) protected tokenService: TokenService
) {} ) {}
@ -53,7 +61,7 @@ export class ParticipantService {
currentRoles.push({ role, permissions: permissions.openvidu }); 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> { 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 { return {
livekit: { livekit: {
roomJoin: addJoinPermission, roomJoin: addJoinPermission,

View File

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