backend: enhance room deletion logic with new policies for meetings and recordings
This commit is contained in:
parent
632d36a470
commit
4802f48ba6
@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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'
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user