refactor(data-schemas): restructure schemas, models, and methods for improved modularity

This commit is contained in:
Danny Avila 2025-05-30 01:42:06 -04:00
parent 30b8a1c6c4
commit f6ca8caf7e
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
17 changed files with 1080 additions and 782 deletions

View file

@ -1,12 +1,27 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { webcrypto } = require('node:crypto'); 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 { SystemRoles, errorsToString } = require('librechat-data-provider');
const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils'); const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils');
const { isEmailDomainAllowed } = require('~/server/services/domains'); const { isEmailDomainAllowed } = require('~/server/services/domains');
const { getBalanceConfig } = require('~/server/services/Config');
const { registerSchema } = require('~/strategies/validators'); const { registerSchema } = require('~/strategies/validators');
const { logger } = require('~/config'); const { logger } = require('~/config');
const db = require('~/lib/db/connectDb');
const { getBalanceConfig } = require('~/server/services/Config');
const domains = { const domains = {
client: process.env.DOMAIN_CLIENT, client: process.env.DOMAIN_CLIENT,
@ -24,14 +39,13 @@ const genericVerificationMessage = 'Please check your email to verify your email
* @returns * @returns
*/ */
const logoutUser = async (req, refreshToken) => { const logoutUser = async (req, refreshToken) => {
const { Session } = db.models;
try { try {
const userId = req.user._id; const userId = req.user._id;
const session = await Session.findSession({ userId: userId, refreshToken }); const session = await findSession({ userId: userId, refreshToken });
if (session) { if (session) {
try { try {
await Session.deleteSession({ sessionId: session._id }); await deleteSession({ sessionId: session._id });
} catch (deleteErr) { } catch (deleteErr) {
logger.error('[logoutUser] Failed to delete session.', deleteErr); logger.error('[logoutUser] Failed to delete session.', deleteErr);
return { status: 500, message: 'Failed to delete session.' }; return { status: 500, message: 'Failed to delete session.' };
@ -83,7 +97,7 @@ const sendVerificationEmail = async (user) => {
template: 'verifyEmail.handlebars', template: 'verifyEmail.handlebars',
}); });
await db.models.Token.createToken({ await createToken({
userId: user._id, userId: user._id,
email: user.email, email: user.email,
token: hash, token: hash,
@ -102,8 +116,7 @@ const verifyEmail = async (req) => {
const { email, token } = req.body; const { email, token } = req.body;
const decodedEmail = decodeURIComponent(email); const decodedEmail = decodeURIComponent(email);
const { User, Token } = db.models; const user = await findUser({ email: decodedEmail }, 'email _id emailVerified');
const user = await User.findUser({ email: decodedEmail }, 'email _id emailVerified');
if (!user) { if (!user) {
logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`); logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`);
@ -115,7 +128,7 @@ const verifyEmail = async (req) => {
return { message: 'Email already verified', status: 'success' }; return { message: 'Email already verified', status: 'success' };
} }
let emailVerificationData = await Token.findToken({ email: decodedEmail }); let emailVerificationData = await findToken({ email: decodedEmail });
if (!emailVerificationData) { if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`); 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'); 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) { if (!updatedUser) {
logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`); logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`);
return new Error('Failed to update user verification status'); 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}]`); logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`);
return { message: 'Email verification was successful', status: 'success' }; return { message: 'Email verification was successful', status: 'success' };
}; };
/** /**
* Register a new user. * Register a new user.
* @param {MongoUser} user <email, password, name, username> * @param {MongoUser} user <email, password, name, username>
@ -148,7 +163,6 @@ const verifyEmail = async (req) => {
* @returns {Promise<{status: number, message: string, user?: MongoUser}>} * @returns {Promise<{status: number, message: string, user?: MongoUser}>}
*/ */
const registerUser = async (user, additionalData = {}) => { const registerUser = async (user, additionalData = {}) => {
const { User } = db.models;
const { error } = registerSchema.safeParse(user); const { error } = registerSchema.safeParse(user);
if (error) { if (error) {
const errorMessage = errorsToString(error.errors); const errorMessage = errorsToString(error.errors);
@ -165,7 +179,7 @@ const registerUser = async (user, additionalData = {}) => {
let newUserId; let newUserId;
try { try {
const existingUser = await User.findUser({ email }, 'email _id'); const existingUser = await findUser({ email }, 'email _id');
if (existingUser) { if (existingUser) {
logger.info( logger.info(
@ -187,7 +201,7 @@ const registerUser = async (user, additionalData = {}) => {
} }
//determine if this is the first registered user (not counting anonymous_user) //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 salt = bcrypt.genSaltSync(10);
const newUserData = { const newUserData = {
@ -204,8 +218,8 @@ const registerUser = async (user, additionalData = {}) => {
const emailEnabled = checkEmailConfig(); const emailEnabled = checkEmailConfig();
const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN); const disableTTL = isEnabled(process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN);
const balanceConfig = await getBalanceConfig(); const balanceConfig = await getBalanceConfig();
const newUser = await User.createUser(newUserData, balanceConfig, disableTTL, true); const newUser = await createUser(newUserData, balanceConfig, disableTTL, true);
newUserId = newUser._id; newUserId = newUser._id;
if (emailEnabled && !newUser.emailVerified) { if (emailEnabled && !newUser.emailVerified) {
await sendVerificationEmail({ await sendVerificationEmail({
@ -214,14 +228,14 @@ const registerUser = async (user, additionalData = {}) => {
name, name,
}); });
} else { } else {
await User.updateUser(newUserId, { emailVerified: true }); await updateUser(newUserId, { emailVerified: true });
} }
return { status: 200, message: genericVerificationMessage }; return { status: 200, message: genericVerificationMessage };
} catch (err) { } catch (err) {
logger.error('[registerUser] Error in registering user:', err); logger.error('[registerUser] Error in registering user:', err);
if (newUserId) { if (newUserId) {
const result = await User.deleteUserById(newUserId); const result = await deleteUserById(newUserId);
logger.warn( logger.warn(
`[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`, `[registerUser] [Email: ${email}] [Temporary User deleted: ${JSON.stringify(result)}]`,
); );
@ -236,8 +250,7 @@ const registerUser = async (user, additionalData = {}) => {
*/ */
const requestPasswordReset = async (req) => { const requestPasswordReset = async (req) => {
const { email } = req.body; const { email } = req.body;
const { User, Token } = db.models; const user = await findUser({ email }, 'email _id');
const user = await User.findUser({ email }, 'email _id');
const emailEnabled = checkEmailConfig(); const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`); 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(); const [resetToken, hash] = createTokenHash();
await Token.createToken({ await createToken({
userId: user._id, userId: user._id,
token: hash, token: hash,
createdAt: Date.now(), createdAt: Date.now(),
@ -298,8 +311,7 @@ const requestPasswordReset = async (req) => {
* @returns * @returns
*/ */
const resetPassword = async (userId, token, password) => { const resetPassword = async (userId, token, password) => {
const { User, Token } = db.models; let passwordResetToken = await findToken({
let passwordResetToken = await Token.findToken({
userId, userId,
}); });
@ -314,7 +326,7 @@ const resetPassword = async (userId, token, password) => {
} }
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
const user = await User.updateUser(userId, { password: hash }); const user = await updateUser(userId, { password: hash });
if (checkEmailConfig()) { if (checkEmailConfig()) {
await sendEmail({ 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}]`); logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' }; return { message: 'Password reset was successful' };
}; };
@ -344,20 +356,19 @@ const resetPassword = async (userId, token, password) => {
*/ */
const setAuthTokens = async (userId, res, sessionId = null) => { const setAuthTokens = async (userId, res, sessionId = null) => {
try { try {
const { User, Session } = db.models; const user = await getUserById(userId);
const user = await User.getUserById(userId); const token = await generateToken(user);
const token = await User.generateToken(user);
let session; let session;
let refreshToken; let refreshToken;
let refreshTokenExpires; let refreshTokenExpires;
if (sessionId) { if (sessionId) {
session = await Session.findSession({ sessionId: sessionId }, { lean: false }); session = await findSession({ sessionId: sessionId }, { lean: false });
refreshTokenExpires = session.expiration.getTime(); refreshTokenExpires = session.expiration.getTime();
refreshToken = await Session.generateRefreshToken(session); refreshToken = await generateRefreshToken(session);
} else { } else {
const result = await Session.createSession(userId); const result = await createSession(userId);
session = result.session; session = result.session;
refreshToken = result.refreshToken; refreshToken = result.refreshToken;
refreshTokenExpires = session.expiration.getTime(); refreshTokenExpires = session.expiration.getTime();
@ -381,6 +392,7 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
throw error; throw error;
} }
}; };
/** /**
* @function setOpenIDAuthTokens * @function setOpenIDAuthTokens
* Set OpenID Authentication Tokens * Set OpenID Authentication Tokens
@ -436,9 +448,8 @@ const setOpenIDAuthTokens = (tokenset, res) => {
const resendVerificationEmail = async (req) => { const resendVerificationEmail = async (req) => {
try { try {
const { email } = req.body; const { email } = req.body;
const { User, Token } = db.models; await deleteTokens(email);
await Token.deleteTokens(email); const user = await findUser({ email }, 'email _id name');
const user = await User.findUser({ email }, 'email _id name');
if (!user) { if (!user) {
logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`); logger.warn(`[resendVerificationEmail] [No user found] [Email: ${email}]`);
@ -463,7 +474,7 @@ const resendVerificationEmail = async (req) => {
template: 'verifyEmail.handlebars', template: 'verifyEmail.handlebars',
}); });
await Token.createToken({ await createToken({
userId: user._id, userId: user._id,
email: user.email, email: user.email,
token: hash, token: hash,

View file

@ -1,6 +1,7 @@
import { connectDb, getModels } from '@librechat/backend/lib/db/connectDb'; import { connectDb, getModels } from '@librechat/backend/lib/db/connectDb';
import { deleteMessages, deleteConvos, User, Balance } from '@librechat/backend/models'; import { findUser, deleteAllUserSessions } from '@librechat/data-schemas';
import { Transaction } from '@librechat/backend/models/Transaction'; import { deleteMessages, deleteConvos } from '@librechat/backend/models';
type TUser = { email: string; password: string }; type TUser = { email: string; password: string };
export default async function cleanupUser(user: TUser) { export default async function cleanupUser(user: TUser) {
@ -10,29 +11,38 @@ export default async function cleanupUser(user: TUser) {
const db = await connectDb(); const db = await connectDb();
console.log('🤖: ✅ Connected to Database'); 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'); console.log('🤖: ✅ Found user in Database');
// Delete all conversations & associated messages // Delete all conversations & associated messages
const { deletedCount, messages } = await deleteConvos(user, {}); const { deletedCount, messages } = await deleteConvos(userId, {});
if (messages.deletedCount > 0 || deletedCount > 0) { if (messages.deletedCount > 0 || deletedCount > 0) {
console.log(`🤖: ✅ Deleted ${deletedCount} convos & ${messages.deletedCount} messages`); console.log(`🤖: ✅ Deleted ${deletedCount} convos & ${messages.deletedCount} messages`);
} }
// Ensure all user messages are deleted // Ensure all user messages are deleted
const { deletedCount: deletedMessages } = await deleteMessages({ user }); const { deletedCount: deletedMessages } = await deleteMessages({ user: userId });
if (deletedMessages > 0) { if (deletedMessages > 0) {
console.log(`🤖: ✅ Deleted ${deletedMessages} remaining message(s)`); console.log(`🤖: ✅ Deleted ${deletedMessages} remaining message(s)`);
} }
// TODO: fix this to delete all user sessions with the user's email // Delete all user sessions
const { User, Session } = getModels(); await deleteAllUserSessions(userId.toString());
await Session.deleteAllUserSessions(user);
await User.deleteMany({ _id: user }); // Get models from the registered models
await Balance.deleteMany({ user }); const { User, Balance, Transaction } = getModels();
await Transaction.deleteMany({ user });
// 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'); console.log('🤖: ✅ Deleted user from Database');

View file

@ -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 { default as actionSchema } from './schema/action';
export type { IAction } 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 { default as roleSchema } from './schema/role';
export type { IRole } 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 { default as shareSchema } from './schema/share';
export type { ISharedLink } 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 { default as toolCallSchema } from './schema/toolCall';
export type { IToolCallData } from './schema/toolCall'; export type { IToolCallData } from './schema/toolCall';
export { default as transactionSchema } from './schema/transaction'; export { default as transactionSchema } from './schema/transaction';
export type { ITransaction } 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';

View file

@ -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 });
```

View file

@ -0,0 +1,8 @@
// User methods
export * from './user';
// Session methods
export * from './session';
// Token methods
export * from './token';

View file

@ -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<SessionResult> {
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<ISession | null> {
try {
const query: Record<string, unknown> = {};
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<string, unknown> = {};
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<ISession>): Promise<string> {
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<string, unknown> = { 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');
}
}

View file

@ -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<IToken> {
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<IToken | null> {
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<TokenDeleteResult> {
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<IToken | null> {
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;
}
}

View file

@ -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<IUser>,
fieldsToSelect?: string | string[] | null,
): Promise<IUser | null> {
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<IUser> = {}): Promise<number> {
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<mongoose.Types.ObjectId | Partial<IUser>> {
const userData: Partial<IUser> = {
...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<IUser>;
}
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<IUser>,
): Promise<IUser | null> {
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<IUser | null> {
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<UserUpdateResult> {
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<string> {
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,
});
}

View file

@ -1,185 +1,25 @@
import type { Mongoose } from 'mongoose'; import 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';
export const registerModels = (mongoose: Mongoose) => { // Import schemas
const User = registerUserModel(mongoose); import userSchema from '~/schema/user';
const Session = registerSessionModel(mongoose); import sessionSchema from '~/schema/session';
const Token = registerTokenModel(mongoose); import tokenSchema from '~/schema/token';
const Message = registerMessageModel(mongoose); import balanceSchema from '~/schema/balance';
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);
return { // Import types
User, import { IUser, ISession, IToken } from '~/types';
Session, import { IBalance } from '~/schema/balance';
Token,
Message,
Action,
Agent,
Assistant,
Balance,
Banner,
Categories,
ConversationTag,
File,
Key,
PluginAuth,
Preset,
Project,
Prompt,
PromptGroup,
Role,
SharedLink,
ToolCall,
Transaction,
Conversation,
};
};
const registerSessionModel = (mongoose: Mongoose) => { // Create and export model instances
return mongoose.models.Session || mongoose.model('Session', sessionSchema); export const User = mongoose.model<IUser>('User', userSchema);
}; export const Session = mongoose.model<ISession>('Session', sessionSchema);
export const Token = mongoose.model<IToken>('Token', tokenSchema);
export const Balance = mongoose.model<IBalance>('Balance', balanceSchema);
const registerUserModel = (mongoose: Mongoose) => { // Default export with all models
return mongoose.models.User || mongoose.model('User', userSchema); export default {
}; User,
Session,
const registerTokenModel = (mongoose: Mongoose) => { Token,
return mongoose.models.Token || mongoose.model('Token', tokenSchema); Balance,
};
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);
}; };

View file

@ -1,13 +1,7 @@
import mongoose, { Schema, Document, Types } from 'mongoose'; import mongoose, { Schema } from 'mongoose';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import logger from '../config/winston'; import { webcrypto } from 'node:crypto';
const { webcrypto } = require('node:crypto'); import { ISession, SignPayloadParams } from '~/types';
export interface ISession extends Document {
refreshTokenHash: string;
expiration: Date;
user: Types.ObjectId;
}
const sessionSchema: Schema<ISession> = new Schema({ const sessionSchema: Schema<ISession> = new Schema({
refreshTokenHash: { refreshTokenHash: {
@ -26,224 +20,18 @@ const sessionSchema: Schema<ISession> = new Schema({
}, },
}); });
/** export async function signPayload({
* Error class for Session-related errors payload,
*/ secret,
class SessionError extends Error { expirationTime,
constructor(message, code = 'SESSION_ERROR') { }: SignPayloadParams): Promise<string> {
super(message); return jwt.sign(payload, secret!, { expiresIn: expirationTime });
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<Session|null>}
* @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<Object>}
* @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<string>}
* @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<Object>}
* @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 hashToken(str) { export async function hashToken(str: string): Promise<string> {
const data = new TextEncoder().encode(str); const data = new TextEncoder().encode(str);
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data); const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
return Buffer.from(hashBuffer).toString('hex'); return Buffer.from(hashBuffer).toString('hex');
} }
export default sessionSchema; export default sessionSchema;

View file

@ -1,16 +1,5 @@
import { Schema, Document, Types } from 'mongoose'; import { Schema } from 'mongoose';
import { logger } from '~/config'; import { IToken } from '~/types';
export interface IToken extends Document {
userId: Types.ObjectId;
email?: string;
type?: string;
identifier?: string;
token: string;
createdAt: Date;
expiresAt: Date;
metadata?: Map<string, unknown>;
}
const tokenSchema: Schema<IToken> = new Schema({ const tokenSchema: Schema<IToken> = new Schema({
userId: { userId: {
@ -48,116 +37,4 @@ const tokenSchema: Schema<IToken> = new Schema({
tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); 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<mongoose.Document>} 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<mongoose.Document|null>} 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<Object>} 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<Object|null>} 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; export default tokenSchema;

View file

@ -1,40 +1,6 @@
import mongoose, { Schema, Document, Model, Types } from 'mongoose'; import { Schema } from 'mongoose';
import { SystemRoles } from 'librechat-data-provider'; import { SystemRoles } from 'librechat-data-provider';
import { default as balanceSchema } from './balance'; import { IUser } from '~/types';
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;
}
// Session sub-schema // Session sub-schema
const SessionSchema = new Schema( const SessionSchema = new Schema(
@ -167,165 +133,4 @@ const userSchema = new Schema<IUser>(
{ timestamps: true }, { timestamps: true },
); );
/**
* Search for a single user based on partial data and return matching user document as plain object.
* @param {Partial<MongoUser>} 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<MongoUser>} A plain object representing the user document, or `null` if no user is found.
*/
userSchema.statics.findUser = async function (
searchCriteria: Partial<IUser>,
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<number>} The count of documents that match the filter.
*/
userSchema.statics.countUsers = async function (filter: Record<string, any> = {}) {
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<ObjectId|MongoUser>} 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<IUser>,
balanceConfig: any,
disableTTL: boolean = true,
returnUser: boolean = false,
) {
const userData: Partial<IUser> = {
...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<MongoUser>} The updated user document as a plain object, or `null` if no user is found.
*/
userSchema.statics.updateUser = async function (userId: string, updateData: Partial<IUser>) {
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<MongoUser>} 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<string>} A promise that resolves to a JWT token.
*/
userSchema.statics.generateToken = async function (user: IUser): Promise<string> {
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; export default userSchema;

View file

@ -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.

View file

@ -0,0 +1,8 @@
// User types
export * from './user';
// Session types
export * from './session';
// Token types
export * from './token';

View file

@ -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<ISession>;
refreshToken: string;
}
export interface SignPayloadParams {
payload: Record<string, unknown>;
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;
}
}

View file

@ -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<string, unknown>;
}
export interface TokenCreateData {
userId: Types.ObjectId | string;
email?: string;
type?: string;
identifier?: string;
token: string;
expiresIn: number;
metadata?: Map<string, unknown>;
}
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<string, unknown>;
}
export interface TokenDeleteResult {
deletedCount?: number;
}

View file

@ -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<IUser> {
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;
}