diff --git a/backend/src/controllers/room.controller.ts b/backend/src/controllers/room.controller.ts index 58112ad..f5d5558 100644 --- a/backend/src/controllers/room.controller.ts +++ b/backend/src/controllers/room.controller.ts @@ -63,13 +63,19 @@ export const deleteRoom = async (req: Request, res: Response) => { const roomService = container.get(RoomService); const { roomId } = req.params; + const { force } = req.query; + const forceDelete = force === 'true'; try { logger.verbose(`Deleting room: ${roomId}`); - await roomService.bulkDeleteRooms([roomId]); - logger.info(`Room deleted: ${roomId}`); - return res.status(204).json(); + const { deleted } = await roomService.bulkDeleteRooms([roomId], forceDelete); + + if (deleted.length > 0) { + return res.status(204).send(); + } + + return res.status(202).json({ message: `Room ${roomId} marked as deleted` }); } catch (error) { logger.error(`Error deleting room: ${roomId}`); handleError(res, error); @@ -79,15 +85,26 @@ export const deleteRoom = async (req: Request, res: Response) => { export const bulkDeleteRooms = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const roomService = container.get(RoomService); - const { roomIds } = req.query; - + const { roomIds, force } = req.query; + const forceDelete = force === 'true'; logger.info(`Deleting rooms: ${roomIds}`); try { const roomIdsArray = (roomIds as string).split(','); - await roomService.bulkDeleteRooms(roomIdsArray); + const { deleted, markedAsDeleted } = await roomService.bulkDeleteRooms(roomIdsArray, forceDelete); - return res.status(204).send(); + if (roomIdsArray.length === 1) { + if (deleted.length > 0) { + return res.status(204).send(); + } + + return res.status(202).json({ message: `Room ${roomIds} marked as deleted` }); + } + + return res.status(200).json({ + deleted, + markedAsDeleted + }); } catch (error) { logger.error(`Error deleting rooms: ${error}`); handleError(res, error); diff --git a/backend/src/middlewares/request-validators/room-validator.middleware.ts b/backend/src/middlewares/request-validators/room-validator.middleware.ts index 90e2265..ac26377 100644 --- a/backend/src/middlewares/request-validators/room-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/room-validator.middleware.ts @@ -25,6 +25,17 @@ const nonEmptySanitizedString = (fieldName: string) => message: `${fieldName} cannot be empty after sanitization` }); +const validForceQueryParam = () => + z + .preprocess((val) => { + if (typeof val === 'string') { + return val.toLowerCase() === 'true'; + } + + return val; + }, z.boolean()) + .default(false); + const RecordingPreferencesSchema: z.ZodType = z.object({ enabled: z.boolean() }); @@ -46,8 +57,8 @@ const RoomPreferencesSchema: z.ZodType = z.object({ const RoomRequestOptionsSchema: z.ZodType = z.object({ autoDeletionDate: z .number() - .positive('Expiration date must be a positive integer') - .refine((date) => date >= Date.now() + 60 * 60 * 1000, 'Expiration date must be at least 1 hour in the future') + .positive('autoDeletionDate must be a positive integer') + .refine((date) => date >= Date.now() + 60 * 60 * 1000, 'autoDeletionDate must be at least 1 hour in the future') .optional(), roomIdPrefix: z .string() @@ -103,7 +114,8 @@ const BulkDeleteRoomsSchema = z.object({ return arg; }, z.array(nonEmptySanitizedString('roomId')).default([]) - ) + ), + force: validForceQueryParam() }); export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => { @@ -162,6 +174,27 @@ export const withValidRoomBulkDeleteRequest = (req: Request, res: Response, next } req.query.roomIds = data.roomIds.join(','); + req.query.force = data.force ? 'true' : 'false'; + next(); +}; + +export const withValidRoomDeleteRequest = (req: Request, res: Response, next: NextFunction) => { + const roomIdResult = nonEmptySanitizedString('roomId').safeParse(req.params.roomId); + + if (!roomIdResult.success) { + return rejectRequest(res, roomIdResult.error); + } + + req.params.roomId = roomIdResult.data; + + const forceResult = validForceQueryParam().safeParse(req.query.force); + + if (!forceResult.success) { + return rejectRequest(res, forceResult.error); + } + + req.query.force = forceResult.data ? 'true' : 'false'; + next(); }; diff --git a/backend/src/routes/room.routes.ts b/backend/src/routes/room.routes.ts index 60e6c2f..0fc6e26 100644 --- a/backend/src/routes/room.routes.ts +++ b/backend/src/routes/room.routes.ts @@ -13,7 +13,8 @@ import { configureRoomAuthorization, withValidRoomPreferences, withValidRoomBulkDeleteRequest, - withValidRoomId + withValidRoomId, + withValidRoomDeleteRequest } from '../middlewares/index.js'; import { UserRole } from '@typings-ce'; @@ -43,7 +44,12 @@ roomRouter.get( withValidRoomId, roomCtrl.getRoom ); -roomRouter.delete('/:roomId', withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), roomCtrl.deleteRoom); +roomRouter.delete( + '/:roomId', + withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)), + withValidRoomDeleteRequest, + roomCtrl.deleteRoom +); // Internal room routes export const internalRoomRouter = Router(); diff --git a/backend/src/services/livekit-webhook.service.ts b/backend/src/services/livekit-webhook.service.ts index da27b07..61c3efa 100644 --- a/backend/src/services/livekit-webhook.service.ts +++ b/backend/src/services/livekit-webhook.service.ts @@ -146,10 +146,17 @@ export class LivekitWebhookService { */ async handleMeetingFinished(room: Room): Promise { try { - await Promise.all([ + const [meetRoom] = await Promise.all([ + this.roomService.getMeetRoom(room.name), this.recordingService.releaseRoomRecordingActiveLock(room.name), this.openViduWebhookService.sendRoomFinishedWebhook(room) ]); + + if (meetRoom.markedForDeletion) { + // If the room is marked for deletion, we need to delete it + this.logger.info(`Deleting room ${room.name} after meeting finished because it was marked for deletion`); + this.roomService.bulkDeleteRooms([room.name], true); + } } catch (error) { this.logger.error(`Error handling room finished event: ${error}`); } diff --git a/backend/src/services/livekit.service.ts b/backend/src/services/livekit.service.ts index 33bc576..54cc230 100644 --- a/backend/src/services/livekit.service.ts +++ b/backend/src/services/livekit.service.ts @@ -75,6 +75,22 @@ export class LiveKitService { } } + /** + * Checks if a LiveKit room has at least one participant. + * + * @param roomName - The name of the room to check + * @returns A promise that resolves to true if the room has at least one participant, + * or false if the room has no participants or if an error occurs + */ + async roomHasParticipants(roomName: string): Promise { + try { + const participants = await this.roomClient.listParticipants(roomName); + return participants.length > 0; + } catch (error) { + return false; + } + } + async getRoom(roomName: string): Promise { let rooms: Room[] = []; diff --git a/backend/src/services/room.service.ts b/backend/src/services/room.service.ts index f5c98e4..8d34440 100644 --- a/backend/src/services/room.service.ts +++ b/backend/src/services/room.service.ts @@ -8,7 +8,7 @@ import { MeetRoom, MeetRoomFilters, MeetRoomOptions, MeetRoomPreferences, Partic import { MeetRoomHelper } from '../helpers/room.helper.js'; import { SystemEventService } from './system-event.service.js'; import { IScheduledTask, TaskSchedulerService } from './task-scheduler.service.js'; -import { errorParticipantUnauthorized } from '../models/error.model.js'; +import { errorParticipantUnauthorized, internalError } from '../models/error.model.js'; import { OpenViduComponentsAdapterHelper } from '../helpers/index.js'; import { uid } from 'uid/single'; import { MEET_NAME_ID, MEET_ROOM_GC_INTERVAL } from '../environment.js'; @@ -159,27 +159,63 @@ export class RoomService { } /** - * Bulk deletes rooms from both storage (e.g., S3) and LiveKit. + * Deletes a room by its ID. * - * @param roomIds - An array with the IDs of the rooms to delete. + * @param roomId - The unique identifier of the room to delete. + * @param forceDelete - Whether to force delete the room even if it has participants. + * @returns A promise that resolves to an object containing the deleted and marked rooms. */ - async bulkDeleteRooms(roomIds: string[]): Promise { + async bulkDeleteRooms( + roomIds: string[], + forceDelete: boolean + ): Promise<{ deleted: string[]; markedAsDeleted: string[] }> { try { - // Delete rooms from storage and LiveKit in parallel - const meetRoomPromise = this.storageService.deleteMeetRooms(roomIds); - const livekitResults = await Promise.allSettled( - roomIds.map((roomId) => this.livekitService.deleteRoom(roomId)) + const results = await Promise.allSettled( + roomIds.map(async (roomId) => { + const hasParticipants = await this.livekitService.roomHasParticipants(roomId); + const shouldDelete = forceDelete || !hasParticipants; + + if (shouldDelete) { + this.logger.verbose(`Deleting room ${roomId}.`); + + await Promise.all([ + this.storageService.deleteMeetRooms([roomId]), + this.livekitService.deleteRoom(roomId) + ]); + + return { roomId, status: 'deleted' } as const; + } + + this.logger.verbose(`Room ${roomId} has participants. Marking as deleted (graceful deletion).`); + // Mark room as deleted + const room = await this.storageService.getMeetRoom(roomId); + room.markedForDeletion = true; + await this.storageService.saveMeetRoom(room); + return { roomId, status: 'marked' } as const; + }) ); - // Wait for the meet rooms deletion to complete - await meetRoomPromise; + const deleted: string[] = []; + const markedAsDeleted: string[] = []; - // Log results of LiveKit room deletions - livekitResults.forEach((result, index) => { - if (result.status === 'rejected') { - this.logger.error(`Failed to delete LiveKit room "${roomIds[index]}": ${result.reason}`); + results.forEach((result) => { + if (result.status === 'fulfilled') { + if (result.value.status === 'deleted') { + deleted.push(result.value.roomId); + } else if (result.value.status === 'marked') { + markedAsDeleted.push(result.value.roomId); + } + } else { + this.logger.error(`Failed to process deletion for a room: ${result.reason}`); } }); + + if (deleted.length === 0 && markedAsDeleted.length === 0) { + this.logger.error('No rooms were deleted or marked as deleted.'); + throw internalError('No rooms were deleted or marked as deleted.'); + } + + return { deleted, markedAsDeleted }; } catch (error) { this.logger.error('Error deleting rooms:', error); throw error;