From b6acebfa1867349ac90a98638c5ba14fd30fb1c1 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Thu, 7 Aug 2025 18:34:09 +0200 Subject: [PATCH] backend: implement participant role management and update signals --- .../src/controllers/participant.controller.ts | 15 ++++++ .../helpers/ov-components-adapter.helper.ts | 25 ++++++++- .../src/middlewares/participant.middleware.ts | 26 +++++++++- backend/src/routes/meeting.routes.ts | 13 ++++- .../src/services/frontend-event.service.ts | 42 ++++++++++++--- backend/src/services/livekit.service.ts | 23 ++++++++ backend/src/services/participant.service.ts | 52 +++++++++++++++++-- backend/src/services/token.service.ts | 14 +++-- 8 files changed, 191 insertions(+), 19 deletions(-) diff --git a/backend/src/controllers/participant.controller.ts b/backend/src/controllers/participant.controller.ts index a7eeeb4..044c45f 100644 --- a/backend/src/controllers/participant.controller.ts +++ b/backend/src/controllers/participant.controller.ts @@ -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}'`); + } +}; diff --git a/backend/src/helpers/ov-components-adapter.helper.ts b/backend/src/helpers/ov-components-adapter.helper.ts index d1feb80..f58f289 100644 --- a/backend/src/helpers/ov-components-adapter.helper.ts +++ b/backend/src/helpers/ov-components-adapter.helper.ts @@ -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, diff --git a/backend/src/middlewares/participant.middleware.ts b/backend/src/middlewares/participant.middleware.ts index df31f87..0e18c87 100644 --- a/backend/src/middlewares/participant.middleware.ts +++ b/backend/src/middlewares/participant.middleware.ts @@ -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(); +}; diff --git a/backend/src/routes/meeting.routes.ts b/backend/src/routes/meeting.routes.ts index cd8ea2c..1dd32da 100644 --- a/backend/src/routes/meeting.routes.ts +++ b/backend/src/routes/meeting.routes.ts @@ -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 +); diff --git a/backend/src/services/frontend-event.service.ts b/backend/src/services/frontend-event.service.ts index 70a5809..695c74f 100644 --- a/backend/src/services/frontend-event.service.ts +++ b/backend/src/services/frontend-event.service.ts @@ -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 { + 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, + rawData: MeetSignalPayload | OpenViduComponentsSignalPayload, options: SendDataOptions ): Promise { this.logger.verbose(`Notifying participants in room ${roomId}: "${options.topic}".`); diff --git a/backend/src/services/livekit.service.ts b/backend/src/services/livekit.service.ts index 3987658..dbfa84a 100644 --- a/backend/src/services/livekit.service.ts +++ b/backend/src/services/livekit.service.ts @@ -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 { + 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 { const participantExists = await this.participantExists(roomName, participantName); diff --git a/backend/src/services/participant.service.ts b/backend/src/services/participant.service.ts index 4f59e13..0b31749 100644 --- a/backend/src/services/participant.service.ts +++ b/backend/src/services/participant.service.ts @@ -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 { @@ -89,7 +97,43 @@ export class ParticipantService { } } - protected generateModeratorPermissions(roomId: string, addJoinPermission = true): ParticipantPermissions { + async changeParticipantRole(roomId: string, participantName: string, newRole: ParticipantRole): Promise { + 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, diff --git a/backend/src/services/token.service.ts b/backend/src/services/token.service.ts index cef9097..9305cec 100644 --- a/backend/src/services/token.service.ts +++ b/backend/src/services/token.service.ts @@ -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 { 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); }