| | import { Types } from 'mongoose'; |
| | import { PrincipalType } from 'librechat-data-provider'; |
| | import type { TUser, TPrincipalSearchResult } from 'librechat-data-provider'; |
| | import type { Model, ClientSession } from 'mongoose'; |
| | import type { IGroup, IRole, IUser } from '~/types'; |
| |
|
| | export function createUserGroupMethods(mongoose: typeof import('mongoose')) { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function findGroupById( |
| | groupId: string | Types.ObjectId, |
| | projection: Record<string, unknown> = {}, |
| | session?: ClientSession, |
| | ): Promise<IGroup | null> { |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| | const query = Group.findOne({ _id: groupId }, projection); |
| | if (session) { |
| | query.session(session); |
| | } |
| | return await query.lean(); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function findGroupByExternalId( |
| | idOnTheSource: string, |
| | source: 'entra' | 'local' = 'entra', |
| | projection: Record<string, unknown> = {}, |
| | session?: ClientSession, |
| | ): Promise<IGroup | null> { |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| | const query = Group.findOne({ idOnTheSource, source }, projection); |
| | if (session) { |
| | query.session(session); |
| | } |
| | return await query.lean(); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function findGroupsByNamePattern( |
| | namePattern: string, |
| | source: 'entra' | 'local' | null = null, |
| | limit: number = 20, |
| | session?: ClientSession, |
| | ): Promise<IGroup[]> { |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| | const regex = new RegExp(namePattern, 'i'); |
| | const query: Record<string, unknown> = { |
| | $or: [{ name: regex }, { email: regex }, { description: regex }], |
| | }; |
| |
|
| | if (source) { |
| | query.source = source; |
| | } |
| |
|
| | const dbQuery = Group.find(query).limit(limit); |
| | if (session) { |
| | dbQuery.session(session); |
| | } |
| | return await dbQuery.lean(); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | async function findGroupsByMemberId( |
| | userId: string | Types.ObjectId, |
| | session?: ClientSession, |
| | ): Promise<IGroup[]> { |
| | const User = mongoose.models.User as Model<IUser>; |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| |
|
| | const userQuery = User.findById(userId, 'idOnTheSource'); |
| | if (session) { |
| | userQuery.session(session); |
| | } |
| | const user = (await userQuery.lean()) as { idOnTheSource?: string } | null; |
| |
|
| | if (!user) { |
| | return []; |
| | } |
| |
|
| | const userIdOnTheSource = user.idOnTheSource || userId.toString(); |
| |
|
| | const query = Group.find({ memberIds: userIdOnTheSource }); |
| | if (session) { |
| | query.session(session); |
| | } |
| | return await query.lean(); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | async function createGroup(groupData: Partial<IGroup>, session?: ClientSession): Promise<IGroup> { |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| | const options = session ? { session } : {}; |
| | return await Group.create([groupData], options).then((groups) => groups[0]); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function upsertGroupByExternalId( |
| | idOnTheSource: string, |
| | source: 'entra' | 'local', |
| | updateData: Partial<IGroup>, |
| | session?: ClientSession, |
| | ): Promise<IGroup | null> { |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| | const options = { |
| | new: true, |
| | upsert: true, |
| | ...(session ? { session } : {}), |
| | }; |
| |
|
| | return await Group.findOneAndUpdate({ idOnTheSource, source }, { $set: updateData }, options); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function addUserToGroup( |
| | userId: string | Types.ObjectId, |
| | groupId: string | Types.ObjectId, |
| | session?: ClientSession, |
| | ): Promise<{ user: IUser; group: IGroup | null }> { |
| | const User = mongoose.models.User as Model<IUser>; |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| |
|
| | const options = { new: true, ...(session ? { session } : {}) }; |
| |
|
| | const user = (await User.findById(userId, 'idOnTheSource', options).lean()) as { |
| | idOnTheSource?: string; |
| | _id: Types.ObjectId; |
| | } | null; |
| | if (!user) { |
| | throw new Error(`User not found: ${userId}`); |
| | } |
| |
|
| | const userIdOnTheSource = user.idOnTheSource || userId.toString(); |
| | const updatedGroup = await Group.findByIdAndUpdate( |
| | groupId, |
| | { $addToSet: { memberIds: userIdOnTheSource } }, |
| | options, |
| | ).lean(); |
| |
|
| | return { user: user as IUser, group: updatedGroup }; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function removeUserFromGroup( |
| | userId: string | Types.ObjectId, |
| | groupId: string | Types.ObjectId, |
| | session?: ClientSession, |
| | ): Promise<{ user: IUser; group: IGroup | null }> { |
| | const User = mongoose.models.User as Model<IUser>; |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| |
|
| | const options = { new: true, ...(session ? { session } : {}) }; |
| |
|
| | const user = (await User.findById(userId, 'idOnTheSource', options).lean()) as { |
| | idOnTheSource?: string; |
| | _id: Types.ObjectId; |
| | } | null; |
| | if (!user) { |
| | throw new Error(`User not found: ${userId}`); |
| | } |
| |
|
| | const userIdOnTheSource = user.idOnTheSource || userId.toString(); |
| | const updatedGroup = await Group.findByIdAndUpdate( |
| | groupId, |
| | { $pull: { memberIds: userIdOnTheSource } }, |
| | options, |
| | ).lean(); |
| |
|
| | return { user: user as IUser, group: updatedGroup }; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | async function getUserGroups( |
| | userId: string | Types.ObjectId, |
| | session?: ClientSession, |
| | ): Promise<IGroup[]> { |
| | return await findGroupsByMemberId(userId, session); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function getUserPrincipals( |
| | params: { |
| | userId: string | Types.ObjectId; |
| | role?: string | null; |
| | }, |
| | session?: ClientSession, |
| | ): Promise<Array<{ principalType: string; principalId?: string | Types.ObjectId }>> { |
| | const { userId, role } = params; |
| | |
| | const userObjectId = typeof userId === 'string' ? new Types.ObjectId(userId) : userId; |
| | const principals: Array<{ principalType: string; principalId?: string | Types.ObjectId }> = [ |
| | { principalType: PrincipalType.USER, principalId: userObjectId }, |
| | ]; |
| |
|
| | |
| | let userRole = role; |
| | if (userRole === undefined) { |
| | const User = mongoose.models.User as Model<IUser>; |
| | const query = User.findById(userId).select('role'); |
| | if (session) { |
| | query.session(session); |
| | } |
| | const user = await query.lean(); |
| | userRole = user?.role; |
| | } |
| |
|
| | |
| | if (userRole && userRole.trim()) { |
| | principals.push({ principalType: PrincipalType.ROLE, principalId: userRole }); |
| | } |
| |
|
| | const userGroups = await getUserGroups(userId, session); |
| | if (userGroups && userGroups.length > 0) { |
| | userGroups.forEach((group) => { |
| | principals.push({ principalType: PrincipalType.GROUP, principalId: group._id }); |
| | }); |
| | } |
| |
|
| | principals.push({ principalType: PrincipalType.PUBLIC }); |
| |
|
| | return principals; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function syncUserEntraGroups( |
| | userId: string | Types.ObjectId, |
| | entraGroups: Array<{ id: string; name: string; description?: string; email?: string }>, |
| | session?: ClientSession, |
| | ): Promise<{ |
| | user: IUser; |
| | addedGroups: IGroup[]; |
| | removedGroups: IGroup[]; |
| | }> { |
| | const User = mongoose.models.User as Model<IUser>; |
| | const Group = mongoose.models.Group as Model<IGroup>; |
| |
|
| | const query = User.findById(userId, { idOnTheSource: 1 }); |
| | if (session) { |
| | query.session(session); |
| | } |
| | const user = (await query.lean()) as { idOnTheSource?: string; _id: Types.ObjectId } | null; |
| |
|
| | if (!user) { |
| | throw new Error(`User not found: ${userId}`); |
| | } |
| |
|
| | |
| | const userIdOnTheSource = user.idOnTheSource || userId.toString(); |
| |
|
| | const entraIdMap = new Map<string, boolean>(); |
| | const addedGroups: IGroup[] = []; |
| | const removedGroups: IGroup[] = []; |
| |
|
| | for (const entraGroup of entraGroups) { |
| | entraIdMap.set(entraGroup.id, true); |
| |
|
| | let group = await findGroupByExternalId(entraGroup.id, 'entra', {}, session); |
| |
|
| | if (!group) { |
| | group = await createGroup( |
| | { |
| | name: entraGroup.name, |
| | description: entraGroup.description, |
| | email: entraGroup.email, |
| | idOnTheSource: entraGroup.id, |
| | source: 'entra', |
| | memberIds: [userIdOnTheSource], |
| | }, |
| | session, |
| | ); |
| |
|
| | addedGroups.push(group); |
| | } else if (!group.memberIds?.includes(userIdOnTheSource)) { |
| | const { group: updatedGroup } = await addUserToGroup(userId, group._id, session); |
| | if (updatedGroup) { |
| | addedGroups.push(updatedGroup); |
| | } |
| | } |
| | } |
| |
|
| | const groupsQuery = Group.find( |
| | { source: 'entra', memberIds: userIdOnTheSource }, |
| | { _id: 1, idOnTheSource: 1 }, |
| | ); |
| | if (session) { |
| | groupsQuery.session(session); |
| | } |
| | const existingGroups = (await groupsQuery.lean()) as Array<{ |
| | _id: Types.ObjectId; |
| | idOnTheSource?: string; |
| | }>; |
| |
|
| | for (const group of existingGroups) { |
| | if (group.idOnTheSource && !entraIdMap.has(group.idOnTheSource)) { |
| | const { group: removedGroup } = await removeUserFromGroup(userId, group._id, session); |
| | if (removedGroup) { |
| | removedGroups.push(removedGroup); |
| | } |
| | } |
| | } |
| |
|
| | const userQuery = User.findById(userId); |
| | if (session) { |
| | userQuery.session(session); |
| | } |
| | const updatedUser = await userQuery.lean(); |
| |
|
| | if (!updatedUser) { |
| | throw new Error(`User not found after update: ${userId}`); |
| | } |
| |
|
| | return { |
| | user: updatedUser, |
| | addedGroups, |
| | removedGroups, |
| | }; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | function calculateRelevanceScore(item: TPrincipalSearchResult, searchPattern: string): number { |
| | const exactRegex = new RegExp(`^${searchPattern}$`, 'i'); |
| | const startsWithPattern = searchPattern.toLowerCase(); |
| |
|
| | |
| | const searchableFields = |
| | item.type === PrincipalType.USER |
| | ? [item.name, item.email, item.username].filter(Boolean) |
| | : [item.name, item.email, item.description].filter(Boolean); |
| |
|
| | let maxScore = 0; |
| |
|
| | for (const field of searchableFields) { |
| | if (!field) continue; |
| | const fieldLower = field.toLowerCase(); |
| | let score = 0; |
| |
|
| | |
| | if (exactRegex.test(field)) { |
| | score = 100; |
| | } else if (fieldLower.startsWith(startsWithPattern)) { |
| | |
| | score = 80; |
| | } else if (fieldLower.includes(startsWithPattern)) { |
| | |
| | score = 50; |
| | } else { |
| | |
| | score = 10; |
| | } |
| |
|
| | maxScore = Math.max(maxScore, score); |
| | } |
| |
|
| | return maxScore; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | function sortPrincipalsByRelevance< |
| | T extends { _searchScore?: number; type: string; name?: string; email?: string }, |
| | >(results: T[]): T[] { |
| | return results.sort((a, b) => { |
| | if (b._searchScore !== a._searchScore) { |
| | return (b._searchScore || 0) - (a._searchScore || 0); |
| | } |
| | if (a.type !== b.type) { |
| | return a.type === PrincipalType.USER ? -1 : 1; |
| | } |
| | const aName = a.name || a.email || ''; |
| | const bName = b.name || b.email || ''; |
| | return aName.localeCompare(bName); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | function transformUserToTPrincipalSearchResult(user: TUser): TPrincipalSearchResult { |
| | return { |
| | id: user.id, |
| | type: PrincipalType.USER, |
| | name: user.name || user.email, |
| | email: user.email, |
| | username: user.username, |
| | avatar: user.avatar, |
| | provider: user.provider, |
| | source: 'local', |
| | idOnTheSource: (user as TUser & { idOnTheSource?: string }).idOnTheSource || user.id, |
| | }; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | function transformGroupToTPrincipalSearchResult(group: IGroup): TPrincipalSearchResult { |
| | return { |
| | id: group._id?.toString(), |
| | type: PrincipalType.GROUP, |
| | name: group.name, |
| | email: group.email, |
| | avatar: group.avatar, |
| | description: group.description, |
| | source: group.source || 'local', |
| | memberCount: group.memberIds ? group.memberIds.length : 0, |
| | idOnTheSource: group.idOnTheSource || group._id?.toString(), |
| | }; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async function searchPrincipals( |
| | searchPattern: string, |
| | limitPerType: number = 10, |
| | typeFilter: Array<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE> | null = null, |
| | session?: ClientSession, |
| | ): Promise<TPrincipalSearchResult[]> { |
| | if (!searchPattern || searchPattern.trim().length === 0) { |
| | return []; |
| | } |
| |
|
| | const trimmedPattern = searchPattern.trim(); |
| | const promises: Promise<TPrincipalSearchResult[]>[] = []; |
| |
|
| | if (!typeFilter || typeFilter.includes(PrincipalType.USER)) { |
| | |
| | const userFields = 'name email username avatar provider idOnTheSource'; |
| | |
| | const User = mongoose.models.User as Model<IUser>; |
| | const regex = new RegExp(trimmedPattern, 'i'); |
| | const userQuery = User.find({ |
| | $or: [{ name: regex }, { email: regex }, { username: regex }], |
| | }) |
| | .select(userFields) |
| | .limit(limitPerType); |
| |
|
| | if (session) { |
| | userQuery.session(session); |
| | } |
| |
|
| | promises.push( |
| | userQuery.lean().then((users) => |
| | users.map((user) => { |
| | const userWithId = user as IUser & { idOnTheSource?: string }; |
| | return transformUserToTPrincipalSearchResult({ |
| | id: userWithId._id?.toString() || '', |
| | name: userWithId.name, |
| | email: userWithId.email, |
| | username: userWithId.username, |
| | avatar: userWithId.avatar, |
| | provider: userWithId.provider, |
| | } as TUser); |
| | }), |
| | ), |
| | ); |
| | } else { |
| | promises.push(Promise.resolve([])); |
| | } |
| |
|
| | if (!typeFilter || typeFilter.includes(PrincipalType.GROUP)) { |
| | promises.push( |
| | findGroupsByNamePattern(trimmedPattern, null, limitPerType, session).then((groups) => |
| | groups.map(transformGroupToTPrincipalSearchResult), |
| | ), |
| | ); |
| | } else { |
| | promises.push(Promise.resolve([])); |
| | } |
| |
|
| | if (!typeFilter || typeFilter.includes(PrincipalType.ROLE)) { |
| | const Role = mongoose.models.Role as Model<IRole>; |
| | if (Role) { |
| | const regex = new RegExp(trimmedPattern, 'i'); |
| | const roleQuery = Role.find({ name: regex }).select('name').limit(limitPerType); |
| |
|
| | if (session) { |
| | roleQuery.session(session); |
| | } |
| |
|
| | promises.push( |
| | roleQuery.lean().then((roles) => |
| | roles.map((role) => ({ |
| | |
| | id: role.name, |
| | type: PrincipalType.ROLE, |
| | name: role.name, |
| | source: 'local' as const, |
| | idOnTheSource: role.name, |
| | })), |
| | ), |
| | ); |
| | } |
| | } else { |
| | promises.push(Promise.resolve([])); |
| | } |
| |
|
| | const results = await Promise.all(promises); |
| | const combined = results.flat(); |
| | return combined; |
| | } |
| |
|
| | return { |
| | findGroupById, |
| | findGroupByExternalId, |
| | findGroupsByNamePattern, |
| | findGroupsByMemberId, |
| | createGroup, |
| | upsertGroupByExternalId, |
| | addUserToGroup, |
| | removeUserFromGroup, |
| | getUserGroups, |
| | getUserPrincipals, |
| | syncUserEntraGroups, |
| | searchPrincipals, |
| | calculateRelevanceScore, |
| | sortPrincipalsByRelevance, |
| | }; |
| | } |
| |
|
| | export type UserGroupMethods = ReturnType<typeof createUserGroupMethods>; |
| |
|