backend: Refactor room-related terminology from 'roomName' to 'roomId' across routes, middlewares, and services

Updated the Livekit room life cycle
This commit is contained in:
Carlos Santos 2025-04-01 17:27:05 +02:00
parent aad16bc28c
commit 67b3426c85
20 changed files with 308 additions and 293 deletions

View File

@ -8,13 +8,13 @@ export const updateRoomPreferences = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
logger.verbose(`Updating room preferences: ${JSON.stringify(req.body)}`); logger.verbose(`Updating room preferences: ${JSON.stringify(req.body)}`);
const { roomName, roomPreferences } = req.body; const { roomId, roomPreferences } = req.body;
try { try {
const preferenceService = container.get(GlobalPreferencesService); const preferenceService = container.get(GlobalPreferencesService);
preferenceService.validateRoomPreferences(roomPreferences); preferenceService.validateRoomPreferences(roomPreferences);
const savedPreferences = await preferenceService.updateOpenViduRoomPreferences(roomName, roomPreferences); const savedPreferences = await preferenceService.updateOpenViduRoomPreferences(roomId, roomPreferences);
return res return res
.status(200) .status(200)
@ -34,9 +34,9 @@ export const getRoomPreferences = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
try { try {
const roomName = req.params.roomName; const { roomId } = req.params;
const preferenceService = container.get(GlobalPreferencesService); const preferenceService = container.get(GlobalPreferencesService);
const preferences = await preferenceService.getOpenViduRoomPreferences(roomName); const preferences = await preferenceService.getOpenViduRoomPreferences(roomId);
if (!preferences) { if (!preferences) {
return res.status(404).json({ message: 'Room preferences not found' }); return res.status(404).json({ message: 'Room preferences not found' });

View File

@ -7,22 +7,24 @@ import { ParticipantService } from '../services/participant.service.js';
import { MEET_PARTICIPANT_TOKEN_EXPIRATION, PARTICIPANT_TOKEN_COOKIE_NAME } from '../environment.js'; import { MEET_PARTICIPANT_TOKEN_EXPIRATION, PARTICIPANT_TOKEN_COOKIE_NAME } from '../environment.js';
import { getCookieOptions } from '../utils/cookie-utils.js'; import { getCookieOptions } from '../utils/cookie-utils.js';
import { TokenService } from '../services/token.service.js'; import { TokenService } from '../services/token.service.js';
import { RoomService } from '../services/room.service.js';
export const generateParticipantToken = async (req: Request, res: Response) => { export const generateParticipantToken = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const tokenOptions: TokenOptions = req.body;
const { roomName } = tokenOptions;
const participantService = container.get(ParticipantService); const participantService = container.get(ParticipantService);
const roomService = container.get(RoomService);
const tokenOptions: TokenOptions = req.body;
const { roomId } = tokenOptions;
try { try {
logger.verbose(`Generating participant token for room ${roomName}`); logger.verbose(`Generating participant token for room ${roomId}`);
await roomService.createLivekitRoom(roomId);
const token = await participantService.generateOrRefreshParticipantToken(tokenOptions); const token = await participantService.generateOrRefreshParticipantToken(tokenOptions);
res.cookie(PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/', MEET_PARTICIPANT_TOKEN_EXPIRATION)); res.cookie(PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/', MEET_PARTICIPANT_TOKEN_EXPIRATION));
logger.verbose(`Participant token generated for room ${roomName}`);
return res.status(200).json({ token }); return res.status(200).json({ token });
} catch (error) { } catch (error) {
logger.error(`Error generating participant token for room: ${roomName}`); logger.error(`Error generating participant token for room: ${roomId}`);
return handleError(res, error); return handleError(res, error);
} }
}; };
@ -47,18 +49,18 @@ export const refreshParticipantToken = async (req: Request, res: Response) => {
} }
const tokenOptions: TokenOptions = req.body; const tokenOptions: TokenOptions = req.body;
const { roomName } = tokenOptions; const { roomId } = tokenOptions;
const participantService = container.get(ParticipantService); const participantService = container.get(ParticipantService);
try { try {
logger.verbose(`Refreshing participant token for room ${roomName}`); logger.verbose(`Refreshing participant token for room ${roomId}`);
const token = await participantService.generateOrRefreshParticipantToken(tokenOptions, true); const token = await participantService.generateOrRefreshParticipantToken(tokenOptions, true);
res.cookie(PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/', MEET_PARTICIPANT_TOKEN_EXPIRATION)); res.cookie(PARTICIPANT_TOKEN_COOKIE_NAME, token, getCookieOptions('/', MEET_PARTICIPANT_TOKEN_EXPIRATION));
logger.verbose(`Participant token refreshed for room ${roomName}`); logger.verbose(`Participant token refreshed for room ${roomId}`);
return res.status(200).json({ token }); return res.status(200).json({ token });
} catch (error) { } catch (error) {
logger.error(`Error refreshing participant token for room: ${roomName}`); logger.error(`Error refreshing participant token for room: ${roomId}`);
return handleError(res, error); return handleError(res, error);
} }
}; };
@ -67,13 +69,13 @@ export const deleteParticipant = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const participantService = container.get(ParticipantService); const participantService = container.get(ParticipantService);
const { participantName } = req.params; const { participantName } = req.params;
const roomName: string = req.query.roomName as string; const roomId: string = req.query.roomId as string;
try { try {
await participantService.deleteParticipant(participantName, roomName); await participantService.deleteParticipant(participantName, roomId);
res.status(200).json({ message: 'Participant deleted' }); res.status(200).json({ message: 'Participant deleted' });
} catch (error) { } catch (error) {
logger.error(`Error deleting participant from room: ${roomName}`); logger.error(`Error deleting participant from room: ${roomId}`);
return handleError(res, error); return handleError(res, error);
} }
}; };

View File

@ -14,7 +14,7 @@ export const createRoom = async (req: Request, res: Response) => {
logger.verbose(`Creating room with options '${JSON.stringify(options)}'`); logger.verbose(`Creating room with options '${JSON.stringify(options)}'`);
const baseUrl = `${req.protocol}://${req.get('host')}`; const baseUrl = `${req.protocol}://${req.get('host')}`;
const room = await roomService.createRoom(baseUrl, options); const room = await roomService.createMeetRoom(baseUrl, options);
return res.status(200).json(room); return res.status(200).json(room);
} catch (error) { } catch (error) {
logger.error(`Error creating room with options '${JSON.stringify(options)}'`); logger.error(`Error creating room with options '${JSON.stringify(options)}'`);
@ -47,14 +47,14 @@ export const getRooms = async (req: Request, res: Response) => {
export const getRoom = async (req: Request, res: Response) => { export const getRoom = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const { roomName } = req.params; const { roomId } = req.params;
const fields = req.query.fields as string[] | undefined; const fields = req.query.fields as string[] | undefined;
try { try {
logger.verbose(`Getting room with id '${roomName}'`); logger.verbose(`Getting room with id '${roomId}'`);
const roomService = container.get(RoomService); const roomService = container.get(RoomService);
const room = await roomService.getOpenViduRoom(roomName); const room = await roomService.getMeetRoom(roomId);
if (fields && fields.length > 0) { if (fields && fields.length > 0) {
const filteredRoom = filterObjectFields(room, fields); const filteredRoom = filterObjectFields(room, fields);
@ -63,7 +63,7 @@ export const getRoom = async (req: Request, res: Response) => {
return res.status(200).json(room); return res.status(200).json(room);
} catch (error) { } catch (error) {
logger.error(`Error getting room with id '${roomName}'`); logger.error(`Error getting room with id '${roomId}'`);
handleError(res, error); handleError(res, error);
} }
}; };
@ -72,14 +72,14 @@ export const deleteRooms = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const roomService = container.get(RoomService); const roomService = container.get(RoomService);
const { roomName } = req.params; const { roomId } = req.params;
const { roomNames } = req.body; const { roomIds } = req.body;
const roomsToDelete = roomName ? [roomName] : roomNames; const roomsToDelete = roomId ? [roomId] : roomIds;
// TODO: Validate roomNames with ZOD // TODO: Validate roomIds with ZOD
if (!Array.isArray(roomsToDelete) || roomsToDelete.length === 0) { if (!Array.isArray(roomsToDelete) || roomsToDelete.length === 0) {
return res.status(400).json({ error: 'roomNames must be a non-empty array' }); return res.status(400).json({ error: 'roomIds must be a non-empty array' });
} }
try { try {
@ -98,16 +98,16 @@ export const getParticipantRole = async (req: Request, res: Response) => {
const logger = container.get(LoggerService); const logger = container.get(LoggerService);
const roomService = container.get(RoomService); const roomService = container.get(RoomService);
const { roomName } = req.params; const { roomId } = req.params;
const { secret } = req.query as { secret: string }; const { secret } = req.query as { secret: string };
try { try {
logger.verbose(`Getting participant role for room '${roomName}'`); logger.verbose(`Getting participant role for room '${roomId}'`);
const role = await roomService.getRoomSecretRole(roomName, secret); const role = await roomService.getRoomSecretRole(roomId, secret);
return res.status(200).json(role); return res.status(200).json(role);
} catch (error) { } catch (error) {
logger.error(`Error getting participant role for room '${roomName}'`); logger.error(`Error getting participant role for room '${roomId}'`);
handleError(res, error); handleError(res, error);
} }
}; };

View File

@ -45,7 +45,7 @@ export class OpenViduComponentsAdapterHelper {
private static parseRecordingInfoToOpenViduComponents(info: MeetRecordingInfo) { private static parseRecordingInfoToOpenViduComponents(info: MeetRecordingInfo) {
return { return {
id: info.recordingId, id: info.recordingId,
roomName: info.details ?? '', roomName: info.roomId,
roomId: info.roomId, roomId: info.roomId,
// outputMode: info.outputMode, // outputMode: info.outputMode,
status: this.mapRecordingStatus(info.status), status: this.mapRecordingStatus(info.status),

View File

@ -1,7 +1,4 @@
import { MeetRoom, MeetRoomOptions } from '@typings-ce'; import { MeetRoom, MeetRoomOptions } from '@typings-ce';
import { CreateOptions } from 'livekit-server-sdk';
import { MEET_NAME_ID } from '../environment.js';
import { uid } from 'uid/single';
export class MeetRoomHelper { export class MeetRoomHelper {
private constructor() { private constructor() {
@ -17,43 +14,9 @@ export class MeetRoomHelper {
static toOpenViduOptions(room: MeetRoom): MeetRoomOptions { static toOpenViduOptions(room: MeetRoom): MeetRoomOptions {
return { return {
expirationDate: room.expirationDate, expirationDate: room.expirationDate,
maxParticipants: room.maxParticipants, // maxParticipants: room.maxParticipants,
preferences: room.preferences, preferences: room.preferences,
roomNamePrefix: room.roomNamePrefix roomIdPrefix: room.roomIdPrefix
}; };
} }
static generateLivekitRoomOptions(roomInput: MeetRoom | MeetRoomOptions): CreateOptions {
const isOpenViduRoom = 'creationDate' in roomInput;
const sanitizedPrefix = roomInput.roomNamePrefix
?.trim()
.replace(/[^a-zA-Z0-9-]/g, '')
.replace(/-+$/, '');
const sanitizedRoomName = sanitizedPrefix ? `${sanitizedPrefix}-${uid(15)}` : uid(15);
const {
roomName = sanitizedRoomName,
expirationDate,
maxParticipants,
creationDate = Date.now()
} = roomInput as MeetRoom;
const timeUntilExpiration = this.calculateExpirationTime(expirationDate, creationDate);
return {
name: roomName,
metadata: JSON.stringify({
createdBy: MEET_NAME_ID,
roomOptions: isOpenViduRoom
? MeetRoomHelper.toOpenViduOptions(roomInput as MeetRoom)
: roomInput
}),
emptyTimeout: timeUntilExpiration,
maxParticipants: maxParticipants || undefined,
departureTimeout: 31_536_000 // 1 year
};
}
private static calculateExpirationTime(expirationDate: number, creationDate: number): number {
return Math.max(0, Math.floor((expirationDate - creationDate) / 1000));
}
} }

View File

@ -19,8 +19,8 @@ export const configureTokenAuth = async (req: Request, res: Response, next: Next
let role: ParticipantRole; let role: ParticipantRole;
try { try {
const { roomName, secret } = req.body as TokenOptions; const { roomId, secret } = req.body as TokenOptions;
role = await roomService.getRoomSecretRole(roomName, secret); role = await roomService.getRoomSecretRole(roomId, secret);
} catch (error) { } catch (error) {
logger.error('Error getting room secret role', error); logger.error('Error getting room secret role', error);
return res.status(500).json({ message: 'Internal server error' }); return res.status(500).json({ message: 'Internal server error' });
@ -55,14 +55,14 @@ export const configureTokenAuth = async (req: Request, res: Response, next: Next
}; };
export const withModeratorPermissions = async (req: Request, res: Response, next: NextFunction) => { export const withModeratorPermissions = async (req: Request, res: Response, next: NextFunction) => {
const roomName = req.query.roomName as string; const roomId = req.query.roomId as string;
const payload = req.session?.tokenClaims; const payload = req.session?.tokenClaims;
if (!payload) { if (!payload) {
return res.status(403).json({ message: 'Insufficient permissions to access this resource' }); return res.status(403).json({ message: 'Insufficient permissions to access this resource' });
} }
const sameRoom = payload.video?.room === roomName; const sameRoom = payload.video?.room === roomId;
const metadata = JSON.parse(payload.metadata || '{}'); const metadata = JSON.parse(payload.metadata || '{}');
const role = metadata.role as ParticipantRole; const role = metadata.role as ParticipantRole;

View File

@ -22,7 +22,7 @@ export const withRecordingEnabled = async (req: Request, res: Response, next: Ne
try { try {
const roomService = container.get(RoomService); const roomService = container.get(RoomService);
room = await roomService.getOpenViduRoom(roomId); room = await roomService.getMeetRoom(roomId);
} catch (error) { } catch (error) {
logger.error('Error checking recording preferences:' + error); logger.error('Error checking recording preferences:' + error);
return res.status(403).json({ message: 'Recording is disabled in this room' }); return res.status(403).json({ message: 'Recording is disabled in this room' });

View File

@ -3,13 +3,13 @@ import { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
const ParticipantTokenRequestSchema: z.ZodType<TokenOptions> = z.object({ const ParticipantTokenRequestSchema: z.ZodType<TokenOptions> = z.object({
roomName: z.string().nonempty('Room name is required'), roomId: z.string().nonempty('Room ID is required'),
participantName: z.string().nonempty('Participant name is required'), participantName: z.string().nonempty('Participant name is required'),
secret: z.string().nonempty('Secret is required') secret: z.string().nonempty('Secret is required')
}); });
const DeleteParticipantSchema = z.object({ const DeleteParticipantSchema = z.object({
roomName: z.string().trim().min(1, 'roomName is required') roomId: z.string().trim().min(1, 'Room ID is required')
}); });
export const validateParticipantTokenRequest = (req: Request, res: Response, next: NextFunction) => { export const validateParticipantTokenRequest = (req: Request, res: Response, next: NextFunction) => {

View File

@ -1,26 +1,26 @@
import { import {
ChatPreferences, MeetChatPreferences,
MeetRoomOptions, MeetRoomOptions,
RecordingPreferences, MeetRecordingPreferences,
RoomPreferences, MeetRoomPreferences,
VirtualBackgroundPreferences MeetVirtualBackgroundPreferences
} from '@typings-ce'; } from '@typings-ce';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
const RecordingPreferencesSchema: z.ZodType<RecordingPreferences> = z.object({ const RecordingPreferencesSchema: z.ZodType<MeetRecordingPreferences> = z.object({
enabled: z.boolean() enabled: z.boolean()
}); });
const ChatPreferencesSchema: z.ZodType<ChatPreferences> = z.object({ const ChatPreferencesSchema: z.ZodType<MeetChatPreferences> = z.object({
enabled: z.boolean() enabled: z.boolean()
}); });
const VirtualBackgroundPreferencesSchema: z.ZodType<VirtualBackgroundPreferences> = z.object({ const VirtualBackgroundPreferencesSchema: z.ZodType<MeetVirtualBackgroundPreferences> = z.object({
enabled: z.boolean() enabled: z.boolean()
}); });
const RoomPreferencesSchema: z.ZodType<RoomPreferences> = z.object({ const RoomPreferencesSchema: z.ZodType<MeetRoomPreferences> = z.object({
recordingPreferences: RecordingPreferencesSchema, recordingPreferences: RecordingPreferencesSchema,
chatPreferences: ChatPreferencesSchema, chatPreferences: ChatPreferencesSchema,
virtualBackgroundPreferences: VirtualBackgroundPreferencesSchema virtualBackgroundPreferences: VirtualBackgroundPreferencesSchema
@ -31,44 +31,41 @@ const RoomRequestOptionsSchema: z.ZodType<MeetRoomOptions> = z.object({
.number() .number()
.positive('Expiration date must be a positive integer') .positive('Expiration date must be a positive integer')
.min(Date.now(), 'Expiration date must be in the future'), .min(Date.now(), 'Expiration date must be in the future'),
roomNamePrefix: z roomIdPrefix: z
.string() .string()
.transform((val) => val.replace(/\s+/g, '-')) .transform(
(val) =>
val
.trim() // Remove leading and trailing spaces
.replace(/\s+/g, '') // Remove all whitespace instead of replacing it with hyphens
.replace(/[^a-zA-Z0-9-]/g, '') // Remove any character except letters, numbers, and hyphens
.replace(/-+/g, '-') // Replace multiple consecutive hyphens with a single one
.replace(/-+$/, '') // Remove trailing hyphens
)
.optional() .optional()
.default(''), .default(''),
preferences: RoomPreferencesSchema.optional().default({ preferences: RoomPreferencesSchema.optional().default({
recordingPreferences: { enabled: true }, recordingPreferences: { enabled: true },
chatPreferences: { enabled: true }, chatPreferences: { enabled: true },
virtualBackgroundPreferences: { enabled: true } virtualBackgroundPreferences: { enabled: true }
}), })
maxParticipants: z // maxParticipants: z
.number() // .number()
.positive('Max participants must be a positive integer') // .positive('Max participants must be a positive integer')
.nullable() // .nullable()
.optional() // .optional()
.default(null) // .default(null)
}); });
const GetParticipantRoleSchema = z.object({ const GetParticipantRoleSchema = z.object({
secret: z.string() secret: z.string()
}); });
export const validateRoomRequest = (req: Request, res: Response, next: NextFunction) => { export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body); const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body);
if (!success) { if (!success) {
const errors = error.errors.map((error) => ({ return rejectRequest(res, error);
field: error.path.join('.'),
message: error.message
}));
console.log(errors);
return res.status(422).json({
error: 'Unprocessable Entity',
message: 'Invalid request body',
details: errors
});
} }
req.body = data; req.body = data;
@ -105,3 +102,16 @@ export const validateGetParticipantRoleRequest = (req: Request, res: Response, n
req.query = data; req.query = data;
next(); next();
}; };
const rejectRequest = (res: Response, error: z.ZodError) => {
const errors = error.errors.map((error) => ({
field: error.path.join('.'),
message: error.message
}));
return res.status(422).json({
error: 'Unprocessable Entity',
message: 'Invalid request body',
details: errors
});
};

View File

@ -52,7 +52,7 @@ export const configureCreateRoomAuth = async (req: Request, res: Response, next:
* - If the user is not a moderator, access is denied. * - If the user is not a moderator, access is denied.
*/ */
export const configureRoomAuthorization = async (req: Request, res: Response, next: NextFunction) => { export const configureRoomAuthorization = async (req: Request, res: Response, next: NextFunction) => {
const roomName = req.params.roomName as string; const roomId = req.params.roomId as string;
const payload = req.session?.tokenClaims; const payload = req.session?.tokenClaims;
// If there is no token, the user is admin or it is invoked using the API key // If there is no token, the user is admin or it is invoked using the API key
@ -61,7 +61,7 @@ export const configureRoomAuthorization = async (req: Request, res: Response, ne
return next(); return next();
} }
const sameRoom = payload.video?.room === roomName; const sameRoom = payload.video?.room === roomId;
const metadata = JSON.parse(payload.metadata || '{}'); const metadata = JSON.parse(payload.metadata || '{}');
const role = metadata.role as ParticipantRole; const role = metadata.role as ParticipantRole;

View File

@ -71,8 +71,8 @@ export const errorRecordingCannotBeStoppedWhileStarting = (recordingId: string):
return new OpenViduMeetError('Recording Error', `Recording '${recordingId}' cannot be stopped while starting`, 409); return new OpenViduMeetError('Recording Error', `Recording '${recordingId}' cannot be stopped while starting`, 409);
}; };
export const errorRecordingAlreadyStarted = (roomName: string): OpenViduMeetError => { export const errorRecordingAlreadyStarted = (roomId: string): OpenViduMeetError => {
return new OpenViduMeetError('Recording Error', `The room '${roomName}' is already being recorded`, 409); return new OpenViduMeetError('Recording Error', `The room '${roomId}' is already being recorded`, 409);
}; };
const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => { const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => {
@ -100,24 +100,24 @@ export const isErrorRecordingCannotBeStoppedWhileStarting = (
}; };
// Room errors // Room errors
export const errorRoomNotFound = (roomName: string): OpenViduMeetError => { export const errorRoomNotFound = (roomId: string): OpenViduMeetError => {
return new OpenViduMeetError('Room Error', `The room '${roomName}' does not exist`, 404); return new OpenViduMeetError('Room Error', `The room '${roomId}' does not exist`, 404);
}; };
// Participant errors // Participant errors
export const errorParticipantUnauthorized = (roomName: string): OpenViduMeetError => { export const errorParticipantUnauthorized = (roomId: string): OpenViduMeetError => {
return new OpenViduMeetError( return new OpenViduMeetError(
'Participant Error', 'Participant Error',
`Unauthorized generating token with received credentials in room '${roomName}'`, `Unauthorized generating token with received credentials in room '${roomId}'`,
406 406
); );
}; };
export const errorParticipantNotFound = (participantName: string, roomName: string): OpenViduMeetError => { export const errorParticipantNotFound = (participantName: string, roomId: string): OpenViduMeetError => {
return new OpenViduMeetError('Participant Error', `'${participantName}' not found in room '${roomName}'`, 404); return new OpenViduMeetError('Participant Error', `'${participantName}' not found in room '${roomId}'`, 404);
}; };
export const errorParticipantAlreadyExists = (participantName: string, roomName: string): OpenViduMeetError => { export const errorParticipantAlreadyExists = (participantName: string, roomId: string): OpenViduMeetError => {
return new OpenViduMeetError('Room Error', `'${participantName}' already exists in room in ${roomName}`, 409); return new OpenViduMeetError('Room Error', `'${participantName}' already exists in room in ${roomId}`, 409);
}; };

View File

@ -46,4 +46,4 @@ preferencesRouter.get(
'/appearance', '/appearance',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
appearancePrefCtrl.getAppearancePreferences appearancePrefCtrl.getAppearancePreferences
); );

View File

@ -5,22 +5,26 @@ import {
withAuth, withAuth,
tokenAndRoleValidator, tokenAndRoleValidator,
apiKeyValidator, apiKeyValidator,
participantTokenValidator participantTokenValidator,
} from '../middlewares/auth.middleware.js';
import {
validateGetParticipantRoleRequest, validateGetParticipantRoleRequest,
validateGetRoomQueryParams, validateGetRoomQueryParams,
validateRoomRequest withValidRoomOptions,
} from '../middlewares/request-validators/room-validator.middleware.js'; configureCreateRoomAuth,
configureRoomAuthorization
} from '../middlewares/index.js';
import { UserRole } from '@typings-ce'; import { UserRole } from '@typings-ce';
import { configureCreateRoomAuth, configureRoomAuthorization } from '../middlewares/room.middleware.js';
export const roomRouter = Router(); export const roomRouter = Router();
roomRouter.use(bodyParser.urlencoded({ extended: true })); roomRouter.use(bodyParser.urlencoded({ extended: true }));
roomRouter.use(bodyParser.json()); roomRouter.use(bodyParser.json());
// Room Routes // Room Routes
roomRouter.post('/', configureCreateRoomAuth, validateRoomRequest, roomCtrl.createRoom); roomRouter.post('/', configureCreateRoomAuth, withValidRoomOptions, roomCtrl.createRoom);
roomRouter.get( roomRouter.get(
'/', '/',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
@ -28,13 +32,13 @@ roomRouter.get(
roomCtrl.getRooms roomCtrl.getRooms
); );
roomRouter.get( roomRouter.get(
'/:roomName', '/:roomId',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), participantTokenValidator), withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), participantTokenValidator),
configureRoomAuthorization, configureRoomAuthorization,
validateGetRoomQueryParams, validateGetRoomQueryParams,
roomCtrl.getRoom roomCtrl.getRoom
); );
roomRouter.delete('/:roomName', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), roomCtrl.deleteRooms); roomRouter.delete('/:roomId', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), roomCtrl.deleteRooms);
// Room preferences // Room preferences
roomRouter.put('/', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), roomCtrl.updateRoomPreferences); roomRouter.put('/', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), roomCtrl.updateRoomPreferences);
@ -44,4 +48,4 @@ export const internalRoomRouter = Router();
internalRoomRouter.use(bodyParser.urlencoded({ extended: true })); internalRoomRouter.use(bodyParser.urlencoded({ extended: true }));
internalRoomRouter.use(bodyParser.json()); internalRoomRouter.use(bodyParser.json());
internalRoomRouter.get('/:roomName/participant-role', validateGetParticipantRoleRequest, roomCtrl.getParticipantRole); internalRoomRouter.get('/:roomId/participant-role', validateGetParticipantRoleRequest, roomCtrl.getParticipantRole);

View File

@ -27,7 +27,8 @@ import {
errorLivekitIsNotAvailable, errorLivekitIsNotAvailable,
errorParticipantNotFound, errorParticipantNotFound,
errorRoomNotFound, errorRoomNotFound,
internalError internalError,
OpenViduMeetError
} from '../models/error.model.js'; } from '../models/error.model.js';
import { ParticipantPermissions, ParticipantRole, TokenOptions } from '@typings-ce'; import { ParticipantPermissions, ParticipantRole, TokenOptions } from '@typings-ce';
import { RecordingHelper } from '../helpers/recording.helper.js'; import { RecordingHelper } from '../helpers/recording.helper.js';
@ -52,6 +53,28 @@ export class LiveKitService {
} }
} }
/**
* Checks if a room with the specified name exists in LiveKit.
*
* @param roomName - The name of the room to check
* @returns A Promise that resolves to true if the room exists, false otherwise
* @throws Will rethrow service availability or other unexpected errors
*/
async roomExists(roomName: string): Promise<boolean> {
try {
await this.getRoom(roomName);
return true;
} catch (error) {
if (error instanceof OpenViduMeetError && error.statusCode === 404) {
return false;
}
// Rethrow other errors as they indicate we couldn't determine if the room exists
this.logger.error(`Error checking if room ${roomName} exists:`, error);
throw error;
}
}
async getRoom(roomName: string): Promise<Room> { async getRoom(roomName: string): Promise<Room> {
let rooms: Room[] = []; let rooms: Room[] = [];
@ -94,6 +117,14 @@ export class LiveKitService {
} }
} }
/**
* Retrieves information about a specific participant in a LiveKit room.
*
* @param roomName - The name of the room where the participant is located
* @param participantName - The name of the participant to retrieve
* @returns A Promise that resolves to the participant's information
* @throws An internal error if the participant cannot be found or another error occurs
*/
async getParticipant(roomName: string, participantName: string): Promise<ParticipantInfo> { async getParticipant(roomName: string, participantName: string): Promise<ParticipantInfo> {
try { try {
return await this.roomClient.getParticipant(roomName, participantName); return await this.roomClient.getParticipant(roomName, participantName);
@ -132,8 +163,8 @@ export class LiveKitService {
permissions: ParticipantPermissions, permissions: ParticipantPermissions,
role: ParticipantRole role: ParticipantRole
): Promise<string> { ): Promise<string> {
const { roomName, participantName } = options; const { roomId, participantName } = options;
this.logger.info(`Generating token for ${participantName} in room ${roomName}`); this.logger.info(`Generating token for ${participantName} in room ${roomId}`);
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: participantName, identity: participantName,

View File

@ -15,64 +15,66 @@ export class ParticipantService {
) {} ) {}
async generateOrRefreshParticipantToken(options: TokenOptions, refresh = false): Promise<string> { async generateOrRefreshParticipantToken(options: TokenOptions, refresh = false): Promise<string> {
const { roomName, participantName, secret } = options; const { roomId, participantName, secret } = options;
// Check if participant with same participantName exists in the room // Check if participant with same participantName exists in the room
const participantExists = await this.participantExists(roomName, participantName); const participantExists = await this.participantExists(roomId, participantName);
if (!refresh && participantExists) { if (!refresh && participantExists) {
this.logger.verbose(`Participant ${participantName} already exists in room ${roomName}`); this.logger.verbose(`Participant ${participantName} already exists in room ${roomId}`);
throw errorParticipantAlreadyExists(participantName, roomName); throw errorParticipantAlreadyExists(participantName, roomId);
} }
if (refresh && !participantExists) { if (refresh && !participantExists) {
this.logger.verbose(`Participant ${participantName} does not exist in room ${roomName}`); this.logger.verbose(`Participant ${participantName} does not exist in room ${roomId}`);
throw errorParticipantNotFound(participantName, roomName); throw errorParticipantNotFound(participantName, roomId);
} }
const role = await this.roomService.getRoomSecretRole(roomName, secret); const role = await this.roomService.getRoomSecretRole(roomId, secret);
return this.generateParticipantToken(role, options); const token = await this.generateParticipantToken(role, options);
this.logger.verbose(`Participant token generated for room ${roomId}`);
return token;
} }
protected async generateParticipantToken(role: ParticipantRole, options: TokenOptions): Promise<string> { protected async generateParticipantToken(role: ParticipantRole, options: TokenOptions): Promise<string> {
const permissions = this.getParticipantPermissions(role, options.roomName); const permissions = this.getParticipantPermissions(role, options.roomId);
return this.livekitService.generateToken(options, permissions, role); return this.livekitService.generateToken(options, permissions, role);
} }
async getParticipant(roomName: string, participantName: string): Promise<ParticipantInfo | null> { async getParticipant(roomId: string, participantName: string): Promise<ParticipantInfo | null> {
this.logger.verbose(`Fetching participant ${participantName}`); this.logger.verbose(`Fetching participant ${participantName}`);
return this.livekitService.getParticipant(roomName, participantName); return this.livekitService.getParticipant(roomId, participantName);
} }
async participantExists(roomName: string, participantName: string): Promise<boolean> { async participantExists(roomId: string, participantName: string): Promise<boolean> {
this.logger.verbose(`Checking if participant ${participantName} exists in room ${roomName}`); this.logger.verbose(`Checking if participant ${participantName} exists in room ${roomId}`);
try { try {
const participant = await this.getParticipant(roomName, participantName); const participant = await this.getParticipant(roomId, participantName);
return participant !== null; return participant !== null;
} catch (error) { } catch (error) {
return false; return false;
} }
} }
async deleteParticipant(participantName: string, roomName: string): Promise<void> { async deleteParticipant(participantName: string, roomId: string): Promise<void> {
this.logger.verbose(`Deleting participant ${participantName} from room ${roomName}`); this.logger.verbose(`Deleting participant ${participantName} from room ${roomId}`);
return this.livekitService.deleteParticipant(participantName, roomName); return this.livekitService.deleteParticipant(participantName, roomId);
} }
getParticipantPermissions(role: ParticipantRole, roomName: string): ParticipantPermissions { getParticipantPermissions(role: ParticipantRole, roomId: string): ParticipantPermissions {
switch (role) { switch (role) {
case ParticipantRole.MODERATOR: case ParticipantRole.MODERATOR:
return this.generateModeratorPermissions(roomName); return this.generateModeratorPermissions(roomId);
case ParticipantRole.PUBLISHER: case ParticipantRole.PUBLISHER:
return this.generatePublisherPermissions(roomName); return this.generatePublisherPermissions(roomId);
default: default:
throw new Error(`Role ${role} not supported`); throw new Error(`Role ${role} not supported`);
} }
} }
protected generateModeratorPermissions(roomName: string): ParticipantPermissions { protected generateModeratorPermissions(roomId: string): ParticipantPermissions {
return { return {
livekit: { livekit: {
roomCreate: true, roomCreate: true,
@ -80,7 +82,7 @@ export class ParticipantService {
roomList: true, roomList: true,
roomRecord: true, roomRecord: true,
roomAdmin: true, roomAdmin: true,
room: roomName, room: roomId,
ingressAdmin: true, ingressAdmin: true,
canPublish: true, canPublish: true,
canSubscribe: true, canSubscribe: true,
@ -99,14 +101,14 @@ export class ParticipantService {
}; };
} }
protected generatePublisherPermissions(roomName: string): ParticipantPermissions { protected generatePublisherPermissions(roomId: string): ParticipantPermissions {
return { return {
livekit: { livekit: {
roomJoin: true, roomJoin: true,
roomList: true, roomList: true,
roomRecord: false, roomRecord: false,
roomAdmin: false, roomAdmin: false,
room: roomName, room: roomId,
ingressAdmin: false, ingressAdmin: false,
canPublish: true, canPublish: true,
canSubscribe: true, canSubscribe: true,

View File

@ -36,10 +36,10 @@ export interface PreferencesStorage<
/** /**
* Retrieves the {@link MeetRoom}. * Retrieves the {@link MeetRoom}.
* *
* @param roomName - The name of the room to retrieve. * @param roomId - The name of the room to retrieve.
* @returns A promise that resolves to the OpenVidu Room, or null if not found. * @returns A promise that resolves to the OpenVidu Room, or null if not found.
**/ **/
getOpenViduRoom(roomName: string): Promise<R | null>; getOpenViduRoom(roomId: string): Promise<R | null>;
/** /**
* Saves the OpenVidu Room. * Saves the OpenVidu Room.
@ -52,8 +52,8 @@ export interface PreferencesStorage<
/** /**
* Deletes the OpenVidu Room for a given room name. * Deletes the OpenVidu Room for a given room name.
* *
* @param roomName - The name of the room whose should be deleted. * @param roomId - The name of the room whose should be deleted.
* @returns A promise that resolves when the room have been deleted. * @returns A promise that resolves when the room have been deleted.
**/ **/
deleteOpenViduRoom(roomName: string): Promise<void>; deleteOpenViduRoom(roomId: string): Promise<void>;
} }

View File

@ -3,7 +3,7 @@
* regardless of the underlying storage mechanism. * regardless of the underlying storage mechanism.
*/ */
import { AuthMode, AuthType, GlobalPreferences, MeetRoom, RoomPreferences } from '@typings-ce'; import { AuthMode, AuthType, GlobalPreferences, MeetRoom, MeetRoomPreferences } from '@typings-ce';
import { LoggerService } from '../logger.service.js'; import { LoggerService } from '../logger.service.js';
import { PreferencesStorage } from './global-preferences-storage.interface.js'; import { PreferencesStorage } from './global-preferences-storage.interface.js';
import { GlobalPreferencesStorageFactory } from './global-preferences.factory.js'; import { GlobalPreferencesStorageFactory } from './global-preferences.factory.js';
@ -64,7 +64,7 @@ export class GlobalPreferencesService<
} }
async saveOpenViduRoom(ovRoom: R): Promise<R> { async saveOpenViduRoom(ovRoom: R): Promise<R> {
this.logger.info(`Saving OpenVidu room ${ovRoom.roomName}`); this.logger.info(`Saving OpenVidu room ${ovRoom.roomId}`);
return this.storage.saveOpenViduRoom(ovRoom) as Promise<R>; return this.storage.saveOpenViduRoom(ovRoom) as Promise<R>;
} }
@ -75,27 +75,27 @@ export class GlobalPreferencesService<
/** /**
* Retrieves the preferences associated with a specific room. * Retrieves the preferences associated with a specific room.
* *
* @param roomName - The unique identifier for the room. * @param roomId - The unique identifier for the room.
* @returns A promise that resolves to the room's preferences. * @returns A promise that resolves to the room's preferences.
* @throws Error if the room preferences are not found. * @throws Error if the room preferences are not found.
*/ */
async getOpenViduRoom(roomName: string): Promise<R> { async getOpenViduRoom(roomId: string): Promise<R> {
const openviduRoom = await this.storage.getOpenViduRoom(roomName); const openviduRoom = await this.storage.getOpenViduRoom(roomId);
if (!openviduRoom) { if (!openviduRoom) {
this.logger.error(`Room not found for room ${roomName}`); this.logger.error(`Room not found for room ${roomId}`);
throw errorRoomNotFound(roomName); throw errorRoomNotFound(roomId);
} }
return openviduRoom as R; return openviduRoom as R;
} }
async deleteOpenViduRoom(roomName: string): Promise<void> { async deleteOpenViduRoom(roomId: string): Promise<void> {
return this.storage.deleteOpenViduRoom(roomName); return this.storage.deleteOpenViduRoom(roomId);
} }
async getOpenViduRoomPreferences(roomName: string): Promise<RoomPreferences> { async getOpenViduRoomPreferences(roomId: string): Promise<MeetRoomPreferences> {
const openviduRoom = await this.getOpenViduRoom(roomName); const openviduRoom = await this.getOpenViduRoom(roomId);
if (!openviduRoom.preferences) { if (!openviduRoom.preferences) {
throw new Error('Room preferences not found'); throw new Error('Room preferences not found');
@ -109,11 +109,11 @@ export class GlobalPreferencesService<
* @param {RoomPreferences} roomPreferences * @param {RoomPreferences} roomPreferences
* @returns {Promise<GlobalPreferences>} * @returns {Promise<GlobalPreferences>}
*/ */
async updateOpenViduRoomPreferences(roomName: string, roomPreferences: RoomPreferences): Promise<R> { async updateOpenViduRoomPreferences(roomId: string, roomPreferences: MeetRoomPreferences): Promise<R> {
// TODO: Move validation to the controller layer // TODO: Move validation to the controller layer
this.validateRoomPreferences(roomPreferences); this.validateRoomPreferences(roomPreferences);
const openviduRoom = await this.getOpenViduRoom(roomName); const openviduRoom = await this.getOpenViduRoom(roomId);
openviduRoom.preferences = roomPreferences; openviduRoom.preferences = roomPreferences;
return this.saveOpenViduRoom(openviduRoom); return this.saveOpenViduRoom(openviduRoom);
} }
@ -122,7 +122,7 @@ export class GlobalPreferencesService<
* Validates the room preferences. * Validates the room preferences.
* @param {RoomPreferences} preferences * @param {RoomPreferences} preferences
*/ */
validateRoomPreferences(preferences: RoomPreferences) { validateRoomPreferences(preferences: MeetRoomPreferences) {
const { recordingPreferences, chatPreferences, virtualBackgroundPreferences } = preferences; const { recordingPreferences, chatPreferences, virtualBackgroundPreferences } = preferences;
if (!recordingPreferences || !chatPreferences || !virtualBackgroundPreferences) { if (!recordingPreferences || !chatPreferences || !virtualBackgroundPreferences) {

View File

@ -80,14 +80,14 @@ export class S3PreferenceStorage<
} }
async saveOpenViduRoom(ovRoom: R): Promise<R> { async saveOpenViduRoom(ovRoom: R): Promise<R> {
const { roomName } = ovRoom; const { roomId } = ovRoom;
const s3Path = `${MEET_S3_ROOMS_PREFIX}/${roomName}/${roomName}.json`; const s3Path = `${MEET_S3_ROOMS_PREFIX}/${roomId}/${roomId}.json`;
const roomStr = JSON.stringify(ovRoom); const roomStr = JSON.stringify(ovRoom);
const results = await Promise.allSettled([ const results = await Promise.allSettled([
this.s3Service.saveObject(s3Path, ovRoom), this.s3Service.saveObject(s3Path, ovRoom),
// TODO: Use a key prefix for Redis // TODO: Use a key prefix for Redis
this.redisService.set(roomName, roomStr, false) this.redisService.set(roomId, roomStr, false)
]); ]);
const s3Result = results[0]; const s3Result = results[0];
@ -102,15 +102,15 @@ export class S3PreferenceStorage<
try { try {
await this.s3Service.deleteObject(s3Path); await this.s3Service.deleteObject(s3Path);
} catch (rollbackError) { } catch (rollbackError) {
this.logger.error(`Error rolling back S3 save for room ${roomName}: ${rollbackError}`); this.logger.error(`Error rolling back S3 save for room ${roomId}: ${rollbackError}`);
} }
} }
if (redisResult.status === 'fulfilled') { if (redisResult.status === 'fulfilled') {
try { try {
await this.redisService.delete(roomName); await this.redisService.delete(roomId);
} catch (rollbackError) { } catch (rollbackError) {
this.logger.error(`Error rolling back Redis set for room ${roomName}: ${rollbackError}`); this.logger.error(`Error rolling back Redis set for room ${roomId}: ${rollbackError}`);
} }
} }
@ -118,7 +118,7 @@ export class S3PreferenceStorage<
const rejectedResult: PromiseRejectedResult = const rejectedResult: PromiseRejectedResult =
s3Result.status === 'rejected' ? s3Result : (redisResult as PromiseRejectedResult); s3Result.status === 'rejected' ? s3Result : (redisResult as PromiseRejectedResult);
const error = rejectedResult.reason; const error = rejectedResult.reason;
this.handleError(error, `Error saving Room preferences for room ${roomName}`); this.handleError(error, `Error saving Room preferences for room ${roomId}`);
throw error; throw error;
} }
@ -138,16 +138,16 @@ export class S3PreferenceStorage<
} }
// Extract room names from file paths // Extract room names from file paths
const roomNamesList = roomFiles.map((file) => this.extractRoomName(file.Key)).filter(Boolean) as string[]; const roomIds = roomFiles.map((file) => this.extractRoomId(file.Key)).filter(Boolean) as string[];
// Fetch room preferences in parallel // Fetch room preferences in parallel
const rooms = await Promise.all( const rooms = await Promise.all(
roomNamesList.map(async (roomName: string) => { roomIds.map(async (roomId: string) => {
if (!roomName) return null; if (!roomId) return null;
try { try {
return await this.getOpenViduRoom(roomName); return await this.getOpenViduRoom(roomId);
} catch (error: any) { } catch (error: any) {
this.logger.warn(`Failed to fetch room "${roomName}": ${error.message}`); this.logger.warn(`Failed to fetch room "${roomId}": ${error.message}`);
return null; return null;
} }
}) })
@ -162,13 +162,13 @@ export class S3PreferenceStorage<
} }
/** /**
* Extracts the room name from the given file path. * Extracts the room id from the given file path.
* Assumes the room name is located one directory before the file name. * Assumes the room name is located one directory before the file name.
* Example: 'path/to/roomName/file.json' -> 'roomName' * Example: 'path/to/roomId/file.json' -> 'roomId'
* @param filePath - The S3 object key representing the file path. * @param filePath - The S3 object key representing the file path.
* @returns The extracted room name or null if extraction fails. * @returns The extracted room name or null if extraction fails.
*/ */
private extractRoomName(filePath?: string): string | null { private extractRoomId(filePath?: string): string | null {
if (!filePath) return null; if (!filePath) return null;
const parts = filePath.split('/'); const parts = filePath.split('/');
@ -198,14 +198,14 @@ export class S3PreferenceStorage<
} }
} }
async deleteOpenViduRoom(roomName: string): Promise<void> { async deleteOpenViduRoom(roomId: string): Promise<void> {
try { try {
await Promise.all([ await Promise.all([
this.s3Service.deleteObject(`${MEET_S3_ROOMS_PREFIX}/${roomName}/${roomName}.json`), this.s3Service.deleteObject(`${MEET_S3_ROOMS_PREFIX}/${roomId}/${roomId}.json`),
this.redisService.delete(roomName) this.redisService.delete(roomId)
]); ]);
} catch (error) { } catch (error) {
this.handleError(error, `Error deleting Room preferences for room ${roomName}`); this.handleError(error, `Error deleting Room preferences for room ${roomId}`);
} }
} }

View File

@ -62,7 +62,7 @@ export class RecordingService {
let acquiredLock: RedisLock | null = null; let acquiredLock: RedisLock | null = null;
try { try {
const room = await this.roomService.getOpenViduRoom(roomId); const room = await this.roomService.getMeetRoom(roomId);
if (!room) throw errorRoomNotFound(roomId); if (!room) throw errorRoomNotFound(roomId);
@ -364,9 +364,9 @@ export class RecordingService {
* and sends it to the OpenVidu Components in the given room. The payload * and sends it to the OpenVidu Components in the given room. The payload
* is adapted to match the expected format for OpenVidu Components. * is adapted to match the expected format for OpenVidu Components.
*/ */
sendRecordingSignalToOpenViduComponents(roomName: string, recordingInfo: MeetRecordingInfo) { sendRecordingSignalToOpenViduComponents(roomId: string, recordingInfo: MeetRecordingInfo) {
const { payload, options } = OpenViduComponentsAdapterHelper.generateRecordingSignal(recordingInfo); const { payload, options } = OpenViduComponentsAdapterHelper.generateRecordingSignal(recordingInfo);
return this.roomService.sendSignal(roomName, payload, options); return this.roomService.sendSignal(roomId, payload, options);
} }
/** /**

View File

@ -10,6 +10,9 @@ import { SystemEventService } from './system-event.service.js';
import { TaskSchedulerService } from './task-scheduler.service.js'; import { TaskSchedulerService } from './task-scheduler.service.js';
import { errorParticipantUnauthorized } from '../models/error.model.js'; import { errorParticipantUnauthorized } from '../models/error.model.js';
import { OpenViduComponentsAdapterHelper } from '../helpers/index.js'; import { OpenViduComponentsAdapterHelper } from '../helpers/index.js';
import { uid } from 'uid/single';
import { MEET_NAME_ID } from '../environment.js';
import ms from 'ms';
/** /**
* Service for managing OpenVidu Meet rooms. * Service for managing OpenVidu Meet rooms.
@ -34,24 +37,23 @@ export class RoomService {
*/ */
async initialize(): Promise<void> { async initialize(): Promise<void> {
this.systemEventService.onRedisReady(async () => { this.systemEventService.onRedisReady(async () => {
try { // try {
await this.deleteOpenViduExpiredRooms(); // await this.deleteOpenViduExpiredRooms();
} catch (error) { // } catch (error) {
this.logger.error('Error deleting OpenVidu expired rooms:', error); // this.logger.error('Error deleting OpenVidu expired rooms:', error);
} // }
// await Promise.all([
await Promise.all([ // //TODO: Livekit rooms should not be created here. They should be created when a user joins a room.
//TODO: Livekit rooms should not be created here. They should be created when a user joins a room. // this.restoreMissingLivekitRooms().catch((error) =>
this.restoreMissingLivekitRooms().catch((error) => // this.logger.error('Error restoring missing rooms:', error)
this.logger.error('Error restoring missing rooms:', error) // ),
), // this.taskSchedulerService.startRoomGarbageCollector(this.deleteExpiredRooms.bind(this))
this.taskSchedulerService.startRoomGarbageCollector(this.deleteExpiredRooms.bind(this)) // ]);
]);
}); });
} }
/** /**
* Creates an OpenVidu room with the specified options. * Creates an OpenVidu Meet room with the specified options.
* *
* @param {string} baseUrl - The base URL for the room. * @param {string} baseUrl - The base URL for the room.
* @param {MeetRoomOptions} options - The options for creating the OpenVidu room. * @param {MeetRoomOptions} options - The options for creating the OpenVidu room.
@ -60,16 +62,57 @@ export class RoomService {
* @throws {Error} If the room creation fails. * @throws {Error} If the room creation fails.
* *
*/ */
async createRoom(baseUrl: string, roomOptions: MeetRoomOptions): Promise<MeetRoom> { async createMeetRoom(baseUrl: string, roomOptions: MeetRoomOptions): Promise<MeetRoom> {
const livekitRoom: Room = await this.createLivekitRoom(roomOptions); const { preferences, expirationDate, roomIdPrefix } = roomOptions;
const roomId = roomIdPrefix ? `${roomIdPrefix}-${uid(15)}` : uid(15);
const openviduRoom: MeetRoom = this.generateOpenViduRoom(baseUrl, livekitRoom, roomOptions); const openviduRoom: MeetRoom = {
roomId,
roomIdPrefix,
creationDate: Date.now(),
// maxParticipants,
expirationDate,
preferences,
moderatorRoomUrl: `${baseUrl}/room/${roomId}?secret=${secureUid(10)}`,
publisherRoomUrl: `${baseUrl}/room/${roomId}?secret=${secureUid(10)}`
};
await this.globalPrefService.saveOpenViduRoom(openviduRoom); await this.globalPrefService.saveOpenViduRoom(openviduRoom);
return openviduRoom; return openviduRoom;
} }
/**
* Creates a LiveKit room for the specified Meet Room.
*
* This method creates a LiveKit room with the specified room name and metadata.
* The metadata includes the room options from the Meet Room.
**/
async createLivekitRoom(roomId: string): Promise<Room> {
const roomExists = await this.livekitService.roomExists(roomId);
if (roomExists) {
this.logger.verbose(`Room ${roomId} already exists in LiveKit.`);
return this.livekitService.getRoom(roomId);
}
const meetRoom: MeetRoom = await this.getMeetRoom(roomId);
const livekitRoomOptions: CreateOptions = {
name: roomId,
metadata: JSON.stringify({
createdBy: MEET_NAME_ID,
roomOptions: MeetRoomHelper.toOpenViduOptions(meetRoom)
}),
emptyTimeout: ms('20s'),
departureTimeout: ms('20s')
// maxParticipants: maxParticipants || undefined,
};
const room = await this.livekitService.createRoom(livekitRoomOptions);
this.logger.verbose(`Room ${roomId} created in LiveKit.`);
return room;
}
/** /**
* Retrieves a list of rooms. * Retrieves a list of rooms.
* @returns A Promise that resolves to an array of {@link MeetRoom} objects. * @returns A Promise that resolves to an array of {@link MeetRoom} objects.
@ -82,11 +125,11 @@ export class RoomService {
/** /**
* Retrieves an OpenVidu room by its name. * Retrieves an OpenVidu room by its name.
* *
* @param roomName - The name of the room to retrieve. * @param roomId - The name of the room to retrieve.
* @returns A promise that resolves to an {@link MeetRoom} object. * @returns A promise that resolves to an {@link MeetRoom} object.
*/ */
async getOpenViduRoom(roomName: string): Promise<MeetRoom> { async getMeetRoom(roomId: string): Promise<MeetRoom> {
return await this.globalPrefService.getOpenViduRoom(roomName); return await this.globalPrefService.getOpenViduRoom(roomId);
} }
/** /**
@ -94,19 +137,19 @@ export class RoomService {
* *
* This method deletes rooms from both LiveKit and OpenVidu services. * This method deletes rooms from both LiveKit and OpenVidu services.
* *
* @param roomNames - An array of room names to be deleted. * @param roomIds - An array of room names to be deleted.
* @returns A promise that resolves with an array of successfully deleted room names. * @returns A promise that resolves with an array of successfully deleted room names.
*/ */
async deleteRooms(roomNames: string[]): Promise<string[]> { async deleteRooms(roomIds: string[]): Promise<string[]> {
const [openViduResults, livekitResults] = await Promise.all([ const [openViduResults, livekitResults] = await Promise.all([
this.deleteOpenViduRooms(roomNames), this.deleteOpenViduRooms(roomIds),
Promise.allSettled(roomNames.map((roomName) => this.livekitService.deleteRoom(roomName))) Promise.allSettled(roomIds.map((roomId) => this.livekitService.deleteRoom(roomId)))
]); ]);
// Log errors from LiveKit deletions // Log errors from LiveKit deletions
livekitResults.forEach((result, index) => { livekitResults.forEach((result, index) => {
if (result.status === 'rejected') { if (result.status === 'rejected') {
this.logger.error(`Failed to delete LiveKit room "${roomNames[index]}": ${result.reason}`); this.logger.error(`Failed to delete LiveKit room "${roomIds[index]}": ${result.reason}`);
} }
}); });
@ -115,7 +158,7 @@ export class RoomService {
livekitResults.forEach((result, index) => { livekitResults.forEach((result, index) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
successfullyDeleted.add(roomNames[index]); successfullyDeleted.add(roomIds[index]);
} }
}); });
@ -125,25 +168,25 @@ export class RoomService {
/** /**
* Deletes OpenVidu rooms. * Deletes OpenVidu rooms.
* *
* @param roomNames - List of room names to delete. * @param roomIds - List of room names to delete.
* @returns A promise that resolves with an array of successfully deleted room names. * @returns A promise that resolves with an array of successfully deleted room names.
*/ */
async deleteOpenViduRooms(roomNames: string[]): Promise<string[]> { async deleteOpenViduRooms(roomIds: string[]): Promise<string[]> {
const results = await Promise.allSettled( const results = await Promise.allSettled(
roomNames.map((roomName) => this.globalPrefService.deleteOpenViduRoom(roomName)) roomIds.map((roomId) => this.globalPrefService.deleteOpenViduRoom(roomId))
); );
const successfulRooms: string[] = []; const successfulRooms: string[] = [];
results.forEach((result, index) => { results.forEach((result, index) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
successfulRooms.push(roomNames[index]); successfulRooms.push(roomIds[index]);
} else { } else {
this.logger.error(`Failed to delete OpenVidu room "${roomNames[index]}": ${result.reason}`); this.logger.error(`Failed to delete OpenVidu room "${roomIds[index]}": ${result.reason}`);
} }
}); });
if (successfulRooms.length === roomNames.length) { if (successfulRooms.length === roomIds.length) {
this.logger.verbose('All OpenVidu rooms have been deleted.'); this.logger.verbose('All OpenVidu rooms have been deleted.');
} }
@ -151,15 +194,16 @@ export class RoomService {
} }
/** /**
* Determines the role of a participant in a room based on the provided secret. * Validates a secret against a room's moderator and publisher secrets and returns the corresponding role.
* *
* @param room - The OpenVidu room object. * @param roomId - The unique identifier of the room to check
* @param secret - The secret used to identify the participant's role. * @param secret - The secret to validate against the room's moderator and publisher secrets
* @returns The role of the participant {@link ParticipantRole}. * @returns A promise that resolves to the participant role (MODERATOR or PUBLISHER) if the secret is valid
* @throws Will throw an error if the secret is invalid. * @throws Error if the moderator or publisher secrets cannot be extracted from their URLs
* @throws Error if the provided secret doesn't match any of the room's secrets (unauthorized)
*/ */
async getRoomSecretRole(roomName: string, secret: string): Promise<ParticipantRole> { async getRoomSecretRole(roomId: string, secret: string): Promise<ParticipantRole> {
const room = await this.getOpenViduRoom(roomName); const room = await this.getMeetRoom(roomId);
const { moderatorRoomUrl, publisherRoomUrl } = room; const { moderatorRoomUrl, publisherRoomUrl } = room;
const extractSecret = (urlString: string, type: string): string => { const extractSecret = (urlString: string, type: string): string => {
@ -180,13 +224,13 @@ export class RoomService {
case publisherSecret: case publisherSecret:
return ParticipantRole.PUBLISHER; return ParticipantRole.PUBLISHER;
default: default:
throw errorParticipantUnauthorized(roomName); throw errorParticipantUnauthorized(roomId);
} }
} }
async sendRoomStatusSignalToOpenViduComponents(roomName: string, participantSid: string) { async sendRoomStatusSignalToOpenViduComponents(roomId: string, participantSid: string) {
// Check if recording is started in the room // Check if recording is started in the room
const activeEgressArray = await this.livekitService.getActiveEgress(roomName); const activeEgressArray = await this.livekitService.getActiveEgress(roomId);
const isRecordingStarted = activeEgressArray.length > 0; const isRecordingStarted = activeEgressArray.length > 0;
// Skip if recording is not started // Skip if recording is not started
@ -200,61 +244,20 @@ export class RoomService {
participantSid participantSid
); );
await this.sendSignal(roomName, payload, options); await this.sendSignal(roomId, payload, options);
} }
/** /**
* Sends a signal to participants in a specified room. * Sends a signal to participants in a specified room.
* *
* @param roomName - The name of the room where the signal will be sent. * @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 rawData - The raw data to be sent as the signal.
* @param options - Options for sending the data, including the topic and destination identities. * @param options - Options for sending the data, including the topic and destination identities.
* @returns A promise that resolves when the signal has been sent. * @returns A promise that resolves when the signal has been sent.
*/ */
async sendSignal(roomName: string, rawData: any, options: SendDataOptions): Promise<void> { async sendSignal(roomId: string, rawData: Record<string, unknown>, options: SendDataOptions): Promise<void> {
this.logger.verbose(`Notifying participants in room ${roomName}: "${options.topic}".`); this.logger.verbose(`Notifying participants in room ${roomId}: "${options.topic}".`);
this.livekitService.sendData(roomName, rawData, options); this.livekitService.sendData(roomId, rawData, options);
}
/**
* Creates a Livekit room with the specified options.
*
* @param roomOptions - The options for creating the room.
* @returns A promise that resolves to the created room.
*/
protected async createLivekitRoom(roomOptions: MeetRoomOptions): Promise<Room> {
const livekitRoomOptions: CreateOptions = MeetRoomHelper.generateLivekitRoomOptions(roomOptions);
return this.livekitService.createRoom(livekitRoomOptions);
}
/**
* Converts a LiveKit room to an OpenVidu room.
*
* @param livekitRoom - The LiveKit room object containing metadata, name, and creation time.
* @param roomOptions - Options for the OpenVidu room including preferences and end date.
* @returns The converted OpenVidu room object.
* @throws Will throw an error if metadata is not found in the LiveKit room.
*/
protected generateOpenViduRoom(
baseUrl: string,
livekitRoom: Room,
roomOptions: MeetRoomOptions
): MeetRoom {
const { name: roomName, creationTime } = livekitRoom;
const { preferences, expirationDate, roomNamePrefix, maxParticipants } = roomOptions;
const openviduRoom: MeetRoom = {
roomName,
roomNamePrefix,
creationDate: Number(creationTime) * 1000,
maxParticipants,
expirationDate,
moderatorRoomUrl: `${baseUrl}/room/${roomName}?secret=${secureUid(10)}`,
publisherRoomUrl: `${baseUrl}/room/${roomName}?secret=${secureUid(10)}`,
preferences
};
return openviduRoom;
} }
/** /**
@ -274,7 +277,7 @@ export class RoomService {
} }
const livekitResults = await Promise.allSettled( const livekitResults = await Promise.allSettled(
ovExpiredRooms.map((roomName) => this.livekitService.deleteRoom(roomName)) ovExpiredRooms.map((roomId) => this.livekitService.deleteRoom(roomId))
); );
const successfulRooms: string[] = []; const successfulRooms: string[] = [];
@ -309,7 +312,7 @@ export class RoomService {
const rooms = await this.listOpenViduRooms(); const rooms = await this.listOpenViduRooms();
const expiredRooms = rooms const expiredRooms = rooms
.filter((room) => room.expirationDate && room.expirationDate < now) .filter((room) => room.expirationDate && room.expirationDate < now)
.map((room) => room.roomName); .map((room) => room.roomId);
if (expiredRooms.length === 0) { if (expiredRooms.length === 0) {
this.logger.verbose('No OpenVidu expired rooms to delete.'); this.logger.verbose('No OpenVidu expired rooms to delete.');
@ -353,7 +356,7 @@ export class RoomService {
} }
const missingRooms: MeetRoom[] = ovRooms.filter( const missingRooms: MeetRoom[] = ovRooms.filter(
(ovRoom) => !lkRooms.some((room) => room.name === ovRoom.roomName) (ovRoom) => !lkRooms.some((room) => room.name === ovRoom.roomId)
); );
if (missingRooms.length === 0) { if (missingRooms.length === 0) {
@ -364,17 +367,17 @@ export class RoomService {
this.logger.info(`Restoring ${missingRooms.length} missing rooms`); this.logger.info(`Restoring ${missingRooms.length} missing rooms`);
const creationResults = await Promise.allSettled( const creationResults = await Promise.allSettled(
missingRooms.map((ovRoom) => { missingRooms.map(({ roomId }: MeetRoom) => {
this.logger.debug(`Restoring room: ${ovRoom.roomName}`); this.logger.debug(`Restoring room: ${roomId}`);
this.createLivekitRoom(ovRoom); this.createLivekitRoom(roomId);
}) })
); );
creationResults.forEach((result, index) => { creationResults.forEach((result, index) => {
if (result.status === 'rejected') { if (result.status === 'rejected') {
this.logger.error(`Failed to restore room "${missingRooms[index].roomName}": ${result.reason}`); this.logger.error(`Failed to restore room "${missingRooms[index].roomId}": ${result.reason}`);
} else { } else {
this.logger.info(`Restored room "${missingRooms[index].roomName}"`); this.logger.info(`Restored room "${missingRooms[index].roomId}"`);
} }
}); });
} }