diff --git a/TODO.md b/TODO.md index e2a73de..fa6af2e 100644 --- a/TODO.md +++ b/TODO.md @@ -5,11 +5,6 @@ ## Role - should protect get Roles, Update, & delete role -- Default roles can't be deleted -- Only the role created by user can be deleted -- Add a flag `canBeDeleted`. If it is true then only alow to deleted -- Even admin can't deleted if the role is `isSystemRole` -- Add proper description about the role ## Permission diff --git a/src/api/v1/auth/__test__/auth.test.ts b/src/api/v1/auth/__test__/auth.test.ts index 68498bb..5efbd55 100644 --- a/src/api/v1/auth/__test__/auth.test.ts +++ b/src/api/v1/auth/__test__/auth.test.ts @@ -1,6 +1,12 @@ import { sign } from 'jsonwebtoken'; import { envConstants, errorMap, ErrorTypeEnum, STATUS_CODES } from '@/constants'; -import { expectSignUpSuccess, login, renewToken, signUp } from '@/utils/test'; +import { + expectSignUpSuccess, + expectUnauthorizedResponseForInvalidToken, + login, + renewToken, + signUp, +} from '@/utils/test'; import { Login, loginSchema } from '@/api/v1/auth/auth.validation'; import { ZodError, ZodIssue } from 'zod'; @@ -61,29 +67,8 @@ describe('Auth Test', () => { }); it('should respond with 401 for invalid token', async () => { - const errorObject = errorMap[ErrorTypeEnum.enum.INVALID_TOKEN]; - const response = await renewToken('Bearer invalidToken'); - - expect(response.statusCode).toBe(STATUS_CODES.UNAUTHORIZED); - expect(response.body).toMatchObject({ - message: errorObject.body.message, - code: errorObject.body.code, - status: 'failed', - }); - }); - - it('should throw an error if the token is invalid', async () => { - const errorObject = errorMap[ErrorTypeEnum.enum.INVALID_TOKEN]; - - const response = await renewToken(`Bearer invalidToken`); - - expect(response.status).toBe(STATUS_CODES.UNAUTHORIZED); - expect(response.body).toMatchObject({ - message: errorObject.body.message, - code: errorObject.body.code, - status: 'failed', - }); + expectUnauthorizedResponseForInvalidToken(response); }); it('should throw an error if the token is expired', async () => { diff --git a/src/api/v1/auth/auth.dal.ts b/src/api/v1/auth/auth.dal.ts index 2557f52..7252084 100644 --- a/src/api/v1/auth/auth.dal.ts +++ b/src/api/v1/auth/auth.dal.ts @@ -11,7 +11,7 @@ export class AuthDAL { } static async upsertAuthTokens({ userId, refreshToken }: Auth): Promise { - return await AuthModel.findOneAndUpdate({ userId }, { refreshToken: refreshToken }, { upsert: true, new: true }); + return await AuthModel.findOneAndUpdate({ userId }, { refreshToken }, { upsert: true, new: true }); } static async deleteAuth(userId: string): Promise { diff --git a/src/api/v1/profile/__test__/profle.test.ts b/src/api/v1/profile/__test__/profle.test.ts new file mode 100644 index 0000000..5b6fea6 --- /dev/null +++ b/src/api/v1/profile/__test__/profle.test.ts @@ -0,0 +1,53 @@ +import { + expectBadRequestResponseForValidationError, + expectLoginSuccess, + expectProfileData, + expectUnauthorizedResponseForInvalidAuthorizationHeader, + expectUnauthorizedResponseForInvalidToken, + expectUnauthorizedResponseForMissingAuthorizationHeader, + login, + upSertProfileData, +} from '@/utils/test'; +import { defaultUsers } from '@/constants'; +import { CreateProfileData } from '../profile.validation'; + +const profileData: CreateProfileData = { avatar: 'http://avatar.com', bio: 'bio', phoneNumber: 'phoneNumber' }; + +describe('Profile Service', () => { + let authorizationHeader: string; + let loggedInUserId: string; + + beforeAll(async () => { + const loginResponse = await login(defaultUsers); + expectLoginSuccess(loginResponse); + loggedInUserId = loginResponse.body.userId; + authorizationHeader = `Bearer ${loginResponse.header['authorization']}`; + }); + + describe('upSertProfileData', () => { + it('should throw error if access token is not provided', async () => { + const response = await upSertProfileData({}, ''); + expectUnauthorizedResponseForMissingAuthorizationHeader(response); + }); + + it('should return 401 for invalid authorization header', async () => { + const response = await upSertProfileData({}, 'invalid'); + expectUnauthorizedResponseForInvalidAuthorizationHeader(response); + }); + + it('should throw an error if invalid access token is provided', async () => { + const response = await upSertProfileData({}, 'Bearer invalid-access-token'); + expectUnauthorizedResponseForInvalidToken(response); + }); + + it('should throw error if body is not provided', async () => { + const response = await upSertProfileData({}, authorizationHeader); + expectBadRequestResponseForValidationError(response); + }); + + it('should update profile data', async () => { + const response = await upSertProfileData(profileData, authorizationHeader); + expectProfileData(response, { userId: loggedInUserId, ...profileData }); + }); + }); +}); diff --git a/src/api/v1/profile/index.ts b/src/api/v1/profile/index.ts new file mode 100644 index 0000000..e50feab --- /dev/null +++ b/src/api/v1/profile/index.ts @@ -0,0 +1,3 @@ +export * from './profile.service'; +export * from './profile.validation'; +export * from './profile.constant'; diff --git a/src/api/v1/profile/profile.constant.ts b/src/api/v1/profile/profile.constant.ts new file mode 100644 index 0000000..8739762 --- /dev/null +++ b/src/api/v1/profile/profile.constant.ts @@ -0,0 +1,3 @@ +export const success = { + PROFILE_UPDATED_SUCCESSFULLY: 'Profile updated successfully', +}; diff --git a/src/api/v1/profile/profile.controller.ts b/src/api/v1/profile/profile.controller.ts new file mode 100644 index 0000000..31d1e6f --- /dev/null +++ b/src/api/v1/profile/profile.controller.ts @@ -0,0 +1,23 @@ +import { sendResponse } from '@/utils'; +import { NextFunction, Request, Response } from 'express'; +import { ProfileService } from './profile.service'; +import { success } from './profile.constant'; + +export class ProfileController { + static upSertProfile = async (req: Request, res: Response, next: NextFunction) => { + try { + const profile = await new ProfileService().upSertProfileData({ + userId: req.body.loggedInUser.userId, + ...req.body, + }); + sendResponse({ + response: res, + message: success.PROFILE_UPDATED_SUCCESSFULLY, + payload: profile, + statusCode: 200, + }); + } catch (error) { + next(error); + } + }; +} diff --git a/src/api/v1/profile/profile.dal.ts b/src/api/v1/profile/profile.dal.ts new file mode 100644 index 0000000..f9010fa --- /dev/null +++ b/src/api/v1/profile/profile.dal.ts @@ -0,0 +1,15 @@ +import { ProfileDataModel } from './profile.modal'; +import { ProfileData } from './profile.validation'; + +interface IProfileDataDal { + upSertProfileData(payload: ProfileData): Promise; +} + +export class ProfileDataDAL implements IProfileDataDal { + async upSertProfileData(payload: ProfileData): Promise { + return await ProfileDataModel.findOneAndUpdate({ userId: payload.userId }, payload, { + upsert: true, + new: true, + }); + } +} diff --git a/src/api/v1/profile/profile.dto.ts b/src/api/v1/profile/profile.dto.ts new file mode 100644 index 0000000..ebf13d1 --- /dev/null +++ b/src/api/v1/profile/profile.dto.ts @@ -0,0 +1,14 @@ +import { ProfileData } from './profile.validation'; + +export const ProfileDto = (profile: ProfileData) => ({ + getProfile: () => { + return { + userId: profile.userId, + avatar: profile.avatar, + bio: profile.bio, + address: profile.address, + phoneNumber: profile.phoneNumber, + socialLinks: profile.socialLinks, + }; + }, +}); diff --git a/src/api/v1/profile/profile.modal.ts b/src/api/v1/profile/profile.modal.ts new file mode 100644 index 0000000..a9ade2a --- /dev/null +++ b/src/api/v1/profile/profile.modal.ts @@ -0,0 +1,47 @@ +import { model, Schema, Types } from 'mongoose'; +import { ProfileData } from './profile.validation'; + +const ProfileData = new Schema( + { + userId: { + type: String, + required: true, + validate: { + validator: (v: string) => Types.ObjectId.isValid(v), + message: 'userId must be a valid MongoDB ObjectId string', + }, + ref: 'User', + }, + avatar: { + type: String, + trim: true, + }, + bio: { + type: String, + trim: true, + maxlength: 500, + }, + address: { + type: { + street: { type: String }, + city: { type: String }, + country: { type: String }, + zipCode: { type: String }, + }, + }, + phoneNumber: { + type: String, + trim: true, + }, + socialLinks: { + type: { + twitter: { type: String }, + linkedin: { type: String }, + github: { type: String }, + }, + }, + }, + { timestamps: true }, +); + +export const ProfileDataModel = model('Profile', ProfileData); diff --git a/src/api/v1/profile/profile.route.ts b/src/api/v1/profile/profile.route.ts new file mode 100644 index 0000000..6ddfe04 --- /dev/null +++ b/src/api/v1/profile/profile.route.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { validateAccessToken } from '@/middlewares'; +import { ProfileController } from './profile.controller'; + +const router = Router(); + +router.put('/profile', validateAccessToken, ProfileController.upSertProfile); + +export { router as profileRoutes }; diff --git a/src/api/v1/profile/profile.service.ts b/src/api/v1/profile/profile.service.ts new file mode 100644 index 0000000..fa684bd --- /dev/null +++ b/src/api/v1/profile/profile.service.ts @@ -0,0 +1,18 @@ +import { ProfileDataDAL } from './profile.dal'; +import { ProfileDto } from './profile.dto'; +import { ProfileData, validateProfileSchema } from './profile.validation'; + +export class ProfileService { + private profileDataDal: ProfileDataDAL; + + constructor() { + this.profileDataDal = new ProfileDataDAL(); + } + + upSertProfileData = async (payload: ProfileData) => { + const profileData = validateProfileSchema(payload); + + const updateProfileData = await this.profileDataDal.upSertProfileData(profileData); + return ProfileDto(updateProfileData).getProfile(); + }; +} diff --git a/src/api/v1/profile/profile.validation.ts b/src/api/v1/profile/profile.validation.ts new file mode 100644 index 0000000..f4faeae --- /dev/null +++ b/src/api/v1/profile/profile.validation.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { objectIdSchema } from '@/utils'; + +export const profileSchema = z.object({ + userId: objectIdSchema, + avatar: z.string().url().optional(), + bio: z.string().max(500).optional(), + address: z + .object({ + street: z.string().optional(), + city: z.string().optional(), + country: z.string().optional(), + zipCode: z.string().optional(), + }) + .optional(), + phoneNumber: z.string().optional(), + socialLinks: z + .object({ + twitter: z.string().url().optional(), + linkedin: z.string().url().optional(), + github: z.string().url().optional(), + }) + .optional(), +}); + +export const validateProfileSchema = (data: ProfileData) => { + // Check if at least one key from profileSchema is present in the data, excluding userId + const hasAnyKey = Object.keys(profileSchema.shape) + .filter((key) => key !== 'userId') + .some((key) => key in data && data[key as keyof ProfileData] !== undefined); + + if (!hasAnyKey) { + throw new z.ZodError([ + { + code: z.ZodIssueCode.custom, + path: Object.keys(profileSchema.shape).filter((key) => key !== 'userId'), + message: 'At least one field from the profile schema must be provided', + }, + ]); + } + + return profileSchema.parse(data); +}; + +export type ProfileData = z.infer; +export type CreateProfileData = Omit; diff --git a/src/routes/index.ts b/src/routes/index.ts index dbb91bf..82ddb3f 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -6,6 +6,7 @@ import { rolePermissionRoutes } from '@/api/v1/rolePermission/role-permission.ro import { authRoutes } from '@/api/v1/auth/auth.route'; import { userRoleRoutes } from '@/api/v1/userRole/user-role.route'; import { otpRoutes } from '@/api/v1/otp/otp.route'; +import { profileRoutes } from '@/api/v1/profile/profile.route'; export const routes = Router(); @@ -18,4 +19,5 @@ routes.use( userRoleRoutes, authRoutes, otpRoutes, + profileRoutes, ); diff --git a/src/utils/test/authorize.middleware.utils.ts b/src/utils/test/authorize.middleware.utils.ts index e0d0a7d..0d50389 100644 --- a/src/utils/test/authorize.middleware.utils.ts +++ b/src/utils/test/authorize.middleware.utils.ts @@ -17,6 +17,14 @@ export const expectUnauthorizedResponseForInvalidAuthorizationHeader = async (re expect(response.body.code).toBe(errorObject.body.code); }; +export const expectUnauthorizedResponseForInvalidToken = async (response: Response) => { + const errorObject = errorMap[ErrorTypeEnum.enum.INVALID_TOKEN]; + + expect(response.statusCode).toBe(STATUS_CODES.UNAUTHORIZED); + expect(response.body.message).toBe(errorObject.body.message); + expect(response.body.code).toBe(errorObject.body.code); +}; + export const expectUnauthorizedResponseWhenUserHasInsufficientPermission = async (response: Response) => { const errorObject = errorMap[ErrorTypeEnum.enum.NOT_ENOUGH_PERMISSION]; diff --git a/src/utils/test/index.ts b/src/utils/test/index.ts index 772f5e4..08ddfea 100644 --- a/src/utils/test/index.ts +++ b/src/utils/test/index.ts @@ -6,3 +6,4 @@ export * from './role.utils'; export * from './authorize.middleware.utils'; export * from './role-permission.utils'; export * from './user-role.utils'; +export * from './profile.utils'; diff --git a/src/utils/test/profile.utils.ts b/src/utils/test/profile.utils.ts new file mode 100644 index 0000000..af44a9f --- /dev/null +++ b/src/utils/test/profile.utils.ts @@ -0,0 +1,27 @@ +import supertest, { Response } from 'supertest'; +import { CreateProfileData, ProfileData, success } from '@/api/v1/profile'; +import { app } from '@/app'; +import { STATUS_CODES } from '@/constants'; + +export const upSertProfileData = async (payload: CreateProfileData, authorizationHeader: string) => { + return await supertest(app).put('/api/v1/profile').set('authorization', authorizationHeader).send(payload); +}; + +export const expectProfileData = (response: Response, payload: ProfileData) => { + expect(response).toBeDefined(); + expect(response.statusCode).toBe(STATUS_CODES.OK); + + const expectedBody: Partial & { message: string; status: string } = { + message: success.PROFILE_UPDATED_SUCCESSFULLY, + status: 'success', + userId: payload.userId, + }; + + if (payload.avatar != null) expectedBody.avatar = payload.avatar; + if (payload.bio != null) expectedBody.bio = payload.bio; + if (payload.phoneNumber != null) expectedBody.phoneNumber = payload.phoneNumber; + if (payload.address) expectedBody.address = payload.address; + if (payload.socialLinks) expectedBody.socialLinks = payload.socialLinks; + + expect(response.body).toMatchObject(expectedBody); +};