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 { 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);

View File

@ -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<MeetRecordingPreferences> = z.object({
enabled: z.boolean()
});
@ -46,8 +57,8 @@ const RoomPreferencesSchema: z.ZodType<MeetRoomPreferences> = z.object({
const RoomRequestOptionsSchema: z.ZodType<MeetRoomOptions> = 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();
};

View File

@ -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();

View File

@ -146,10 +146,17 @@ export class LivekitWebhookService {
*/
async handleMeetingFinished(room: Room): Promise<void> {
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}`);
}

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> {
let rooms: Room[] = [];

View File

@ -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<void> {
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;