From 5f786709529721ff0b1353c98d6f04847ba59acc Mon Sep 17 00:00:00 2001 From: kola-1 Date: Fri, 30 Aug 2019 08:27:20 +0000 Subject: [PATCH] feature(request): manager reject trip request - add request validation schema to validate the request Id - add request middleware to verify manager - add a controller to reject a trip request [Finishes #167727738] --- .sequelizerc | 1 - src/controllers/requestController.js | 39 +++++++++- .../migrations/20190815205048-create-user.js | 4 +- .../20190820173619-create-request.js | 11 ++- src/database/seeders/20190819153902-user.js | 29 ++++--- .../seeders/20190826210429-demo-requests.js | 26 ++++--- src/middlewares/requestMiddlewares.js | 35 +++++++++ src/models/request.js | 6 +- src/routes/api/request.js | 75 ++++++++++++++++++- src/services/dbServices.js | 26 +++++++ .../middlewares/requestMiddlewares.spec.js | 27 +++++++ src/test/mockData/requestMock.js | 3 + src/test/mockData/userMock.js | 5 +- src/test/routes/request.spec.js | 18 ++++- src/utils/messages.js | 2 + src/validation/requestSchema.js | 10 ++- 16 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 src/middlewares/requestMiddlewares.js create mode 100644 src/test/middlewares/requestMiddlewares.spec.js diff --git a/.sequelizerc b/.sequelizerc index a46b24d..bd52caa 100644 --- a/.sequelizerc +++ b/.sequelizerc @@ -1,5 +1,4 @@ require('@babel/register'); - const path = require('path'); module.exports = { diff --git a/src/controllers/requestController.js b/src/controllers/requestController.js index f5bd074..290a5a9 100644 --- a/src/controllers/requestController.js +++ b/src/controllers/requestController.js @@ -8,8 +8,12 @@ import { findById } from '../services/userServices'; import { createNotification } from '../services/notificationServices'; const { Request, Subrequest } = models; -const { serverError, unauthorizedUserRequest, noRequests } = messages; -const { create, getAll, bulkCreate } = DbServices; +const { + serverError, unauthorizedUserRequest, noRequests, rejectedTripRequest +} = messages; +const { + create, getAll, bulkCreate, update +} = DbServices; /** * request trip controller @@ -119,8 +123,37 @@ const searchRequest = async (req, res) => { } }; +/** + * reject request controller + * @param {Object} req - server request + * @param {Object} res - server response + * @returns {Object} - custom response +*/ +const updateApprovalStatus = async (req, res) => { + try { + let approvalStatusValue, approvalStatusMessage; + const { requestId } = req.params; + const options = { returning: true, where: { id: requestId } }; + const action = await req.url.match(/\/requests\/([a-z]+).*/); + if (action[1] === 'reject') { + approvalStatusValue = 'rejected'; + approvalStatusMessage = rejectedTripRequest; + } + const updateColumn = { approvalStatus: approvalStatusValue }; + await update(Request, updateColumn, options); + return response(res, 201, 'success', { + message: approvalStatusMessage + }); + } catch (error) { + return response(res, 500, 'error', { + message: serverError, + }); + } +}; + export default { requestTrip, getUserRequest, - searchRequest + searchRequest, + updateApprovalStatus }; diff --git a/src/database/migrations/20190815205048-create-user.js b/src/database/migrations/20190815205048-create-user.js index 0e13abf..6909f7a 100644 --- a/src/database/migrations/20190815205048-create-user.js +++ b/src/database/migrations/20190815205048-create-user.js @@ -84,8 +84,8 @@ module.exports = { allowNull: true, type: Sequelize.BOOLEAN, defaultValue: true - }, - }) + } + }) }, down: (queryInterface, Sequelize) => { return queryInterface.dropTable('Users'); diff --git a/src/database/migrations/20190820173619-create-request.js b/src/database/migrations/20190820173619-create-request.js index f6d2ebd..b0996ea 100644 --- a/src/database/migrations/20190820173619-create-request.js +++ b/src/database/migrations/20190820173619-create-request.js @@ -42,8 +42,9 @@ module.exports = { type: Sequelize.STRING, }, approvalStatus: { - type: Sequelize.BOOLEAN, - allowNull: false, + type: Sequelize.ENUM, + allowNull: true, + values: ['accepted', 'rejected'] }, multiCity: { type: Sequelize.BOOLEAN, @@ -51,12 +52,14 @@ module.exports = { defaultValue: false, }, createdAt: { - allowNull: false, + allowNull: true, type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), }, updatedAt: { - allowNull: false, + allowNull: true, type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), }, }); }, diff --git a/src/database/seeders/20190819153902-user.js b/src/database/seeders/20190819153902-user.js index 36f3a75..fda2236 100644 --- a/src/database/seeders/20190819153902-user.js +++ b/src/database/seeders/20190819153902-user.js @@ -4,6 +4,15 @@ import { hashPassword } from '../../utils/authHelper'; export default { up: (queryInterface, Sequelize) => { return queryInterface.bulkInsert('Users', [ + { + id: '38eb202c-3f67-4eed-b7ac-9c31bc226e0c', + firstName: 'line', + lastName: 'manager', + email: 'linemanager@gmail.com', + password: hashPassword('Password'), + phoneNo: '2347033545645', + verified: true + }, { id: '122a0d86-8b78-4bb8-b28f-8e5f7811c456', firstName: 'James', @@ -11,8 +20,6 @@ export default { email: 'jammy@gmail.com', phoneNo: '2347032123304', password: hashPassword('jammy11167'), - createdAt: new Date(), - updatedAt: new Date(), verified: true, roleId: roles.SUPER_ADMIN, }, @@ -23,8 +30,6 @@ export default { email: 'samuelman@gmail.com', password: hashPassword('samman12358'), phoneNo: null, - createdAt: new Date(), - updatedAt: new Date(), verified: false, roleId: roles.MANAGER, }, { @@ -34,8 +39,6 @@ export default { email: 'polman@gmail.com', password: hashPassword('polly11167'), phoneNo: '2347032123404', - createdAt: new Date(), - updatedAt: new Date(), verified: true, roleId: roles.REQUESTER, }, { @@ -45,11 +48,19 @@ export default { email: 'freeman@gmail.com', password: hashPassword('polly123456'), phoneNo: '2347032123409', - createdAt: new Date(), - updatedAt: new Date(), - verified: true, lineManager: '0ce36391-2c08-4703-bddb-a4ea8cccbbc5', roleId: roles.REQUESTER, + verified: true + }, + { + id: '2999c776-0f6f-471d-bced-b661d6e75586', + firstName: 'request', + lastName: 'man', + email: 'requestman@gmail.com', + password: hashPassword('requestman'), + phoneNo: '2347032746854', + lineManager: '38eb202c-3f67-4eed-b7ac-9c31bc226e0c', + verified: true } ], {}); }, diff --git a/src/database/seeders/20190826210429-demo-requests.js b/src/database/seeders/20190826210429-demo-requests.js index d3ef62f..4a7c0c3 100644 --- a/src/database/seeders/20190826210429-demo-requests.js +++ b/src/database/seeders/20190826210429-demo-requests.js @@ -8,12 +8,10 @@ export default { destinationCity: 'Istanbul', userId: '122a0d86-8b78-4bb8-b28f-8e5f7811c456', departureDate: '01-07-2017', - createdAt: new Date(), - updatedAt: new Date(), returnDate: '02-08-2017', reason: 'Check stocks', accommodation: 'Great Istanbul Arena', - approvalStatus: false + approvalStatus: 'rejected' }, { id: 'fb94de4d-47ff-4079-89e8-b0186c0a3be8', @@ -22,25 +20,33 @@ export default { destinationCity: 'Lagos', departureDate: '01-07-2017', userId: 'fb94de4d-47ff-4079-89e8-b0186c0a3be8', - createdAt: new Date(), - updatedAt: new Date(), returnDate: '02-08-2017', reason: 'Annual meeting', accommodation: 'Eko Hotels & Suites', - approvalStatus: true - }, { + approvalStatus: 'accepted' + }, + { id: '0ce36391-2c08-4703-bddb-a4ea8cccbbc5', type: 'return', originCity: 'Abuja', destinationCity: 'Lagos', departureDate: '01-07-2018', userId: '122a0d86-8b78-4bb8-b28f-8e5f7811c456', - createdAt: new Date(), - updatedAt: new Date(), returnDate: '02-08-2018', reason: 'Annual meeting', accommodation: 'Eko Hotels & Suites', - approvalStatus: true + approvalStatus: 'accepted' + }, + { + id: 'b2092fb0-502a-4105-961f-2d310d340168', + userId: '2999c776-0f6f-471d-bced-b661d6e75586', + type: 'return', + originCity: 'lagos', + destinationCity: 'bahamas', + departureDate: '2019-09-21 17:59:04.305+00', + returnDate: '2020-08-21 17:59:04.305+00', + reason: 'vacation', + accommodation: 'Hotel Transylvania', } ], {}); }, diff --git a/src/middlewares/requestMiddlewares.js b/src/middlewares/requestMiddlewares.js new file mode 100644 index 0000000..bbcba0a --- /dev/null +++ b/src/middlewares/requestMiddlewares.js @@ -0,0 +1,35 @@ +import response from '../utils/response'; +import messages from '../utils/messages'; +import DbServices from '../services/dbServices'; +import models from '../models'; + +const { User, Request } = models; +const { findOneIncludeModel } = DbServices; + +const verifyRequestLineManager = async (req, res, next) => { + try { + const { id: loggedInUserId } = req.decoded; + const { requestId } = req.params; + const table2 = { + model: User, + alias: 'User', + column: { lineManager: loggedInUserId } + }; + const data = await findOneIncludeModel(Request, requestId, table2); + + const requestLineManagerId = data.User.lineManager; + const manager = loggedInUserId === requestLineManagerId; + if (!manager) { + return response(res, 401, 'error', { + message: messages.unauthorized + }); + } + next(); + } catch (error) { + return response(res, 500, 'error', { + errors: error + }); + } +}; + +export default verifyRequestLineManager; diff --git a/src/models/request.js b/src/models/request.js index 9751a77..b747a7b 100644 --- a/src/models/request.js +++ b/src/models/request.js @@ -35,9 +35,9 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING, }, approvalStatus: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, + type: DataTypes.ENUM, + allowNull: true, + values: ['accepted', 'rejected'] }, multiCity: { type: DataTypes.BOOLEAN, diff --git a/src/routes/api/request.js b/src/routes/api/request.js index 495eba8..ab89fd6 100644 --- a/src/routes/api/request.js +++ b/src/routes/api/request.js @@ -3,9 +3,14 @@ import validate from '../../middlewares/validator'; import requestSchema from '../../validation/requestSchema'; import { checkToken, checkUserId } from '../../middlewares/userMiddlewares'; import checkBlacklist from '../../middlewares/blacklistMiddleware'; +import verifyRequestLineManager from '../../middlewares/requestMiddlewares'; -const { requestTrip, getUserRequest, searchRequest } = requestController; -const { requestTripSchema, getUserRequestSchema, searchRequestTripSchema } = requestSchema; +const { + requestTrip, getUserRequest, searchRequest, updateApprovalStatus +} = requestController; +const { + requestTripSchema, getUserRequestSchema, searchRequestTripSchema, requestIdSchema +} = requestSchema; const requestRoute = (router) => { router.route('/requests') @@ -122,6 +127,7 @@ const requestRoute = (router) => { * - bearerAuth: [] */ .post(checkToken, checkBlacklist, validate(requestTripSchema), requestTrip); + router.route('/requests/user/:userId') /** * @swagger @@ -281,6 +287,71 @@ const requestRoute = (router) => { * - bearerAuth: [] */ .post(checkToken, validate(searchRequestTripSchema), searchRequest); + + router.route('/requests/reject/:requestId') + /** + * @swagger + * components: + * schemas: + * acceptOrRejectTrip: + * properties: + * message: + * type: string + * readOnly: true + * ErrorResponse: + * properties: + * status: + * type: string + * example: error + * data: + * type: string + */ + + /** + * @swagger + * /api/v1/requests/reject/{requestId}: + * patch: + * tags: + * - Requests + * description: Reject a request for a trip + * parameters: + * - in: path + * name: requestId + * schema: + * type: string + * required: true + * produces: + * - application/json + * responses: + * 201: + * description: Trip request successfully rejected + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * allOf: + * - $ref: '#/components/schemas/acceptOrRejectTrip' + * 400: + * description: Input validation error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * security: + * - bearerAuth: [] + */ + .patch(checkToken, validate(requestIdSchema), verifyRequestLineManager, updateApprovalStatus); }; export default requestRoute; diff --git a/src/services/dbServices.js b/src/services/dbServices.js index e9b4cd9..a622df3 100644 --- a/src/services/dbServices.js +++ b/src/services/dbServices.js @@ -63,6 +63,32 @@ const DbServices = { */ bulkCreate(model, data) { return model.bulkCreate(data, { returning: true }); + }, + + /** + * @param {object} model primary model /table + * @param {string} id the id (primary model /table) + * @param {object} modelDetailsToInclude object containing 3 properties (secondary model /table) + * @param {object} modelDetailsToInclude.model secondary model /table + * @param {string} modelDetailsToInclude.alias alias of secondary model /table + * @param {object} modelDetailsToInclude.column column of secondary model /table + * @returns {Promise} Promise resolved or rejected + * @description gets data from a primary table and also joins data from a secondary table + * + * @example + * getTwoTables(Request, 1, { model: User, alias: 'User', column: { lineManager: managerId } }) + */ + findOneIncludeModel(model, id, modelDetailsToInclude) { + return model.findOne({ + where: { id }, + include: [ + { + model: modelDetailsToInclude.model, + as: modelDetailsToInclude.alias, + where: modelDetailsToInclude.column + } + ] + }); } }; diff --git a/src/test/middlewares/requestMiddlewares.spec.js b/src/test/middlewares/requestMiddlewares.spec.js new file mode 100644 index 0000000..fb1054f --- /dev/null +++ b/src/test/middlewares/requestMiddlewares.spec.js @@ -0,0 +1,27 @@ +import { + app, chai, expect, BACKEND_BASE_URL +} from '../testHelpers/config'; +import mockData from '../mockData'; +import authHelper from '../../utils/authHelper'; + +const { generateToken } = authHelper; +const { userMock } = mockData; +const { requestToBeRejected } = mockData.requestMock; + +describe('REQUESTS', () => { + const rejectRequestTripEndpoint = `${BACKEND_BASE_URL}/requests/reject/${requestToBeRejected.requestId}`; + let token; + + before(async () => { + token = generateToken({ id: `${userMock.wrongId}` }); + }); + + describe('VERIFY MANAGER', () => { + it('should return a error response when manager is not a line manager', async () => { + const response = await chai.request(app) + .patch(rejectRequestTripEndpoint) + .set('authorization', `Bearer ${token}`); + expect(response.status).to.equal(401); + }); + }); +}); diff --git a/src/test/mockData/requestMock.js b/src/test/mockData/requestMock.js index 82fa15e..d2d5489 100644 --- a/src/test/mockData/requestMock.js +++ b/src/test/mockData/requestMock.js @@ -86,5 +86,8 @@ export default { subTripAccommodation: 'Abuja Hotel' } ] + }, + requestToBeRejected: { + requestId: 'b2092fb0-502a-4105-961f-2d310d340168' } }; diff --git a/src/test/mockData/userMock.js b/src/test/mockData/userMock.js index 9abc8f6..68f9393 100644 --- a/src/test/mockData/userMock.js +++ b/src/test/mockData/userMock.js @@ -44,7 +44,7 @@ export default { lastName: 'Skywalker', phoneNo: '080777778654', gender: 'gender is required', - lineManager: 'lineManager is required', + lineManager: 'fb94de4d-47ff-4079-89e8-b0186c0a3be8', birthDate: '11-09-1990', preferredCurrency: 'preferredCurrency is required', preferredLanguage: 'preferredLanguage is required', @@ -53,5 +53,6 @@ export default { anotherUserId: 'fb94de4d-47ff-4079-89e8-b0186c0a3be8', userId: '122a0d86-8b78-4bb8-b28f-8e5f7811c456', wrongId: '122a0d86-8b78-4bb8-b28f-8e5f7811c459', - invalidUuid: '122a0d86-8b78-4bb8-b28f-8e5f7' + invalidUuid: '122a0d86-8b78-4bb8-b28f-8e5f7', + validLineManager: '38eb202c-3f67-4eed-b7ac-9c31bc226e0c' }; diff --git a/src/test/routes/request.spec.js b/src/test/routes/request.spec.js index b61bf82..ab761c1 100644 --- a/src/test/routes/request.spec.js +++ b/src/test/routes/request.spec.js @@ -7,23 +7,26 @@ import mockData from '../mockData'; import authHelper from '../../utils/authHelper'; const { Request, User } = models; +const { userMock } = mockData; const { validTripRequest, badInputTripRequest, oneWayTripRequestWithReturnDate, validReturnTripRequest, returnTripRequestWithDepartureGreaterThanReturnDate, - validMultiCityRequest, multiCityBadRequest + validMultiCityRequest, multiCityBadRequest, requestToBeRejected } = mockData.requestMock; const { generateToken } = authHelper; describe('REQUESTS', () => { - let user, token, unassignedUser, unassignedUserToken; + let user, token, unassignedUser, unassignedUserToken, validManagerToken; const requestTripEndpoint = `${BACKEND_BASE_URL}/requests`; const searchRequestTripEndpoint = `${BACKEND_BASE_URL}/search/requests`; + const rejectRequestTripEndpoint = `${BACKEND_BASE_URL}/requests/reject/${requestToBeRejected.requestId}`; before(async () => { user = await User.findOne({ where: { lineManager: { [Op.ne]: null } } }); unassignedUser = await User.findOne({ where: { lineManager: { [Op.eq]: null } } }); token = `Bearer ${generateToken({ id: user.id })}`; unassignedUserToken = `Bearer ${generateToken({ id: unassignedUser.id })}`; + validManagerToken = generateToken({ id: `${userMock.validLineManager}` }); }); describe('POST /requests', () => { @@ -220,7 +223,7 @@ describe('REQUESTS', () => { chai.request(app) .post(searchRequestTripEndpoint) .set('authorization', token) - .send({ originCity: 'Lagos', approvalStatus: true }) + .send({ originCity: 'Lagos', approvalStatus: 'accepted' }) .end((err, res) => { expect(res.status).to.equal(200); done(err); @@ -246,6 +249,15 @@ describe('REQUESTS', () => { expect(res.status).to.equal(200); done(err); }); + + describe('LINE MANAGER REJECTS A TRIP REQUEST', () => { + it('should return 201 response when trip is rejected', async () => { + const response = await chai.request(app) + .patch(rejectRequestTripEndpoint) + .set('Authorization', `Bearer ${validManagerToken}`); + expect(response.status).to.equal(201); + }); + }); }); }); }); diff --git a/src/utils/messages.js b/src/utils/messages.js index 833800f..6702d9c 100644 --- a/src/utils/messages.js +++ b/src/utils/messages.js @@ -23,6 +23,8 @@ const messages = { noResult: 'No request result found', roleChanged: 'The staff role has been successfully set to a', forbidden: 'You are not authorized to perform this operation', + unauthorized: 'You do not have authorization', + rejectedTripRequest: 'Trip request successfully rejected' }; export default messages; diff --git a/src/validation/requestSchema.js b/src/validation/requestSchema.js index a0c529d..abb1197 100644 --- a/src/validation/requestSchema.js +++ b/src/validation/requestSchema.js @@ -31,7 +31,7 @@ const getUserRequestSchema = Joi.object({ const searchRequestTripSchema = Joi.object({ page: JoiValidator.validateNumber().min(1), perPage: JoiValidator.validateNumber().min(1), - approvalStatus: JoiValidator.validateBoolean(), + approvalStatus: JoiValidator.validateString().valid('accepted', 'rejected'), multiCity: JoiValidator.validateBoolean(), type: tripTypeSchema, originCity: JoiValidator.validateAlphabet(), @@ -41,8 +41,14 @@ const searchRequestTripSchema = Joi.object({ accommodation: JoiValidator.validateAlphabet(), }).min(1); +const requestIdSchema = Joi.object({ + requestId: JoiValidator.validateString().uuid().required() +}); + + export default { requestTripSchema, getUserRequestSchema, - searchRequestTripSchema + searchRequestTripSchema, + requestIdSchema };