From 7361b71a7a042b0dfb6be29776af245198fec171 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Fri, 4 Jul 2025 15:10:14 +0200 Subject: [PATCH] backend: introduce FrontendEventService for frontend communication and update dependencies --- .../src/config/dependency-injector.config.ts | 4 +- .../src/services/frontend-event.service.ts | 103 ++++++++++++++++++ backend/src/services/index.ts | 1 + .../src/services/livekit-webhook.service.ts | 6 +- backend/src/services/recording.service.ts | 31 ++---- backend/src/services/room.service.ts | 53 ++------- .../integration/api/rooms/update-room.test.ts | 25 ++++- typings/src/event.model.ts | 3 + 8 files changed, 156 insertions(+), 70 deletions(-) create mode 100644 backend/src/services/frontend-event.service.ts create mode 100644 typings/src/event.model.ts diff --git a/backend/src/config/dependency-injector.config.ts b/backend/src/config/dependency-injector.config.ts index a257331..b6f8920 100644 --- a/backend/src/config/dependency-injector.config.ts +++ b/backend/src/config/dependency-injector.config.ts @@ -23,7 +23,8 @@ import { DistributedEventService, TaskSchedulerService, TokenService, - UserService + UserService, + FrontendEventService } from '../services/index.js'; export const container: Container = new Container(); @@ -57,6 +58,7 @@ export const registerDependencies = () => { container.bind(UserService).toSelf().inSingletonScope(); container.bind(AuthService).toSelf().inSingletonScope(); + container.bind(FrontendEventService).toSelf().inSingletonScope(); container.bind(LiveKitService).toSelf().inSingletonScope(); container.bind(RoomService).toSelf().inSingletonScope(); container.bind(ParticipantService).toSelf().inSingletonScope(); diff --git a/backend/src/services/frontend-event.service.ts b/backend/src/services/frontend-event.service.ts new file mode 100644 index 0000000..561e618 --- /dev/null +++ b/backend/src/services/frontend-event.service.ts @@ -0,0 +1,103 @@ +import { MeetRoom, MeetRecordingInfo } from '@typings-ce'; +import { inject, injectable } from 'inversify'; +import { SendDataOptions } from 'livekit-server-sdk'; +import { OpenViduComponentsAdapterHelper } from '../helpers/index.js'; +import { LiveKitService, LoggerService } from './index.js'; +import { MeetSignalType } from '../typings/ce/event.model.js'; + +/** + * Service responsible for all communication with the frontend + * Centralizes all signals and events sent to the frontend + */ +@injectable() +export class FrontendEventService { + constructor( + @inject(LoggerService) protected logger: LoggerService, + @inject(LiveKitService) protected livekitService: LiveKitService + ) {} + + /** + * Sends a recording signal to OpenVidu Components within a specified room. + * + * This method constructs a signal with the appropriate topic and payload, + * and sends it to the OpenVidu Components in the given room. The payload + * is adapted to match the expected format for OpenVidu Components. + */ + async sendRecordingSignalToOpenViduComponents(roomId: string, recordingInfo: MeetRecordingInfo) { + this.logger.debug(`Sending recording signal to OpenVidu Components for room '${roomId}'`); + const { payload, options } = OpenViduComponentsAdapterHelper.generateRecordingSignal(recordingInfo); + + try { + await this.sendSignal(roomId, payload, options); + } catch (error) { + this.logger.debug(`Error sending recording signal to OpenVidu Components for room '${roomId}': ${error}`); + } + } + + /** + * Sends a room status signal to OpenVidu Components. + * + * This method checks if recording is started in the room and sends a signal + * with the room status to OpenVidu Components. If recording is not started, + * it skips sending the signal. + */ + async sendRoomStatusSignalToOpenViduComponents(roomId: string, participantSid: string) { + this.logger.debug(`Sending room status signal for room ${roomId} to OpenVidu Components.`); + + try { + // Check if recording is started in the room + const activeEgressArray = await this.livekitService.getActiveEgress(roomId); + const isRecordingStarted = activeEgressArray.length > 0; + + // Skip if recording is not started + if (!isRecordingStarted) { + return; + } + + // Construct the payload and signal options + const { payload, options } = OpenViduComponentsAdapterHelper.generateRoomStatusSignal( + isRecordingStarted, + participantSid + ); + + await this.sendSignal(roomId, payload, options); + } catch (error) { + this.logger.debug(`Error sending room status signal for room ${roomId}:`, error); + } + } + + /** + * Sends a signal to notify participants in a room about updated room preferences. + */ + async sendRoomPreferencesUpdatedSignal(roomId: string, updatedRoom: MeetRoom): Promise { + this.logger.debug(`Sending room preferences updated signal for room ${roomId}`); + + try { + const payload = { + roomId, + preferences: updatedRoom.preferences + }; + + const options: SendDataOptions = { + topic: MeetSignalType.MEET_ROOM_PREFERENCES_UPDATED + }; + + await this.sendSignal(roomId, payload, options); + } catch (error) { + this.logger.error(`Error sending room preferences updated signal for room ${roomId}:`, error); + } + } + + /** + * Generic method to send signals to the frontend + */ + + protected async sendSignal( + roomId: string, + rawData: Record, + options: SendDataOptions + ): Promise { + this.logger.verbose(`Notifying participants in room ${roomId}: "${options.topic}".`); + await this.livekitService.sendData(roomId, rawData, options); + } +} diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index 222270c..ed5a722 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -11,6 +11,7 @@ export * from './user.service.js'; export * from './auth.service.js'; export * from './livekit.service.js'; +export * from './frontend-event.service.js'; export * from './room.service.js'; export * from './participant.service.js'; export * from './recording.service.js'; diff --git a/backend/src/services/livekit-webhook.service.ts b/backend/src/services/livekit-webhook.service.ts index fee1580..174317d 100644 --- a/backend/src/services/livekit-webhook.service.ts +++ b/backend/src/services/livekit-webhook.service.ts @@ -14,6 +14,7 @@ import { RoomService, DistributedEventService } from './index.js'; +import { FrontendEventService } from './frontend-event.service.js'; @injectable() export class LivekitWebhookService { @@ -26,6 +27,7 @@ export class LivekitWebhookService { @inject(OpenViduWebhookService) protected openViduWebhookService: OpenViduWebhookService, @inject(MutexService) protected mutexService: MutexService, @inject(DistributedEventService) protected distributedEventService: DistributedEventService, + @inject(FrontendEventService) protected frontendEventService: FrontendEventService, @inject(LoggerService) protected logger: LoggerService ) { this.webhookReceiver = new WebhookReceiver(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); @@ -139,7 +141,7 @@ export class LivekitWebhookService { if (this.livekitService.isEgressParticipant(participant)) return; try { - await this.roomService.sendRoomStatusSignalToOpenViduComponents(room.name, participant.sid); + await this.frontendEventService.sendRoomStatusSignalToOpenViduComponents(room.name, participant.sid); } catch (error) { this.logger.error('Error sending room status signal on participant join:', error); } @@ -229,7 +231,7 @@ export class LivekitWebhookService { // Common tasks for all webhook types const commonTasks = [ this.storageService.saveRecordingMetadata(recordingInfo), - this.recordingService.sendRecordingSignalToOpenViduComponents(roomId, recordingInfo) + this.frontendEventService.sendRecordingSignalToOpenViduComponents(roomId, recordingInfo) ]; const specificTasks: Promise[] = []; diff --git a/backend/src/services/recording.service.ts b/backend/src/services/recording.service.ts index f9e45ce..9018c85 100644 --- a/backend/src/services/recording.service.ts +++ b/backend/src/services/recording.service.ts @@ -6,8 +6,9 @@ import { Readable } from 'stream'; import { uid } from 'uid'; import INTERNAL_CONFIG from '../config/internal-config.js'; import { MEET_S3_SUBBUCKET } from '../environment.js'; -import { MeetLock, OpenViduComponentsAdapterHelper, RecordingHelper, UtilsHelper } from '../helpers/index.js'; +import { MeetLock, RecordingHelper, UtilsHelper } from '../helpers/index.js'; import { + DistributedEventType, errorRecordingAlreadyStarted, errorRecordingAlreadyStopped, errorRecordingCannotBeStoppedWhileStarting, @@ -19,10 +20,10 @@ import { isErrorRecordingAlreadyStopped, isErrorRecordingCannotBeStoppedWhileStarting, isErrorRecordingNotFound, - OpenViduMeetError, - DistributedEventType + OpenViduMeetError } from '../models/index.js'; import { + DistributedEventService, IScheduledTask, LiveKitService, LoggerService, @@ -30,7 +31,6 @@ import { MutexService, RedisLock, RoomService, - DistributedEventService, TaskSchedulerService } from './index.js'; @@ -254,7 +254,10 @@ export class RecordingService { if (recRoomId !== roomId) { this.logger.warn(`Skipping recording '${recordingId}' as it does not belong to room '${roomId}'`); - notDeletedRecordings.add({ recordingId, error: `Recording '${recordingId}' does not belong to room '${roomId}'` }); + notDeletedRecordings.add({ + recordingId, + error: `Recording '${recordingId}' does not belong to room '${roomId}'` + }); continue; } } @@ -496,24 +499,6 @@ export class RecordingService { } } - /** - * Sends a recording signal to OpenVidu Components within a specified room. - * - * This method constructs a signal with the appropriate topic and payload, - * and sends it to the OpenVidu Components in the given room. The payload - * is adapted to match the expected format for OpenVidu Components. - */ - async sendRecordingSignalToOpenViduComponents(roomId: string, recordingInfo: MeetRecordingInfo) { - this.logger.debug(`Sending recording signal to OpenVidu Components for room '${roomId}'`); - const { payload, options } = OpenViduComponentsAdapterHelper.generateRecordingSignal(recordingInfo); - - try { - await this.roomService.sendSignal(roomId, payload, options); - } catch (error) { - this.logger.debug(`Error sending recording signal to OpenVidu Components for room '${roomId}': ${error}`); - } - } - protected generateCompositeOptionsFromRequest(layout = 'grid'): RoomCompositeOptions { return { layout: layout diff --git a/backend/src/services/room.service.ts b/backend/src/services/room.service.ts index a8488c4..2ee2850 100644 --- a/backend/src/services/room.service.ts +++ b/backend/src/services/room.service.ts @@ -8,13 +8,13 @@ import { RecordingPermissions } from '@typings-ce'; import { inject, injectable } from 'inversify'; -import { CreateOptions, Room, SendDataOptions } from 'livekit-server-sdk'; +import { CreateOptions, Room } from 'livekit-server-sdk'; import ms from 'ms'; import { uid as secureUid } from 'uid/secure'; import { uid } from 'uid/single'; import INTERNAL_CONFIG from '../config/internal-config.js'; import { MEET_NAME_ID } from '../environment.js'; -import { MeetRoomHelper, OpenViduComponentsAdapterHelper, UtilsHelper } from '../helpers/index.js'; +import { MeetRoomHelper, UtilsHelper } from '../helpers/index.js'; import { errorInvalidRoomSecret, errorRoomMetadataNotFound, @@ -22,13 +22,14 @@ import { internalError } from '../models/error.model.js'; import { + DistributedEventService, IScheduledTask, LiveKitService, LoggerService, MeetStorageService, - DistributedEventService, TaskSchedulerService, - TokenService + TokenService, + FrontendEventService } from './index.js'; /** @@ -44,6 +45,7 @@ export class RoomService { @inject(MeetStorageService) protected storageService: MeetStorageService, @inject(LiveKitService) protected livekitService: LiveKitService, @inject(DistributedEventService) protected distributedEventService: DistributedEventService, + @inject(FrontendEventService) protected frontendEventService: FrontendEventService, @inject(TaskSchedulerService) protected taskSchedulerService: TaskSchedulerService, @inject(TokenService) protected tokenService: TokenService ) { @@ -131,7 +133,10 @@ export class RoomService { await this.storageService.saveMeetRoom(room); // Update the archived room metadata if it exists - await this.storageService.archiveRoomMetadata(roomId, true); + await Promise.all([ + this.storageService.archiveRoomMetadata(roomId, true), + this.frontendEventService.sendRoomPreferencesUpdatedSignal(roomId, room) + ]); return room; } @@ -298,44 +303,6 @@ export class RoomService { }; } - async sendRoomStatusSignalToOpenViduComponents(roomId: string, participantSid: string) { - this.logger.debug(`Sending room status signal for room ${roomId} to OpenVidu Components.`); - - try { - // Check if recording is started in the room - const activeEgressArray = await this.livekitService.getActiveEgress(roomId); - const isRecordingStarted = activeEgressArray.length > 0; - - // Skip if recording is not started - if (!isRecordingStarted) { - return; - } - - // Construct the payload and signal options - const { payload, options } = OpenViduComponentsAdapterHelper.generateRoomStatusSignal( - isRecordingStarted, - participantSid - ); - - await this.sendSignal(roomId, payload, options); - } catch (error) { - this.logger.debug(`Error sending room status signal for room ${roomId}:`, error); - } - } - - /** - * Sends a signal to participants in a specified room. - * - * @param roomId - The name of the room where the signal will be sent. - * @param rawData - The raw data to be sent as the signal. - * @param options - Options for sending the data, including the topic and destination identities. - * @returns A promise that resolves when the signal has been sent. - */ - async sendSignal(roomId: string, rawData: Record, options: SendDataOptions): Promise { - this.logger.verbose(`Notifying participants in room ${roomId}: "${options.topic}".`); - await this.livekitService.sendData(roomId, rawData, options); - } - /** * Classifies rooms into those that should be deleted immediately vs marked for deletion */ diff --git a/backend/tests/integration/api/rooms/update-room.test.ts b/backend/tests/integration/api/rooms/update-room.test.ts index 37d158f..04eba72 100644 --- a/backend/tests/integration/api/rooms/update-room.test.ts +++ b/backend/tests/integration/api/rooms/update-room.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; +import { afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals'; import { MeetRecordingAccess } from '../../../../src/typings/ce/index.js'; import { createRoom, @@ -7,6 +7,9 @@ import { startTestServer, updateRoomPreferences } from '../../../helpers/request-helpers.js'; +import { FrontendEventService } from '../../../../src/services/index.js'; +import { container } from '../../../../src/config/index.js'; +import { MeetSignalType } from '../../../../src/typings/ce/event.model.js'; describe('Room API Tests', () => { beforeAll(() => { @@ -19,7 +22,15 @@ describe('Room API Tests', () => { }); describe('Update Room Tests', () => { + let frontendEventService: FrontendEventService; + + beforeAll(() => { + // Ensure the FrontendEventService is registered + frontendEventService = container.get(FrontendEventService); + }); + it('should successfully update room preferences', async () => { + const sendSignalSpy = jest.spyOn(frontendEventService as any, 'sendSignal'); const createdRoom = await createRoom({ roomIdPrefix: 'update-test', preferences: { @@ -44,6 +55,18 @@ describe('Room API Tests', () => { const updateResponse = await updateRoomPreferences(createdRoom.roomId, updatedPreferences); + // Verify a method of frontend event service is called + expect(sendSignalSpy).toHaveBeenCalledWith( + createdRoom.roomId, + { + roomId: createdRoom.roomId, + preferences: updatedPreferences + }, + { + topic: MeetSignalType.MEET_ROOM_PREFERENCES_UPDATED + } + ); + // Verify update response expect(updateResponse.status).toBe(200); expect(updateResponse.body).toBeDefined(); diff --git a/typings/src/event.model.ts b/typings/src/event.model.ts new file mode 100644 index 0000000..08dcdf7 --- /dev/null +++ b/typings/src/event.model.ts @@ -0,0 +1,3 @@ +export enum MeetSignalType { + MEET_ROOM_PREFERENCES_UPDATED = 'meet_room_preferences_updated', +}