diff --git a/constants/application.ts b/constants/application.ts index 9cd9b6398..30d33a419 100644 --- a/constants/application.ts +++ b/constants/application.ts @@ -25,6 +25,8 @@ const APPLICATION_ERROR_MESSAGES = { APPLICATION_ALREADY_REVIEWED: "Application has already been reviewed", NUDGE_TOO_SOON: "Nudge unavailable. You'll be able to nudge again after 24 hours.", NUDGE_ONLY_PENDING_ALLOWED: "Nudge unavailable. Only pending applications can be nudged.", + EDIT_TOO_SOON: "You can edit your application again after 24 hours from the last edit.", + EMPTY_UPDATE_PAYLOAD: "Update payload must contain at least one allowed field.", }; const APPLICATION_LOG_MESSAGES = { diff --git a/controllers/applications.ts b/controllers/applications.ts index 169ba9db4..b75b964d5 100644 --- a/controllers/applications.ts +++ b/controllers/applications.ts @@ -101,30 +101,61 @@ const addApplication = async (req: CustomRequest, res: CustomResponse) => { } }; +const buildApplicationUpdatePayload = (body: Record) => { + const dataToUpdate: Record = {}; + + if (body.imageUrl !== undefined) dataToUpdate.imageUrl = body.imageUrl; + if (body.foundFrom !== undefined) dataToUpdate.foundFrom = body.foundFrom; + if (body.socialLink !== undefined) dataToUpdate.socialLink = body.socialLink; + + if (body.introduction !== undefined) dataToUpdate["intro.introduction"] = body.introduction; + if (body.forFun !== undefined) dataToUpdate["intro.forFun"] = body.forFun; + if (body.funFact !== undefined) dataToUpdate["intro.funFact"] = body.funFact; + if (body.whyRds !== undefined) dataToUpdate["intro.whyRds"] = body.whyRds; + if (body.numberOfHours !== undefined) dataToUpdate["intro.numberOfHours"] = body.numberOfHours; + + if (body.professional && typeof body.professional === "object") { + if (body.professional.institution !== undefined) dataToUpdate["professional.institution"] = body.professional.institution; + if (body.professional.skills !== undefined) dataToUpdate["professional.skills"] = body.professional.skills; + } + + return dataToUpdate; +}; + const updateApplication = async (req: CustomRequest, res: CustomResponse) => { try { const { applicationId } = req.params; const rawBody = req.body; + const dataToUpdate = buildApplicationUpdatePayload(rawBody); + const userId = req.userData.id; - const applicationLog = { - type: logType.APPLICATION_UPDATED, - meta: { - applicationId, - username: req.userData.username, - userId: req.userData.id, - }, - body: rawBody, - }; - - const promises = [ - ApplicationModel.updateApplication(rawBody, applicationId), - addLog(applicationLog.type, applicationLog.meta, applicationLog.body), - ]; + const result = await ApplicationModel.updateApplication(dataToUpdate, applicationId, userId); - await Promise.all(promises); - return res.json({ - message: "Application updated successfully!", - }); + switch (result.status) { + case APPLICATION_STATUS.notFound: + return res.boom.notFound("Application not found"); + case APPLICATION_STATUS.unauthorized: + return res.boom.unauthorized("You are not authorized to edit this application"); + case APPLICATION_STATUS.tooSoon: + return res.boom.conflict(APPLICATION_ERROR_MESSAGES.EDIT_TOO_SOON); + case APPLICATION_STATUS.success: { + const applicationLog = { + type: logType.APPLICATION_UPDATED, + meta: { + applicationId, + username: req.userData.username, + userId, + }, + body: rawBody, + }; + await addLog(applicationLog.type, applicationLog.meta, applicationLog.body); + return res.json({ + message: "Application updated successfully!", + }); + } + default: + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } } catch (err) { logger.error(`Error while updating the application: ${err}`); return res.boom.badImplementation(INTERNAL_SERVER_ERROR); diff --git a/middlewares/validators/application.ts b/middlewares/validators/application.ts index bd8c79d97..69cf80894 100644 --- a/middlewares/validators/application.ts +++ b/middlewares/validators/application.ts @@ -69,7 +69,7 @@ const validateApplicationData = async (req: CustomRequest, res: CustomResponse, } }; -const validateApplicationUpdateData = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => { +const validateApplicationFeedbackData = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => { const schema = joi .object({ status: joi @@ -113,6 +113,68 @@ const validateApplicationUpdateData = async (req: CustomRequest, res: CustomResp } }; +const validateApplicationUpdateData = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => { + if (req.body.socialLink?.phoneNo) { + req.body.socialLink.phoneNo = req.body.socialLink.phoneNo.trim(); + } + + const socialLinkSchema = joi + .object({ + phoneNo: joi.string().optional().regex(phoneNumberRegex).message('"phoneNo" must be in a valid format'), + github: joi.string().min(1).optional(), + instagram: joi.string().min(1).optional(), + linkedin: joi.string().min(1).optional(), + twitter: joi.string().min(1).optional(), + peerlist: joi.string().min(1).optional(), + behance: joi.string().min(1).optional(), + dribbble: joi.string().min(1).optional(), + }) + .optional(); + + const professionalSchema = joi + .object({ + institution: joi.string().min(1).optional(), + skills: joi.string().min(5).optional(), + }) + .optional(); + + const schema = joi + .object() + .strict() + .min(1) + .keys({ + imageUrl: joi.string().uri().optional(), + foundFrom: joi.string().min(1).optional(), + introduction: joi.string().min(1).optional(), + forFun: joi + .string() + .custom((value, helpers) => customWordCountValidator(value, helpers, 100)) + .optional(), + funFact: joi + .string() + .custom((value, helpers) => customWordCountValidator(value, helpers, 100)) + .optional(), + whyRds: joi + .string() + .custom((value, helpers) => customWordCountValidator(value, helpers, 100)) + .optional(), + numberOfHours: joi.number().min(1).max(100).optional(), + professional: professionalSchema, + socialLink: socialLinkSchema, + }) + .messages({ + "object.min": "Update payload must contain at least one allowed field.", + }); + + try { + await schema.validateAsync(req.body); + next(); + } catch (error) { + logger.error(`Error in validating application update data: ${error}`); + res.boom.badRequest(error.details[0].message); + } +}; + const validateApplicationQueryParam = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => { const schema = joi.object().strict().keys({ userId: joi.string().optional(), @@ -133,6 +195,7 @@ const validateApplicationQueryParam = async (req: CustomRequest, res: CustomResp module.exports = { validateApplicationData, + validateApplicationFeedbackData, validateApplicationUpdateData, validateApplicationQueryParam, }; diff --git a/models/applications.ts b/models/applications.ts index 636178460..48827cc7c 100644 --- a/models/applications.ts +++ b/models/applications.ts @@ -128,13 +128,48 @@ const addApplication = async (data: application) => { } }; -const updateApplication = async (dataToUpdate: object, applicationId: string) => { - try { - await ApplicationsModel.doc(applicationId).update(dataToUpdate); - } catch (err) { - logger.error("Error in updating intro", err); - throw err; - } +const updateApplication = async ( + dataToUpdate: object, + applicationId: string, + userId: string +) => { + const currentTime = Date.now(); + const twentyFourHoursInMilliseconds = convertDaysToMilliseconds(1); + + const result = await firestore.runTransaction(async (transaction) => { + const applicationRef = ApplicationsModel.doc(applicationId); + const applicationDoc = await transaction.get(applicationRef); + + if (!applicationDoc.exists) { + return { status: APPLICATION_STATUS.notFound }; + } + + const application = applicationDoc.data(); + + if (application.userId !== userId) { + return { status: APPLICATION_STATUS.unauthorized }; + } + + const lastEditAt = application.lastEditAt; + if (lastEditAt) { + const lastEditTimestamp = new Date(lastEditAt).getTime(); + const timeDifference = currentTime - lastEditTimestamp; + + if (timeDifference < twentyFourHoursInMilliseconds) { + return { status: APPLICATION_STATUS.tooSoon }; + } + } + + const requestBody = { + ...dataToUpdate, + lastEditAt: new Date(currentTime).toISOString(), + }; + transaction.update(applicationRef, requestBody); + + return { status: APPLICATION_STATUS.success }; + }); + + return result; }; const nudgeApplication = async ({ applicationId, userId }: { applicationId: string; userId: string }) => { diff --git a/routes/applications.ts b/routes/applications.ts index 2b309d883..e2e7d88aa 100644 --- a/routes/applications.ts +++ b/routes/applications.ts @@ -17,11 +17,12 @@ router.get( ); router.get("/:applicationId", authenticate, authorizeRoles([SUPERUSER]), applications.getApplicationById); router.post("/", authenticate, applicationValidator.validateApplicationData, applications.addApplication); +router.patch("/:applicationId", authenticate, applicationValidator.validateApplicationUpdateData, applications.updateApplication); router.patch( "/:applicationId/feedback", authenticate, authorizeRoles([SUPERUSER]), - applicationValidator.validateApplicationUpdateData, + applicationValidator.validateApplicationFeedbackData, applications.submitApplicationFeedback ); router.patch("/:applicationId/nudge", authenticate, applications.nudgeApplication); diff --git a/test/integration/application.test.ts b/test/integration/application.test.ts index 3d1212a3b..a3104d33e 100644 --- a/test/integration/application.test.ts +++ b/test/integration/application.test.ts @@ -689,7 +689,7 @@ describe("Application", function () { expect(res.body.nudgeCount).to.be.equal(1); const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); - applicationModel.updateApplication({ lastNudgeAt: twentyFiveHoursAgo }, nudgeApplicationId).then(() => { + applicationModel.updateApplication({ lastNudgeAt: twentyFiveHoursAgo }, nudgeApplicationId, userId).then(() => { chai .request(app) .patch(`/applications/${nudgeApplicationId}/nudge`) diff --git a/test/unit/middlewares/application-validator.test.ts b/test/unit/middlewares/application-validator.test.ts index 633d8231f..84c80faf9 100644 --- a/test/unit/middlewares/application-validator.test.ts +++ b/test/unit/middlewares/application-validator.test.ts @@ -111,7 +111,7 @@ describe("application validator test", function () { status: "accepted", feedback: "some feedback", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(1); }); @@ -119,7 +119,7 @@ describe("application validator test", function () { req.body = { batman: true, }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(0); }); @@ -127,7 +127,7 @@ describe("application validator test", function () { req.body = { status: "something", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(0); }); @@ -136,7 +136,7 @@ describe("application validator test", function () { status: "accepted", feedback: "Great work!", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(1); }); @@ -145,7 +145,7 @@ describe("application validator test", function () { status: "rejected", feedback: "Not a good fit", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(1); }); @@ -154,7 +154,7 @@ describe("application validator test", function () { status: "changes_requested", feedback: "Please update your skills section", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(1); }); @@ -162,7 +162,7 @@ describe("application validator test", function () { req.body = { status: "changes_requested", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(0); }); @@ -171,7 +171,7 @@ describe("application validator test", function () { status: "changes_requested", feedback: "", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(0); }); @@ -180,7 +180,7 @@ describe("application validator test", function () { status: "accepted", feedback: "", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(1); }); @@ -189,7 +189,7 @@ describe("application validator test", function () { status: "rejected", feedback: "", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(1); }); @@ -197,7 +197,7 @@ describe("application validator test", function () { req.body = { feedback: "Some feedback", }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(0); }); @@ -205,7 +205,7 @@ describe("application validator test", function () { req.body = { status: null, }; - await applicationValidator.validateApplicationUpdateData(req, res, nextSpy); + await applicationValidator.validateApplicationFeedbackData(req, res, nextSpy); expect(nextSpy.callCount).to.equal(0); }); }); diff --git a/test/unit/models/application.test.ts b/test/unit/models/application.test.ts index 07e1080aa..7a939a3bc 100644 --- a/test/unit/models/application.test.ts +++ b/test/unit/models/application.test.ts @@ -109,16 +109,6 @@ describe("applications", function () { }); }); - describe("updateApplication", function () { - it("should update a particular application", async function () { - const dataToUpdate = { status: "accepted" }; - await ApplicationModel.updateApplication(dataToUpdate, applicationId1); - const application = await ApplicationModel.getApplicationById(applicationId1); - - expect(application.status).to.be.equal("accepted"); - }); - }); - describe("addApplicationFeedback", function () { let testApplicationId: string; const reviewerName = "test-reviewer";