diff --git a/meet-ce/backend/src/config/dependency-injector.config.ts b/meet-ce/backend/src/config/dependency-injector.config.ts index 4cec0f07..b0e86265 100644 --- a/meet-ce/backend/src/config/dependency-injector.config.ts +++ b/meet-ce/backend/src/config/dependency-injector.config.ts @@ -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(); diff --git a/meet-ce/backend/src/models/mongoose-schemas/global-config.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/global-config.schema.ts index f0d5b11e..c290edd2 100644 --- a/meet-ce/backend/src/models/mongoose-schemas/global-config.schema.ts +++ b/meet-ce/backend/src/models/mongoose-schemas/global-config.schema.ts @@ -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 } diff --git a/meet-ce/backend/src/models/mongoose-schemas/room-member.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/room-member.schema.ts new file mode 100644 index 00000000..9f8facc3 --- /dev/null +++ b/meet-ce/backend/src/models/mongoose-schemas/room-member.schema.ts @@ -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, 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 = {}; + + 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( + { + 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(meetRoomMemberCollectionName, MeetRoomMemberSchema); diff --git a/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts index 8843106e..c32809f6 100644 --- a/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts +++ b/meet-ce/backend/src/models/mongoose-schemas/room.schema.ts @@ -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( type: String, required: true }, + owner: { + type: String, + required: true + }, creationDate: { type: Number, required: true @@ -214,11 +261,15 @@ const MeetRoomSchema = new Schema( type: MeetRoomConfigSchema, required: true }, - moderatorUrl: { - type: String, + roles: { + type: MeetRoomRolesSchema, required: true }, - speakerUrl: { + anonymous: { + type: MeetRoomAnonymousSchema, + required: true + }, + accessUrl: { type: String, required: true }, diff --git a/meet-ce/backend/src/models/mongoose-schemas/user.schema.ts b/meet-ce/backend/src/models/mongoose-schemas/user.schema.ts index e45b7df9..cd17255b 100644 --- a/meet-ce/backend/src/models/mongoose-schemas/user.schema.ts +++ b/meet-ce/backend/src/models/mongoose-schemas/user.schema.ts @@ -22,19 +22,22 @@ const MeetUserSchema = new Schema( 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( ); // 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'; diff --git a/meet-ce/backend/src/repositories/room-member.repository.ts b/meet-ce/backend/src/repositories/room-member.repository.ts new file mode 100644 index 00000000..8fd8af05 --- /dev/null +++ b/meet-ce/backend/src/repositories/room-member.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 = { 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 { + 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 { + 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 { + 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 { + const basePermissions = roomRoles[baseRole].permissions; + + if (!customPermissions) { + return basePermissions; + } + + return { + ...basePermissions, + ...customPermissions + }; + } +} diff --git a/meet-ce/backend/src/repositories/room.repository.ts b/meet-ce/backend/src/repositories/room.repository.ts index dd457ddf..2b4c203b 100644 --- a/meet-ce/backend/src/repositories/room.repository.ts +++ b/meet-ce/backend/src/repositories/room.repository.ts @@ -196,7 +196,7 @@ export class RoomRepository 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 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 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 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}` + } + } + }) }; } } diff --git a/meet-ce/backend/src/repositories/user.repository.ts b/meet-ce/backend/src/repositories/user.repository.ts index 6cfb1bc6..e588635b 100644 --- a/meet-ce/backend/src/repositories/user.repository.ts +++ b/meet-ce/backend/src/repositories/user.repository.ts @@ -40,23 +40,100 @@ export class UserRepository 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 { - 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 { - const document = await this.findOne({ username }); + async findByUserId(userId: string): Promise { + 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 = {}; + + 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 { + 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 { + await this.deleteMany({ userId: { $in: userIds } }); + } } diff --git a/meet-ce/typings/src/room-member.ts b/meet-ce/typings/src/room-member.ts index 32e40196..cc456f87 100644 --- a/meet-ce/typings/src/room-member.ts +++ b/meet-ce/typings/src/room-member.ts @@ -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; // Custom permissions for the member (if any)