backend: enhance room deletion logic with new policies for meetings and recordings

This commit is contained in:
juancarmore 2025-09-02 11:45:20 +02:00
parent 632d36a470
commit 4802f48ba6
7 changed files with 462 additions and 219 deletions

View File

@ -61,10 +61,10 @@ export const registerDependencies = () => {
container.bind(FrontendEventService).toSelf().inSingletonScope(); container.bind(FrontendEventService).toSelf().inSingletonScope();
container.bind(LiveKitService).toSelf().inSingletonScope(); container.bind(LiveKitService).toSelf().inSingletonScope();
container.bind(RecordingService).toSelf().inSingletonScope();
container.bind(RoomService).toSelf().inSingletonScope(); container.bind(RoomService).toSelf().inSingletonScope();
container.bind(ParticipantNameService).toSelf().inSingletonScope(); container.bind(ParticipantNameService).toSelf().inSingletonScope();
container.bind(ParticipantService).toSelf().inSingletonScope(); container.bind(ParticipantService).toSelf().inSingletonScope();
container.bind(RecordingService).toSelf().inSingletonScope();
container.bind(OpenViduWebhookService).toSelf().inSingletonScope(); container.bind(OpenViduWebhookService).toSelf().inSingletonScope();
container.bind(LivekitWebhookService).toSelf().inSingletonScope(); container.bind(LivekitWebhookService).toSelf().inSingletonScope();
}; };

View File

@ -1,4 +1,12 @@
import { MeetRoomFilters, MeetRoomOptions, MeetRoomRoleAndPermissions, ParticipantRole } from '@typings-ce'; import {
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomDeletionSuccessCode,
MeetRoomFilters,
MeetRoomOptions,
MeetRoomRoleAndPermissions,
ParticipantRole
} from '@typings-ce';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { container } from '../config/index.js'; import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js'; import INTERNAL_CONFIG from '../config/internal-config.js';
@ -63,21 +71,26 @@ 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 { withMeeting, withRecordings } = req.query as {
const forceDelete = force === 'true'; withMeeting: MeetRoomDeletionPolicyWithMeeting;
withRecordings: MeetRoomDeletionPolicyWithRecordings;
};
try { try {
logger.verbose(`Deleting room '${roomId}'`); logger.verbose(`Deleting room '${roomId}'`);
const response = await roomService.deleteMeetRoom(roomId, withMeeting, withRecordings);
const { deleted } = await roomService.bulkDeleteRooms([roomId], forceDelete); // Determine the status code based on the success code
// If the room action is scheduled, return 202. Otherwise, return 200.
const scheduledSuccessCodes = [
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED,
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_CLOSED,
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_AND_RECORDINGS_SCHEDULED_TO_BE_DELETED
];
const statusCode = scheduledSuccessCodes.includes(response.successCode) ? 202 : 200;
if (deleted.length > 0) { logger.info(response.message);
// Room was deleted return res.status(statusCode).json(response);
return res.status(204).send();
}
// Room was marked as deleted
return res.status(202).json({ message: `Room '${roomId}' marked for deletion` });
} catch (error) { } catch (error) {
handleError(res, error, `deleting room '${roomId}'`); handleError(res, error, `deleting room '${roomId}'`);
} }
@ -86,34 +99,30 @@ 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, force } = req.query;
const forceDelete = force === 'true'; const { roomIds, withMeeting, withRecordings } = req.query as {
logger.verbose(`Deleting rooms: ${roomIds}`); roomIds: string[];
withMeeting: MeetRoomDeletionPolicyWithMeeting;
withRecordings: MeetRoomDeletionPolicyWithRecordings;
};
try { try {
const roomIdsArray = roomIds as string[]; logger.verbose(`Deleting rooms: ${roomIds}`);
const { successful, failed } = await roomService.bulkDeleteMeetRooms(roomIds, withMeeting, withRecordings);
const { deleted, markedForDeletion } = await roomService.bulkDeleteRooms(roomIdsArray, forceDelete); logger.info(
`Bulk delete operation - Successfully processed rooms: ${successful.length}, failed to process: ${failed.length}`
);
logger.info(`Deleted rooms: ${deleted.length}, marked for deletion: ${markedForDeletion.length}`); if (failed.length === 0) {
// All rooms were successfully processed
// All rooms were deleted return res.status(200).json({ message: 'All rooms successfully processed for deletion', successful });
if (deleted.length > 0 && markedForDeletion.length === 0) { } else {
return res.sendStatus(204); // Some rooms failed to process
return res
.status(400)
.json({ message: `${failed.length} room(s) failed to process while deleting`, successful, failed });
} }
// All room were marked for deletion
if (deleted.length === 0 && markedForDeletion.length > 0) {
const message =
markedForDeletion.length === 1
? `Room '${markedForDeletion[0]}' marked for deletion`
: `Rooms '${markedForDeletion.join(', ')}' marked for deletion`;
return res.status(202).json({ message });
}
// Mixed result (some rooms deleted, some marked for deletion)
return res.status(200).json({ deleted, markedForDeletion });
} catch (error) { } catch (error) {
handleError(res, error, `deleting rooms`); handleError(res, error, `deleting rooms`);
} }

View File

@ -1,7 +1,8 @@
import { MeetRoomDeletionErrorCode } from '@typings-ce';
import { Response } from 'express'; import { Response } from 'express';
import { z } from 'zod';
import { container } from '../config/index.js'; import { container } from '../config/index.js';
import { LoggerService } from '../services/index.js'; import { LoggerService } from '../services/index.js';
import { z } from 'zod';
type StatusError = 400 | 401 | 402 | 403 | 404 | 409 | 415 | 416 | 422 | 500 | 503; type StatusError = 400 | 401 | 402 | 403 | 404 | 409 | 415 | 416 | 422 | 500 | 503;
export class OpenViduMeetError extends Error { export class OpenViduMeetError extends Error {
@ -220,6 +221,10 @@ export const errorInvalidRoomSecret = (roomId: string, secret: string): OpenVidu
return new OpenViduMeetError('Room Error', `Secret '${secret}' is not recognized for room '${roomId}'`, 400); return new OpenViduMeetError('Room Error', `Secret '${secret}' is not recognized for room '${roomId}'`, 400);
}; };
export const errorDeletingRoom = (errorCode: MeetRoomDeletionErrorCode, message: string): OpenViduMeetError => {
return new OpenViduMeetError(errorCode, message, 409);
};
// Participant errors // Participant errors
export const errorParticipantNotFound = (participantIdentity: string, roomId: string): OpenViduMeetError => { export const errorParticipantNotFound = (participantIdentity: string, roomId: string): OpenViduMeetError => {

View File

@ -12,9 +12,9 @@ export * from './auth.service.js';
export * from './livekit.service.js'; export * from './livekit.service.js';
export * from './frontend-event.service.js'; export * from './frontend-event.service.js';
export * from './recording.service.js';
export * from './room.service.js'; export * from './room.service.js';
export * from './participant-name.service.js'; export * from './participant-name.service.js';
export * from './participant.service.js'; export * from './participant.service.js';
export * from './recording.service.js';
export * from './openvidu-webhook.service.js'; export * from './openvidu-webhook.service.js';
export * from './livekit-webhook.service.js'; export * from './livekit-webhook.service.js';

View File

@ -31,7 +31,6 @@ import {
MeetStorageService, MeetStorageService,
MutexService, MutexService,
RedisLock, RedisLock,
RoomService,
TaskSchedulerService TaskSchedulerService
} from './index.js'; } from './index.js';
@ -39,7 +38,6 @@ import {
export class RecordingService { export class RecordingService {
constructor( constructor(
@inject(LiveKitService) protected livekitService: LiveKitService, @inject(LiveKitService) protected livekitService: LiveKitService,
@inject(RoomService) protected roomService: RoomService,
@inject(MutexService) protected mutexService: MutexService, @inject(MutexService) protected mutexService: MutexService,
@inject(TaskSchedulerService) protected taskSchedulerService: TaskSchedulerService, @inject(TaskSchedulerService) protected taskSchedulerService: TaskSchedulerService,
@inject(DistributedEventService) protected systemEventService: DistributedEventService, @inject(DistributedEventService) protected systemEventService: DistributedEventService,
@ -552,6 +550,22 @@ export class RecordingService {
} }
} }
/**
* Helper method to check if a room has recordings
*
* @param roomId - The ID of the room to check
* @returns A promise that resolves to true if the room has recordings, false otherwise
*/
async hasRoomRecordings(roomId: string): Promise<boolean> {
try {
const response = await this.storageService.getAllRecordings(roomId, 1);
return response.recordings.length > 0;
} catch (error) {
this.logger.warn(`Error checking recordings for room '${roomId}': ${error}`);
return false;
}
}
async getRecordingAsStream( async getRecordingAsStream(
recordingId: string, recordingId: string,
rangeHeader?: string rangeHeader?: string
@ -584,7 +598,7 @@ export class RecordingService {
} }
protected async validateRoomForStartRecording(roomId: string): Promise<void> { protected async validateRoomForStartRecording(roomId: string): Promise<void> {
const room = await this.roomService.getMeetRoom(roomId); const room = await this.storageService.getMeetRoom(roomId);
if (!room) throw errorRoomNotFound(roomId); if (!room) throw errorRoomNotFound(roomId);

View File

@ -2,6 +2,10 @@ import {
MeetingEndAction, MeetingEndAction,
MeetRecordingAccess, MeetRecordingAccess,
MeetRoom, MeetRoom,
MeetRoomDeletionErrorCode,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomDeletionSuccessCode,
MeetRoomFilters, MeetRoomFilters,
MeetRoomOptions, MeetRoomOptions,
MeetRoomPreferences, MeetRoomPreferences,
@ -19,10 +23,12 @@ import { MEET_NAME_ID } from '../environment.js';
import { MeetRoomHelper, UtilsHelper } from '../helpers/index.js'; import { MeetRoomHelper, UtilsHelper } from '../helpers/index.js';
import { validateRecordingTokenMetadata } from '../middlewares/index.js'; import { validateRecordingTokenMetadata } from '../middlewares/index.js';
import { import {
errorDeletingRoom,
errorInvalidRoomSecret, errorInvalidRoomSecret,
errorRoomMetadataNotFound, errorRoomMetadataNotFound,
errorRoomNotFound, errorRoomNotFound,
internalError internalError,
OpenViduMeetError
} from '../models/error.model.js'; } from '../models/error.model.js';
import { import {
DistributedEventService, DistributedEventService,
@ -31,6 +37,7 @@ import {
LiveKitService, LiveKitService,
LoggerService, LoggerService,
MeetStorageService, MeetStorageService,
RecordingService,
TaskSchedulerService, TaskSchedulerService,
TokenService TokenService
} from './index.js'; } from './index.js';
@ -46,6 +53,7 @@ export class RoomService {
constructor( constructor(
@inject(LoggerService) protected logger: LoggerService, @inject(LoggerService) protected logger: LoggerService,
@inject(MeetStorageService) protected storageService: MeetStorageService, @inject(MeetStorageService) protected storageService: MeetStorageService,
@inject(RecordingService) protected recordingService: RecordingService,
@inject(LiveKitService) protected livekitService: LiveKitService, @inject(LiveKitService) protected livekitService: LiveKitService,
@inject(DistributedEventService) protected distributedEventService: DistributedEventService, @inject(DistributedEventService) protected distributedEventService: DistributedEventService,
@inject(FrontendEventService) protected frontendEventService: FrontendEventService, @inject(FrontendEventService) protected frontendEventService: FrontendEventService,
@ -235,45 +243,382 @@ export class RoomService {
} }
/** /**
* Deletes multiple rooms in bulk, with the option to force delete or gracefully handle rooms with active participants. * Deletes a room based on the specified policies for handling active meetings and recordings.
* For rooms with participants, when `forceDelete` is false, the method performs a "graceful deletion"
* by marking the room for deletion without disrupting active sessions.
* However, if `forceDelete` is true, it will also end the meetings by removing the rooms from LiveKit.
* *
* @param roomIds - Array of room identifiers to be deleted * @param roomId - The unique identifier of the room to delete
* @param forceDelete - If true, deletes rooms even if they have active participants. * @param withMeeting - Policy for handling rooms with active meetings
* If false, rooms with participants will be marked for deletion instead of being deleted immediately. * @param withRecordings - Policy for handling rooms with recordings
* @returns Promise with deletion result including status code, success code, message and room (if updated instead of deleted)
* @throws Error with specific error codes for conflict scenarios
*/ */
async bulkDeleteRooms( async deleteMeetRoom(
roomIds: string[], roomId: string,
forceDelete: boolean withMeeting: MeetRoomDeletionPolicyWithMeeting,
): Promise<{ deleted: string[]; markedForDeletion: string[] }> { withRecordings: MeetRoomDeletionPolicyWithRecordings
): Promise<{
successCode: MeetRoomDeletionSuccessCode;
message: string;
room?: MeetRoom;
}> {
try { try {
this.logger.info(`Starting bulk deletion of ${roomIds.length} rooms (forceDelete: ${forceDelete})`);
// Classify rooms into those to delete and those to mark for deletion
const { toDelete, toMark } = await this.classifyRoomsForDeletion(roomIds, forceDelete);
// Process each group in parallel
const [deletedRooms, markedRooms] = await Promise.all([
this.batchDeleteRooms(toDelete),
this.batchMarkRoomsForDeletion(toMark)
]);
this.logger.info( this.logger.info(
`Bulk deletion completed: ${deletedRooms.length} deleted, ${markedRooms.length} marked for deletion` `Deleting room '${roomId}' with policies: withMeeting=${withMeeting}, withRecordings=${withRecordings}`
); );
return { // Check if there's an active meeting in the room and/or if it has recordings associated
deleted: deletedRooms, const room = await this.getMeetRoom(roomId);
markedForDeletion: markedRooms const hasActiveMeeting = room.status === MeetRoomStatus.ACTIVE_MEETING;
}; const hasRecordings = await this.recordingService.hasRoomRecordings(roomId);
this.logger.debug(
`Room '${roomId}' status: hasActiveMeeting=${hasActiveMeeting}, hasRecordings=${hasRecordings}`
);
const updatedRoom = await this.executeDeletionStrategy(
roomId,
hasActiveMeeting,
hasRecordings,
withMeeting,
withRecordings
);
return this.getDeletionResponse(
roomId,
hasActiveMeeting,
hasRecordings,
withMeeting,
withRecordings,
updatedRoom
);
} catch (error) { } catch (error) {
this.logger.error('Error deleting rooms:', error); this.logger.error(`Error deleting room '${roomId}': ${error}`);
throw error; throw error;
} }
} }
/**
* Executes the deletion strategy for a room based on its state and the provided deletion policies.
* - Validates the deletion policies (throws if not allowed).
* - If no active meeting and no recordings, deletes the room directly.
* - If there is an active meeting, sets the meeting end action (DELETE or CLOSE) and optionally ends the meeting.
* - If there are recordings and policy is CLOSE, closes the room.
* - If force delete is requested, deletes all recordings and the room.
*/
protected async executeDeletionStrategy(
roomId: string,
hasActiveMeeting: boolean,
hasRecordings: boolean,
withMeeting: MeetRoomDeletionPolicyWithMeeting,
withRecordings: MeetRoomDeletionPolicyWithRecordings
): Promise<MeetRoom | undefined> {
// Validate policies first (fail-fast)
this.validateDeletionPolicies(roomId, hasActiveMeeting, hasRecordings, withMeeting, withRecordings);
// No meeting, no recordings: simple deletion
if (!hasActiveMeeting && !hasRecordings) {
await this.storageService.deleteMeetRooms([roomId]);
return undefined;
}
const room = await this.getMeetRoom(roomId);
// Determine actions based on policies
const shouldForceEndMeeting = hasActiveMeeting && withMeeting === MeetRoomDeletionPolicyWithMeeting.FORCE;
const shouldCloseRoom = hasRecordings && withRecordings === MeetRoomDeletionPolicyWithRecordings.CLOSE;
if (hasActiveMeeting) {
// Set meeting end action (DELETE or CLOSE) depending on recording policy
room.meetingEndAction = shouldCloseRoom ? MeetingEndAction.CLOSE : MeetingEndAction.DELETE;
await this.storageService.saveMeetRoom(room);
if (shouldForceEndMeeting) {
// Force end meeting by deleting the LiveKit room
await this.livekitService.deleteRoom(roomId);
}
return room;
}
if (shouldCloseRoom) {
// Close room instead of deleting if recordings exist and policy is CLOSE
room.status = MeetRoomStatus.CLOSED;
await this.storageService.saveMeetRoom(room);
return room;
}
// Force delete: delete room and all recordings
await Promise.all([
this.recordingService.deleteAllRoomRecordings(roomId),
this.storageService.deleteMeetRooms([roomId])
]);
return undefined;
}
/**
* Validates deletion policies and throws appropriate errors for conflicts.
*/
protected validateDeletionPolicies(
roomId: string,
hasActiveMeeting: boolean,
hasRecordings: boolean,
withMeeting: MeetRoomDeletionPolicyWithMeeting,
withRecordings: MeetRoomDeletionPolicyWithRecordings
) {
const baseMessage = `Room '${roomId}'`;
// Meeting policy validation
if (hasActiveMeeting && withMeeting === MeetRoomDeletionPolicyWithMeeting.FAIL) {
if (hasRecordings) {
throw errorDeletingRoom(
MeetRoomDeletionErrorCode.ROOM_WITH_RECORDINGS_HAS_ACTIVE_MEETING,
`${baseMessage} with recordings cannot be deleted because it has an active meeting.`
);
} else {
throw errorDeletingRoom(
MeetRoomDeletionErrorCode.ROOM_HAS_ACTIVE_MEETING,
`${baseMessage} cannot be deleted because it has an active meeting.`
);
}
}
// Recording policy validation
if (hasRecordings && withRecordings === MeetRoomDeletionPolicyWithRecordings.FAIL) {
if (hasActiveMeeting) {
if (withMeeting === MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS) {
throw errorDeletingRoom(
MeetRoomDeletionErrorCode.ROOM_WITH_ACTIVE_MEETING_HAS_RECORDINGS_CANNOT_SCHEDULE_DELETION,
`${baseMessage} with active meeting cannot be scheduled to be deleted because it has recordings.`
);
} else {
throw errorDeletingRoom(
MeetRoomDeletionErrorCode.ROOM_WITH_ACTIVE_MEETING_HAS_RECORDINGS,
`${baseMessage} with active meeting cannot be deleted because it has recordings.`
);
}
} else {
throw errorDeletingRoom(
MeetRoomDeletionErrorCode.ROOM_HAS_RECORDINGS,
`${baseMessage} cannot be deleted because it has recordings.`
);
}
}
}
/**
* Gets the appropriate response information based on room state and policies.
*/
private getDeletionResponse(
roomId: string,
hasActiveMeeting: boolean,
hasRecordings: boolean,
withMeeting: MeetRoomDeletionPolicyWithMeeting,
withRecordings: MeetRoomDeletionPolicyWithRecordings,
room?: MeetRoom
): {
successCode: MeetRoomDeletionSuccessCode;
message: string;
room?: MeetRoom;
} {
const baseMessage = `Room '${roomId}'`;
// No meeting, no recordings
if (!hasActiveMeeting && !hasRecordings) {
return {
successCode: MeetRoomDeletionSuccessCode.ROOM_DELETED,
message: `${baseMessage} deleted successfully`
};
}
// Has active meeting, no recordings
if (hasActiveMeeting && !hasRecordings) {
switch (withMeeting) {
case MeetRoomDeletionPolicyWithMeeting.FORCE:
return {
successCode: MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_DELETED,
message: `${baseMessage} with active meeting deleted successfully`
};
case MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS:
return {
successCode: MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED,
message: `${baseMessage} with active meeting scheduled to be deleted when the meeting ends`,
room
};
default:
throw internalError(`Unexpected meeting deletion policy: ${withMeeting}`);
}
}
// No active meeting, has recordings
if (!hasActiveMeeting && hasRecordings) {
switch (withRecordings) {
case MeetRoomDeletionPolicyWithRecordings.FORCE:
return {
successCode: MeetRoomDeletionSuccessCode.ROOM_AND_RECORDINGS_DELETED,
message: `${baseMessage} and its recordings deleted successfully`
};
case MeetRoomDeletionPolicyWithRecordings.CLOSE:
return {
successCode: MeetRoomDeletionSuccessCode.ROOM_CLOSED,
message: `${baseMessage} has been closed instead of deleted because it has recordings.`,
room
};
default:
throw internalError(`Unexpected recording deletion policy: ${withRecordings}`);
}
}
// Has active meeting, has recordings
switch (withMeeting) {
case MeetRoomDeletionPolicyWithMeeting.FORCE: {
switch (withRecordings) {
case MeetRoomDeletionPolicyWithRecordings.FORCE:
return {
successCode: MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_AND_RECORDINGS_DELETED,
message: `${baseMessage} with active meeting and its recordings deleted successfully`
};
case MeetRoomDeletionPolicyWithRecordings.CLOSE:
return {
successCode: MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_CLOSED,
message: `${baseMessage} with active meeting has been closed instead of deleted because it has recordings.`,
room
};
default:
throw internalError(`Unexpected recording deletion policy: ${withRecordings}`);
}
}
case MeetRoomDeletionPolicyWithMeeting.WHEN_MEETING_ENDS: {
switch (withRecordings) {
case MeetRoomDeletionPolicyWithRecordings.FORCE:
return {
successCode:
MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_AND_RECORDINGS_SCHEDULED_TO_BE_DELETED,
message: `${baseMessage} with active meeting and its recordings scheduled to be deleted when the meeting ends`,
room
};
case MeetRoomDeletionPolicyWithRecordings.CLOSE:
return {
successCode: MeetRoomDeletionSuccessCode.ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_CLOSED,
message: `${baseMessage} with active meeting scheduled to be closed when the meeting ends because it has recordings.`,
room
};
default:
throw internalError(`Unexpected recording deletion policy: ${withRecordings}`);
}
}
default:
throw internalError(`Unexpected meeting deletion policy: ${withMeeting}`);
}
}
/**
* Deletes multiple rooms in bulk using the deleteMeetRoom method, processing them in batches.
*
* @param roomIds - Array of room identifiers to be deleted
* @param withMeeting - Policy for handling rooms with active meetings
* @param withRecordings - Policy for handling rooms with recordings
* @param batchSize - Number of rooms to process in each batch (default: 10)
* @returns Promise with arrays of successful and failed deletions
*/
async bulkDeleteMeetRooms(
roomIds: string[],
withMeeting: MeetRoomDeletionPolicyWithMeeting,
withRecordings: MeetRoomDeletionPolicyWithRecordings,
batchSize = 10
): Promise<{
successful: {
roomId: string;
successCode: MeetRoomDeletionSuccessCode;
message: string;
room?: MeetRoom;
}[];
failed: {
roomId: string;
error: string;
message: string;
}[];
}> {
this.logger.info(
`Starting bulk deletion of ${roomIds.length} rooms with policies: withMeeting=${withMeeting}, withRecordings=${withRecordings}`
);
const successful: {
roomId: string;
successCode: MeetRoomDeletionSuccessCode;
message: string;
room?: MeetRoom;
}[] = [];
const failed: {
roomId: string;
error: string;
message: string;
}[] = [];
// Process rooms in batches
for (let i = 0; i < roomIds.length; i += batchSize) {
const batch = roomIds.slice(i, i + batchSize);
const batchNumber = Math.floor(i / batchSize) + 1;
const totalBatches = Math.ceil(roomIds.length / batchSize);
this.logger.debug(`Processing batch ${batchNumber}/${totalBatches} with ${batch.length} rooms`);
// Process all rooms in the current batch concurrently
const batchResults = await Promise.all(
batch.map(async (roomId) => {
try {
const result = await this.deleteMeetRoom(roomId, withMeeting, withRecordings);
return {
roomId,
success: true,
result
};
} catch (error) {
return {
roomId,
success: false,
error
};
}
})
);
// Process batch results
batchResults.forEach((result) => {
const { roomId, success, result: deletionResult, error } = result;
if (success) {
successful.push({
roomId,
successCode: deletionResult!.successCode,
message: deletionResult!.message,
room: deletionResult!.room
});
} else {
let meetError: OpenViduMeetError;
if (error instanceof OpenViduMeetError) {
meetError = error;
} else {
meetError = internalError(`deleting room '${roomId}'`);
}
failed.push({
roomId,
error: meetError.name,
message: meetError.message
});
}
});
this.logger.debug(`Batch ${batchNumber} completed`);
}
this.logger.info(
`Bulk deletion completed: ${successful.length}/${roomIds.length} successful, ${failed.length}/${roomIds.length} failed`
);
return { successful, failed };
}
/** /**
* Validates a secret against a room's moderator and speaker secrets and returns the corresponding role. * Validates a secret against a room's moderator and speaker secrets and returns the corresponding role.
* *
@ -352,156 +697,6 @@ export class RoomService {
} }
} }
/**
* Classifies rooms into those that should be deleted immediately vs marked for deletion
*/
protected async classifyRoomsForDeletion(
roomIds: string[],
forceDelete: boolean
): Promise<{ toDelete: string[]; toMark: string[] }> {
this.logger.debug(`Classifying ${roomIds.length} rooms for deletion strategy`);
// Check all rooms in parallel
const classificationResults = await Promise.allSettled(
roomIds.map(async (roomId) => {
try {
const activeMeeting = await this.livekitService.roomExists(roomId);
const shouldDelete = forceDelete || !activeMeeting;
return {
roomId,
action: shouldDelete ? 'delete' : 'mark'
} as const;
} catch (error) {
this.logger.warn(`Failed to check participants for room ${roomId}: ${error}`);
// Default to marking for deletion if we can't check participants
return {
roomId,
action: 'mark'
} as const;
}
})
);
// Group results
const toDelete: string[] = [];
const toMark: string[] = [];
classificationResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
if (result.value.action === 'delete') {
toDelete.push(result.value.roomId);
} else {
toMark.push(result.value.roomId);
}
} else {
this.logger.warn(`Failed to classify room ${roomIds[index]}: ${result.reason}`);
// Default to marking for deletion
toMark.push(roomIds[index]);
}
});
this.logger.debug(`Classification complete: ${toDelete.length} to delete, ${toMark.length} to mark`);
return { toDelete, toMark };
}
/**
* Performs batch deletion of rooms that can be deleted immediately
*/
protected async batchDeleteRooms(roomIds: string[]): Promise<string[]> {
if (roomIds.length === 0) {
return [];
}
this.logger.info(`Batch deleting ${roomIds.length} rooms`);
try {
// Check which rooms have an active LiveKit room (active meeting)
const activeRoomChecks = await Promise.all(
roomIds.map(async (roomId) => ({
roomId,
activeMeeting: await this.livekitService.roomExists(roomId)
}))
);
const withActiveMeeting = activeRoomChecks.filter((r) => r.activeMeeting).map((r) => r.roomId);
const withoutActiveMeeting = activeRoomChecks.filter((r) => !r.activeMeeting).map((r) => r.roomId);
// Mark all rooms with active meetings for deletion (in batch)
// This must be done before deleting the LiveKit rooms to ensure
// the rooms are marked when 'room_finished' webhook is sent
if (withActiveMeeting.length > 0) {
await this.batchMarkRoomsForDeletion(withActiveMeeting);
}
// Delete all LiveKit rooms for rooms with active meetings (in batch)
const livekitDeletePromise =
withActiveMeeting.length > 0
? this.livekitService.batchDeleteRooms(withActiveMeeting)
: Promise.resolve();
// Delete Meet rooms that do not have an active meeting (in batch)
const meetRoomsDeletePromise =
withoutActiveMeeting.length > 0
? this.storageService.deleteMeetRooms(withoutActiveMeeting)
: Promise.resolve();
await Promise.all([livekitDeletePromise, meetRoomsDeletePromise]);
return roomIds;
} catch (error) {
this.logger.error(`Batch deletion failed for rooms: ${roomIds.join(', ')}`, error);
throw internalError('Failed to delete rooms');
}
}
/**
* Marks multiple rooms for deletion in batch
*/
private async batchMarkRoomsForDeletion(roomIds: string[]): Promise<string[]> {
if (roomIds.length === 0) {
return [];
}
this.logger.info(`Batch marking ${roomIds.length} rooms for deletion`);
try {
// Get all rooms in parallel
const roomResults = await Promise.allSettled(
roomIds.map((roomId) => this.storageService.getMeetRoom(roomId))
);
// Prepare rooms for batch update
const roomsToUpdate: { roomId: string; room: MeetRoom }[] = [];
const successfulRoomIds: string[] = [];
roomResults.forEach((result, index) => {
const roomId = roomIds[index];
if (result.status === 'fulfilled' && result.value) {
const room = result.value;
room.meetingEndAction = MeetingEndAction.DELETE;
roomsToUpdate.push({ roomId, room });
successfulRoomIds.push(roomId);
} else {
this.logger.warn(
`Failed to get room ${roomId} for marking: ${result.status === 'rejected' ? result.reason : 'Room not found'}`
);
}
});
// Batch save all updated rooms
if (roomsToUpdate.length > 0) {
await Promise.allSettled(roomsToUpdate.map(({ room }) => this.storageService.saveMeetRoom(room)));
}
this.logger.info(`Successfully marked ${successfulRoomIds.length} rooms for deletion`);
return successfulRoomIds;
} catch (error) {
this.logger.error(`Batch marking failed for rooms: ${roomIds.join(', ')}`, error);
throw internalError('Failed to mark rooms for deletion');
}
}
/** /**
* Gracefully deletes expired rooms. * Gracefully deletes expired rooms.
* *

View File

@ -68,3 +68,23 @@ export type MeetRoomFilters = {
roomName?: string; roomName?: string;
fields?: string; fields?: string;
}; };
export const enum MeetRoomDeletionSuccessCode {
ROOM_DELETED = 'room_deleted',
ROOM_WITH_ACTIVE_MEETING_DELETED = 'room_with_active_meeting_deleted',
ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_DELETED = 'room_with_active_meeting_scheduled_to_be_deleted',
ROOM_AND_RECORDINGS_DELETED = 'room_and_recordings_deleted',
ROOM_CLOSED = 'room_closed',
ROOM_WITH_ACTIVE_MEETING_AND_RECORDINGS_DELETED = 'room_with_active_meeting_and_recordings_deleted',
ROOM_WITH_ACTIVE_MEETING_CLOSED = 'room_with_active_meeting_closed',
ROOM_WITH_ACTIVE_MEETING_AND_RECORDINGS_SCHEDULED_TO_BE_DELETED = 'room_with_active_meeting_and_recordings_scheduled_to_be_deleted',
ROOM_WITH_ACTIVE_MEETING_SCHEDULED_TO_BE_CLOSED = 'room_with_active_meeting_scheduled_to_be_closed'
}
export const enum MeetRoomDeletionErrorCode {
ROOM_HAS_ACTIVE_MEETING = 'room_has_active_meeting',
ROOM_HAS_RECORDINGS = 'room_has_recordings',
ROOM_WITH_ACTIVE_MEETING_HAS_RECORDINGS = 'room_with_active_meeting_has_recordings',
ROOM_WITH_ACTIVE_MEETING_HAS_RECORDINGS_CANNOT_SCHEDULE_DELETION = 'room_with_active_meeting_has_recordings_cannot_schedule_deletion',
ROOM_WITH_RECORDINGS_HAS_ACTIVE_MEETING = 'room_with_recordings_has_active_meeting'
}