diff --git a/backend/src/controllers/room.controller.ts b/backend/src/controllers/room.controller.ts index d160e8d..58112ad 100644 --- a/backend/src/controllers/room.controller.ts +++ b/backend/src/controllers/room.controller.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express'; import { LoggerService } from '../services/logger.service.js'; import { OpenViduMeetError } from '../models/error.model.js'; import { RoomService } from '../services/room.service.js'; -import { MeetRoomOptions } from '@typings-ce'; +import { MeetRoomFilters, MeetRoomOptions } from '@typings-ce'; export const createRoom = async (req: Request, res: Response) => { const logger = container.get(LoggerService); @@ -24,20 +24,15 @@ export const createRoom = async (req: Request, res: Response) => { export const getRooms = async (req: Request, res: Response) => { const logger = container.get(LoggerService); - const fields = req.query.fields as string[] | undefined; + const roomService = container.get(RoomService); + const queryParams = req.query as unknown as MeetRoomFilters; + + logger.verbose('Getting all rooms'); try { - logger.verbose('Getting rooms'); + const response = await roomService.getAllMeetRooms(queryParams); - const roomService = container.get(RoomService); - const rooms = await roomService.listOpenViduRooms(); - - if (fields && fields.length > 0) { - const filteredRooms = rooms.map((room) => filterObjectFields(room, fields)); - return res.status(200).json(filteredRooms); - } - - return res.status(200).json(rooms); + return res.status(200).json(response); } catch (error) { logger.error('Error getting rooms'); handleError(res, error); @@ -48,18 +43,13 @@ export const getRoom = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const { roomId } = req.params; - const fields = req.query.fields as string[] | undefined; + const fields = req.query.fields as string | undefined; try { logger.verbose(`Getting room with id '${roomId}'`); const roomService = container.get(RoomService); - const room = await roomService.getMeetRoom(roomId); - - if (fields && fields.length > 0) { - const filteredRoom = filterObjectFields(room, fields); - return res.status(200).json(filteredRoom); - } + const room = await roomService.getMeetRoom(roomId, fields); return res.status(200).json(room); } catch (error) { @@ -68,28 +58,38 @@ export const getRoom = async (req: Request, res: Response) => { } }; -export const deleteRooms = async (req: Request, res: Response) => { +export const deleteRoom = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const roomService = container.get(RoomService); const { roomId } = req.params; - const { roomIds } = req.body; - - const roomsToDelete = roomId ? [roomId] : roomIds; - - // TODO: Validate roomIds with ZOD - if (!Array.isArray(roomsToDelete) || roomsToDelete.length === 0) { - return res.status(400).json({ error: 'roomIds must be a non-empty array' }); - } try { - logger.verbose(`Deleting rooms: ${roomsToDelete.join(', ')}`); + logger.verbose(`Deleting room: ${roomId}`); - await roomService.deleteRooms(roomsToDelete); - logger.info(`Rooms deleted: ${roomsToDelete.join(', ')}`); - return res.status(200).json({ message: 'Rooms deleted', deletedRooms: roomsToDelete }); + await roomService.bulkDeleteRooms([roomId]); + logger.info(`Room deleted: ${roomId}`); + return res.status(204).json(); } catch (error) { - logger.error(`Error deleting rooms: ${roomsToDelete.join(', ')}`); + logger.error(`Error deleting room: ${roomId}`); + handleError(res, error); + } +}; + +export const bulkDeleteRooms = async (req: Request, res: Response) => { + const logger = container.get(LoggerService); + const roomService = container.get(RoomService); + const { roomIds } = req.query; + + logger.info(`Deleting rooms: ${roomIds}`); + + try { + const roomIdsArray = (roomIds as string).split(','); + await roomService.bulkDeleteRooms(roomIdsArray); + + return res.status(204).send(); + } catch (error) { + logger.error(`Error deleting rooms: ${error}`); handleError(res, error); } }; @@ -114,41 +114,19 @@ export const getParticipantRole = async (req: Request, res: Response) => { export const updateRoomPreferences = async (req: Request, res: Response) => { const logger = container.get(LoggerService); + const roomService = container.get(RoomService); + const roomPreferences = req.body; + const { roomId } = req.params; - logger.verbose(`Updating room preferences: ${JSON.stringify(req.body)}`); - // const { roomName, roomPreferences } = req.body; + logger.verbose(`Updating room preferences`); - // try { - // const preferenceService = container.get(GlobalPreferencesService); - // preferenceService.validateRoomPreferences(roomPreferences); - - // const savedPreferences = await preferenceService.updateOpenViduRoomPreferences(roomName, roomPreferences); - - // return res - // .status(200) - // .json({ message: 'Room preferences updated successfully.', preferences: savedPreferences }); - // } catch (error) { - // if (error instanceof OpenViduCallError) { - // logger.error(`Error saving room preferences: ${error.message}`); - // return res.status(error.statusCode).json({ name: error.name, message: error.message }); - // } - - // logger.error('Error saving room preferences:' + error); - // return res.status(500).json({ message: 'Error saving room preferences', error }); - // } -}; - -const filterObjectFields = (obj: Record, fields: string[]): Record => { - return fields.reduce( - (acc, field) => { - if (Object.prototype.hasOwnProperty.call(obj, field)) { - acc[field] = obj[field]; - } - - return acc; - }, - {} as Record - ); + try { + const room = await roomService.updateMeetRoomPreferences(roomId, roomPreferences); + return res.status(200).json(room); + } catch (error) { + logger.error(`Error saving room preferences: ${error}`); + handleError(res, error); + } }; const handleError = (res: Response, error: OpenViduMeetError | unknown) => { diff --git a/backend/src/helpers/utils.helper.ts b/backend/src/helpers/utils.helper.ts new file mode 100644 index 0000000..9211648 --- /dev/null +++ b/backend/src/helpers/utils.helper.ts @@ -0,0 +1,18 @@ +export class UtilsHelper { + private constructor() { + // Prevent instantiation of this utility class + } + + static filterObjectFields = (obj: Record, fields: string[]): Record => { + return fields.reduce( + (acc, field) => { + if (Object.prototype.hasOwnProperty.call(obj, field)) { + acc[field] = obj[field]; + } + + return acc; + }, + {} as Record + ); + }; +} diff --git a/backend/src/middlewares/request-validators/room-validator.middleware.ts b/backend/src/middlewares/request-validators/room-validator.middleware.ts index 0adf10e..1c90e80 100644 --- a/backend/src/middlewares/request-validators/room-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/room-validator.middleware.ts @@ -3,11 +3,28 @@ import { MeetRoomOptions, MeetRecordingPreferences, MeetRoomPreferences, - MeetVirtualBackgroundPreferences + MeetVirtualBackgroundPreferences, + MeetRoomFilters } from '@typings-ce'; import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; +const sanitizeId = (val: string): string => { + return val + .trim() // Remove leading and trailing spaces + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/[^a-zA-Z0-9_-]/g, ''); // Remove special characters (allow alphanumeric, hyphens and underscores) +}; + +const nonEmptySanitizedString = (fieldName: string) => + z + .string() + .min(1, { message: `${fieldName} is required and cannot be empty` }) + .transform(sanitizeId) + .refine((data) => data !== '', { + message: `${fieldName} cannot be empty after sanitization` + }); + const RecordingPreferencesSchema: z.ZodType = z.object({ enabled: z.boolean() }); @@ -61,6 +78,33 @@ const GetParticipantRoleSchema = z.object({ secret: z.string() }); +const GetRoomFiltersSchema: z.ZodType = z.object({ + maxItems: z.coerce + .number() + .int() + .transform((val) => (val > 100 ? 100 : val)) + .default(10), + nextPageToken: z.string().optional(), + fields: z.string().optional() +}); + +const BulkDeleteRoomsSchema = z.object({ + roomIds: z.preprocess( + (arg) => { + if (typeof arg === 'string') { + // If the argument is a string, it is expected to be a comma-separated list of recording IDs. + return arg + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''); + } + + return arg; + }, + z.array(nonEmptySanitizedString('recordingId')).default([]) + ) +}); + export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => { const { success, error, data } = RoomRequestOptionsSchema.safeParse(req.body); @@ -72,14 +116,40 @@ export const withValidRoomOptions = (req: Request, res: Response, next: NextFunc next(); }; -export const validateGetRoomQueryParams = (req: Request, res: Response, next: NextFunction) => { - const fieldsQuery = req.query.fields as string | undefined; +export const withValidRoomFiltersRequest = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = GetRoomFiltersSchema.safeParse(req.query); - if (fieldsQuery) { - const fields = fieldsQuery.split(',').map((f) => f.trim()); - req.query.fields = fields; + if (!success) { + return rejectRequest(res, error); } + req.query = { + ...data, + maxItems: data.maxItems?.toString() + }; + + next(); +}; + +export const withValidRoomPreferences = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = RoomPreferencesSchema.safeParse(req.body); + + if (!success) { + return rejectRequest(res, error); + } + + req.body = data; + next(); +}; + +export const withValidRoomBulkDeleteRequest = (req: Request, res: Response, next: NextFunction) => { + const { success, error, data } = BulkDeleteRoomsSchema.safeParse(req.query); + + if (!success) { + return rejectRequest(res, error); + } + + req.query.roomIds = data.roomIds.join(','); next(); }; @@ -87,16 +157,7 @@ export const validateGetParticipantRoleRequest = (req: Request, res: Response, n const { success, error, data } = GetParticipantRoleSchema.safeParse(req.query); if (!success) { - const errors = error.errors.map((error) => ({ - field: error.path.join('.'), - message: error.message - })); - - return res.status(422).json({ - error: 'Unprocessable Entity', - message: 'Invalid request query', - details: errors - }); + return rejectRequest(res, error); } req.query = data; diff --git a/backend/src/models/redis.model.ts b/backend/src/models/redis.model.ts index da3e926..94e2ff4 100644 --- a/backend/src/models/redis.model.ts +++ b/backend/src/models/redis.model.ts @@ -3,7 +3,8 @@ export const enum RedisKeyPrefix { } export const enum RedisKeyName { - GLOBAL_PREFERENCES = `${RedisKeyPrefix.BASE}global_preferences` + GLOBAL_PREFERENCES = `${RedisKeyPrefix.BASE}global_preferences`, + ROOM = `${RedisKeyPrefix.BASE}room:`, } export const enum RedisLockPrefix { diff --git a/backend/src/routes/room.routes.ts b/backend/src/routes/room.routes.ts index df5d5b2..e542ebd 100644 --- a/backend/src/routes/room.routes.ts +++ b/backend/src/routes/room.routes.ts @@ -7,10 +7,12 @@ import { apiKeyValidator, participantTokenValidator, validateGetParticipantRoleRequest, - validateGetRoomQueryParams, + withValidRoomFiltersRequest, withValidRoomOptions, configureCreateRoomAuth, - configureRoomAuthorization + configureRoomAuthorization, + withValidRoomPreferences, + withValidRoomBulkDeleteRequest } from '../middlewares/index.js'; import { UserRole } from '@typings-ce'; @@ -21,31 +23,37 @@ roomRouter.use(bodyParser.json()); // Room Routes roomRouter.post('/', configureCreateRoomAuth, withValidRoomOptions, roomCtrl.createRoom); - - - - roomRouter.get( '/', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), - validateGetRoomQueryParams, + withValidRoomFiltersRequest, roomCtrl.getRooms ); +roomRouter.delete( + '/', + withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), + withValidRoomBulkDeleteRequest, + roomCtrl.bulkDeleteRooms +); roomRouter.get( '/:roomId', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), participantTokenValidator), configureRoomAuthorization, - validateGetRoomQueryParams, roomCtrl.getRoom ); -roomRouter.delete('/:roomId', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), roomCtrl.deleteRooms); - -// Room preferences -roomRouter.put('/', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), roomCtrl.updateRoomPreferences); +roomRouter.delete('/:roomId', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), roomCtrl.deleteRoom); // Internal room routes export const internalRoomRouter = Router(); internalRoomRouter.use(bodyParser.urlencoded({ extended: true })); internalRoomRouter.use(bodyParser.json()); +// Room preferences +internalRoomRouter.put( + '/:roomId', + withAuth(tokenAndRoleValidator(UserRole.ADMIN)), + withValidRoomPreferences, + roomCtrl.updateRoomPreferences +); + internalRoomRouter.get('/:roomId/participant-role', validateGetParticipantRoleRequest, roomCtrl.getParticipantRole);