backend: remove currentParticipantIdentity from room member schema and refactor RoomMemberService to use meberId as participant identity when joining a meeting. Update related tests

This commit is contained in:
juancarmore 2026-02-09 10:03:06 +01:00
parent f61fa6183c
commit 70ca7a0fa9
8 changed files with 114 additions and 243 deletions

View File

@ -95,10 +95,6 @@ const MeetRoomMemberSchema = new Schema<MeetRoomMemberDocument>(
permissionsUpdatedAt: {
type: Number,
required: true
},
currentParticipantIdentity: {
type: String,
required: false
}
},
{

View File

@ -158,9 +158,7 @@ export class LivekitWebhookService {
}
/**
*
* Handles the 'participant_joined' event by gathering relevant room and participant information,
* updating the room member's currentParticipantIdentity if applicable,
* and sending a room status signal to OpenVidu components.
* @param room - Information about the room where the participant joined.
* @param participant - Information about the newly joined participant.
@ -169,23 +167,6 @@ export class LivekitWebhookService {
// Skip if the participant is not a standard participant
if (!this.livekitService.isStandardParticipant(participant)) return;
// Update room member's currentParticipantIdentity if this is an identified member
if (participant.metadata) {
try {
const metadata = JSON.parse(participant.metadata);
if (metadata.memberId) {
await this.roomMemberService.updateCurrentParticipantIdentity(
room.name,
metadata.memberId,
participant.identity
);
}
} catch (error) {
this.logger.warn(`Failed to set room member currentParticipantIdentity:`, error);
}
}
try {
const { recordings } = await this.recordingService.getAllRecordings({ roomId: room.name });
await this.frontendEventService.sendRoomStatusSignalToOpenViduComponents(
@ -200,7 +181,6 @@ export class LivekitWebhookService {
/**
* Handles the 'participant_left' event by gathering relevant room and participant information,
* clearing the participant identity from the room member if applicable,
* and releasing any reserved participant names.
* @param room - Information about the room where the participant left.
* @param participant - Information about the participant who left.
@ -209,23 +189,6 @@ export class LivekitWebhookService {
// Skip if the participant is not a standard participant
if (!this.livekitService.isStandardParticipant(participant)) return;
// Clear room member's currentParticipantIdentity if this is an identified member
if (participant.metadata) {
try {
const metadata = JSON.parse(participant.metadata);
if (metadata.memberId) {
await this.roomMemberService.updateCurrentParticipantIdentity(
room.name,
metadata.memberId,
undefined
);
}
} catch (error) {
this.logger.warn(`Failed to clear room member currentParticipantIdentity:`, error);
}
}
try {
// Release the participant's reserved name
await this.roomMemberService.releaseParticipantName(room.name, participant.name);

View File

@ -29,7 +29,8 @@ import {
errorRoomMemberCannotBeOwnerOrAdmin,
errorRoomMemberNotFound,
errorUnauthorized,
errorUserNotFound
errorUserNotFound,
OpenViduMeetError
} from '../models/error.model.js';
import { RoomMemberRepository } from '../repositories/room-member.repository.js';
import { FrontendEventService } from './frontend-event.service.js';
@ -228,27 +229,11 @@ export class RoomMemberService {
const updatedMember = await this.roomMemberRepository.update(member);
// If member is currently in a meeting, check if they still have permission to join
if (updatedMember.currentParticipantIdentity) {
const effectivePermissions = updatedMember.effectivePermissions;
if (!effectivePermissions.canJoinMeeting) {
// Member lost permission to join meeting, kick them out
try {
await this.kickParticipantFromMeeting(roomId, updatedMember.currentParticipantIdentity);
this.logger.info(
`Kicked participant '${updatedMember.currentParticipantIdentity}' from meeting after losing canJoinMeeting permission (member '${memberId}' in room '${roomId}')`
);
} catch (error) {
this.logger.warn(
`Failed to kick participant '${updatedMember.currentParticipantIdentity}' from meeting after permission update:`,
error
);
// Don't throw error, update was already saved
}
} else {
// TODO: Notify participant of role/permission changes if currently in a meeting
}
// If member lost permission to join meeting, kick them out
if (!updatedMember.effectivePermissions.canJoinMeeting) {
await this.kickMembersFromMeetingInBatches(roomId, [memberId]);
} else {
// TODO: Notify participant of role/permission changes if currently in a meeting
}
return updatedMember;
@ -267,9 +252,10 @@ export class RoomMemberService {
this.logger.verbose(`Updating effective permissions for all members in room '${roomId}'`);
const BATCH_SIZE = 20; // Process members in smaller batches
let batchNumber = 0;
let nextPageToken: string | undefined;
let totalUpdated = 0;
let batchNumber = 0;
const totalMembers: MeetRoomMember[] = [];
do {
batchNumber++;
@ -283,6 +269,7 @@ export class RoomMemberService {
maxItems: BATCH_SIZE,
nextPageToken
});
totalMembers.push(...members);
if (members.length === 0) {
break;
@ -331,33 +318,14 @@ export class RoomMemberService {
return;
}
this.logger.info(`Successfully updated effective permissions for ${totalUpdated} members in room '${roomId}'`);
}
// Kick members who lost canJoinMeeting permission
const membersToKick = totalMembers.filter((m) => !m.effectivePermissions.canJoinMeeting).map((m) => m.memberId);
/**
* Updates the currentParticipantIdentity for a room member.
*
* @param roomId - The ID of the room
* @param memberId - The ID of the member
* @param participantIdentity - The participant identity to set (or undefined to clear it)
*/
async updateCurrentParticipantIdentity(
roomId: string,
memberId: string,
participantIdentity: string | undefined
): Promise<void> {
const member = await this.getRoomMember(roomId, memberId);
if (!member) {
this.logger.warn(
`Cannot update currentParticipantIdentity: member '${memberId}' not found in room '${roomId}'`
);
return;
if (membersToKick.length > 0) {
await this.kickMembersFromMeetingInBatches(roomId, membersToKick);
}
member.currentParticipantIdentity = participantIdentity;
await this.roomMemberRepository.update(member);
this.logger.info(`Updated currentParticipantIdentity for member '${memberId}' in room '${roomId}'`);
this.logger.info(`Successfully updated effective permissions for ${totalUpdated} members in room '${roomId}'`);
}
/**
@ -374,17 +342,7 @@ export class RoomMemberService {
}
// If member is currently in a meeting, kick them out first
if (member.currentParticipantIdentity) {
try {
await this.kickParticipantFromMeeting(roomId, member.currentParticipantIdentity);
this.logger.info(
`Kicked participant '${member.currentParticipantIdentity}' from meeting before deleting member '${memberId}'`
);
} catch (error) {
this.logger.warn(`Failed to kick participant from meeting during member deletion:`, error);
// Continue with deletion even if kick fails
}
}
await this.kickMembersFromMeetingInBatches(roomId, [memberId]);
return this.roomMemberRepository.deleteByRoomAndMemberId(roomId, memberId);
}
@ -415,40 +373,8 @@ export class RoomMemberService {
.map((id) => ({ memberId: id, error: 'Room member not found' }));
if (foundMemberIds.length > 0) {
// Kick participants that are currently in a meeting
const membersInMeeting = membersToDelete.filter((m) => m.currentParticipantIdentity);
if (membersInMeeting.length > 0) {
const KICK_BATCH_SIZE = 10;
// Process kicks in batches to avoid overwhelming the system
for (let i = 0; i < membersInMeeting.length; i += KICK_BATCH_SIZE) {
const batch = membersInMeeting.slice(i, i + KICK_BATCH_SIZE);
await Promise.allSettled(
batch.map(async (member) => {
try {
await this.kickParticipantFromMeeting(roomId, member.currentParticipantIdentity!);
this.logger.info(
`Kicked participant '${member.currentParticipantIdentity}' from meeting before deleting member '${member.memberId}'`
);
} catch (error) {
this.logger.warn(
`Failed to kick participant '${member.currentParticipantIdentity}' from meeting during bulk deletion:`,
error
);
// Continue with deletion even if kick fails
}
})
);
this.logger.verbose(`Processed batch of ${batch.length} participant kicks for room '${roomId}'`);
}
this.logger.info(
`Kicked ${membersInMeeting.length} participant(s) from meeting before bulk deletion in room '${roomId}'`
);
}
// Kick participants that are currently in a meeting before deletion
await this.kickMembersFromMeetingInBatches(roomId, foundMemberIds);
await this.roomMemberRepository.deleteByRoomIdAndMemberIds(roomId, foundMemberIds);
}
@ -473,6 +399,7 @@ export class RoomMemberService {
let customPermissions: Partial<MeetRoomMemberPermissions> | undefined = undefined;
let effectivePermissions: MeetRoomMemberPermissions;
let memberId: string | undefined;
let userId: string | undefined;
if (secret) {
// Case 1: Secret provided (Anonymous access or External Member)
@ -510,6 +437,8 @@ export class RoomMemberService {
throw errorUnauthorized();
}
userId = user.userId;
// Check if user is admin or owner
const isOwner = await this.roomService.isRoomOwner(roomId, user.userId);
@ -540,7 +469,8 @@ export class RoomMemberService {
participantName,
participantIdentity,
customPermissions,
memberId
memberId,
userId
);
}
@ -558,7 +488,8 @@ export class RoomMemberService {
participantName: string,
participantIdentity?: string,
customPermissions?: Partial<MeetRoomMemberPermissions>,
memberId?: string
memberId?: string,
userId?: string
): Promise<string> {
// Check that room is open
const room = await this.roomService.getMeetRoom(roomId);
@ -592,9 +523,16 @@ export class RoomMemberService {
// Create the Livekit room if it doesn't exist
await this.roomService.createLivekitRoom(roomId);
// Create a unique participant identity based on the participant name
const identityPrefix = this.createParticipantIdentityPrefixFromName(participantName) || 'participant';
participantIdentity = `${identityPrefix}-${uid(15)}`;
if (memberId || userId) {
// Use memberId as participant identity for identified members
// (registered users or external members with a record in the database)
// Use userId as participant identity for registered users without a member record
participantIdentity = memberId || userId;
} else {
// For anonymous users, create a unique participant identity based on the provided participant name
const identityPrefix = this.createParticipantIdentityPrefixFromName(participantName) || 'participant';
participantIdentity = `${identityPrefix}-${uid(15)}`;
}
} else {
// REFRESH MODE
this.logger.verbose(
@ -764,6 +702,73 @@ export class RoomMemberService {
return livekitPermissions;
}
/**
* Kicks multiple members from a meeting in batches.
* This method processes the kicks in parallel batches to avoid overwhelming the system.
*
* @param roomId - The ID of the room
* @param memberIds - Array of member IDs to kick from the meeting
* @param batchSize - Number of kicks to process in parallel (default: 10)
*/
protected async kickMembersFromMeetingInBatches(
roomId: string,
memberIds: string[],
batchSize = 10
): Promise<void> {
if (memberIds.length === 0) {
return;
}
let kickedCount = 0;
let failedCount = 0;
// Process kicks in batches to avoid overwhelming the system
for (let i = 0; i < memberIds.length; i += batchSize) {
const batch = memberIds.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(async (memberId) => {
try {
await this.kickParticipantFromMeeting(roomId, memberId);
this.logger.verbose(`Kicked participant '${memberId}' from meeting in room '${roomId}'`);
return true;
} catch (error) {
const isParticipantNotFound = error instanceof OpenViduMeetError && error.statusCode === 404;
if (!isParticipantNotFound) {
// Real error, log warning
this.logger.warn(
`Failed to kick participant '${memberId}' from meeting in room '${roomId}':`,
error
);
return false;
}
// Participant not in meeting, nothing to do
return true;
}
})
);
// Count only successful kicks and real failures (not "participant not found")
results.forEach((result) => {
if (result) {
kickedCount++;
} else {
failedCount++;
}
});
}
if (kickedCount > 0) {
this.logger.info(`Kicked ${kickedCount} participant(s) from meeting in room '${roomId}'`);
}
if (failedCount > 0) {
this.logger.warn(`Failed to kick ${failedCount} participant(s) from meeting in room '${roomId}'`);
}
}
async kickParticipantFromMeeting(roomId: string, participantIdentity: string): Promise<void> {
this.logger.verbose(`Kicking participant '${participantIdentity}' from room '${roomId}'`);
return this.livekitService.deleteParticipant(roomId, participantIdentity);

View File

@ -663,7 +663,9 @@ export const expectValidRoomMemberTokenResponse = (
expect(decodedToken).toHaveProperty('name', participantName);
expect(decodedToken).toHaveProperty('sub');
if (participantIdentityPrefix) {
if (memberId) {
expect(decodedToken.sub).toBe(memberId);
} else if (participantIdentityPrefix) {
expect(decodedToken.sub?.startsWith(participantIdentityPrefix)).toBe(true);
}

View File

@ -1,11 +1,8 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import { MeetRoomMember, MeetRoomMemberRole, MeetUserRole } from '@openvidu-meet/typings';
import { container } from '../../../../src/config/dependency-injector.config.js';
import { MEET_ENV } from '../../../../src/environment.js';
import { OpenViduMeetError } from '../../../../src/models/error.model.js';
import { RoomMemberRepository } from '../../../../src/repositories/room-member.repository.js';
import { LiveKitService } from '../../../../src/services/livekit.service.js';
import { TokenService } from '../../../../src/services/token.service.js';
import { expectValidationError } from '../../../helpers/assertion-helpers.js';
import {
bulkDeleteRoomMembers,
@ -15,12 +12,10 @@ import {
deleteAllRooms,
deleteAllUsers,
disconnectFakeParticipants,
generateRoomMemberTokenRequest,
getRoomMember,
getUser,
joinFakeParticipant,
startTestServer,
updateParticipantMetadata
startTestServer
} from '../../../helpers/request-helpers.js';
describe('Bulk Delete Room Members API Tests', () => {
@ -190,52 +185,13 @@ describe('Bulk Delete Room Members API Tests', () => {
const member1 = member1Response.body as MeetRoomMember;
const member2 = member2Response.body as MeetRoomMember;
// Generate tokens and join meeting for both
const token1Response = await generateRoomMemberTokenRequest(roomId, {
secret: member1.memberId,
joinMeeting: true,
participantName: 'Meeting Member 1'
});
const token2Response = await generateRoomMemberTokenRequest(roomId, {
secret: member2.memberId,
joinMeeting: true,
participantName: 'Meeting Member 2'
});
// Participant identity is the same as memberId for members
const participantIdentity1 = member1.memberId;
const participantIdentity2 = member2.memberId;
// Get participant identities from tokens
const tokenService = container.get(TokenService);
const decodedToken1 = await tokenService.verifyToken(token1Response.body.token);
const decodedToken2 = await tokenService.verifyToken(token2Response.body.token);
const participantIdentity1 = decodedToken1.sub!;
const participantIdentity2 = decodedToken2.sub!;
// Join fake participants and update metadata
// Join fake participants to the room to simulate real join
await joinFakeParticipant(roomId, participantIdentity1);
await updateParticipantMetadata(roomId, participantIdentity1, {
iat: Date.now(),
livekitUrl: MEET_ENV.LIVEKIT_URL,
roomId,
memberId: member1.memberId,
baseRole: MeetRoomMemberRole.SPEAKER,
effectivePermissions: member1.effectivePermissions
});
await joinFakeParticipant(roomId, participantIdentity2);
await updateParticipantMetadata(roomId, participantIdentity2, {
iat: Date.now(),
livekitUrl: MEET_ENV.LIVEKIT_URL,
roomId,
memberId: member2.memberId,
baseRole: MeetRoomMemberRole.MODERATOR,
effectivePermissions: member2.effectivePermissions
});
// Update room members currentParticipantIdentity
const roomMemberRepository = container.get(RoomMemberRepository);
member1.currentParticipantIdentity = participantIdentity1;
member2.currentParticipantIdentity = participantIdentity2;
await roomMemberRepository.update(member1);
await roomMemberRepository.update(member2);
// Verify both participants exist
const livekitService = container.get(LiveKitService);

View File

@ -3,9 +3,7 @@ import { MeetRoomMember, MeetRoomMemberRole, MeetUserRole } from '@openvidu-meet
import { container } from '../../../../src/config/dependency-injector.config.js';
import { MEET_ENV } from '../../../../src/environment.js';
import { OpenViduMeetError } from '../../../../src/models/error.model.js';
import { RoomMemberRepository } from '../../../../src/repositories/room-member.repository.js';
import { LiveKitService } from '../../../../src/services/livekit.service.js';
import { TokenService } from '../../../../src/services/token.service.js';
import {
createRoom,
createRoomMember,
@ -14,7 +12,6 @@ import {
deleteAllUsers,
deleteRoomMember,
disconnectFakeParticipants,
generateRoomMemberTokenRequest,
getRoomMember,
getUser,
joinFakeParticipant,
@ -110,20 +107,8 @@ describe('Room Members API Tests', () => {
const member = createResponse.body as MeetRoomMember;
const memberId = member.memberId;
// Generate room member token for joining meeting
const tokenResponse = await generateRoomMemberTokenRequest(roomId, {
secret: memberId,
joinMeeting: true,
participantName: 'Test Member'
});
const roomMemberToken = tokenResponse.body.token;
// Get participant identity from token
const tokenService = container.get(TokenService);
const decodedToken = await tokenService.verifyToken(roomMemberToken);
const participantIdentity = decodedToken.sub!;
// Join fake participant to the room and update metadata to simulate real join
// Join fake participant to the room to simulate real join
const participantIdentity = memberId; // Participant identity is the same as memberId for members
await joinFakeParticipant(roomId, participantIdentity);
await updateParticipantMetadata(roomId, participantIdentity, {
iat: Date.now(),
@ -134,11 +119,6 @@ describe('Room Members API Tests', () => {
effectivePermissions: member.effectivePermissions
});
// Update room member currentParticipantIdentity manually to simulate real join
const roomMemberRepository = container.get(RoomMemberRepository);
member.currentParticipantIdentity = participantIdentity;
await roomMemberRepository.update(member);
// Verify participant exists before deletion
const livekitService = container.get(LiveKitService);
const participant = await livekitService.getParticipant(roomId, participantIdentity);

View File

@ -1,11 +1,8 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import { MeetRoomMember, MeetRoomMemberRole, MeetRoomRoles, MeetUserRole } from '@openvidu-meet/typings';
import { container } from '../../../../src/config/dependency-injector.config.js';
import { MEET_ENV } from '../../../../src/environment.js';
import { OpenViduMeetError } from '../../../../src/models/error.model.js';
import { RoomMemberRepository } from '../../../../src/repositories/room-member.repository.js';
import { LiveKitService } from '../../../../src/services/livekit.service.js';
import { TokenService } from '../../../../src/services/token.service.js';
import { expectValidationError } from '../../../helpers/assertion-helpers.js';
import {
createRoom,
@ -14,12 +11,10 @@ import {
deleteAllRooms,
deleteAllUsers,
disconnectFakeParticipants,
generateRoomMemberTokenRequest,
getRoomMember,
joinFakeParticipant,
sleep,
startTestServer,
updateParticipantMetadata,
updateRoomMember
} from '../../../helpers/request-helpers.js';
@ -260,34 +255,9 @@ describe('Room Members API Tests', () => {
const member = createResponse.body as MeetRoomMember;
const memberId = member.memberId;
// Generate room member token for joining meeting
const tokenResponse = await generateRoomMemberTokenRequest(roomId, {
secret: memberId,
joinMeeting: true,
participantName: 'Test Member'
});
const roomMemberToken = tokenResponse.body.token;
// Get participant identity from token
const tokenService = container.get(TokenService);
const decodedToken = await tokenService.verifyToken(roomMemberToken);
const participantIdentity = decodedToken.sub!;
// Join fake participant to the room and update metadata to simulate real join
// Join fake participant to the room to simulate real join
const participantIdentity = memberId; // Participant identity is the same as memberId for members
await joinFakeParticipant(roomId, participantIdentity);
await updateParticipantMetadata(roomId, participantIdentity, {
iat: Date.now(),
livekitUrl: MEET_ENV.LIVEKIT_URL,
roomId,
memberId,
baseRole: MeetRoomMemberRole.SPEAKER,
effectivePermissions: roomRoles.speaker.permissions
});
// Update room member currentParticipantIdentity manually to simulate real join
const roomMemberRepository = container.get(RoomMemberRepository);
member.currentParticipantIdentity = participantIdentity;
await roomMemberRepository.update(member);
// Verify participant exists before deletion
const livekitService = container.get(LiveKitService);

View File

@ -25,7 +25,6 @@ export interface MeetRoomMember {
customPermissions?: Partial<MeetRoomMemberPermissions>; // Custom permissions for the member (if any)
effectivePermissions: MeetRoomMemberPermissions; // Effective permissions for the member (base role + custom permissions)
permissionsUpdatedAt: number; // Timestamp when the effective permissions were last updated
currentParticipantIdentity?: string; // The participant identity if the member is currently in a meeting, undefined otherwise
}
/**