From f6ca8caf7e2456450d91c4fe4b75d2de757d2dd7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 30 May 2025 01:42:06 -0400 Subject: [PATCH] refactor(data-schemas): restructure schemas, models, and methods for improved modularity --- api/server/services/AuthService.js | 83 ++++--- e2e/setup/cleanupUser.ts | 32 ++- packages/data-schemas/src/index.ts | 27 ++- packages/data-schemas/src/methods/README.md | 97 ++++++++ packages/data-schemas/src/methods/index.ts | 8 + packages/data-schemas/src/methods/session.ts | 202 ++++++++++++++++ packages/data-schemas/src/methods/token.ts | 105 +++++++++ packages/data-schemas/src/methods/user.ts | 151 ++++++++++++ packages/data-schemas/src/models/index.ts | 200 ++-------------- packages/data-schemas/src/schema/session.ts | 234 +------------------ packages/data-schemas/src/schema/token.ts | 127 +--------- packages/data-schemas/src/schema/user.ts | 199 +--------------- packages/data-schemas/src/types/README.md | 223 ++++++++++++++++++ packages/data-schemas/src/types/index.ts | 8 + packages/data-schemas/src/types/session.ts | 52 +++++ packages/data-schemas/src/types/token.ts | 42 ++++ packages/data-schemas/src/types/user.ts | 72 ++++++ 17 files changed, 1080 insertions(+), 782 deletions(-) create mode 100644 packages/data-schemas/src/methods/README.md create mode 100644 packages/data-schemas/src/methods/index.ts create mode 100644 packages/data-schemas/src/methods/session.ts create mode 100644 packages/data-schemas/src/methods/token.ts create mode 100644 packages/data-schemas/src/methods/user.ts create mode 100644 packages/data-schemas/src/types/README.md create mode 100644 packages/data-schemas/src/types/index.ts create mode 100644 packages/data-schemas/src/types/session.ts create mode 100644 packages/data-schemas/src/types/token.ts create mode 100644 packages/data-schemas/src/types/user.ts diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 6f30a8a095..0caf8c893d 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1,12 +1,27 @@ const bcrypt = require('bcryptjs'); const { webcrypto } = require('node:crypto'); +const { + findUser, + createUser, + updateUser, + findToken, + countUsers, + getUserById, + findSession, + createToken, + deleteTokens, + deleteSession, + createSession, + generateToken, + deleteUserById, + generateRefreshToken, +} = require('@librechat/data-schemas'); const { SystemRoles, errorsToString } = require('librechat-data-provider'); const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils'); const { isEmailDomainAllowed } = require('~/server/services/domains'); +const { getBalanceConfig } = require('~/server/services/Config'); const { registerSchema } = require('~/strategies/validators'); const { logger } = require('~/config'); -const db = require('~/lib/db/connectDb'); -const { getBalanceConfig } = require('~/server/services/Config'); const domains = { client: process.env.DOMAIN_CLIENT, @@ -24,14 +39,13 @@ const genericVerificationMessage = 'Please check your email to verify your email * @returns */ const logoutUser = async (req, refreshToken) => { - const { Session } = db.models; try { const userId = req.user._id; - const session = await Session.findSession({ userId: userId, refreshToken }); + const session = await findSession({ userId: userId, refreshToken }); if (session) { try { - await Session.deleteSession({ sessionId: session._id }); + await deleteSession({ sessionId: session._id }); } catch (deleteErr) { logger.error('[logoutUser] Failed to delete session.', deleteErr); return { status: 500, message: 'Failed to delete session.' }; @@ -83,7 +97,7 @@ const sendVerificationEmail = async (user) => { template: 'verifyEmail.handlebars', }); - await db.models.Token.createToken({ + await createToken({ userId: user._id, email: user.email, token: hash, @@ -102,8 +116,7 @@ const verifyEmail = async (req) => { const { email, token } = req.body; const decodedEmail = decodeURIComponent(email); - const { User, Token } = db.models; - const user = await User.findUser({ email: decodedEmail }, 'email _id emailVerified'); + const user = await findUser({ email: decodedEmail }, 'email _id emailVerified'); if (!user) { logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`); @@ -115,7 +128,7 @@ const verifyEmail = async (req) => { return { message: 'Email already verified', status: 'success' }; } - let emailVerificationData = await Token.findToken({ email: decodedEmail }); + let emailVerificationData = await findToken({ email: decodedEmail }); if (!emailVerificationData) { logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`); @@ -131,16 +144,18 @@ const verifyEmail = async (req) => { return new Error('Invalid or expired email verification token'); } - const updatedUser = await User.updateUser(emailVerificationData.userId, { emailVerified: true }); + const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true }); + if (!updatedUser) { logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`); return new Error('Failed to update user verification status'); } - await Token.deleteTokens({ token: emailVerificationData.token }); + await deleteTokens({ token: emailVerificationData.token }); logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`); return { message: 'Email verification was successful', status: 'success' }; }; + /** * Register a new user. * @param {MongoUser} user @@ -148,7 +163,6 @@ const verifyEmail = async (req) => { * @returns {Promise<{status: number, message: string, user?: MongoUser}>} */ const registerUser = async (user, additionalData = {}) => { - const { User } = db.models; const { error } = registerSchema.safeParse(user); if (error) { const errorMessage = errorsToString(error.errors); @@ -165,7 +179,7 @@ const registerUser = async (user, additionalData = {}) => { let newUserId; try { - const existingUser = await User.findUser({ email }, 'email _id'); + const existingUser = await findUser({ email }, 'email _id'); if (existingUser) { logger.info( @@ -187,7 +201,7 @@ const registerUser = async (user, additionalData = {}) => { } //determine if this is the first registered user (not counting anonymous_user) - const isFirstRegisteredUser = (await User.countUsers()) === 0; + const isFirstRegisteredUser = (await countUsers()) === 0; const salt = bcrypt.genSaltSync(10); const newUserData = { @@ -204,8 +218,8 @@ const registerUser = async (user, additionalData = {}) => { const emailEnabled = checkEmailConfig(); const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN); const balanceConfig = await getBalanceConfig(); - - const newUser = await User.createUser(newUserData, balanceConfig, disableTTL, true); + + const newUser = await createUser(newUserData, balanceConfig, disableTTL, true); newUserId = newUser._id; if (emailEnabled && !newUser.emailVerified) { await sendVerificationEmail({ @@ -214,14 +228,14 @@ const registerUser = async (user, additionalData = {}) => { name, }); } else { - await User.updateUser(newUserId, { emailVerified: true }); + await updateUser(newUserId, { emailVerified: true }); } return { status: 200, message: genericVerificationMessage }; } catch (err) { logger.error('[registerUser] Error in registering user:', err); if (newUserId) { - const result = await User.deleteUserById(newUserId); + const result = await deleteUserById(newUserId); logger.warn( `[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`, ); @@ -236,8 +250,7 @@ const registerUser = async (user, additionalData = {}) => { */ const requestPasswordReset = async (req) => { const { email } = req.body; - const { User, Token } = db.models; - const user = await User.findUser({ email }, 'email _id'); + const user = await findUser({ email }, 'email _id'); const emailEnabled = checkEmailConfig(); logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`); @@ -249,11 +262,11 @@ const requestPasswordReset = async (req) => { }; } - await Token.deleteTokens({ userId: user._id }); + await deleteTokens({ userId: user._id }); const [resetToken, hash] = createTokenHash(); - await Token.createToken({ + await createToken({ userId: user._id, token: hash, createdAt: Date.now(), @@ -298,8 +311,7 @@ const requestPasswordReset = async (req) => { * @returns */ const resetPassword = async (userId, token, password) => { - const { User, Token } = db.models; - let passwordResetToken = await Token.findToken({ + let passwordResetToken = await findToken({ userId, }); @@ -314,7 +326,7 @@ const resetPassword = async (userId, token, password) => { } const hash = bcrypt.hashSync(password, 10); - const user = await User.updateUser(userId, { password: hash }); + const user = await updateUser(userId, { password: hash }); if (checkEmailConfig()) { await sendEmail({ @@ -329,7 +341,7 @@ const resetPassword = async (userId, token, password) => { }); } - await Token.deleteTokens({ token: passwordResetToken.token }); + await deleteTokens({ token: passwordResetToken.token }); logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`); return { message: 'Password reset was successful' }; }; @@ -344,20 +356,19 @@ const resetPassword = async (userId, token, password) => { */ const setAuthTokens = async (userId, res, sessionId = null) => { try { - const { User, Session } = db.models; - const user = await User.getUserById(userId); - const token = await User.generateToken(user); + const user = await getUserById(userId); + const token = await generateToken(user); let session; let refreshToken; let refreshTokenExpires; if (sessionId) { - session = await Session.findSession({ sessionId: sessionId }, { lean: false }); + session = await findSession({ sessionId: sessionId }, { lean: false }); refreshTokenExpires = session.expiration.getTime(); - refreshToken = await Session.generateRefreshToken(session); + refreshToken = await generateRefreshToken(session); } else { - const result = await Session.createSession(userId); + const result = await createSession(userId); session = result.session; refreshToken = result.refreshToken; refreshTokenExpires = session.expiration.getTime(); @@ -381,6 +392,7 @@ const setAuthTokens = async (userId, res, sessionId = null) => { throw error; } }; + /** * @function setOpenIDAuthTokens * Set OpenID Authentication Tokens @@ -436,9 +448,8 @@ const setOpenIDAuthTokens = (tokenset, res) => { const resendVerificationEmail = async (req) => { try { const { email } = req.body; - const { User, Token } = db.models; - await Token.deleteTokens(email); - const user = await User.findUser({ email }, 'email _id name'); + await deleteTokens(email); + const user = await findUser({ email }, 'email _id name'); if (!user) { logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`); @@ -463,7 +474,7 @@ const resendVerificationEmail = async (req) => { template: 'verifyEmail.handlebars', }); - await Token.createToken({ + await createToken({ userId: user._id, email: user.email, token: hash, diff --git a/e2e/setup/cleanupUser.ts b/e2e/setup/cleanupUser.ts index 5f9b82676d..e2992debad 100644 --- a/e2e/setup/cleanupUser.ts +++ b/e2e/setup/cleanupUser.ts @@ -1,6 +1,7 @@ import { connectDb, getModels } from '@librechat/backend/lib/db/connectDb'; -import { deleteMessages, deleteConvos, User, Balance } from '@librechat/backend/models'; -import { Transaction } from '@librechat/backend/models/Transaction'; +import { findUser, deleteAllUserSessions } from '@librechat/data-schemas'; +import { deleteMessages, deleteConvos } from '@librechat/backend/models'; + type TUser = { email: string; password: string }; export default async function cleanupUser(user: TUser) { @@ -10,29 +11,38 @@ export default async function cleanupUser(user: TUser) { const db = await connectDb(); console.log('๐Ÿค–: โœ… Connected to Database'); - const { _id: user } = await User.findOne({ email }).lean(); + const foundUser = await findUser({ email }); + if (!foundUser) { + console.log('๐Ÿค–: โš ๏ธ User not found in Database'); + return; + } + + const userId = foundUser._id; console.log('๐Ÿค–: โœ… Found user in Database'); // Delete all conversations & associated messages - const { deletedCount, messages } = await deleteConvos(user, {}); + const { deletedCount, messages } = await deleteConvos(userId, {}); if (messages.deletedCount > 0 || deletedCount > 0) { console.log(`๐Ÿค–: โœ… Deleted ${deletedCount} convos & ${messages.deletedCount} messages`); } // Ensure all user messages are deleted - const { deletedCount: deletedMessages } = await deleteMessages({ user }); + const { deletedCount: deletedMessages } = await deleteMessages({ user: userId }); if (deletedMessages > 0) { console.log(`๐Ÿค–: โœ… Deleted ${deletedMessages} remaining message(s)`); } - // TODO: fix this to delete all user sessions with the user's email - const { User, Session } = getModels(); - await Session.deleteAllUserSessions(user); + // Delete all user sessions + await deleteAllUserSessions(userId.toString()); - await User.deleteMany({ _id: user }); - await Balance.deleteMany({ user }); - await Transaction.deleteMany({ user }); + // Get models from the registered models + const { User, Balance, Transaction } = getModels(); + + // Delete user, balance, and transactions using the registered models + await User.deleteMany({ _id: userId }); + await Balance.deleteMany({ user: userId }); + await Transaction.deleteMany({ user: userId }); console.log('๐Ÿค–: โœ… Deleted user from Database'); diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 4252ba6990..c135bacfb6 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -1,3 +1,20 @@ +// Export all types +export * from './types'; + +// Export all models +export * from './models'; + +// Export all methods +export * from './methods'; + +// Export schemas (if needed for direct access) +export { default as userSchema } from './schema/user'; +export { default as sessionSchema } from './schema/session'; +export { default as tokenSchema } from './schema/token'; + +// Export utility functions from schemas +export { signPayload, hashToken } from './schema/session'; + export { default as actionSchema } from './schema/action'; export type { IAction } from './schema/action'; @@ -49,21 +66,11 @@ export type { IPromptGroup, IPromptGroupDocument } from './schema/promptGroup'; export { default as roleSchema } from './schema/role'; export type { IRole } from './schema/role'; -export { default as sessionSchema } from './schema/session'; -export type { ISession } from './schema/session'; - export { default as shareSchema } from './schema/share'; export type { ISharedLink } from './schema/share'; -export { default as tokenSchema } from './schema/token'; -export type { IToken } from './schema/token'; - export { default as toolCallSchema } from './schema/toolCall'; export type { IToolCallData } from './schema/toolCall'; export { default as transactionSchema } from './schema/transaction'; export type { ITransaction } from './schema/transaction'; - -export { default as userSchema } from './schema/user'; -export type { IUser } from './schema/user'; -export { registerModels } from './models'; diff --git a/packages/data-schemas/src/methods/README.md b/packages/data-schemas/src/methods/README.md new file mode 100644 index 0000000000..af7cc03a29 --- /dev/null +++ b/packages/data-schemas/src/methods/README.md @@ -0,0 +1,97 @@ +# Methods + +This directory contains pure functions that replace the static methods from the schema files. This refactoring improves testability, type safety, and code modularity. + +## Structure + +- `userMethods.ts` - Functions for user operations +- `sessionMethods.ts` - Functions for session operations +- `tokenMethods.ts` - Functions for token operations +- `index.ts` - Exports all methods for convenient importing + +## Migration from Static Methods + +Instead of calling static methods on models: + +```typescript +// OLD: Using static methods +const user = await UserModel.findUser({ email: 'test@example.com' }); +const result = await UserModel.deleteUserById(userId); +``` + +Use the pure functions with the model as the first parameter: + +```typescript +// NEW: Using pure functions +import { findUser, deleteUserById } from '~/methods'; +import UserModel from '~/schema/user'; + +const user = await findUser(UserModel, { email: 'test@example.com' }); +const result = await deleteUserById(UserModel, userId); +``` + +## Benefits + +1. **Pure Functions**: Methods are now side-effect free and testable +2. **Better Types**: Proper TypeScript typing throughout +3. **Dependency Injection**: Models are passed as parameters +4. **Modular**: Functions can be imported individually or as a group +5. **No Magic**: Clear explicit dependencies + +## Usage Examples + +### User Methods + +```typescript +import { createUser, findUser, updateUser } from '~/methods'; +import UserModel from '~/schema/user'; + +// Create a user +const newUser = await createUser( + UserModel, + { email: 'user@example.com', name: 'John' }, + { enabled: true, startBalance: 100 } +); + +// Find a user +const user = await findUser(UserModel, { email: 'user@example.com' }); + +// Update a user +const updated = await updateUser(UserModel, userId, { name: 'Jane' }); +``` + +### Session Methods + +```typescript +import { createSession, findSession, deleteSession } from '~/methods'; +import SessionModel from '~/schema/session'; + +// Create session +const { session, refreshToken } = await createSession(SessionModel, userId); + +// Find session +const foundSession = await findSession(SessionModel, { refreshToken }); + +// Delete session +await deleteSession(SessionModel, { sessionId }); +``` + +### Token Methods + +```typescript +import { createToken, findToken, deleteTokens } from '~/methods'; +import TokenModel from '~/schema/token'; + +// Create token +const token = await createToken(TokenModel, { + userId, + token: 'abc123', + expiresIn: 3600 +}); + +// Find token +const foundToken = await findToken(TokenModel, { token: 'abc123' }); + +// Delete tokens +await deleteTokens(TokenModel, { userId }); +``` \ No newline at end of file diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts new file mode 100644 index 0000000000..0aecd9a1b1 --- /dev/null +++ b/packages/data-schemas/src/methods/index.ts @@ -0,0 +1,8 @@ +// User methods +export * from './user'; + +// Session methods +export * from './session'; + +// Token methods +export * from './token'; diff --git a/packages/data-schemas/src/methods/session.ts b/packages/data-schemas/src/methods/session.ts new file mode 100644 index 0000000000..5f931507e4 --- /dev/null +++ b/packages/data-schemas/src/methods/session.ts @@ -0,0 +1,202 @@ +import mongoose from 'mongoose'; +import { Session } from '~/models'; +import { + ISession, + CreateSessionOptions, + SessionSearchParams, + SessionQueryOptions, + DeleteSessionParams, + DeleteAllSessionsOptions, + SessionResult, + SessionError, +} from '~/types'; +import { signPayload, hashToken } from '~/schema/session'; +import logger from '~/config/winston'; + +const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; +const expires = eval(REFRESH_TOKEN_EXPIRY ?? '0') ?? 1000 * 60 * 60 * 24 * 7; // 7 days default + +/** + * Creates a new session for a user + */ +export async function createSession( + userId: string, + options: CreateSessionOptions = {}, +): Promise { + if (!userId) { + throw new SessionError('User ID is required', 'INVALID_USER_ID'); + } + + try { + const session = { + _id: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(userId), + expiration: options.expiration || new Date(Date.now() + expires), + }; + const refreshToken = await generateRefreshToken(session); + + return { session, refreshToken }; + } catch (error) { + logger.error('[createSession] Error creating session:', error); + throw new SessionError('Failed to create session', 'CREATE_SESSION_FAILED'); + } +} + +/** + * Finds a session by various parameters + */ +export async function findSession( + params: SessionSearchParams, + options: SessionQueryOptions = { lean: true }, +): Promise { + try { + const query: Record = {}; + + if (!params.refreshToken && !params.userId && !params.sessionId) { + throw new SessionError('At least one search parameter is required', 'INVALID_SEARCH_PARAMS'); + } + + if (params.refreshToken) { + const tokenHash = await hashToken(params.refreshToken); + query.refreshTokenHash = tokenHash; + } + + if (params.userId) { + query.user = params.userId; + } + + if (params.sessionId) { + const sessionId = + typeof params.sessionId === 'object' && 'sessionId' in params.sessionId + ? params.sessionId.sessionId + : params.sessionId; + if (!mongoose.Types.ObjectId.isValid(sessionId)) { + throw new SessionError('Invalid session ID format', 'INVALID_SESSION_ID'); + } + query._id = sessionId; + } + + // Add expiration check to only return valid sessions + query.expiration = { $gt: new Date() }; + + const sessionQuery = Session.findOne(query); + + if (options.lean) { + return await sessionQuery.lean(); + } + + return await sessionQuery.exec(); + } catch (error) { + logger.error('[findSession] Error finding session:', error); + throw new SessionError('Failed to find session', 'FIND_SESSION_FAILED'); + } +} + +/** + * Deletes a session by refresh token or session ID + */ +export async function deleteSession( + params: DeleteSessionParams, +): Promise<{ deletedCount?: number }> { + try { + if (!params.refreshToken && !params.sessionId) { + throw new SessionError( + 'Either refreshToken or sessionId is required', + 'INVALID_DELETE_PARAMS', + ); + } + + const query: Record = {}; + + if (params.refreshToken) { + query.refreshTokenHash = await hashToken(params.refreshToken); + } + + if (params.sessionId) { + query._id = params.sessionId; + } + + const result = await Session.deleteOne(query); + + if (result.deletedCount === 0) { + logger.warn('[deleteSession] No session found to delete'); + } + + return result; + } catch (error) { + logger.error('[deleteSession] Error deleting session:', error); + throw new SessionError('Failed to delete session', 'DELETE_SESSION_FAILED'); + } +} + +/** + * Generates a refresh token for a session + */ +export async function generateRefreshToken(session: Partial): Promise { + if (!session || !session.user) { + throw new SessionError('Invalid session object', 'INVALID_SESSION'); + } + + try { + const expiresIn = session.expiration ? session.expiration.getTime() : Date.now() + expires; + if (!session.expiration) { + session.expiration = new Date(expiresIn); + } + + const refreshToken = await signPayload({ + payload: { + id: session.user, + sessionId: session._id, + }, + secret: process.env.JWT_REFRESH_SECRET, + expirationTime: Math.floor((expiresIn - Date.now()) / 1000), + }); + + session.refreshTokenHash = await hashToken(refreshToken); + await Session.create(session); + return refreshToken; + } catch (error) { + logger.error('[generateRefreshToken] Error generating refresh token:', error); + throw new SessionError('Failed to generate refresh token', 'GENERATE_TOKEN_FAILED'); + } +} + +/** + * Deletes all sessions for a user + */ +export async function deleteAllUserSessions( + userId: string | { userId: string }, + options: DeleteAllSessionsOptions = {}, +): Promise<{ deletedCount?: number }> { + try { + if (!userId) { + throw new SessionError('User ID is required', 'INVALID_USER_ID'); + } + + // Extract userId if it's passed as an object + const userIdString = typeof userId === 'object' ? userId.userId : userId; + + if (!mongoose.Types.ObjectId.isValid(userIdString)) { + throw new SessionError('Invalid user ID format', 'INVALID_USER_ID_FORMAT'); + } + + const query: Record = { user: userIdString }; + + if (options.excludeCurrentSession && options.currentSessionId) { + query._id = { $ne: options.currentSessionId }; + } + + const result = await Session.deleteMany(query); + + if (result.deletedCount && result.deletedCount > 0) { + logger.debug( + `[deleteAllUserSessions] Deleted ${result.deletedCount} sessions for user ${userIdString}.`, + ); + } + + return result; + } catch (error) { + logger.error('[deleteAllUserSessions] Error deleting user sessions:', error); + throw new SessionError('Failed to delete user sessions', 'DELETE_ALL_SESSIONS_FAILED'); + } +} diff --git a/packages/data-schemas/src/methods/token.ts b/packages/data-schemas/src/methods/token.ts new file mode 100644 index 0000000000..ccb0ae8588 --- /dev/null +++ b/packages/data-schemas/src/methods/token.ts @@ -0,0 +1,105 @@ +import { Token } from '~/models'; +import { IToken, TokenCreateData, TokenQuery, TokenUpdateData, TokenDeleteResult } from '~/types'; +import logger from '~/config/winston'; + +/** + * Creates a new Token instance. + */ +export async function createToken(tokenData: TokenCreateData): Promise { + try { + const currentTime = new Date(); + const expiresAt = new Date(currentTime.getTime() + tokenData.expiresIn * 1000); + + const newTokenData = { + ...tokenData, + createdAt: currentTime, + expiresAt, + }; + + return await Token.create(newTokenData); + } catch (error) { + logger.debug('An error occurred while creating token:', error); + throw error; + } +} + +/** + * Updates a Token document that matches the provided query. + */ +export async function updateToken( + query: TokenQuery, + updateData: TokenUpdateData, +): Promise { + try { + return await Token.findOneAndUpdate(query, updateData, { new: true }); + } catch (error) { + logger.debug('An error occurred while updating token:', error); + throw error; + } +} + +/** + * Deletes all Token documents that match the provided token, user ID, or email. + */ +export async function deleteTokens(query: TokenQuery): Promise { + try { + const conditions = []; + + if (query.userId) { + conditions.push({ userId: query.userId }); + } + if (query.token) { + conditions.push({ token: query.token }); + } + if (query.email) { + conditions.push({ email: query.email }); + } + if (query.identifier) { + conditions.push({ identifier: query.identifier }); + } + + if (conditions.length === 0) { + throw new Error('At least one query parameter must be provided'); + } + + return await Token.deleteMany({ + $or: conditions, + }); + } catch (error) { + logger.debug('An error occurred while deleting tokens:', error); + throw error; + } +} + +/** + * Finds a Token document that matches the provided query. + */ +export async function findToken(query: TokenQuery): Promise { + try { + const conditions = []; + + if (query.userId) { + conditions.push({ userId: query.userId }); + } + if (query.token) { + conditions.push({ token: query.token }); + } + if (query.email) { + conditions.push({ email: query.email }); + } + if (query.identifier) { + conditions.push({ identifier: query.identifier }); + } + + if (conditions.length === 0) { + throw new Error('At least one query parameter must be provided'); + } + + return (await Token.findOne({ + $and: conditions, + }).lean()) as IToken | null; + } catch (error) { + logger.debug('An error occurred while finding token:', error); + throw error; + } +} diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts new file mode 100644 index 0000000000..ea5722aa33 --- /dev/null +++ b/packages/data-schemas/src/methods/user.ts @@ -0,0 +1,151 @@ +import mongoose, { FilterQuery } from 'mongoose'; +import { User, Balance } from '~/models'; +import { IUser, BalanceConfig, UserCreateData, UserUpdateResult } from '~/types'; +import { signPayload } from '~/schema/session'; + +/** + * Search for a single user based on partial data and return matching user document as plain object. + */ +export async function findUser( + searchCriteria: FilterQuery, + fieldsToSelect?: string | string[] | null, +): Promise { + const query = User.findOne(searchCriteria); + if (fieldsToSelect) { + query.select(fieldsToSelect); + } + return await query.lean(); +} + +/** + * Count the number of user documents in the collection based on the provided filter. + */ +export async function countUsers(filter: FilterQuery = {}): Promise { + return await User.countDocuments(filter); +} + +/** + * Creates a new user, optionally with a TTL of 1 week. + */ +export async function createUser( + data: UserCreateData, + balanceConfig?: BalanceConfig, + disableTTL: boolean = true, + returnUser: boolean = false, +): Promise> { + const userData: Partial = { + ...data, + expiresAt: disableTTL ? undefined : new Date(Date.now() + 604800 * 1000), // 1 week in milliseconds + }; + + if (disableTTL) { + delete userData.expiresAt; + } + + const user = await User.create(userData); + + // If balance is enabled, create or update a balance record for the user + if (balanceConfig?.enabled && balanceConfig?.startBalance) { + const update: { + $inc: { tokenCredits: number }; + $set?: { + autoRefillEnabled: boolean; + refillIntervalValue: number; + refillIntervalUnit: string; + refillAmount: number; + }; + } = { + $inc: { tokenCredits: balanceConfig.startBalance }, + }; + + if ( + balanceConfig.autoRefillEnabled && + balanceConfig.refillIntervalValue != null && + balanceConfig.refillIntervalUnit != null && + balanceConfig.refillAmount != null + ) { + update.$set = { + autoRefillEnabled: true, + refillIntervalValue: balanceConfig.refillIntervalValue, + refillIntervalUnit: balanceConfig.refillIntervalUnit, + refillAmount: balanceConfig.refillAmount, + }; + } + + await Balance.findOneAndUpdate({ user: user._id }, update, { upsert: true, new: true }).lean(); + } + + if (returnUser) { + return user.toObject() as Partial; + } + return user._id as mongoose.Types.ObjectId; +} + +/** + * Update a user with new data without overwriting existing properties. + */ +export async function updateUser( + userId: string, + updateData: Partial, +): Promise { + const updateOperation = { + $set: updateData, + $unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL + }; + return await User.findByIdAndUpdate(userId, updateOperation, { + new: true, + runValidators: true, + }).lean(); +} + +/** + * Retrieve a user by ID and convert the found user document to a plain object. + */ +export async function getUserById( + userId: string, + fieldsToSelect?: string | string[] | null, +): Promise { + const query = User.findById(userId); + if (fieldsToSelect) { + query.select(fieldsToSelect); + } + return await query.lean(); +} + +/** + * Delete a user by their unique ID. + */ +export async function deleteUserById(userId: string): Promise { + try { + const result = await User.deleteOne({ _id: userId }); + if (result.deletedCount === 0) { + return { deletedCount: 0, message: 'No user found with that ID.' }; + } + return { deletedCount: result.deletedCount, message: 'User was deleted successfully.' }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error('Error deleting user: ' + errorMessage); + } +} + +/** + * Generates a JWT token for a given user. + */ +export async function generateToken(user: IUser): Promise { + if (!user) { + throw new Error('No user provided'); + } + + const expires = eval(process.env.SESSION_EXPIRY ?? '0') ?? 1000 * 60 * 15; + + return await signPayload({ + payload: { + id: user._id, + username: user.username, + provider: user.provider, + email: user.email, + }, + secret: process.env.JWT_SECRET, + expirationTime: expires / 1000, + }); +} diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index cd4ee10d84..a53380286e 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -1,185 +1,25 @@ -import type { Mongoose } from 'mongoose'; -import { default as actionSchema } from '../schema/action'; -import { default as agentSchema } from '../schema/agent'; -import { default as assistantSchema } from '../schema/assistant'; -import { default as balanceSchema } from '../schema/balance'; -import { default as bannerSchema } from '../schema/banner'; -import { default as categoriesSchema } from '../schema/categories'; -import { default as conversationTagSchema } from '../schema/conversationTag'; -import { default as convoSchema } from '../schema/convo'; -import { default as fileSchema } from '../schema/file'; -import { default as keySchema } from '../schema/key'; -import { default as messageSchema } from '../schema/message'; -import { default as pluginAuthSchema } from '../schema/pluginAuth'; -import { default as presetSchema } from '../schema/preset'; -import { default as projectSchema } from '../schema/project'; -import { default as promptSchema } from '../schema/prompt'; -import { default as promptGroupSchema } from '../schema/promptGroup'; -import { default as roleSchema } from '../schema/role'; -import { default as sessionSchema } from '../schema/session'; -import { default as shareSchema } from '../schema/share'; -import { default as tokenSchema } from '../schema/token'; -import { default as toolCallSchema } from '../schema/toolCall'; -import { default as transactionSchema } from '../schema/transaction'; -import { default as userSchema } from '../schema/user'; -import mongoMeili from './plugins/mongoMeili'; +import mongoose from 'mongoose'; -export const registerModels = (mongoose: Mongoose) => { - const User = registerUserModel(mongoose); - const Session = registerSessionModel(mongoose); - const Token = registerTokenModel(mongoose); - const Message = registerMessageModel(mongoose); - const Action = registerActionModel(mongoose); - const Agent = registerAgentModel(mongoose); - const Assistant = registerAssistantModel(mongoose); - const Balance = registerBalanceModel(mongoose); - const Banner = registerBannerModel(mongoose); - const Categories = registerCategoriesModel(mongoose); - const ConversationTag = registerConversationTagModel(mongoose); - const File = registerFileModel(mongoose); - const Key = registerKeyModel(mongoose); - const PluginAuth = registerPluginAuthModel(mongoose); - const Preset = registerPresetModel(mongoose); - const Project = registerProjectModel(mongoose); - const Prompt = registerPromptModel(mongoose); - const PromptGroup = registerPromptGroupModel(mongoose); - const Role = registerRoleModel(mongoose); - const SharedLink = registerShareModel(mongoose); - const ToolCall = registerToolCallModel(mongoose); - const Transaction = registerTransactionModel(mongoose); - const Conversation = registerConversationModel(mongoose); +// Import schemas +import userSchema from '~/schema/user'; +import sessionSchema from '~/schema/session'; +import tokenSchema from '~/schema/token'; +import balanceSchema from '~/schema/balance'; - return { - User, - Session, - Token, - Message, - Action, - Agent, - Assistant, - Balance, - Banner, - Categories, - ConversationTag, - File, - Key, - PluginAuth, - Preset, - Project, - Prompt, - PromptGroup, - Role, - SharedLink, - ToolCall, - Transaction, - Conversation, - }; -}; +// Import types +import { IUser, ISession, IToken } from '~/types'; +import { IBalance } from '~/schema/balance'; -const registerSessionModel = (mongoose: Mongoose) => { - return mongoose.models.Session || mongoose.model('Session', sessionSchema); -}; +// Create and export model instances +export const User = mongoose.model('User', userSchema); +export const Session = mongoose.model('Session', sessionSchema); +export const Token = mongoose.model('Token', tokenSchema); +export const Balance = mongoose.model('Balance', balanceSchema); -const registerUserModel = (mongoose: Mongoose) => { - return mongoose.models.User || mongoose.model('User', userSchema); -}; - -const registerTokenModel = (mongoose: Mongoose) => { - return mongoose.models.Token || mongoose.model('Token', tokenSchema); -}; - -const registerActionModel = (mongoose: Mongoose) => { - return mongoose.models.Action || mongoose.model('Action', actionSchema); -}; - -const registerMessageModel = (mongoose: Mongoose) => { - if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { - messageSchema.plugin(mongoMeili, { - host: process.env.MEILI_HOST, - apiKey: process.env.MEILI_MASTER_KEY, - indexName: 'messages', - primaryKey: 'messageId', - }); - } - - return mongoose.models.Message || mongoose.model('Message', messageSchema); -}; - -const registerAgentModel = (mongoose: Mongoose) => { - return mongoose.models.Agent || mongoose.model('Agent', agentSchema); -}; - -const registerAssistantModel = (mongoose: Mongoose) => { - return mongoose.models.Assistant || mongoose.model('Assistant', assistantSchema); -}; - -const registerBalanceModel = (mongoose: Mongoose) => { - return mongoose.models.Balance || mongoose.model('Balance', balanceSchema); -}; - -const registerBannerModel = (mongoose: Mongoose) => { - return mongoose.models.Banner || mongoose.model('Banner', bannerSchema); -}; - -const registerCategoriesModel = (mongoose: Mongoose) => { - return mongoose.models.Categories || mongoose.model('Categories', categoriesSchema); -}; - -const registerConversationTagModel = (mongoose: Mongoose) => { - return ( - mongoose.models.ConversationTag || mongoose.model('ConversationTag', conversationTagSchema) - ); -}; -const registerFileModel = (mongoose: Mongoose) => { - return mongoose.models.File || mongoose.model('File', fileSchema); -}; - -const registerKeyModel = (mongoose: Mongoose) => { - return mongoose.models.Key || mongoose.model('Key', keySchema); -}; - -const registerPluginAuthModel = (mongoose: Mongoose) => { - return mongoose.models.PluginAuth || mongoose.model('PluginAuth', pluginAuthSchema); -}; - -const registerPresetModel = (mongoose: Mongoose) => { - return mongoose.models.Preset || mongoose.model('Preset', presetSchema); -}; - -const registerProjectModel = (mongoose: Mongoose) => { - return mongoose.models.Project || mongoose.model('Project', projectSchema); -}; -const registerPromptModel = (mongoose: Mongoose) => { - return mongoose.models.Prompt || mongoose.model('Prompt', promptSchema); -}; -const registerPromptGroupModel = (mongoose: Mongoose) => { - return mongoose.models.PromptGroup || mongoose.model('PromptGroup', promptGroupSchema); -}; - -const registerRoleModel = (mongoose: Mongoose) => { - return mongoose.models.Role || mongoose.model('Role', roleSchema); -}; -const registerShareModel = (mongoose: Mongoose) => { - return mongoose.models.SharedLink || mongoose.model('SharedLink', shareSchema); -}; - -const registerToolCallModel = (mongoose: Mongoose) => { - return mongoose.models.ToolCall || mongoose.model('ToolCall', toolCallSchema); -}; - -const registerTransactionModel = (mongoose: Mongoose) => { - return mongoose.models.Transaction || mongoose.model('Transaction', transactionSchema); -}; -const registerConversationModel = (mongoose: Mongoose) => { - if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { - convoSchema.plugin(mongoMeili, { - host: process.env.MEILI_HOST, - apiKey: process.env.MEILI_MASTER_KEY, - /** Note: Will get created automatically if it doesn't exist already */ - indexName: 'convos', - primaryKey: 'conversationId', - }); - } - - return mongoose.models.Conversation || mongoose.model('Conversation', convoSchema); +// Default export with all models +export default { + User, + Session, + Token, + Balance, }; diff --git a/packages/data-schemas/src/schema/session.ts b/packages/data-schemas/src/schema/session.ts index 10cd13bb2e..4797726cd7 100644 --- a/packages/data-schemas/src/schema/session.ts +++ b/packages/data-schemas/src/schema/session.ts @@ -1,13 +1,7 @@ -import mongoose, { Schema, Document, Types } from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; import jwt from 'jsonwebtoken'; -import logger from '../config/winston'; -const { webcrypto } = require('node:crypto'); - -export interface ISession extends Document { - refreshTokenHash: string; - expiration: Date; - user: Types.ObjectId; -} +import { webcrypto } from 'node:crypto'; +import { ISession, SignPayloadParams } from '~/types'; const sessionSchema: Schema = new Schema({ refreshTokenHash: { @@ -26,224 +20,18 @@ const sessionSchema: Schema = new Schema({ }, }); -/** - * Error class for Session-related errors - */ -class SessionError extends Error { - constructor(message, code = 'SESSION_ERROR') { - super(message); - this.name = 'SessionError'; - this.code = code; - } -} -const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; -const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7; // 7 days default - -/** - * Creates a new session for a user - * @param {string} userId - The ID of the user - * @param {Object} options - Additional options for session creation - * @param {Date} options.expiration - Custom expiration date - * @returns {Promise<{session: Session, refreshToken: string}>} - * @throws {SessionError} - */ -sessionSchema.statics.createSession = async function (userId, options = {}) { - if (!userId) { - throw new SessionError('User ID is required', 'INVALID_USER_ID'); - } - - try { - const session = { - _id: new Types.ObjectId(), - user: userId, - expiration: options.expiration || new Date(Date.now() + expires), - }; - const refreshToken = await this.generateRefreshToken(session); - - return { session, refreshToken }; - } catch (error) { - logger.error('[createSession] Error creating session:', error); - throw new SessionError('Failed to create session', 'CREATE_SESSION_FAILED'); - } -}; - -/** - * Finds a session by various parameters - * @param {Object} params - Search parameters - * @param {string} [params.refreshToken] - The refresh token to search by - * @param {string} [params.userId] - The user ID to search by - * @param {string} [params.sessionId] - The session ID to search by - * @param {Object} [options] - Additional options - * @param {boolean} [options.lean=true] - Whether to return plain objects instead of documents - * @returns {Promise} - * @throws {SessionError} - */ -sessionSchema.statics.findSession = async function (params, options = { lean: true }) { - try { - const query = {}; - - if (!params.refreshToken && !params.userId && !params.sessionId) { - throw new SessionError('At least one search parameter is required', 'INVALID_SEARCH_PARAMS'); - } - - if (params.refreshToken) { - const tokenHash = await hashToken(params.refreshToken); - query.refreshTokenHash = tokenHash; - } - - if (params.userId) { - query.user = params.userId; - } - - if (params.sessionId) { - const sessionId = params.sessionId.sessionId || params.sessionId; - if (!mongoose.Types.ObjectId.isValid(sessionId)) { - throw new SessionError('Invalid session ID format', 'INVALID_SESSION_ID'); - } - query._id = sessionId; - } - - // Add expiration check to only return valid sessions - query.expiration = { $gt: new Date() }; - - const sessionQuery = this.findOne(query); - - if (options.lean) { - return await sessionQuery.lean(); - } - - return await sessionQuery.exec(); - } catch (error) { - logger.error('[findSession] Error finding session:', error); - throw new SessionError('Failed to find session', 'FIND_SESSION_FAILED'); - } -}; - -/** - * Deletes a session by refresh token or session ID - * @param {Object} params - Delete parameters - * @param {string} [params.refreshToken] - The refresh token of the session to delete - * @param {string} [params.sessionId] - The ID of the session to delete - * @returns {Promise} - * @throws {SessionError} - */ -sessionSchema.statics.deleteSession = async function (params) { - try { - if (!params.refreshToken && !params.sessionId) { - throw new SessionError( - 'Either refreshToken or sessionId is required', - 'INVALID_DELETE_PARAMS', - ); - } - - const query = {}; - - if (params.refreshToken) { - query.refreshTokenHash = await hashToken(params.refreshToken); - } - - if (params.sessionId) { - query._id = params.sessionId; - } - - const result = await this.deleteOne(query); - - if (result.deletedCount === 0) { - logger.warn('[deleteSession] No session found to delete'); - } - - return result; - } catch (error) { - logger.error('[deleteSession] Error deleting session:', error); - throw new SessionError('Failed to delete session', 'DELETE_SESSION_FAILED'); - } -}; - -/** - * Generates a refresh token for a session - * @param {Session} session - The session to generate a token for - * @returns {Promise} - * @throws {SessionError} - */ -sessionSchema.statics.generateRefreshToken = async function (session) { - if (!session || !session.user) { - throw new SessionError('Invalid session object', 'INVALID_SESSION'); - } - - try { - const expiresIn = session.expiration ? session.expiration.getTime() : Date.now() + expires; - if (!session.expiration) { - session.expiration = new Date(expiresIn); - } - - const refreshToken = await signPayload({ - payload: { - id: session.user, - sessionId: session._id, - }, - secret: process.env.JWT_REFRESH_SECRET, - expirationTime: Math.floor((expiresIn - Date.now()) / 1000), - }); - - session.refreshTokenHash = await hashToken(refreshToken); - await this.create(session); - return refreshToken; - } catch (error) { - logger.error('[generateRefreshToken] Error generating refresh token:', error); - throw new SessionError('Failed to generate refresh token', 'GENERATE_TOKEN_FAILED'); - } -}; - -/** - * Deletes all sessions for a user - * @param {string} userId - The ID of the user - * @param {Object} [options] - Additional options - * @param {boolean} [options.excludeCurrentSession] - Whether to exclude the current session - * @param {string} [options.currentSessionId] - The ID of the current session to exclude - * @returns {Promise} - * @throws {SessionError} - */ -sessionSchema.statics.deleteAllUserSessions = async function (userId, options = {}) { - try { - if (!userId) { - throw new SessionError('User ID is required', 'INVALID_USER_ID'); - } - - // Extract userId if it's passed as an object - const userIdString = userId.userId || userId; - - if (!mongoose.Types.ObjectId.isValid(userIdString)) { - throw new SessionError('Invalid user ID format', 'INVALID_USER_ID_FORMAT'); - } - - const query = { user: userIdString }; - - if (options.excludeCurrentSession && options.currentSessionId) { - query._id = { $ne: options.currentSessionId }; - } - - const result = await this.deleteMany(query); - - if (result.deletedCount > 0) { - logger.debug( - `[deleteAllUserSessions] Deleted ${result.deletedCount} sessions for user ${userIdString}.`, - ); - } - - return result; - } catch (error) { - logger.error('[deleteAllUserSessions] Error deleting user sessions:', error); - throw new SessionError('Failed to delete user sessions', 'DELETE_ALL_SESSIONS_FAILED'); - } -}; - -export async function signPayload({ payload, secret, expirationTime }) { - return jwt.sign(payload, secret, { expiresIn: expirationTime }); +export async function signPayload({ + payload, + secret, + expirationTime, +}: SignPayloadParams): Promise { + return jwt.sign(payload, secret!, { expiresIn: expirationTime }); } -export async function hashToken(str) { +export async function hashToken(str: string): Promise { const data = new TextEncoder().encode(str); const hashBuffer = await webcrypto.subtle.digest('SHA-256', data); return Buffer.from(hashBuffer).toString('hex'); } + export default sessionSchema; diff --git a/packages/data-schemas/src/schema/token.ts b/packages/data-schemas/src/schema/token.ts index f5b91ba49e..8cb17eec5d 100644 --- a/packages/data-schemas/src/schema/token.ts +++ b/packages/data-schemas/src/schema/token.ts @@ -1,16 +1,5 @@ -import { Schema, Document, Types } from 'mongoose'; -import { logger } from '~/config'; - -export interface IToken extends Document { - userId: Types.ObjectId; - email?: string; - type?: string; - identifier?: string; - token: string; - createdAt: Date; - expiresAt: Date; - metadata?: Map; -} +import { Schema } from 'mongoose'; +import { IToken } from '~/types'; const tokenSchema: Schema = new Schema({ userId: { @@ -48,116 +37,4 @@ const tokenSchema: Schema = new Schema({ tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); -/** - * Creates a new Token instance. - * @param {Object} tokenData - The data for the new Token. - * @param {mongoose.Types.ObjectId} tokenData.userId - The user's ID. It is required. - * @param {String} tokenData.email - The user's email. - * @param {String} tokenData.token - The token. It is required. - * @param {Number} tokenData.expiresIn - The number of seconds until the token expires. - * @returns {Promise} The new Token instance. - * @throws Will throw an error if token creation fails. - */ -tokenSchema.statics.createToken = async function (tokenData) { - try { - const currentTime = new Date(); - const expiresAt = new Date(currentTime.getTime() + tokenData.expiresIn * 1000); - - const newTokenData = { - ...tokenData, - createdAt: currentTime, - expiresAt, - }; - - return await this.create(newTokenData); - } catch (error) { - logger.debug('An error occurred while creating token:', error); - throw error; - } -}; - -/** - * Updates a Token document that matches the provided query. - * @param {Object} query - The query to match against. - * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. - * @param {String} query.token - The token value. - * @param {String} [query.email] - The email of the user. - * @param {String} [query.identifier] - Unique, alternative identifier for the token. - * @param {Object} updateData - The data to update the Token with. - * @returns {Promise} The updated Token document, or null if not found. - * @throws Will throw an error if the update operation fails. - */ -tokenSchema.statics.updateToken = async function (query, updateData) { - try { - return await this.findOneAndUpdate(query, updateData, { new: true }); - } catch (error) { - logger.debug('An error occurred while updating token:', error); - throw error; - } -}; - -/** - * Deletes all Token documents that match the provided token, user ID, or email. - * @param {Object} query - The query to match against. - * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. - * @param {String} query.token - The token value. - * @param {String} [query.email] - The email of the user. - * @param {String} [query.identifier] - Unique, alternative identifier for the token. - * @returns {Promise} The result of the delete operation. - * @throws Will throw an error if the delete operation fails. - */ -tokenSchema.statics.deleteTokens = async function (query) { - try { - return await Token.deleteMany({ - $or: [ - { userId: query.userId }, - { token: query.token }, - { email: query.email }, - { identifier: query.identifier }, - ], - }); - } catch (error) { - logger.debug('An error occurred while deleting tokens:', error); - throw error; - } -}; - -/** - * Finds a Token document that matches the provided query. - * @param {Object} query - The query to match against. - * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. - * @param {String} query.token - The token value. - * @param {String} [query.email] - The email of the user. - * @param {String} [query.identifier] - Unique, alternative identifier for the token. - * @returns {Promise} The matched Token document, or null if not found. - * @throws Will throw an error if the find operation fails. - */ -tokenSchema.statics.findToken = async function (query) { - try { - const conditions = []; - - if (query.userId) { - conditions.push({ userId: query.userId }); - } - if (query.token) { - conditions.push({ token: query.token }); - } - if (query.email) { - conditions.push({ email: query.email }); - } - if (query.identifier) { - conditions.push({ identifier: query.identifier }); - } - - const token = await this.findOne({ - $and: conditions, - }).lean(); - - return token; - } catch (error) { - logger.debug('An error occurred while finding token:', error); - throw error; - } -}; - export default tokenSchema; diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index 62274a10ed..6107488ee8 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -1,40 +1,6 @@ -import mongoose, { Schema, Document, Model, Types } from 'mongoose'; +import { Schema } from 'mongoose'; import { SystemRoles } from 'librechat-data-provider'; -import { default as balanceSchema } from './balance'; -import { signPayload } from './session'; -export interface IUser extends Document { - name?: string; - username?: string; - email: string; - emailVerified: boolean; - password?: string; - avatar?: string; - provider: string; - role?: string; - googleId?: string; - facebookId?: string; - openidId?: string; - samlId?: string; - ldapId?: string; - githubId?: string; - discordId?: string; - appleId?: string; - plugins?: unknown[]; - twoFactorEnabled?: boolean; - totpSecret?: string; - backupCodes?: Array<{ - codeHash: string; - used: boolean; - usedAt?: Date | null; - }>; - refreshToken?: Array<{ - refreshToken: string; - }>; - expiresAt?: Date; - termsAccepted?: boolean; - createdAt?: Date; - updatedAt?: Date; -} +import { IUser } from '~/types'; // Session sub-schema const SessionSchema = new Schema( @@ -167,165 +133,4 @@ const userSchema = new Schema( { timestamps: true }, ); -/** - * Search for a single user based on partial data and return matching user document as plain object. - * @param {Partial} searchCriteria - The partial data to use for searching the user. - * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. - * @returns {Promise} A plain object representing the user document, or `null` if no user is found. - */ -userSchema.statics.findUser = async function ( - searchCriteria: Partial, - fieldsToSelect: string | string[] | null = null, -) { - const query = this.findOne(searchCriteria); - if (fieldsToSelect) { - query.select(fieldsToSelect); - } - return await query.lean(); -}; - -/** - * Count the number of user documents in the collection based on the provided filter. - * - * @param {Object} [filter={}] - The filter to apply when counting the documents. - * @returns {Promise} The count of documents that match the filter. - */ -userSchema.statics.countUsers = async function (filter: Record = {}) { - return await this.countDocuments(filter); -}; -/** - * Creates a new user, optionally with a TTL of 1 week. - * @param {MongoUser} data - The user data to be created, must contain user_id. - * @param {boolean} [disableTTL=true] - Whether to disable the TTL. Defaults to `true`. - * @param {boolean} [returnUser=false] - Whether to return the created user object. - * @returns {Promise} A promise that resolves to the created user document ID or user object. - * @throws {Error} If a user with the same user_id already exists. - */ -userSchema.statics.createUser = async function ( - data: Partial, - balanceConfig: any, - disableTTL: boolean = true, - returnUser: boolean = false, -) { - const userData: Partial = { - ...data, - expiresAt: disableTTL ? null : new Date(Date.now() + 604800 * 1000), // 1 week in milliseconds - }; - - if (disableTTL) { - delete userData.expiresAt; - } - - const user = await this.create(userData); - - // If balance is enabled, create or update a balance record for the user using global.interfaceConfig.balance - if (balanceConfig?.enabled && balanceConfig?.startBalance) { - const update = { - $inc: { tokenCredits: balanceConfig.startBalance }, - }; - - if ( - balanceConfig.autoRefillEnabled && - balanceConfig.refillIntervalValue != null && - balanceConfig.refillIntervalUnit != null && - balanceConfig.refillAmount != null - ) { - update.$set = { - autoRefillEnabled: true, - refillIntervalValue: balanceConfig.refillIntervalValue, - refillIntervalUnit: balanceConfig.refillIntervalUnit, - refillAmount: balanceConfig.refillAmount, - }; - } - - const balanceModel = mongoose.model('Balance', balanceSchema); - await balanceModel - .findOneAndUpdate({ user: user._id }, update, { upsert: true, new: true }) - .lean(); - } - - if (returnUser) { - return user.toObject(); - } - return user._id; -}; -/** - * Update a user with new data without overwriting existing properties. - * - * @param {string} userId - The ID of the user to update. - * @param {Object} updateData - An object containing the properties to update. - * @returns {Promise} The updated user document as a plain object, or `null` if no user is found. - */ -userSchema.statics.updateUser = async function (userId: string, updateData: Partial) { - const updateOperation = { - $set: updateData, - $unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL - }; - return await this.findByIdAndUpdate(userId, updateOperation, { - new: true, - runValidators: true, - }).lean(); -}; - -/** - * Retrieve a user by ID and convert the found user document to a plain object. - * - * @param {string} userId - The ID of the user to find and return as a plain object. - * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. - * @returns {Promise} A plain object representing the user document, or `null` if no user is found. - */ -userSchema.statics.getUserById = async function ( - userId: string, - fieldsToSelect: string | string[] | null = null, -) { - const query = this.findById(userId); - if (fieldsToSelect) { - query.select(fieldsToSelect); - } - return await query.lean(); -}; - -/** - * Delete a user by their unique ID. - * - * @param {string} userId - The ID of the user to delete. - * @returns {Promise<{ deletedCount: number }>} An object indicating the number of deleted documents. - */ -userSchema.statics.deleteUserById = async function (userId: string) { - try { - const result = await this.deleteOne({ _id: userId }); - if (result.deletedCount === 0) { - return { deletedCount: 0, message: 'No user found with that ID.' }; - } - return { deletedCount: result.deletedCount, message: 'User was deleted successfully.' }; - } catch (error: any) { - throw new Error('Error deleting user: ' + error?.message); - } -}; - -/** - * Generates a JWT token for a given user. - * - * @param {MongoUser} user - The user for whom the token is being generated. - * @returns {Promise} A promise that resolves to a JWT token. - */ -userSchema.statics.generateToken = async function (user: IUser): Promise { - if (!user) { - throw new Error('No user provided'); - } - - const expires = eval(process.env.SESSION_EXPIRY ?? '0') ?? 1000 * 60 * 15; - - return await signPayload({ - payload: { - id: user._id, - username: user.username, - provider: user.provider, - email: user.email, - }, - secret: process.env.JWT_SECRET, - expirationTime: expires / 1000, - }); -}; - export default userSchema; diff --git a/packages/data-schemas/src/types/README.md b/packages/data-schemas/src/types/README.md new file mode 100644 index 0000000000..803b899304 --- /dev/null +++ b/packages/data-schemas/src/types/README.md @@ -0,0 +1,223 @@ +# Data Schemas - Refactored Architecture + +This package has been refactored to follow a clean, modular architecture with clear separation of concerns. + +## ๐Ÿ“ Directory Structure + +``` +packages/data-schemas/src/ +โ”œโ”€โ”€ index.ts # Main exports +โ”œโ”€โ”€ schema/ # ๐Ÿ—„๏ธ Mongoose schema definitions +โ”‚ โ”œโ”€โ”€ user.ts +โ”‚ โ”œโ”€โ”€ session.ts +โ”‚ โ””โ”€โ”€ token.ts +โ”œโ”€โ”€ models/ # ๐Ÿ—๏ธ Mongoose model instances +โ”‚ โ””โ”€โ”€ index.ts +โ”œโ”€โ”€ methods/ # โš™๏ธ Business logic functions +โ”‚ โ”œโ”€โ”€ index.ts +โ”‚ โ”œโ”€โ”€ user.ts +โ”‚ โ”œโ”€โ”€ session.ts +โ”‚ โ””โ”€โ”€ token.ts +โ””โ”€โ”€ types/ # ๐Ÿ“‹ TypeScript interfaces & types + โ”œโ”€โ”€ index.ts + โ”œโ”€โ”€ user.ts + โ”œโ”€โ”€ session.ts + โ””โ”€โ”€ token.ts +``` + +## ๐ŸŽฏ Key Benefits + +### 1. **Separation of Concerns** +- **Schema**: Pure Mongoose schema definitions +- **Models**: Model instances created once +- **Methods**: Business logic as pure functions +- **Types**: Shared TypeScript interfaces + +### 2. **No Dynamic Imports** +- Models are created once in the models directory +- Methods import model instances directly +- No more dynamic `import()` calls + +### 3. **Better Type Safety** +- Shared types across all layers +- Proper TypeScript typing throughout +- Clear interfaces for all operations + +### 4. **Pure Functions** +- Methods are now side-effect free +- Easy to test and reason about +- No magic or hidden dependencies + +## ๐Ÿš€ Migration Guide + +### Before (Static Methods) +```typescript +import { User } from 'some-model-registry'; + +// Old way with static methods +const user = await User.findUser({ email: 'test@example.com' }); +const result = await User.deleteUserById(userId); +``` + +### After (Pure Functions) +```typescript +import { findUser, deleteUserById } from '~/methods'; + +// New way with pure functions +const user = await findUser({ email: 'test@example.com' }); +const result = await deleteUserById(userId); +``` + +## ๐Ÿ“š Usage Examples + +### User Operations +```typescript +import { + findUser, + createUser, + updateUser, + deleteUserById, + generateToken +} from '~/methods'; + +// Find a user +const user = await findUser( + { email: 'user@example.com' }, + 'name email role' +); + +// Create a user with balance config +const newUser = await createUser( + { email: 'new@example.com', name: 'John' }, + { enabled: true, startBalance: 100 }, + true, // disable TTL + true // return user object +); + +// Update user +const updated = await updateUser(userId, { name: 'Jane' }); + +// Delete user +const result = await deleteUserById(userId); + +// Generate JWT token +const token = await generateToken(user); +``` + +### Session Operations +```typescript +import { + createSession, + findSession, + deleteSession, + deleteAllUserSessions +} from '~/methods'; + +// Create session +const { session, refreshToken } = await createSession(userId); + +// Find session by refresh token +const foundSession = await findSession({ refreshToken }); + +// Delete specific session +await deleteSession({ sessionId }); + +// Delete all user sessions +await deleteAllUserSessions(userId, { + excludeCurrentSession: true, + currentSessionId +}); +``` + +### Token Operations +```typescript +import { + createToken, + findToken, + updateToken, + deleteTokens +} from '~/methods'; + +// Create token +const token = await createToken({ + userId, + token: 'abc123', + type: 'verification', + expiresIn: 3600 // 1 hour +}); + +// Find token +const foundToken = await findToken({ + token: 'abc123', + type: 'verification' +}); + +// Update token +const updated = await updateToken( + { token: 'abc123' }, + { type: 'password-reset' } +); + +// Delete tokens +await deleteTokens({ userId }); +``` + +## ๐Ÿ”ง Path Aliases + +The project uses `~/` as an alias for `./src/`: + +```typescript +import { IUser } from '~/types'; +import { User } from '~/models'; +import { findUser } from '~/methods'; +import userSchema from '~/schema/user'; +``` + +## โš ๏ธ Breaking Changes + +1. **Static Methods Removed**: All static methods have been removed from schema files +2. **Function Signatures**: Methods no longer take model as first parameter +3. **Import Paths**: Import from `~/methods` instead of calling static methods +4. **Type Definitions**: Types moved to dedicated `~/types` directory + +## ๐Ÿงช Testing + +The new pure function approach makes testing much easier: + +```typescript +import { findUser } from '~/methods'; + +// Easy to mock and test +jest.mock('~/models', () => ({ + User: { + findOne: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(mockUser) + }) + }) + } +})); + +test('findUser should return user', async () => { + const result = await findUser({ email: 'test@example.com' }); + expect(result).toEqual(mockUser); +}); +``` + +## ๐Ÿ”„ Error Handling + +All methods include proper error handling with typed errors: + +```typescript +import { SessionError } from '~/types'; + +try { + const session = await createSession(userId); +} catch (error) { + if (error instanceof SessionError) { + console.log('Session error:', error.code, error.message); + } +} +``` + +This refactoring provides a much cleaner, more maintainable, and type-safe architecture for data operations. \ No newline at end of file diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts new file mode 100644 index 0000000000..1077dfd676 --- /dev/null +++ b/packages/data-schemas/src/types/index.ts @@ -0,0 +1,8 @@ +// User types +export * from './user'; + +// Session types +export * from './session'; + +// Token types +export * from './token'; diff --git a/packages/data-schemas/src/types/session.ts b/packages/data-schemas/src/types/session.ts new file mode 100644 index 0000000000..46dfd3df82 --- /dev/null +++ b/packages/data-schemas/src/types/session.ts @@ -0,0 +1,52 @@ +import { Document, Types } from 'mongoose'; + +export interface ISession extends Document { + refreshTokenHash: string; + expiration: Date; + user: Types.ObjectId; +} + +export interface CreateSessionOptions { + expiration?: Date; +} + +export interface SessionSearchParams { + refreshToken?: string; + userId?: string; + sessionId?: string | { sessionId: string }; +} + +export interface SessionQueryOptions { + lean?: boolean; +} + +export interface DeleteSessionParams { + refreshToken?: string; + sessionId?: string; +} + +export interface DeleteAllSessionsOptions { + excludeCurrentSession?: boolean; + currentSessionId?: string; +} + +export interface SessionResult { + session: Partial; + refreshToken: string; +} + +export interface SignPayloadParams { + payload: Record; + secret?: string; + expirationTime: number; +} + +export class SessionError extends Error { + public code: string; + + constructor(message: string, code: string = 'SESSION_ERROR') { + super(message); + this.name = 'SessionError'; + this.code = code; + } +} diff --git a/packages/data-schemas/src/types/token.ts b/packages/data-schemas/src/types/token.ts new file mode 100644 index 0000000000..43fb808ebf --- /dev/null +++ b/packages/data-schemas/src/types/token.ts @@ -0,0 +1,42 @@ +import { Document, Types } from 'mongoose'; + +export interface IToken extends Document { + userId: Types.ObjectId; + email?: string; + type?: string; + identifier?: string; + token: string; + createdAt: Date; + expiresAt: Date; + metadata?: Map; +} + +export interface TokenCreateData { + userId: Types.ObjectId | string; + email?: string; + type?: string; + identifier?: string; + token: string; + expiresIn: number; + metadata?: Map; +} + +export interface TokenQuery { + userId?: Types.ObjectId | string; + token?: string; + email?: string; + identifier?: string; +} + +export interface TokenUpdateData { + email?: string; + type?: string; + identifier?: string; + token?: string; + expiresAt?: Date; + metadata?: Map; +} + +export interface TokenDeleteResult { + deletedCount?: number; +} diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts new file mode 100644 index 0000000000..206d051819 --- /dev/null +++ b/packages/data-schemas/src/types/user.ts @@ -0,0 +1,72 @@ +import { Document, Types } from 'mongoose'; + +export interface IUser extends Document { + name?: string; + username?: string; + email: string; + emailVerified: boolean; + password?: string; + avatar?: string; + provider: string; + role?: string; + googleId?: string; + facebookId?: string; + openidId?: string; + samlId?: string; + ldapId?: string; + githubId?: string; + discordId?: string; + appleId?: string; + plugins?: unknown[]; + twoFactorEnabled?: boolean; + totpSecret?: string; + backupCodes?: Array<{ + codeHash: string; + used: boolean; + usedAt?: Date | null; + }>; + refreshToken?: Array<{ + refreshToken: string; + }>; + expiresAt?: Date; + termsAccepted?: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +export interface BalanceConfig { + enabled?: boolean; + startBalance?: number; + autoRefillEnabled?: boolean; + refillIntervalValue?: number; + refillIntervalUnit?: string; + refillAmount?: number; +} + +export interface UserCreateData extends Partial { + email: string; +} + +export interface UserUpdateResult { + deletedCount: number; + message: string; +} + +export interface UserSearchCriteria { + email?: string; + username?: string; + googleId?: string; + facebookId?: string; + openidId?: string; + samlId?: string; + ldapId?: string; + githubId?: string; + discordId?: string; + appleId?: string; + _id?: Types.ObjectId | string; +} + +export interface UserQueryOptions { + fieldsToSelect?: string | string[] | null; + lean?: boolean; +}