diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e329028f..b98987ec 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1773,6 +1773,109 @@ paths: type: string security: - access_token: [] + /users/{id}: + patch: + security: + - access_token: [] + tags: + - user + operationId: Update a user + summary: Update a user + description: Update a user + parameters: + - in : path + name : id + schema: + type: integer + required: true + description : Numeric ID of the user to get + requestBody: + description: Update a user + content: + application/json: + schema: + type: object + properties: + firstName: + type: string + description: First name of user + example: John + lastName: + type: string + description: Last name of user + example: Doe + gender: + type: string + description: gender of the user + example: non-binary + address: + type: string + description: address of the user + example: 10, Ogunlana Drive, Surulere, Lagos + jobRole: + type: string + description: job role of the user + example: Software Developer + department: + type: string + description: department of the user + example: Software Development + profilePictureUrl: + type: string + description: profile picture url of the user + example: https://imgbb.com/ + responses: + '200': + description: Successfully updating a user + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: successful updating a user + example: success + data: + type: object + description: Data for updating a user + properties: + id: + type: integer + description: user id + example: 10 + firstName: + type: string + description: First name of user + example: John + lastName: + type: string + description: Last name of user + example: Doe + email: + type: string + description: Email of user + example: atilade@gmail.com + gender: + type: string + description: gender of the user + example: non-binary + address: + type: string + description: address of the user + example: 10, Ogunlana Drive, Surulere, Lagos + jobRole: + type: string + description: job role of the user + example: Software Developer + department: + type: string + description: department of the user + example: Software Development + refreshToken: + type: string + description: Refresh token + example: 44yytruy76iiui945436t components: schemas: User: diff --git a/src/routes/auth/index.js b/src/routes/auth/index.js index 4210909c..d61cee21 100644 --- a/src/routes/auth/index.js +++ b/src/routes/auth/index.js @@ -1,9 +1,10 @@ const express = require('express') -const userSevice = require('../../services/users') +const userService = require('../../services/users') const validateSchema = require('../../middleware/validateSchema') const isAuthenticated = require('../../middleware/isAuthenticated') const isAdmin = require('../../middleware/isAdmin') const { catchAsync , AppError} = require('../../lib') +const { transformUserResponse } = require('../common/transformers') const { @@ -36,7 +37,7 @@ const ERROR_MAP = { [ InvalidResetTokenError.name ] : 401 } -const transformUserResponse = (userDetails) => ({ +const transformCreateUserResponse = (userDetails) => ({ accessToken : userDetails.accessToken, userId : userDetails.userId, refreshToken : userDetails.refreshToken @@ -49,24 +50,15 @@ router.post( catchAsync(async (req, res) => { const { email, password } = req.body - const userDetails = await userSevice + const userDetails = await userService .signInUserByEmail(email, password) return res.json({ status: 'success', data:{ - ...transformUserResponse(userDetails), + ...transformUserResponse(userDetails.user), refreshToken: userDetails.user.refreshToken, - userId : userDetails.user.id, - firstName: userDetails.user.firstName, - lastName: userDetails.user.lastName, - email: userDetails.user.email, - gender: userDetails.user.gender, - role: userDetails.user.role, - department: userDetails.user.department, - address: userDetails.user.department, - jobRole: userDetails.user.jobRole, createdAt : userDetails.user.createdAt, - profilePictureUrl: userDetails.user.profilePictureUrl + accessToken : userDetails.accessToken } }) }) @@ -81,7 +73,7 @@ router.post( catchAsync(async (req, res) => { const { email } = req.body - const { email:userEmail, status } = await userSevice.inviteUser(email) + const { email:userEmail, status } = await userService.inviteUser(email) res.status(200).json({ status: 'success', @@ -109,7 +101,7 @@ router.post( const userDetails = - await userSevice.createNewUser({ + await userService.createNewUser({ firstName, lastName, email, @@ -122,7 +114,7 @@ router.post( status: 'success', data: { message: 'User account successfully created', - ...transformUserResponse(userDetails) + ...transformCreateUserResponse(userDetails) } }) }) @@ -138,7 +130,7 @@ router.post( refreshToken : currentRefreshToken } = req.body - const userDetails = await userSevice.getNewTokens( + const userDetails = await userService.getNewTokens( email, currentRefreshToken ) @@ -158,7 +150,7 @@ router.get( token } = req.params const userDetails = - await userSevice.getInvitedUserDetail( + await userService.getInvitedUserDetail( token ) @@ -177,7 +169,7 @@ router.post('/password', catchAsync(async (req, res) => { const { email } = req.body - await userSevice.sendPasswordResetLink(email) + await userService.sendPasswordResetLink(email) return res.status(200).json({ status: 'success', @@ -195,7 +187,7 @@ router.patch( const { token } = req.params const { newPassword } = req.body - await userSevice.resetPassword({ token, newPassword }) + await userService.resetPassword({ token, newPassword }) return res.status(200).json({ status: "success", diff --git a/src/routes/common/transformers.js b/src/routes/common/transformers.js index 3c757952..6316a7c8 100644 --- a/src/routes/common/transformers.js +++ b/src/routes/common/transformers.js @@ -20,7 +20,21 @@ const transformGifResponse = (gif) => ({ }) +const transformUserResponse = (userDetails) => ({ + userId: userDetails.id, + firstName: userDetails.firstName, + lastName: userDetails.lastName, + email: userDetails.email, + gender: userDetails.gender, + jobRole: userDetails.jobRole, + department: userDetails.department, + address: userDetails.address, + profilePictureUrl: userDetails.profilePictureUrl +}) + + module.exports = { transformArticleResponse, - transformGifResponse + transformGifResponse, + transformUserResponse } \ No newline at end of file diff --git a/src/routes/users.js b/src/routes/users.js deleted file mode 100644 index 1e3acbc2..00000000 --- a/src/routes/users.js +++ /dev/null @@ -1,32 +0,0 @@ -const express = require("express") -const db = require("../db") -const isAuthenticated = require("../middleware/isAuthenticated") - -const router = express.Router() - -const fetchUsers = (req, res) => { - res.send("get users") -} -const createUsers = () => {} -const getUser = async (req, res) => { - const { id } = req.params - const { rows } = await db.query("SELECT * FROM users WHERE id = $1", [id]) - res.send(rows[0]) -} -const updateUser = () => {} -const deleteUser = () => {} - -// isAuthenticated middle to protect all posts related requests -router.use(isAuthenticated()) - -router - .route("/") - .get(fetchUsers) - .post(createUsers) -router - .route("/:id") - .get(getUser) - .patch(updateUser) - .delete(deleteUser) - -module.exports = router diff --git a/src/routes/users/index.js b/src/routes/users/index.js new file mode 100644 index 00000000..418c5ecb --- /dev/null +++ b/src/routes/users/index.js @@ -0,0 +1,67 @@ +const express = require("express") +const db = require("../../db") +const isAuthenticated = require("../../middleware/isAuthenticated") +const userService = require('../../services/users') +const validateSchema = require('../../middleware/validateSchema') +const { + updateUserSchema +} = require("../../schema") +const { catchAsync, AppError } = require("../../lib") +const { UserNotFoundError } = require("../../services/errors") +const { transformUserResponse }= require("../common/transformers") + +const ERROR_MAP = { + [ UserNotFoundError.name ] : 404 +} + + +const router = express.Router() + +const fetchUsers = (req, res) => { + res.send("get users") +} +const createUsers = () => {} +const getUser = async (req, res) => { + const { id } = req.params + const { rows } = await db.query("SELECT * FROM users WHERE id = $1", [id]) + res.send(rows[0]) +} +const updateUser = catchAsync(async (req, res ) => { + + const { id } = req.params + + + const userDetails = await userService.updateUser(id, req.body) + + res.status(200).json({ + status: "success", + data: transformUserResponse(userDetails) + }) +}) +const deleteUser = () => {} + +// isAuthenticated middle to protect all posts related requests +router.use(isAuthenticated()) + +router + .route("/") + .get(fetchUsers) + .post(createUsers) +router + .route("/:id") + .get(getUser) + .patch(validateSchema(updateUserSchema), updateUser) + .delete(deleteUser) + +router + .use((err, req, res, next)=> { + const error = err + error.success = false + if(ERROR_MAP[error.name] ){ + next(new AppError( error.message ,ERROR_MAP[error.name] )) + + } + next(err) + }) + +module.exports = router diff --git a/src/routes/users/update-user.e2e.test.js b/src/routes/users/update-user.e2e.test.js new file mode 100644 index 00000000..85172d0e --- /dev/null +++ b/src/routes/users/update-user.e2e.test.js @@ -0,0 +1,201 @@ +const { expect } = require('chai') +const { faker } = require('@faker-js/faker') +const { fixtures } = require('../../../test/utils') + +describe('PATCH /users/:id', () => { + + describe('Failure', () => { + let user + let accessToken + before(async ()=>{ + user = await fixtures.insertUser() + const body = { id: user.id, email: user.email } + accessToken = fixtures.generateAccessToken( + {user : body} + ) + }) + it('should return 401 if request is not authenticated', async () => + fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .expect(401) + ) + it('should return 400 if id is not a number', async () => { + const expectedError = { + "error": { + "message": "id must be a number" + }, + "status": "failed" + } + return fixtures.api() + .patch(`/api/v1/users/sdsdsd`) + .set('Authorization', `Bearer ${accessToken}`) + .send( + {firstName : faker.name.firstName(), + lastName : faker.name.lastName() + }) + .expect(400, expectedError) + + }) + + it('should return 400 if firstName is not a string', async () => { + const expectedError = { + "error": { + "message": "firstName must be a string" + }, + "status": "failed" + } + return fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ firstName: 123, + lastName: user.lastName}) + .expect(400, expectedError) + }) + it('should return 400 if lastName is not a string', async () => { + const expectedError = { + "error": { + "message": "lastName must be a string" + }, + "status": "failed" + } + return fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ lastName: 123, + firstName: user.firstName }) + .expect(400, expectedError) + }) + it('should return 400 if jobRole is not a string', async () => { + const expectedError = { + "error": { + "message": "jobRole must be a string" + }, + "status": "failed" + } + return fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ jobRole: 123 }) + .expect(400, expectedError) + }) + it('should return 400 if department is not a string', async () => { + const expectedError = { + "error": { + "message": "department must be a string" + }, + "status": "failed" + } + return fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ department: 123 }) + .expect(400, expectedError) + }) + it('should return 400 if address is not a string', async () => { + const expectedError = { + "error": { + "message": "address must be a string" + }, + "status": "failed" + } + return fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ address: 123 }) + .expect(400, expectedError) + }) + + it('should return 400 if gender is not a string', async () => { + const expectedError = { + "error": { + "message": "gender must be a string" + }, + "status": "failed" + } + return fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + gender: 123 + }) + .expect(400, expectedError) + }) + + it('should return 400 if profilePictureUrl is not a valid url', + async () => { + const expectedError = { + "error": { + "message": "profilePictureUrl must be a valid uri" + }, + "status": "failed" + } + return fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + profilePictureUrl: "inavlid-profile-picture-url" + }) + .expect(400, expectedError) + }) + + it('should return 404 if user is not found', async () => + + fixtures.api() + .patch(`/api/v1/users/${user.id + 2}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + address: faker.address.streetAddress() }) + .expect(404) + .then((res) => { + expect(res.body.status).eql('fail') + expect(res.body.message).eql('User not found') + }) + ) + + }) + describe('Success', () => { + let user + let accessToken + before(async ()=>{ + user = await fixtures.insertUser() + const body = { id: user.id, email: user.email } + accessToken = fixtures.generateAccessToken( + {user : body} + ) + }) + it('should return 200 if user is updated', async () => + fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + firstName: user.firstName, + address: faker.address.streetAddress(), + department: faker.name.jobArea(), + gender: faker.name.gender() + }) + .expect(200) + + ) + it('should return the right response', async () => + fixtures.api() + .patch(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + lastName: user.lastName, + address: faker.address.streetAddress(), + department: faker.name.jobArea() + }) + .expect(200) + .then((res) => { + expect(res.body.status).eql('success') + expect(res.body.data.firstName).to.be.a('string') + expect(res.body.data.lastName).to.be.a('string') + expect(res.body.data.email).to.be.a('string') + expect(res.body.data.userId).to.be.a('number') + expect(res.body.data.address).to.be.a('string') + expect(res.body.data.department).to.be.a('string') + }) + ) + + }) +}) \ No newline at end of file diff --git a/src/schema.js b/src/schema.js index f0f8466e..4d28c961 100644 --- a/src/schema.js +++ b/src/schema.js @@ -258,6 +258,35 @@ const unflagPostSchema = Joi.object({ .required() } }) + +const updateUserSchema = Joi.object({ + params: { + id: Joi.number() + .required() + }, + body: { + firstName: Joi.string() + .alphanum() + .min(3) + .max(30), + lastName: Joi.string() + .alphanum() + .min(3) + .max(30), + gender: Joi.string(), + jobRole: Joi.string() + .alphanum() + .min(3) + .max(100), + department: Joi.string() + .alphanum() + .min(3) + .max(100), + address: Joi.string(), + profilePictureUrl: Joi.string() + .uri() + } +}) module.exports = { authSchema, signinSchema, @@ -279,5 +308,6 @@ module.exports = { likePostSchema, unlikePostSchema, flagPostSchema, - unflagPostSchema + unflagPostSchema, + updateUserSchema } diff --git a/src/services/errors.js b/src/services/errors.js index 87785581..bae3a2a1 100644 --- a/src/services/errors.js +++ b/src/services/errors.js @@ -62,6 +62,10 @@ module.exports= { ArticleHasAlreadyBeenFlaggedError : { name : "ArticleHasAlreadyBeenFlaggedError", message : "Article has already been flagged" + }, + UserNotFoundError : { + name : "UserNotFoundError", + message : "User not found" } } \ No newline at end of file diff --git a/src/services/users/index.js b/src/services/users/index.js index 115352ba..468c608e 100644 --- a/src/services/users/index.js +++ b/src/services/users/index.js @@ -12,6 +12,7 @@ const updateRefreshToken = require("./update-refresh-token") const sendPasswordResetLink = require('./send-password-reset-link') const resetPassword = require('./reset-password') const createNewUser = require('./create-new-user') +const updateUser = require('./update-user') const { RefreshTokenIsInvalidError, @@ -157,5 +158,6 @@ module.exports = { getNewTokens, getInvitedUserDetail, sendPasswordResetLink, - resetPassword + resetPassword, + updateUser } diff --git a/src/services/users/update-user.js b/src/services/users/update-user.js new file mode 100644 index 00000000..7ffd2265 --- /dev/null +++ b/src/services/users/update-user.js @@ -0,0 +1,64 @@ +const db = require("../../db") +const customError = require("../../lib/custom-error") +const { UserNotFoundError } = require("../errors") + +/** + * + * @param {Number} id - id of the user to be updated + * @param {Object} cols - columns to be updated + * @returns {String} - query to be executed + */ +const constructQuery = (cols) => { + + // Setup static beginning of query + const query = ['UPDATE users'] + query.push('SET') + // Create another array storing each set command + // and assigning a number value for parameterized query + const set = [] + Object.keys(cols).forEach((key, i) => { + set.push(`"${key}" = ($${(i + 1)})`) + }) + + query.push(set.join(', ')) + + // Add the WHERE statement to look up by id + + query.push(`WHERE "id" = ($${set.length + 1}) RETURNING *` ) + + // Return a complete query string + return query.join(' ') +} + +/** + * + * @param {Number} id - id of the user to be updated + * @param {Object} requestBody - request body + * @returns {Object} - updated user + */ +const updateUser = async (id, + data +) => { + + const query = constructQuery(data) + + const colValues = Object.values( + data + ) + + colValues.push(id) + + const { rows } = await db.query( + query, colValues) + + const updatedUser = rows[0] + + + if(!updatedUser) { + throw customError(UserNotFoundError) + } + + return updatedUser +} + +module.exports = updateUser \ No newline at end of file diff --git a/src/services/users/update-user.test.js b/src/services/users/update-user.test.js new file mode 100644 index 00000000..d7bf2f13 --- /dev/null +++ b/src/services/users/update-user.test.js @@ -0,0 +1,60 @@ +const { expect } = require('chai') +const { faker } = require('@faker-js/faker') +const db = require('../../db') +const {fixtures} = require('../../../test/utils') +const updateUser = require('./update-user') + + +describe('Update User', () => { + let user + let updatedInfo = {} + + beforeEach(async () => { + user = await fixtures.insertUser() + updatedInfo = { + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + gender: faker.name.gender(), + jobRole: faker.name.jobTitle(), + department: faker.name.jobArea(), + address: faker.address.streetAddress(), + profilePictureUrl: faker.image.imageUrl() + } + }) + + it('should throw an error if user does not exist', async () => { + const { id } = user + await db.query( + `DELETE FROM users + WHERE id = $1`, [id]) + + return expect(updateUser(id, updatedInfo)) + .to.be.rejectedWith( + 'User not found') + }) + + it('should update user', async () => { + const { id } = user + await updateUser( + id, updatedInfo) + + const { rows } = await db.query( + `SELECT * FROM users + WHERE id = $1`, [id]) + + const updatedUser = rows[0] + + + return expect(updatedUser).to.include({ + firstName: updatedInfo.firstName, + lastName: updatedInfo.lastName, + gender: updatedInfo.gender, + jobRole: updatedInfo.jobRole, + department: updatedInfo.department, + address: updatedInfo.address, + profilePictureUrl: updatedInfo.profilePictureUrl + }) + + }) + +}) \ No newline at end of file