From f1da67efa886765e0992b2dd5cc801260881c627 Mon Sep 17 00:00:00 2001 From: jkarenzi Date: Mon, 24 Jun 2024 21:45:03 +0200 Subject: [PATCH] feat(profile): implement profile management functionality -implement endpoints for updating email, password and profile image [Delivers #13] --- .env.example | 3 + __tests__/testSetup.ts | 18 +- __tests__/userController.test.ts | 219 ++++++++++++++++++++++++ src/controllers/boardController.ts | 12 +- src/controllers/categoryController.ts | 12 +- src/controllers/labelController.ts | 12 +- src/controllers/stickyController.ts | 9 +- src/controllers/taskController.ts | 15 +- src/controllers/userController.ts | 159 +++++++++++++++++ src/custom.d.ts | 29 ++++ src/docs/stickyDocs.ts | 12 +- src/docs/userDocs.ts | 184 ++++++++++++++++++++ src/middleware/authenticateToken.ts | 8 +- src/middleware/authorizeAdmin.ts | 5 +- src/middleware/cloudinary.ts | 11 ++ src/middleware/multer.ts | 5 + src/middleware/validators/userSchema.ts | 32 ++++ src/models/User.ts | 2 +- src/routes/index.ts | 2 + src/routes/userRoutes.ts | 29 ++++ tsconfig.json | 117 ++----------- 21 files changed, 736 insertions(+), 159 deletions(-) create mode 100644 __tests__/userController.test.ts create mode 100644 src/controllers/userController.ts create mode 100644 src/custom.d.ts create mode 100644 src/docs/userDocs.ts create mode 100644 src/middleware/cloudinary.ts create mode 100644 src/middleware/multer.ts create mode 100644 src/middleware/validators/userSchema.ts create mode 100644 src/routes/userRoutes.ts diff --git a/.env.example b/.env.example index eb4c536..81e4f2d 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,7 @@ EMAIL = EMAIL_PASS = MONGO_INITDB_ROOT_USERNAME = MONGO_INITDB_ROOT_PASSWORD = +CLOUD_NAME = +CLOUD_API_KEY = +CLOUD_API_SECRET = MONGO_URL = \ No newline at end of file diff --git a/__tests__/testSetup.ts b/__tests__/testSetup.ts index 20d5fa3..938e12b 100644 --- a/__tests__/testSetup.ts +++ b/__tests__/testSetup.ts @@ -3,6 +3,8 @@ const request = require('supertest') const app = require('../src/app'); const mongoose = require('mongoose') require('dotenv').config() +const User = require('../src/models/User') +const bcrypt = require('bcrypt') const url = process.env.MONGO_URL const dbName = process.env.DB_NAME @@ -35,4 +37,18 @@ const getToken = async() => { return loginResponse.body.token } -module.exports = {getToken, connectDB, disconnectDB} \ No newline at end of file +const getAdminToken = async() => { + const password = await bcrypt.hash('admin123456', 10); + const user = new User({ + fullName: 'Test Admin', + email: 'admin@admin.com', + password: password, + role: 'admin' + }); + + const newUser = await user.save(); + const loginResponse = await request(app).post('/api/auth/login').send({email:newUser.email, password: 'admin123456'}) + return loginResponse.body.token +} + +module.exports = {getToken, getAdminToken, connectDB, disconnectDB} \ No newline at end of file diff --git a/__tests__/userController.test.ts b/__tests__/userController.test.ts new file mode 100644 index 0000000..075f65d --- /dev/null +++ b/__tests__/userController.test.ts @@ -0,0 +1,219 @@ +export {}; +const request = require('supertest'); +const app = require('../src/app'); +const mongoose = require('mongoose') +const User = require('../src/models/User') +const bcrypt = require('bcrypt') +require('dotenv').config(); +const cloudinary = require('../src/middleware/cloudinary') +const { connectDB, disconnectDB, getAdminToken } = require('./testSetup'); + +jest.mock('../src/middleware/cloudinary') + +beforeAll(connectDB); +afterAll(disconnectDB); + +interface cloudinaryUploadResult { + public_id: string, + url: string, + [key: string]: unknown; +} + +describe('User Controller tests', () => { + let token:string; + let userId:string; + + beforeAll(async() => { + const password = await bcrypt.hash('test123456', 10); + const user = new User({ + fullName: 'Test User', + email: 'test@example.com', + password: password + }); + + const newUser = await user.save(); + userId = newUser._id; + + token = await getAdminToken() + }) + + it('should get all users', async () => { + const res = await request(app) + .get('/api/users') + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200); + }); + + it('should get a user by ID', async () => { + const res = await request(app) + .get(`/api/users/${userId}`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200); + expect(res.body.data).toHaveProperty('_id', userId.toString()); + }); + + it('should return 404 if user not found', async () => { + const nonExistentUserId = new mongoose.Types.ObjectId(); + const res = await request(app) + .get(`/api/users/${nonExistentUserId}`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(404); + expect(res.body.message).toBe('User not found'); + }); + + it('should change the password', async () => { + const res = await request(app) + .patch('/api/users/password') + .set('Authorization', `Bearer ${token}`) + .send({ + oldPassword: 'admin123456', + newPassword: 'test123456', + }) + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Password successfully updated'); + }); + + it('should return 400 if password validation fails', async () => { + const res = await request(app) + .patch('/api/users/password') + .set('Authorization', `Bearer ${token}`) + .send({ + oldPassword: '', + newPassword: 'short', + }) + + expect(res.status).toBe(400) + expect(res.body.message).toBeDefined(); + }); + + it('should return 401 if old password is incorrect', async () => { + const res = await request(app) + .patch('/api/users/password') + .set('Authorization', `Bearer ${token}`) + .send({ + oldPassword: 'wrongpassword', + newPassword: 'newpassword', + }) + + expect(res.status).toBe(401); + expect(res.body.message).toBe('Incorrect password'); + }); + + it('should change the email', async () => { + const res = await request(app) + .patch('/api/users/email') + .set('Authorization', `Bearer ${token}`) + .send({ + password: 'test123456', + newEmail: 'new@example.com', + }) + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Email successfully updated'); + expect(res.body.data.email).toBe('new@example.com'); + }); + + it('should return 400 if email validation fails', async () => { + const res = await request(app) + .patch('/api/users/email') + .set('Authorization', `Bearer ${token}`) + .send({ + password: 'newpassword', + newEmail: 'invalid-email', + }) + + expect(res.status).toBe(400) + expect(res.body.message).toBeDefined(); + }); + + it('should return 401 if password is incorrect when changing email', async () => { + const res = await request(app) + .patch('/api/users/email') + .set('Authorization', `Bearer ${token}`) + .send({ + password: 'wrongpassword', + newEmail: 'new@example.com', + }) + + expect(res.status).toBe(401); + expect(res.body.message).toBe('Incorrect password'); + }); + + it('should return a 409 if email already exists upon changing email', async () => { + const res = await request(app) + .patch('/api/users/email') + .set('Authorization', `Bearer ${token}`) + .send({ + password: 'test123456', + newEmail: 'test@example.com', + }) + + expect(res.status).toBe(409); + expect(res.body.message).toBe('Email already exists'); + }); + + it('should return a 204 upon successfully deleting a user', async () => { + const res = await request(app) + .delete(`/api/users/${userId}`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(204); + }) + + it('should return a 404 if user is not found upon deletion', async () => { + const nonExistentUserId = new mongoose.Types.ObjectId(); + const res = await request(app) + .delete(`/api/users/${nonExistentUserId}`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(404); + expect(res.body.message).toBe('User not found') + }) + + it('should return a 409 if user tries to remove a non existent profile image', async () => { + const res = await request(app) + .delete('/api/users/profileImg') + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(409); + expect(res.body.message).toBe('No profile image exists') + }) + + it('should change profile image successfully', async () => { + cloudinary.uploader.upload_stream.mockImplementationOnce((callback:(error: Error|null, result: cloudinaryUploadResult) => void) => { + callback(null, {public_id:'testid', url:'https://fakeurl.com/fake.png'}); + }); + + const res = await request(app) + .patch('/api/users/profileImg') + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'multipart/form-data') + .attach('image', Buffer.from('mock-image-data'), 'image.jpg') + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Profile Image successfully updated') + }) + + it('should return a 400 if an error occurs while removing profile image', async () => { + cloudinary.uploader.destroy.mockImplementationOnce(() => Promise.resolve({result:'not ok'})) + const res = await request(app) + .delete('/api/users/profileImg') + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(400); + expect(res.body.message).toBe('An error occured. Try again later') + }) + + it('should remove profile image successfully', async () => { + cloudinary.uploader.destroy.mockImplementationOnce(() => Promise.resolve({result:'ok'})) + const res = await request(app) + .delete('/api/users/profileImg') + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(204); + }) +}); \ No newline at end of file diff --git a/src/controllers/boardController.ts b/src/controllers/boardController.ts index 12d5fb1..a298765 100644 --- a/src/controllers/boardController.ts +++ b/src/controllers/boardController.ts @@ -8,8 +8,7 @@ const { const createBoard = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const formData = req.body const validationResult = createBoardSchema.validate(formData); @@ -38,15 +37,13 @@ const createBoard = errorHandler(async (req:Request, res:Response) => { }) const getBoards = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const boards = await Board.find({userId}) return res.status(200).json({status:'success', data:boards}) }) const updateBoard = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const boardId = req.params.boardId const formData = req.body @@ -78,8 +75,7 @@ const updateBoard = errorHandler(async (req:Request, res:Response) => { const deleteBoard = errorHandler(async (req:Request, res:Response) => { const boardId = req.params.boardId - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const board = await Board.findOne({_id:boardId, userId}) if(!board){ diff --git a/src/controllers/categoryController.ts b/src/controllers/categoryController.ts index bb8c3f8..6d45292 100644 --- a/src/controllers/categoryController.ts +++ b/src/controllers/categoryController.ts @@ -8,8 +8,7 @@ const { const createCategory = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const formData = req.body const validationResult = createCategorySchema.validate(formData); @@ -38,16 +37,14 @@ const createCategory = errorHandler(async (req:Request, res:Response) => { }) const getCategories = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const boardId = req.params.boardId const categories = await Category.find({boardId, userId}) return res.status(200).json({status:'success', data:categories}) }) const updateCategory = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const categoryId = req.params.categoryId const formData = req.body @@ -79,8 +76,7 @@ const updateCategory = errorHandler(async (req:Request, res:Response) => { const deleteCategory = errorHandler(async (req:Request, res:Response) => { const categoryId = req.params.categoryId - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const category = await Category.findOne({_id:categoryId, userId}) if(!category){ diff --git a/src/controllers/labelController.ts b/src/controllers/labelController.ts index 79241c8..895ef2d 100644 --- a/src/controllers/labelController.ts +++ b/src/controllers/labelController.ts @@ -8,8 +8,7 @@ const { const createLabel = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const formData = req.body const validationResult = createLabelSchema.validate(formData); @@ -38,16 +37,14 @@ const createLabel = errorHandler(async (req:Request, res:Response) => { }) const getLabels = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const boardId = req.params.boardId const labels = await Label.find({boardId, userId}) return res.status(200).json({status:'success', data:labels}) }) const updateLabel = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const labelId = req.params.labelId const formData = req.body @@ -79,8 +76,7 @@ const updateLabel = errorHandler(async (req:Request, res:Response) => { const deleteLabel = errorHandler(async (req:Request, res:Response) => { const labelId = req.params.labelId - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const label = await Label.findOne({_id:labelId, userId}) if(!label){ diff --git a/src/controllers/stickyController.ts b/src/controllers/stickyController.ts index e881451..20d5623 100644 --- a/src/controllers/stickyController.ts +++ b/src/controllers/stickyController.ts @@ -7,8 +7,7 @@ const { } = require('../middleware/validators/stickySchema') const createStickyNote = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const formData = req.body const validationResult = createStickyNoteSchema.validate(formData); @@ -37,8 +36,7 @@ const getStickyNotes = errorHandler(async (req:Request, res:Response) => { }) const updateStickyNote = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const stickyId = req.params.id const formData = req.body @@ -64,8 +62,7 @@ const updateStickyNote = errorHandler(async (req:Request, res:Response) => { const deleteStickyNote = errorHandler(async (req:Request, res:Response) => { const stickyId = req.params.id - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const stickyNote = await StickyNote.findOne({_id:stickyId, userId}) if(!stickyNote){ diff --git a/src/controllers/taskController.ts b/src/controllers/taskController.ts index a478510..1f5a26c 100644 --- a/src/controllers/taskController.ts +++ b/src/controllers/taskController.ts @@ -9,8 +9,7 @@ const { const createTask = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const formData = req.body const validationResult = createTaskSchema.validate(formData); @@ -34,8 +33,7 @@ const createTask = errorHandler(async (req:Request, res:Response) => { }) const getTasks = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const boardId = req.params.boardId const tasks = await Task.find({boardId, userId}).populate({ path:'labels', @@ -45,8 +43,7 @@ const getTasks = errorHandler(async (req:Request, res:Response) => { }) const getTask = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const taskId = req.params.taskId const task = await Task.findOne({_id:taskId, userId}).populate({ path:'labels', @@ -56,8 +53,7 @@ const getTask = errorHandler(async (req:Request, res:Response) => { }) const updateTask = errorHandler(async (req:Request, res:Response) => { - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const taskId = req.params.taskId const formData = req.body @@ -87,8 +83,7 @@ const updateTask = errorHandler(async (req:Request, res:Response) => { const deleteTask = errorHandler(async (req:Request, res:Response) => { const taskId = req.params.taskId - //@ts-expect-error yet to come up with the right type - const userId = req.user._id + const userId = req.user!._id const task = await Task.findOne({_id:taskId, userId}) if(!task){ diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts new file mode 100644 index 0000000..07fde9f --- /dev/null +++ b/src/controllers/userController.ts @@ -0,0 +1,159 @@ +import { Request, Response } from 'express'; +const { errorHandler } = require('../middleware/errorHandler'); +const User = require('../models/User') +const bcrypt = require('bcrypt') +const {updatePasswordSchema, updateEmailSchema} = require('../middleware/validators/userSchema') +const cloudinary = require('../middleware/cloudinary') + +interface cloudinaryUploadResult { + public_id: string, + url: string, + [key: string]: unknown; +} + +const getAllUsers = errorHandler(async (req: Request, res: Response) => { + const users = await User.find() + return res.status(200).json({status:'success',data:users}) +}); + +const getUser = errorHandler(async (req: Request, res: Response) => { + const userId = req.params.userId + const user = await User.findById(userId) + if(!user){ + return res.status(404).json({status:'error',message:'User not found'}) + } + + return res.status(200).json({status:'success',data:user}) +}); + +const changePassword = errorHandler(async (req: Request, res: Response) => { + const formData = req.body + const userId = req.user!._id + + const validationResult = updatePasswordSchema.validate(formData) + + if(validationResult.error){ + return res.status(400).json({status:'error', message:validationResult.error.details[0].message}) + } + + const user = await User.findOne({_id:userId}) + const passwordMatch:boolean = await bcrypt.compare(formData.oldPassword, user.password); + + if(!passwordMatch){ + return res.status(401).json({status:'error',message:'Incorrect password'}) + } + + const hashedPassword = await bcrypt.hash(formData.newPassword, 10) + await User.findByIdAndUpdate(userId,{password: hashedPassword},{new:true}) + return res.status(200).json({status:'success', message:'Password successfully updated'}) +}); + +const changeEmail = errorHandler(async (req: Request, res: Response) => { + const formData = req.body + const userId = req.user!._id + + const validationResult = updateEmailSchema.validate(formData) + + if(validationResult.error){ + return res.status(400).json({status:'error', message:validationResult.error.details[0].message}) + } + + + const user = await User.findOne({_id:userId}) + + const passwordMatch:boolean = await bcrypt.compare(formData.password, user.password); + if(!passwordMatch){ + return res.status(401).json({status:'error', message: 'Incorrect password'}) + } + + const existingUser = await User.findOne({email:formData.newEmail}) + if(existingUser){ + return res.status(409).json({status:'error', message:'Email already exists'}) + } + + const updatedDoc = await User.findByIdAndUpdate(userId,{email:formData.newEmail},{new:true}) + return res.status(200).json({status:'success', message: 'Email successfully updated', data:{email:updatedDoc.email}}) +}); + +const changeProfileImg = errorHandler(async (req: Request, res: Response) => { + const userId = req.user!._id + const user = await User.findOne({_id:userId}) + + if(!(req.files && req.files.image)){ + return res.status(409).json({status:'error', message:'No file uploaded'}); + } + + const file = req.files.image[0] + + const uploadResult:cloudinaryUploadResult = await new Promise((resolve, reject) => { + cloudinary.uploader.upload_stream((error:Error, uploadResult:cloudinaryUploadResult) => { + if (error) { + return reject(error); + } + return resolve(uploadResult); + }).end(file.buffer); + }); + + + await cloudinary.uploader.destroy(user.profileImg.publicId,{invalidate:true}) + + const updatedUser = await User.findByIdAndUpdate( + userId, + { + profileImg: { + publicId:uploadResult.public_id, + url:uploadResult.url + } + }, + {new:true} + ) + + return res.status(200).json({status:'success', message:'Profile Image successfully updated', data:{profileImg:updatedUser.profileImg}}) +}); + +const removeProfileImg = errorHandler(async (req: Request, res: Response) => { + const userId = req.user!._id + const user = await User.findOne({_id:userId}) + + if(user.profileImg.publicId === 'default'){ + return res.status(409).json({status:'error', message:'No profile image exists'}) + } + + const result = await cloudinary.uploader.destroy(user.profileImg.publicId,{invalidate:true}) + + if(result.result === 'ok'){ + await User.findByIdAndUpdate( + user._id, + { + profileImg: { + publicId: 'default', + url: process.env.DEFAULT_PROFILE_IMG + } + } + ) + + return res.status(204).json({}) + }else{ + return res.status(400).json({status:'error', message:'An error occured. Try again later'}) + } +}); + +const deleteUser = errorHandler(async (req: Request, res: Response) => { + const userId = req.params.userId + const deletedUser = await User.findByIdAndDelete(userId) + if(!deletedUser){ + return res.status(404).json({status:'error',message:'User not found'}) + } + + return res.status(204).json({}) +}); + +module.exports = { + getAllUsers, + getUser, + changeEmail, + changePassword, + changeProfileImg, + removeProfileImg, + deleteUser +} \ No newline at end of file diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 0000000..e752cb1 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,29 @@ +// custom.d.ts + + +import { Document } from 'mongoose'; + +interface IUser extends Document { + fullName: string, + email: string, + password: string, + role: string, + profileImg: { + publicId: string, + url?: string + }, + isVerified: boolean, + twoFactorAuth: { + isEnabled: boolean, + code: number, + } +} + +declare module 'express-serve-static-core' { + interface Request { + user?: IUser, + files?: { + image?: Express.Multer.File[]; + }; + } +} diff --git a/src/docs/stickyDocs.ts b/src/docs/stickyDocs.ts index 402d133..d5767d1 100644 --- a/src/docs/stickyDocs.ts +++ b/src/docs/stickyDocs.ts @@ -3,7 +3,7 @@ * /api/sticky_notes: * get: * summary: Get sticky notes - * tags: [Sticky Notes] + * tags: [Sticky Note] * security: * - bearerAuth: [] * responses: @@ -20,7 +20,7 @@ * /api/sticky_notes: * post: * summary: Create a sticky note - * tags: [Sticky Notes] + * tags: [Sticky Note] * security: * - bearerAuth: [] * requestBody: @@ -32,6 +32,8 @@ * properties: * content: * type: string + * color: + * type: string * responses: * '201': * description: Sticky note successfully created @@ -48,7 +50,7 @@ * /api/sticky_notes/{id}: * patch: * summary: Update a sticky note - * tags: [Sticky Notes] + * tags: [Sticky Note] * security: * - bearerAuth: [] * parameters: @@ -67,6 +69,8 @@ * properties: * content: * type: string + * color: + * type: string * responses: * '200': * description: Sticky note successfully updated @@ -85,7 +89,7 @@ * /api/sticky_notes/{id}: * delete: * summary: Delete a sticky note - * tags: [Sticky Notes] + * tags: [Sticky Note] * security: * - bearerAuth: [] * parameters: diff --git a/src/docs/userDocs.ts b/src/docs/userDocs.ts new file mode 100644 index 0000000..a3560f5 --- /dev/null +++ b/src/docs/userDocs.ts @@ -0,0 +1,184 @@ +/** + * @swagger + * /api/users: + * get: + * summary: Get all users + * tags: [User] + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Successful + * '401': + * description: Unauthorized + * '403': + * description: Forbidden + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/users/{userId}: + * get: + * summary: Get one user + * tags: [User] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * schema: + * type: string + * required: true + * description: User ID + * responses: + * '200': + * description: Successful + * '401': + * description: Unauthorized + * '403': + * description: Forbidden + * '404': + * description: User not found + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/users/email: + * patch: + * summary: Change email + * tags: [User] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * password: + * type: string + * newEmail: + * type: string + * responses: + * '200': + * description: Email successfully changed + * '400': + * description: Failed validation + * '401': + * description: Unauthorized + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/users/password: + * patch: + * summary: Change password + * tags: [User] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * oldPassword: + * type: string + * newPassword: + * type: string + * responses: + * '200': + * description: Password successfully changed + * '400': + * description: Failed validation + * '401': + * description: Unauthorized or Incorrect password + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/users/profileImg: + * patch: + * summary: Change profile image + * tags: [User] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * image: + * type: string + * format: binary + * responses: + * '200': + * description: Profile Image successfully changed + * '400': + * description: Failed validation or unexpected error + * '401': + * description: Unauthorized or Incorrect password + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/users/profileImg: + * delete: + * summary: Remove profile image + * tags: [User] + * security: + * - bearerAuth: [] + * responses: + * '204': + * description: Profile Image removed + * '401': + * description: Unauthorized + * '409': + * description: No profile image exists + * '400': + * description: An error occured during cloudinary upload + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/users/{userId}: + * delete: + * summary: Delete a user + * tags: [User] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * schema: + * type: string + * required: true + * description: User ID + * responses: + * '204': + * description: User successfully deleted + * '401': + * description: Unauthorized + * '403': + * description: Forbidden + * '404': + * description: User not found + * '500': + * description: Internal Server Error + */ \ No newline at end of file diff --git a/src/middleware/authenticateToken.ts b/src/middleware/authenticateToken.ts index 1b6dac0..ddd40f0 100644 --- a/src/middleware/authenticateToken.ts +++ b/src/middleware/authenticateToken.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from 'express'; +import { IUser } from '../custom'; const jwt = require('jsonwebtoken'); const authenticateToken = async ( @@ -9,7 +10,7 @@ const authenticateToken = async ( const header = req.header('Authorization'); if (!header) { - return res.status(401).json({ status: 'error', message: 'Access denied' }); + return res.status(401).json({ status: 'error', message: 'Unauthorized' }); } const token = header.split(' ')[1]; @@ -17,12 +18,11 @@ const authenticateToken = async ( try { const decoded = await jwt.verify(token, process.env.JWT_SECRET); - //@ts-expect-errors still figuring out how to extend request - req.user = decoded.user; + req.user = decoded.user as IUser; next(); } catch (err) { - return res.status(403).json({ status: 'error', message: 'Invalid token' }); + return res.status(401).json({ status: 'error', message: 'Unauthorized' }); } }; diff --git a/src/middleware/authorizeAdmin.ts b/src/middleware/authorizeAdmin.ts index 498bef4..784c9e1 100644 --- a/src/middleware/authorizeAdmin.ts +++ b/src/middleware/authorizeAdmin.ts @@ -1,8 +1,9 @@ import { Request, Response, NextFunction } from 'express'; +import { IUser } from '../custom'; const authorizeAdmin = (req: Request, res: Response, next: NextFunction) => { - //@ts-expect-errors still figuring out how to extend request - const user = req.user; + + const user = req.user as IUser; if (user.role !== 'admin') { return res.status(403).json({ status: 'error', message: 'Forbidden' }); } diff --git a/src/middleware/cloudinary.ts b/src/middleware/cloudinary.ts new file mode 100644 index 0000000..7847788 --- /dev/null +++ b/src/middleware/cloudinary.ts @@ -0,0 +1,11 @@ +const cloudinary = require('cloudinary').v2; +require('dotenv').config() + + +cloudinary.config({ + cloud_name: process.env.CLOUD_NAME, + api_key: process.env.CLOUD_API_KEY, + api_secret: process.env.CLOUD_API_SECRET +}); + +module.exports = cloudinary \ No newline at end of file diff --git a/src/middleware/multer.ts b/src/middleware/multer.ts new file mode 100644 index 0000000..a77bdad --- /dev/null +++ b/src/middleware/multer.ts @@ -0,0 +1,5 @@ +const multer = require('multer') +const storage = multer.memoryStorage(); +const upload = multer({ storage }); + +module.exports = upload \ No newline at end of file diff --git a/src/middleware/validators/userSchema.ts b/src/middleware/validators/userSchema.ts new file mode 100644 index 0000000..a0f73c2 --- /dev/null +++ b/src/middleware/validators/userSchema.ts @@ -0,0 +1,32 @@ +export {} +const Joi = require('joi') + +const updatePasswordSchema = Joi.object({ + oldPassword: Joi.string() + .regex(/^[A-Za-z0-9]{8,}$/) + .message( + 'Password must be at least 8 characters long and contain only letters and numbers' + ) + .required(), + newPassword: Joi.string() + .regex(/^[A-Za-z0-9]{8,}$/) + .message( + 'Password must be at least 8 characters long and contain only letters and numbers' + ) + .required(), +}) + +const updateEmailSchema = Joi.object({ + password: Joi.string() + .regex(/^[A-Za-z0-9]{8,}$/) + .message( + 'Password must be at least 8 characters long and contain only letters and numbers' + ) + .required(), + newEmail: Joi.string().email().required(), +}) + +module.exports = { + updateEmailSchema, + updatePasswordSchema +} \ No newline at end of file diff --git a/src/models/User.ts b/src/models/User.ts index 023087b..6c4a0f0 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -19,7 +19,7 @@ const UserSchema = new Schema( enum: ['user', 'admin'], default: 'user', }, - imageUrl: { + profileImg: { type: Object, default: { publicId: 'default', diff --git a/src/routes/index.ts b/src/routes/index.ts index 178de87..3ce8f0e 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -6,6 +6,7 @@ const boardRoutes = require('./boardRoutes') const categoryRoutes = require('./categoryRoutes') const labelRoutes = require('./labelRoutes') const taskRoutes = require('./taskRoutes') +const userRoutes = require('./userRoutes') const router = Router(); @@ -16,5 +17,6 @@ router.use('/boards', boardRoutes) router.use('/categories', categoryRoutes) router.use('/labels', labelRoutes) router.use('/tasks', taskRoutes) +router.use('/users', userRoutes) module.exports = router; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts new file mode 100644 index 0000000..2f3f445 --- /dev/null +++ b/src/routes/userRoutes.ts @@ -0,0 +1,29 @@ +const { + getAllUsers, + getUser, + changeEmail, + changePassword, + changeProfileImg, + removeProfileImg, + deleteUser +} = require('../controllers/userController') +import { Router } from 'express'; +const authenticateToken = require('../middleware/authenticateToken') +const authorizeAdmin = require('../middleware/authorizeAdmin') +const upload = require('../middleware/multer') + +const userRouter = Router(); + +userRouter.use(authenticateToken) + +userRouter.patch('/email', changeEmail) +userRouter.patch('/password', changePassword) +userRouter.route('/profileImg').patch(upload.fields([{name:'image'}]), changeProfileImg).delete(removeProfileImg) + + +userRouter.use(authorizeAdmin) + +userRouter.get('/', getAllUsers) +userRouter.route('/:userId').get(getUser).delete(deleteUser) + +module.exports = userRouter; diff --git a/tsconfig.json b/tsconfig.json index 5e40167..be50625 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,111 +1,14 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "ES6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs" /* Specify what module code is generated. */, - // "rootDir": "/", /* Specify the root folder within your source files. */ - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ - // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ - // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "dist/" /* Specify an output folder for all emitted files. */, - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - - /* Type Checking */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "target": "ES6", + "module": "commonjs", + "outDir": "dist/", + "esModuleInterop": true, + + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true }, "exclude": ["node_modules", "__tests__/"], - "include": ["src/**/*.ts"] -} + "include": ["src/**/*.ts", "src/custom.d.ts"] +} \ No newline at end of file