backend: implement room member management with repository and database schema definition

This commit is contained in:
juancarmore 2025-12-12 12:45:58 +01:00
parent 79cef519b8
commit e72566dd8c
9 changed files with 538 additions and 44 deletions

View File

@ -7,6 +7,7 @@ import { GlobalConfigRepository } from '../repositories/global-config.repository
import { MigrationRepository } from '../repositories/migration.repository.js';
import { RecordingRepository } from '../repositories/recording.repository.js';
import { RoomRepository } from '../repositories/room.repository.js';
import { RoomMemberRepository } from '../repositories/room-member.repository.js';
import { UserRepository } from '../repositories/user.repository.js';
/*
@ -86,6 +87,7 @@ export const registerDependencies = () => {
container.bind(MongoDBService).toSelf().inSingletonScope();
container.bind(BaseRepository).toSelf().inSingletonScope();
container.bind(RoomRepository).toSelf().inSingletonScope();
container.bind(RoomMemberRepository).toSelf().inSingletonScope();
container.bind(UserRepository).toSelf().inSingletonScope();
container.bind(ApiKeyRepository).toSelf().inSingletonScope();
container.bind(GlobalConfigRepository).toSelf().inSingletonScope();

View File

@ -1,4 +1,4 @@
import { AuthMode, AuthType, GlobalConfig } from '@openvidu-meet/typings';
import { GlobalConfig, OAuthProvider } from '@openvidu-meet/typings';
import { Document, model, Schema } from 'mongoose';
import { INTERNAL_CONFIG } from '../../config/internal-config.js';
import { MeetAppearanceConfigSchema } from './room.schema.js';
@ -13,14 +13,25 @@ export interface MeetGlobalConfigDocument extends GlobalConfig, Document {
}
/**
* Sub-schema for authentication method.
* Currently only supports single_user type.
* Sub-schema for OAuth provider configuration.
*/
const AuthMethodSchema = new Schema(
const OAuthProviderConfigSchema = new Schema(
{
type: {
provider: {
type: String,
enum: Object.values(OAuthProvider),
required: true
},
clientId: {
type: String,
required: true
},
clientSecret: {
type: String,
required: true
},
redirectUri: {
type: String,
enum: Object.values(AuthType),
required: true
}
},
@ -32,14 +43,13 @@ const AuthMethodSchema = new Schema(
*/
const AuthenticationConfigSchema = new Schema(
{
authMethod: {
type: AuthMethodSchema,
allowUserCreation: {
type: Boolean,
required: true
},
authModeToAccessRoom: {
type: String,
enum: Object.values(AuthMode),
required: true
oauthProviders: {
type: [OAuthProviderConfigSchema],
required: false
}
},
{ _id: false }

View File

@ -0,0 +1,100 @@
import { MeetRoomMember, MeetRoomMemberRole } from '@openvidu-meet/typings';
import { Document, Schema, model } from 'mongoose';
/**
* Mongoose Document interface for MeetRoomMember.
* Extends the MeetRoomMember interface with MongoDB Document functionality.
* Note: effectivePermissions is computed, not stored.
*/
export interface MeetRoomMemberDocument extends Omit<MeetRoomMember, 'effectivePermissions'>, Document {
/** Schema version for migration tracking (internal use only) */
schemaVersion?: number;
}
const permissionFields = {
canRecord: { type: Boolean },
canRetrieveRecordings: { type: Boolean },
canDeleteRecordings: { type: Boolean },
canJoinMeeting: { type: Boolean },
canShareAccessLinks: { type: Boolean },
canMakeModerator: { type: Boolean },
canKickParticipants: { type: Boolean },
canEndMeeting: { type: Boolean },
canPublishVideo: { type: Boolean },
canPublishAudio: { type: Boolean },
canShareScreen: { type: Boolean },
canReadChat: { type: Boolean },
canWriteChat: { type: Boolean },
canChangeVirtualBackground: { type: Boolean }
};
function createPermissionsSchema(required: boolean) {
const schemaDefinition: Record<string, unknown> = {};
for (const key of Object.keys(permissionFields)) {
schemaDefinition[key] = { ...permissionFields[key as keyof typeof permissionFields], required };
}
return new Schema(schemaDefinition, { _id: false });
}
/**
* Sub-schema for room member permissions.
*/
export const MeetRoomMemberPermissionsSchema = createPermissionsSchema(true);
/**
* Sub-schema for partial room member permissions.
*/
const MeetRoomMemberPartialPermissionsSchema = createPermissionsSchema(false);
/**
* Mongoose schema for MeetRoomMember entity.
*/
const MeetRoomMemberSchema = new Schema<MeetRoomMemberDocument>(
{
memberId: {
type: String,
required: true
},
roomId: {
type: String,
required: true
},
name: {
type: String,
required: true
},
baseRole: {
type: String,
enum: Object.values(MeetRoomMemberRole),
required: true
},
customPermissions: {
type: MeetRoomMemberPartialPermissionsSchema,
required: false
}
},
{
toObject: {
versionKey: false,
transform: (_doc, ret) => {
delete ret._id;
delete ret.schemaVersion;
return ret;
}
}
}
);
// Create indexes for efficient querying
MeetRoomMemberSchema.index({ roomId: 1, memberId: 1 }, { unique: true });
MeetRoomMemberSchema.index({ roomId: 1, name: 1, _id: 1 });
MeetRoomMemberSchema.index({ memberId: 1 });
export const meetRoomMemberCollectionName = 'MeetRoomMember';
/**
* Mongoose model for MeetRoomMember.
*/
export const MeetRoomMemberModel = model<MeetRoomMemberDocument>(meetRoomMemberCollectionName, MeetRoomMemberSchema);

View File

@ -1,5 +1,4 @@
import {
MeetRecordingAccess,
MeetRoom,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
@ -9,6 +8,7 @@ import {
} from '@openvidu-meet/typings';
import { Document, Schema, model } from 'mongoose';
import { INTERNAL_CONFIG } from '../../config/internal-config.js';
import { MeetRoomMemberPermissionsSchema } from './room-member.schema.js';
/**
* Mongoose Document interface for MeetRoom.
@ -48,11 +48,6 @@ const MeetRecordingConfigSchema = new Schema(
enabled: {
type: Boolean,
required: true
},
allowAccessTo: {
type: String,
enum: Object.values(MeetRecordingAccess),
required: false
}
},
{ _id: false }
@ -91,8 +86,7 @@ const MeetE2EEConfigSchema = new Schema(
{
enabled: {
type: Boolean,
required: true,
default: false
required: true
}
},
{ _id: false }
@ -153,6 +147,56 @@ export const MeetAppearanceConfigSchema = new Schema(
{ _id: false }
);
/**
* Sub-schema for room roles configuration.
*/
const MeetRoomRolesSchema = new Schema(
{
moderator: {
permissions: {
type: MeetRoomMemberPermissionsSchema,
required: true
}
},
speaker: {
permissions: {
type: MeetRoomMemberPermissionsSchema,
required: true
}
}
},
{ _id: false }
);
/**
* Sub-schema for anonymous access configuration.
*/
const MeetRoomAnonymousSchema = new Schema(
{
moderator: {
enabled: {
type: Boolean,
required: true
},
accessUrl: {
type: String,
required: true
}
},
speaker: {
enabled: {
type: Boolean,
required: true
},
accessUrl: {
type: String,
required: true
}
}
},
{ _id: false }
);
/**
* Mongoose schema for MeetRoom configuration.
*/
@ -172,8 +216,7 @@ const MeetRoomConfigSchema = new Schema(
},
e2ee: {
type: MeetE2EEConfigSchema,
required: true,
default: { enabled: false }
required: true
}
},
{ _id: false }
@ -198,6 +241,10 @@ const MeetRoomSchema = new Schema<MeetRoomDocument>(
type: String,
required: true
},
owner: {
type: String,
required: true
},
creationDate: {
type: Number,
required: true
@ -214,11 +261,15 @@ const MeetRoomSchema = new Schema<MeetRoomDocument>(
type: MeetRoomConfigSchema,
required: true
},
moderatorUrl: {
type: String,
roles: {
type: MeetRoomRolesSchema,
required: true
},
speakerUrl: {
anonymous: {
type: MeetRoomAnonymousSchema,
required: true
},
accessUrl: {
type: String,
required: true
},

View File

@ -22,19 +22,22 @@ const MeetUserSchema = new Schema<MeetUserDocument>(
required: true,
default: INTERNAL_CONFIG.USER_SCHEMA_VERSION
},
username: {
userId: {
type: String,
required: true
},
name: {
type: String,
required: true
},
role: {
type: String,
enum: Object.values(MeetUserRole),
required: true
},
passwordHash: {
type: String,
required: true
},
roles: {
type: [String],
enum: Object.values(MeetUserRole),
required: true,
default: [MeetUserRole.USER]
}
},
{
@ -50,7 +53,8 @@ const MeetUserSchema = new Schema<MeetUserDocument>(
);
// Create indexes for efficient querying
MeetUserSchema.index({ username: 1 }, { unique: true });
MeetUserSchema.index({ userId: 1 }, { unique: true });
MeetUserSchema.index({ name: 1, _id: 1 });
export const meetUserCollectionName = 'MeetUser';

View File

@ -0,0 +1,227 @@
import { MeetRoomMember, MeetRoomMemberPermissions, MeetRoomMemberRole, MeetRoomRoles } from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { MeetRoomMemberDocument, MeetRoomMemberModel } from '../models/mongoose-schemas/room-member.schema.js';
import { LoggerService } from '../services/logger.service.js';
import { BaseRepository } from './base.repository.js';
import { RoomRepository } from './room.repository.js';
import { errorRoomNotFound } from '../models/error.model.js';
/**
* Repository for managing MeetRoomMember entities in MongoDB.
* Handles the storage and retrieval of room members.
*/
@injectable()
export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoomMemberDocument> {
private currentRoomRoles: MeetRoomRoles | undefined;
constructor(
@inject(LoggerService) logger: LoggerService,
@inject(RoomRepository) private roomRepository: RoomRepository
) {
super(logger, MeetRoomMemberModel);
}
/**
* Transforms a MongoDB document into a domain room member object.
* Computes effective permissions based on base role and custom permissions.
*
* @param document - The MongoDB document
* @returns Room member with computed permissions
*/
protected toDomain(document: MeetRoomMemberDocument): MeetRoomMember {
const doc = document.toObject();
const effectivePermissions = this.computeEffectivePermissions(
this.currentRoomRoles!,
doc.baseRole,
doc.customPermissions
);
return {
...doc,
effectivePermissions
};
}
/**
* Adds a member to a room.
*
* @param member - The room member data to add
* @returns The created room member
*/
async create(member: MeetRoomMember): Promise<MeetRoomMember> {
const room = await this.roomRepository.findByRoomId(member.roomId);
if (!room) {
throw errorRoomNotFound(member.roomId);
}
this.currentRoomRoles = room.roles;
const document = await this.createDocument(member);
const domain = this.toDomain(document);
this.currentRoomRoles = undefined;
return domain;
}
/**
* Updates an existing room member.
*
* @param member - The complete updated room member data
* @returns The updated room member
* @throws Error if room member not found
*/
async update(member: MeetRoomMember): Promise<MeetRoomMember> {
const room = await this.roomRepository.findByRoomId(member.roomId);
if (!room) {
throw errorRoomNotFound(member.roomId);
}
this.currentRoomRoles = room.roles;
const document = await this.updateOne({ roomId: member.roomId, memberId: member.memberId }, member);
const domain = this.toDomain(document);
this.currentRoomRoles = undefined;
return domain;
}
/**
* Finds a specific member in a room.
*
* @param roomId - The ID of the room
* @param memberId - The ID of the member
* @returns The room member or null if not found
*/
async findByRoomAndMemberId(roomId: string, memberId: string): Promise<MeetRoomMember | null> {
const room = await this.roomRepository.findByRoomId(roomId);
if (!room) {
return null;
}
this.currentRoomRoles = room.roles;
const document = await this.findOne({ roomId, memberId });
const domain = document ? this.toDomain(document) : null;
this.currentRoomRoles = undefined;
return domain;
}
/**
* Finds members of a room with optional filtering, pagination, and sorting.
*
* @param roomId - The ID of the room
* @param options - Query options
* @param options.name - Optional member name to filter by (case-insensitive partial match)
* @param options.maxItems - Maximum number of results to return (default: 100)
* @param options.nextPageToken - Token for pagination
* @param options.sortField - Field to sort by (default: 'name')
* @param options.sortOrder - Sort order: 'asc' or 'desc' (default: 'asc')
* @returns Object containing members array, pagination info, and optional next page token
*/
async findByRoomId(
roomId: string,
options: {
name?: string;
maxItems?: number;
nextPageToken?: string;
sortField?: string;
sortOrder?: 'asc' | 'desc';
} = {}
): Promise<{
members: MeetRoomMember[];
isTruncated: boolean;
nextPageToken?: string;
}> {
const room = await this.roomRepository.findByRoomId(roomId);
if (!room) {
throw errorRoomNotFound(roomId);
}
this.currentRoomRoles = room.roles;
const { name, maxItems = 100, nextPageToken, sortField = 'name', sortOrder = 'asc' } = options;
// Build base filter
const filter: Record<string, unknown> = { roomId };
if (name) {
filter.name = new RegExp(name, 'i');
}
// Use base repository's pagination method
const result = await this.findMany(filter, {
maxItems,
nextPageToken,
sortField,
sortOrder
});
this.currentRoomRoles = undefined;
return {
members: result.items,
isTruncated: result.isTruncated,
nextPageToken: result.nextPageToken
};
}
/**
* Removes a member from a room.
*
* @param roomId - The ID of the room
* @param memberId - The ID of the member to remove
* @throws Error if room member not found or could not be deleted
*/
async deleteByRoomAndMemberId(roomId: string, memberId: string): Promise<void> {
await this.deleteOne({ roomId, memberId });
}
/**
* Removes multiple members from a room.
*
* @param roomId - The ID of the room
* @param memberIds - Array of member IDs to remove
* @throws Error if no room members were found or could not be deleted
*/
async deleteByRoomIdAndMemberIds(roomId: string, memberIds: string[]): Promise<void> {
await this.deleteMany({ roomId, memberId: { $in: memberIds } });
}
/**
* Removes all members from a room.
*
* @param roomId - The ID of the room
* @throws Error if members could not be deleted
*/
async deleteAllByRoomId(roomId: string): Promise<void> {
await this.deleteMany({ roomId });
}
// ==========================================
// PRIVATE HELPER METHODS
// ==========================================
/**
* Computes effective permissions by merging base role permissions with custom permissions.
*
* @param roomRoles - The room roles configuration
* @param baseRole - The base role of the member
* @param customPermissions - Optional custom permissions that override the base role
* @returns The effective permissions object
*/
private computeEffectivePermissions(
roomRoles: MeetRoomRoles,
baseRole: MeetRoomMemberRole,
customPermissions?: Partial<MeetRoomMemberPermissions>
): MeetRoomMemberPermissions {
const basePermissions = roomRoles[baseRole].permissions;
if (!customPermissions) {
return basePermissions;
}
return {
...basePermissions,
...customPermissions
};
}
}

View File

@ -196,7 +196,7 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
// ==========================================
/**
* Normalizes room data for storage by removing the base URL from URLs.
* Normalizes room data for storage by removing the base URL from access URLs.
* This ensures only the path is stored in the database.
*
* @param room - The room data to normalize
@ -205,8 +205,18 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
private normalizeRoomForStorage(room: TRoom): TRoom {
return {
...room,
moderatorUrl: this.extractPathFromUrl(room.moderatorUrl),
speakerUrl: this.extractPathFromUrl(room.speakerUrl)
accessUrl: this.extractPathFromUrl(room.accessUrl),
anonymous: {
...room.anonymous,
moderator: {
...room.anonymous.moderator,
accessUrl: this.extractPathFromUrl(room.anonymous.moderator.accessUrl)
},
speaker: {
...room.anonymous.speaker,
accessUrl: this.extractPathFromUrl(room.anonymous.speaker.accessUrl)
}
}
};
}
@ -232,7 +242,7 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
}
/**
* Enriches room data by adding the base URL to URLs.
* Enriches room data by adding the base URL to access URLs.
* Converts MongoDB document to domain object.
* Only enriches URLs that are present in the document.
*
@ -245,8 +255,20 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
return {
...room,
...(room.moderatorUrl !== undefined && { moderatorUrl: `${baseUrl}${room.moderatorUrl}` }),
...(room.speakerUrl !== undefined && { speakerUrl: `${baseUrl}${room.speakerUrl}` })
...(room.accessUrl !== undefined && { accessUrl: `${baseUrl}${room.accessUrl}` }),
...(room.anonymous !== undefined && {
anonymous: {
...room.anonymous,
moderator: {
...room.anonymous.moderator,
accessUrl: `${baseUrl}${room.anonymous.moderator.accessUrl}`
},
speaker: {
...room.anonymous.speaker,
accessUrl: `${baseUrl}${room.anonymous.speaker.accessUrl}`
}
}
})
};
}
}

View File

@ -40,23 +40,100 @@ export class UserRepository<TUser extends MeetUser = MeetUser> extends BaseRepos
/**
* Updates an existing user.
*
* @param user - The complete updated user data (must include username)
* @param user - The complete updated user data
* @returns The updated user
* @throws Error if user not found
*/
async update(user: TUser): Promise<TUser> {
const document = await this.updateOne({ username: user.username }, user);
const document = await this.updateOne({ userId: user.userId }, user);
return this.toDomain(document);
}
/**
* Finds a user by their username.
*
* @param username - The unique username identifier
* @param userId - The unique user identifier
* @returns The user or null if not found
*/
async findByUsername(username: string): Promise<TUser | null> {
const document = await this.findOne({ username });
async findByUserId(userId: string): Promise<TUser | null> {
const document = await this.findOne({ userId });
return document ? this.toDomain(document) : null;
}
/**
* Finds users with optional filtering, pagination, and sorting.
*
* @param options - Query options
* @param options.userId - Optional user ID to filter by (case-insensitive partial match)
* @param options.name - Optional name to filter by (case-insensitive partial match)
* @param options.maxItems - Maximum number of results to return (default: 100)
* @param options.nextPageToken - Token for pagination
* @param options.sortField - Field to sort by (default: 'name')
* @param options.sortOrder - Sort order: 'asc' or 'desc' (default: 'asc')
* @returns Object containing users array, pagination info, and optional next page token
*/
async find(
options: {
userId?: string;
name?: string;
maxItems?: number;
nextPageToken?: string;
sortField?: string;
sortOrder?: 'asc' | 'desc';
} = {}
): Promise<{
users: TUser[];
isTruncated: boolean;
nextPageToken?: string;
}> {
const { userId, name, maxItems = 100, nextPageToken, sortField = 'name', sortOrder = 'asc' } = options;
// Build base filter
const filter: Record<string, unknown> = {};
if (userId && name) {
// Both defined: OR filter with regex userId match and regex name match
filter.$or = [{ userId: new RegExp(userId, 'i') }, { name: new RegExp(name, 'i') }];
} else if (userId) {
// Only userId defined: regex match (case-insensitive)
filter.userId = new RegExp(userId, 'i');
} else if (name) {
// Only name defined: regex match (case-insensitive)
filter.name = new RegExp(name, 'i');
}
// Use base repository's pagination method
const result = await this.findMany(filter, {
maxItems,
nextPageToken,
sortField,
sortOrder
});
return {
users: result.items,
isTruncated: result.isTruncated,
nextPageToken: result.nextPageToken
};
}
/**
* Deletes a user by their userId.
*
* @param userId - The unique user identifier
* @throws Error if the user was not found or could not be deleted
*/
async deleteByUserId(userId: string): Promise<void> {
await this.deleteOne({ userId });
}
/**
* Deletes multiple users by their userIds.
*
* @param userIds - Array of user identifiers
* @throws Error if no users were found or could not be deleted
*/
async deleteByUserIds(userIds: string[]): Promise<void> {
await this.deleteMany({ userId: { $in: userIds } });
}
}

View File

@ -16,6 +16,7 @@ export interface MeetRoomMemberOptions {
*/
export interface MeetRoomMember {
memberId: string; // Unique identifier for the member (equals userId for internal users, or generated for external users)
roomId: string; // ID of the room the member belongs to
name: string; // Name of the member (either internal or external user name)
baseRole: MeetRoomMemberRole; // The base role of the member in the room
customPermissions?: Partial<MeetRoomMemberPermissions>; // Custom permissions for the member (if any)