backend: Enhance room deletion functionality with force delete option and improved response handling

This commit is contained in:
Carlos Santos 2025-04-09 12:06:19 +02:00
parent 14d5637151
commit ac12841418
6 changed files with 142 additions and 27 deletions

View File

@ -63,13 +63,19 @@ export const deleteRoom = async (req: Request, res: Response) => {
const roomService = container.get(RoomService); const roomService = container.get(RoomService);
const { roomId } = req.params; const { roomId } = req.params;
const { force } = req.query;
const forceDelete = force === 'true';
try { try {
logger.verbose(`Deleting room: ${roomId}`); logger.verbose(`Deleting room: ${roomId}`);
await roomService.bulkDeleteRooms([roomId]); const { deleted } = await roomService.bulkDeleteRooms([roomId], forceDelete);
logger.info(`Room deleted: ${roomId}`);
return res.status(204).json(); if (deleted.length > 0) {
return res.status(204).send();
}
return res.status(202).json({ message: `Room ${roomId} marked as deleted` });
} catch (error) { } catch (error) {
logger.error(`Error deleting room: ${roomId}`); logger.error(`Error deleting room: ${roomId}`);
handleError(res, error); handleError(res, error);
@ -79,15 +85,26 @@ export const deleteRoom = async (req: Request, res: Response) => {
export const bulkDeleteRooms = async (req: Request, res: Response) => { export const bulkDeleteRooms = 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 { roomIds } = req.query; const { roomIds, force } = req.query;
const forceDelete = force === 'true';
logger.info(`Deleting rooms: ${roomIds}`); logger.info(`Deleting rooms: ${roomIds}`);
try { try {
const roomIdsArray = (roomIds as string).split(','); 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) { } catch (error) {
logger.error(`Error deleting rooms: ${error}`); logger.error(`Error deleting rooms: ${error}`);
handleError(res, error); handleError(res, error);

View File

@ -25,6 +25,17 @@ const nonEmptySanitizedString = (fieldName: string) =>
message: `${fieldName} cannot be empty after sanitization` 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<MeetRecordingPreferences> = z.object({ const RecordingPreferencesSchema: z.ZodType<MeetRecordingPreferences> = z.object({
enabled: z.boolean() enabled: z.boolean()
}); });
@ -46,8 +57,8 @@ const RoomPreferencesSchema: z.ZodType<MeetRoomPreferences> = z.object({
const RoomRequestOptionsSchema: z.ZodType<MeetRoomOptions> = z.object({ const RoomRequestOptionsSchema: z.ZodType<MeetRoomOptions> = z.object({
autoDeletionDate: z autoDeletionDate: z
.number() .number()
.positive('Expiration date must be a positive integer') .positive('autoDeletionDate must be a positive integer')
.refine((date) => date >= Date.now() + 60 * 60 * 1000, 'Expiration date must be at least 1 hour in the future') .refine((date) => date >= Date.now() + 60 * 60 * 1000, 'autoDeletionDate must be at least 1 hour in the future')
.optional(), .optional(),
roomIdPrefix: z roomIdPrefix: z
.string() .string()
@ -103,7 +114,8 @@ const BulkDeleteRoomsSchema = z.object({
return arg; return arg;
}, },
z.array(nonEmptySanitizedString('roomId')).default([]) z.array(nonEmptySanitizedString('roomId')).default([])
) ),
force: validForceQueryParam()
}); });
export const withValidRoomOptions = (req: Request, res: Response, next: NextFunction) => { 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.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(); next();
}; };

View File

@ -13,7 +13,8 @@ import {
configureRoomAuthorization, configureRoomAuthorization,
withValidRoomPreferences, withValidRoomPreferences,
withValidRoomBulkDeleteRequest, withValidRoomBulkDeleteRequest,
withValidRoomId withValidRoomId,
withValidRoomDeleteRequest
} from '../middlewares/index.js'; } from '../middlewares/index.js';
import { UserRole } from '@typings-ce'; import { UserRole } from '@typings-ce';
@ -43,7 +44,12 @@ roomRouter.get(
withValidRoomId, withValidRoomId,
roomCtrl.getRoom 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 // Internal room routes
export const internalRoomRouter = Router(); export const internalRoomRouter = Router();

View File

@ -146,10 +146,17 @@ export class LivekitWebhookService {
*/ */
async handleMeetingFinished(room: Room): Promise<void> { async handleMeetingFinished(room: Room): Promise<void> {
try { try {
await Promise.all([ const [meetRoom] = await Promise.all([
this.roomService.getMeetRoom(room.name),
this.recordingService.releaseRoomRecordingActiveLock(room.name), this.recordingService.releaseRoomRecordingActiveLock(room.name),
this.openViduWebhookService.sendRoomFinishedWebhook(room) 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) { } catch (error) {
this.logger.error(`Error handling room finished event: ${error}`); this.logger.error(`Error handling room finished event: ${error}`);
} }

View File

@ -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<boolean> {
try {
const participants = await this.roomClient.listParticipants(roomName);
return participants.length > 0;
} catch (error) {
return false;
}
}
async getRoom(roomName: string): Promise<Room> { async getRoom(roomName: string): Promise<Room> {
let rooms: Room[] = []; let rooms: Room[] = [];

View File

@ -8,7 +8,7 @@ import { MeetRoom, MeetRoomFilters, MeetRoomOptions, MeetRoomPreferences, Partic
import { MeetRoomHelper } from '../helpers/room.helper.js'; import { MeetRoomHelper } from '../helpers/room.helper.js';
import { SystemEventService } from './system-event.service.js'; import { SystemEventService } from './system-event.service.js';
import { IScheduledTask, TaskSchedulerService } from './task-scheduler.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 { OpenViduComponentsAdapterHelper } from '../helpers/index.js';
import { uid } from 'uid/single'; import { uid } from 'uid/single';
import { MEET_NAME_ID, MEET_ROOM_GC_INTERVAL } from '../environment.js'; 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<void> { async bulkDeleteRooms(
roomIds: string[],
forceDelete: boolean
): Promise<{ deleted: string[]; markedAsDeleted: string[] }> {
try { try {
// Delete rooms from storage and LiveKit in parallel const results = await Promise.allSettled(
const meetRoomPromise = this.storageService.deleteMeetRooms(roomIds); roomIds.map(async (roomId) => {
const livekitResults = await Promise.allSettled( const hasParticipants = await this.livekitService.roomHasParticipants(roomId);
roomIds.map((roomId) => this.livekitService.deleteRoom(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 const deleted: string[] = [];
await meetRoomPromise; const markedAsDeleted: string[] = [];
// Log results of LiveKit room deletions results.forEach((result) => {
livekitResults.forEach((result, index) => { if (result.status === 'fulfilled') {
if (result.status === 'rejected') { if (result.value.status === 'deleted') {
this.logger.error(`Failed to delete LiveKit room "${roomIds[index]}": ${result.reason}`); 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) { } catch (error) {
this.logger.error('Error deleting rooms:', error); this.logger.error('Error deleting rooms:', error);
throw error; throw error;