Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions LocalMind-Backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@types/mongoose": "^5.11.97",
"@types/morgan": "^1.9.10",
"argon2": "^0.44.0",
"bcrypt": "^5.1.1",
"axios": "^1.12.2",
"bcrypt": "^6.0.0",
"chalk": "^5.6.2",
Expand Down
14 changes: 9 additions & 5 deletions LocalMind-Backend/src/api/v1/user/__test__/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ describe('User Registration Tests', () => {
}

try {
const res = await axios.post(`${env.BACKEND_URL}/user/register`, {
name: 'Test User',
const res = await axios.post(`${env.BACKEND_URL}/auth/signup`, {
firstName: 'Test User',
birthPlace: 'Test City',
location: 'Test Country',
email: testEmail,
password: 'Test@1234',
})
Expand Down Expand Up @@ -70,8 +72,10 @@ describe('User Registration Tests', () => {
}

try {
const res = await axios.post(`${env.BACKEND_URL}/user/register`, {
name: 'Duplicate User',
const res = await axios.post(`${env.BACKEND_URL}/auth/signup`, {
firstName: 'Duplicate User',
birthPlace: 'Duplicate City',
location: 'Duplicate Country',
email: testEmail,
password: 'Test@1234',
})
Expand All @@ -81,7 +85,7 @@ describe('User Registration Tests', () => {
throw Error('Should not be able to register with existing email')
} catch (error: any) {
expect(error.response).toBeDefined()
expect(error.response.status).toBe(404)
expect(error.response.status).toBe(409)
expect(error.response.data.message).toMatch(/already exists/i)
}
}, 10000)
Expand Down
18 changes: 18 additions & 0 deletions LocalMind-Backend/src/api/v1/user/user.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ enum UserConstant {
INVALID_INPUT = 'User is not available in request',
INVALID_CREDENTIALS = 'Invalid credentials',
NAME_REQUIRED = 'Name is required!',
FIRST_NAME_REQUIRED = 'First name is required',
BIRTH_PLACE_REQUIRED = 'Birth place is required',
LOCATION_REQUIRED = 'Location is required',
INVALID_ROLE = 'Invalid user role',
INVALID_PORTFOLIO_URL = 'Portfolio URL is invalid',
BIO_TOO_LONG = 'Bio exceeds the maximum allowed length',

// ✅ DATABASE & SERVER ERRORS

Expand All @@ -84,3 +90,15 @@ enum UserConstant {
}

export default UserConstant

export const AllowedUserRoles = ['user', 'admin', 'creator'] as const

export const PasswordConfig = {
minLength: 8,
maxLength: 20,
saltRounds: 10,
}

export const BioConfig = {
maxLength: 500,
}
20 changes: 11 additions & 9 deletions LocalMind-Backend/src/api/v1/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,17 @@ class UserController {
try {
const validatedData = await userRegisterSchema.parseAsync(req.body)

if (!req.body.password) {
throw new Error(UserConstant.PASSWORD_REQUIRED)
const existingUser = await UserUtils.findUserByEmail(validatedData.email)

if (existingUser) {
throw new Error(UserConstant.EMAIL_ALREADY_EXISTS)
}

const user = await userService.createUser(validatedData)

const userObj = {
id: user._id,
name: user.name,
email: user.email,
role: user.role,
}

const userObj = UserUtils.sanitizeUser(user)


const token = UserUtils.generateToken({
userId: String(user._id),
Expand Down Expand Up @@ -87,6 +86,10 @@ class UserController {

SendResponse.success(res, UserConstant.LOGIN_USER_SUCCESS, { user, token }, StatusConstant.OK)
} catch (err: any) {
if (err?.name === 'ZodError') {
SendResponse.error(res, err.message || UserConstant.INVALID_CREDENTIALS, 400, err)
return
}
SendResponse.error(res, err.message || UserConstant.INVALID_CREDENTIALS, 401, err)
}
}
Expand All @@ -110,7 +113,6 @@ class UserController {
}

const userObj: Partial<IUser> = { ...user }
delete userObj.password

SendResponse.success(res, UserConstant.USER_PROFILE_SUCCESS, userObj, 200)
} catch (err: any) {
Expand Down
2 changes: 1 addition & 1 deletion LocalMind-Backend/src/api/v1/user/user.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class UserMiddleware {
throw new Error(UserConstant.TOKEN_MISSING)
}

const decodedData: IUser | null = UserUtils.verifyToken(token)
const decodedData: Partial<IUser> | null = UserUtils.verifyToken(token)

if (!decodedData) {
throw new Error(UserConstant.INVALID_TOKEN)
Expand Down
27 changes: 26 additions & 1 deletion LocalMind-Backend/src/api/v1/user/user.model.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import mongoose, { Schema, Model } from 'mongoose'
import { IUser } from './user.type'
import { AllowedUserRoles } from './user.constant'

const userSchema: Schema<IUser> = new Schema<IUser>(
{
name: {
firstName: {
type: String,
required: true,
trim: true,
},
email: {
type: String,
required: true,
unique: true,
index: true,
lowercase: true,
trim: true,
},
password: {
type: String,
Expand All @@ -20,8 +24,29 @@ const userSchema: Schema<IUser> = new Schema<IUser>(
},
role: {
type: String,
enum: AllowedUserRoles,
default: 'user',
},
birthPlace: {
type: String,
required: true,
trim: true,
},
location: {
type: String,
required: true,
trim: true,
},
portfolioUrl: {
type: String,
default: null,
trim: true,
},
bio: {
type: String,
default: null,
trim: true,
},
apikey: {
type: String,
default: null,
Expand Down
2 changes: 1 addition & 1 deletion LocalMind-Backend/src/api/v1/user/user.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import userController from './user.controller'
import userMiddleware from './user.middleware'

router.post('/v1/auth/signup', userController.register)
router.post('/v1/user/login', userController.login)

router.post('/v1/auth/login', userController.login)

router.get('/v1/auth/apiKey/generate', userMiddleware.middleware, userController.apiEndPointCreater)
router.get('/v1/auth/profile', userMiddleware.middleware, userController.profile)
Expand Down
3 changes: 3 additions & 0 deletions LocalMind-Backend/src/api/v1/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class userService {
const user = await User.create({
...data,
password: hashPassword,
role: data.role || 'user',
portfolioUrl: data.portfolioUrl ?? null,
bio: data.bio ?? null,
})
return user
}
Expand Down
16 changes: 13 additions & 3 deletions LocalMind-Backend/src/api/v1/user/user.type.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { AllowedUserRoles } from './user.constant'

export type UserRole = (typeof AllowedUserRoles)[number]

export interface IUser {
_id?: string | undefined
name?: string | null
_id?: string
firstName: string
email: string
password?: string
role?: string
role?: UserRole
birthPlace: string
location: string
portfolioUrl?: string | null
bio?: string | null
apikey?: string | null
model?: string | null
modelApiKey?: string | null
createdAt?: Date
updatedAt?: Date
}
46 changes: 29 additions & 17 deletions LocalMind-Backend/src/api/v1/user/user.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import jwt, { SignOptions } from 'jsonwebtoken'
import { env } from '../../../constant/env.constant'
import { IUser } from './user.type'
import User from './user.model'
import bcrypt from 'bcrypt'
import * as argon2 from 'argon2'
import UserConstant from './user.constant'
import UserConstant, { PasswordConfig } from './user.constant'

export interface JwtPayload {
userId: string
Expand All @@ -21,7 +22,7 @@ class UserUtils {
} as SignOptions)
}

public static verifyToken(token: string): IUser | null {
public static verifyToken(token: string): Partial<IUser> | null {
try {
const decoded = jwt.verify(token, this.JWT_SECRET)

Expand Down Expand Up @@ -61,11 +62,11 @@ class UserUtils {
if (!PassMatch) {
throw new Error(UserConstant.INVALID_PASSWORD)
}
const userObj: IUser = user.toObject()
delete (userObj as { password?: string }).password
delete (userObj as { createdAt?: Date }).createdAt
delete (userObj as { updatedAt?: Date }).updatedAt
delete (userObj as { __v?: number }).__v
const userObj = this.sanitizeUser(user)

if (!userObj) {
throw new Error(UserConstant.USER_NOT_FOUND)
}
Comment on lines +67 to +69
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This if (!userObj) check is unreachable. The sanitizeUser function returns null only when its input user is null. However, there's a check on line 47 that throws an error if user is null. Therefore, user will always be a valid user object when sanitizeUser is called on line 65, and userObj will never be null. You can safely remove this redundant check to improve code clarity.


const token = this.generateToken({
userId: String(user._id),
Expand All @@ -84,22 +85,16 @@ class UserUtils {

public static async findById(userId: string): Promise<IUser | null> {
const user = await User.findById(userId)
return user
return this.sanitizeUser(user) as IUser | null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Changing findById to return a sanitized user breaks existing functionality. For example, the getApiKey controller method calls findById and then accesses user.apikey. However, the new sanitizeUser function you've introduced explicitly deletes the apikey property. This will cause the getApiKey method to always fail by incorrectly throwing an API_KEY_NOT_FOUND error.

Additionally, the type assertion as IUser | null is incorrect because sanitizeUser returns a Partial<IUser>, hiding the issue from the TypeScript compiler.

A safer approach is to keep findById as a utility for fetching the complete user document and call sanitizeUser explicitly where needed (e.g., in the profile controller after fetching the user).

Suggested change
return this.sanitizeUser(user) as IUser | null
return user

}

public static async passwordHash(password: string): Promise<string> {
return await argon2.hash(password)
return bcrypt.hash(password, PasswordConfig.saltRounds)
}

public static async findUserByEmail(email: string): Promise<Partial<IUser> | null> {
const user = await User.findOne({ email })
if (!user) return null
const userObj = user.toObject() as IUser
delete (userObj as { password?: string }).password
delete (userObj as { createdAt?: Date }).createdAt
delete (userObj as { updatedAt?: Date }).updatedAt
delete (userObj as { __v?: number }).__v
return userObj
return this.sanitizeUser(user)
}

private static async passwordMatching({
Expand All @@ -109,7 +104,24 @@ class UserUtils {
dbPass: string
userPass: string
}): Promise<boolean> {
return await argon2.verify(dbPass, userPass)
const isBcryptMatch = await bcrypt.compare(userPass, dbPass)
if (isBcryptMatch) return true

try {
return await argon2.verify(dbPass, userPass)
} catch {
return false
}
}
public static sanitizeUser(user: IUser | null): Partial<IUser> | null {
if (!user) return null
const userObj = typeof (user as any).toObject === 'function' ? (user as any).toObject() : { ...user }

delete (userObj as { password?: string }).password
delete (userObj as { __v?: number }).__v
delete (userObj as { apikey?: string | null }).apikey
delete (userObj as { modelApiKey?: string | null }).modelApiKey
return userObj as Partial<IUser>
}
Comment on lines +116 to 125
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new sanitizeUser function is a great refactor to centralize sanitization logic. However, I noticed a change in behavior compared to the previous implementation. The old sanitization logic (e.g., in findUserByEmail) explicitly removed createdAt and updatedAt fields from the user object. The new sanitizeUser function does not.

Was it intentional to start exposing these timestamp fields in API responses? If not, you might want to add them to the list of deleted properties for consistency with the previous behavior.

public static maskApiKey(apiKey: string): string {
if (!apiKey || apiKey.length < 8) return '*'
Expand Down
63 changes: 36 additions & 27 deletions LocalMind-Backend/src/api/v1/user/user.validator.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import { z } from 'zod'
import UserConstant from './user.constant'
import UserConstant, { AllowedUserRoles, BioConfig, PasswordConfig } from './user.constant'

export const userRegisterSchema = z.object({
name: z.string().trim().min(1, UserConstant.NAME_REQUIRED),
const passwordSchema = z
.string()
.min(PasswordConfig.minLength, UserConstant.PASSWORD_MIN_LENGTH)
.max(PasswordConfig.maxLength, UserConstant.PASSWORD_MAX_LENGTH)
.regex(/[A-Z]/, UserConstant.PASSWORD_UPPERCASE_REQUIRED)
.regex(/[a-z]/, UserConstant.PASSWORD_LOWERCASE_REQUIRED)
.regex(/[0-9]/, UserConstant.PASSWORD_NUMBER_REQUIRED)
.regex(/[@$!%*?&]/, UserConstant.PASSWORD_SPECIAL_CHAR_REQUIRED)

role: z
.enum(['user', 'admin', 'creator'] as const)
.refine(
role => ['user', 'admin', 'creator'].includes(role),
{ message: UserConstant.INVALID_ROLE }
),

email: z.string().email(UserConstant.INVALID_CREDENTIALS).toLowerCase(),
const roleSchema = z.enum(AllowedUserRoles, {
errorMap: () => ({ message: UserConstant.INVALID_ROLE }),
})

password: z
.string()
.min(8, UserConstant.PASSWORD_MIN_LENGTH)
.max(20, UserConstant.PASSWORD_MAX_LENGTH)
.regex(/[A-Z]/, UserConstant.PASSWORD_UPPERCASE_REQUIRED)
.regex(/[a-z]/, UserConstant.PASSWORD_LOWERCASE_REQUIRED)
.regex(/[0-9]/, UserConstant.PASSWORD_NUMBER_REQUIRED)
.regex(/[@$!%*?&]/, UserConstant.PASSWORD_SPECIAL_CHAR_REQUIRED),
const portfolioUrlSchema = z
.string()
.trim()
.url(UserConstant.INVALID_PORTFOLIO_URL)
.max(2048, UserConstant.INVALID_PORTFOLIO_URL)
.optional()

portfolioUrl: z.string().url(UserConstant.INVALID_URL).optional(),
export const userRegisterSchema = z
.object({
firstName: z.string().trim().min(1, UserConstant.FIRST_NAME_REQUIRED),
role: roleSchema.default('user'),
email: z.string().email(UserConstant.INVALID_CREDENTIALS).toLowerCase(),
birthPlace: z.string().trim().min(1, UserConstant.BIRTH_PLACE_REQUIRED),
location: z.string().trim().min(1, UserConstant.LOCATION_REQUIRED),
password: passwordSchema,
portfolioUrl: portfolioUrlSchema,
bio: z.string().trim().max(BioConfig.maxLength, UserConstant.BIO_TOO_LONG).optional(),
})
.strict()

bio: z.string().max(300, UserConstant.BIO_MAX_LENGTH).optional(),
})

export const userLoginSchema = z.object({
email: z.string().email(UserConstant.INVALID_CREDENTIALS).toLowerCase(),
password: z.string(),
})
export const userLoginSchema = z
.object({
email: z.string().email(UserConstant.INVALID_CREDENTIALS).toLowerCase(),
password: z.string(),
})
.strict()
2 changes: 1 addition & 1 deletion LocalMind-Backend/types/express.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { IUser } from '../src/api/v1/user/user.type'

declare module 'express' {
export interface Request {
user?: IUser
user?: Partial<IUser>
}
}