diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index f22c5756e..b251ac00a 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -55,6 +55,16 @@ module.exports = { }, }, + emailServiceConfig: { + email: "RDS_EMAIL", + password: "RDS_EMAIL_PASSWORD", + host: "SMTP_HOST", + port: { + __name: "SMTP_PORT", + __format: "number", + }, + }, + userToken: { cookieName: "COOKIE_NAME", ttl: { diff --git a/config/default.js b/config/default.js index 71098835d..6e5f9cee9 100644 --- a/config/default.js +++ b/config/default.js @@ -25,6 +25,13 @@ module.exports = { clientSecret: "", }, + emailServiceConfig: { + email: "", + password: "", + host: "", + port: "", + }, + firestore: `{ "type": "service_account", "project_id": "", diff --git a/constants/subscription-validator.ts b/constants/subscription-validator.ts new file mode 100644 index 000000000..78b05a88a --- /dev/null +++ b/constants/subscription-validator.ts @@ -0,0 +1,2 @@ +export const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; +export const phoneNumberRegex = /^[+]{1}(?:[0-9\-\\(\\)\\/.]\s?){6,15}[0-9]{1}$/; \ No newline at end of file diff --git a/controllers/subscription.ts b/controllers/subscription.ts new file mode 100644 index 000000000..f3621987e --- /dev/null +++ b/controllers/subscription.ts @@ -0,0 +1,74 @@ +import { CustomRequest, CustomResponse } from "../types/global"; +const { addOrUpdate } = require("../models/users"); +const { INTERNAL_SERVER_ERROR } = require("../constants/errorMessages"); +const nodemailer = require("nodemailer"); +const config = require("config"); +const emailServiceConfig = config.get("emailServiceConfig"); + +export const subscribe = async (req: CustomRequest, res: CustomResponse) => { + const { email } = req.body; + const phoneNumber = req.body.phoneNumber || null; + const userId = req.userData.id; + const data = { email, isSubscribed: true, phoneNumber }; + const userAlreadySubscribed = req.userData.isSubscribed; + try { + if (userAlreadySubscribed) { + return res.boom.badRequest("User already subscribed"); + } + await addOrUpdate(data, userId); + return res.status(201).json("User subscribed successfully"); + } catch (error) { + logger.error(`Error occurred while subscribing: ${error.message}`); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; + +export const unsubscribe = async (req: CustomRequest, res: CustomResponse) => { + const userId = req.userData.id; + const userAlreadySubscribed = req.userData.isSubscribed; + try { + if (!userAlreadySubscribed) { + return res.boom.badRequest("User is already unsubscribed"); + } + await addOrUpdate( + { + isSubscribed: false, + }, + userId + ); + return res.status(200).json("User unsubscribed successfully"); + } catch (error) { + logger.error(`Error occurred while unsubscribing: ${error.message}`); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; + +// TODO: currently we are sending test email to a user only (i.e., Tejas sir as decided) +// later we need to make service which send email to all subscribed user +export const sendEmail = async (req: CustomRequest, res: CustomResponse) => { + try { + const transporter = nodemailer.createTransport({ + host: emailServiceConfig.host, + port: emailServiceConfig.port, + secure: false, + + auth: { + user: emailServiceConfig.email, + pass: emailServiceConfig.password, + }, + }); + + const info = await transporter.sendMail({ + from: `"Real Dev Squad" <${emailServiceConfig.email}>`, + to: "tejasatrds@gmail.com", + subject: "Hello local, Testing in progress.", + text: "working for notification feature", + html: "Hello world!", + }); + + return res.send({ message: "Email sent successfully", info }); + } catch (error) { + logger.error("Error occurred while sending email:", error.message); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; \ No newline at end of file diff --git a/middlewares/devFlag.ts b/middlewares/devFlag.ts new file mode 100644 index 000000000..643208cfb --- /dev/null +++ b/middlewares/devFlag.ts @@ -0,0 +1,15 @@ +import { NextFunction } from "express"; +import { CustomRequest, CustomResponse } from "../types/global"; + +export const devFlagMiddleware = (req: CustomRequest, res: CustomResponse, next: NextFunction) => { + try { + const dev = req.query.dev === "true"; + if (!dev) { + return res.boom.notFound("Route not found"); + } + next(); + } catch (err) { + logger.error("Error occurred in devFlagMiddleware:", err.message); + next(err); + } +}; diff --git a/middlewares/validators/subscription.ts b/middlewares/validators/subscription.ts new file mode 100644 index 000000000..dc129e797 --- /dev/null +++ b/middlewares/validators/subscription.ts @@ -0,0 +1,23 @@ +import { NextFunction } from "express"; +import { CustomRequest, CustomResponse } from "../../types/global"; +import { emailRegex, phoneNumberRegex } from "../../constants/subscription-validator"; +import Joi from 'joi'; + +export const validateSubscribe = (req: CustomRequest, res: CustomResponse, next: NextFunction) => { + + if(req.body.email){ + req.body.email = req.body.email.trim(); + } + if (req.body.phoneNumber) { + req.body.phoneNumber = req.body.phoneNumber.trim(); + } + const subscribeSchema = Joi.object({ + phoneNumber: Joi.string().allow('').optional().regex(phoneNumberRegex), + email: Joi.string().required().regex(emailRegex) + }); + const { error } = subscribeSchema.validate(req.body); + if (error) { + return res.status(400).json({ error: error.details[0].message }); + } + next(); +}; diff --git a/package.json b/package.json index 7df8a48a8..b01a32092 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "tdd:watch": "sh scripts/tests/tdd.sh" }, "dependencies": { + "@types/nodemailer": "^6.4.15", "axios": "1.7.2", "cloudinary": "2.0.3", "config": "3.3.7", @@ -34,6 +35,8 @@ "morgan": "1.10.0", "multer": "1.4.5-lts.1", "newrelic": "11.19.0", + "nodemailer": "^6.9.15", + "nodemailer-mock": "^2.0.6", "passport": "0.7.0", "passport-github2": "0.1.12", "rate-limiter-flexible": "5.0.3", diff --git a/routes/index.ts b/routes/index.ts index 575797bc6..8cd97bd3e 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -1,5 +1,6 @@ import express from "express"; const app = express.Router(); +import { devFlagMiddleware } from "../middlewares/devFlag"; app.use("/answers", require("./answers")); app.use("/auctions", require("./auctions")); @@ -39,4 +40,5 @@ app.use("/v1/notifications", require("./notify")); app.use("/goals", require("./goals")); app.use("/invites", require("./invites")); app.use("/requests", require("./requests")); +app.use("/subscription", devFlagMiddleware, require("./subscription")); module.exports = app; diff --git a/routes/subscription.ts b/routes/subscription.ts new file mode 100644 index 000000000..5827a3fd4 --- /dev/null +++ b/routes/subscription.ts @@ -0,0 +1,12 @@ +import express from "express"; +import authenticate from "../middlewares/authenticate"; +import { subscribe, unsubscribe, sendEmail } from "../controllers/subscription"; +import { validateSubscribe } from "../middlewares/validators/subscription"; +const authorizeRoles = require("../middlewares/authorizeRoles"); +const router = express.Router(); +const { SUPERUSER } = require("../constants/roles"); + +router.post("/", authenticate, validateSubscribe, subscribe); +router.patch("/", authenticate, unsubscribe); +router.get("/notify", authenticate, authorizeRoles([SUPERUSER]), sendEmail); +module.exports = router; diff --git a/test/config/test.js b/test/config/test.js index a90dc3d78..688b40c26 100644 --- a/test/config/test.js +++ b/test/config/test.js @@ -35,6 +35,13 @@ module.exports = { "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-hqc2v%40dev-rds.iam.gserviceaccount.com" }`, + + emailServiceConfig: { + email: "", + password: "", + host: "", + port: "", + }, services: { rdsApi: { baseUrl: `http://localhost:${port}`, diff --git a/test/fixtures/subscription/subscription.ts b/test/fixtures/subscription/subscription.ts new file mode 100644 index 000000000..c39463123 --- /dev/null +++ b/test/fixtures/subscription/subscription.ts @@ -0,0 +1,7 @@ +export const subscribedMessage = "User subscribed successfully"; +export const unSubscribedMessage = "User unsubscribed successfully"; +export const subscriptionData = { + phoneNumber: "+911234567890", + email: "example@gmail.com", +}; + diff --git a/test/integration/subscription.test.js b/test/integration/subscription.test.js new file mode 100644 index 000000000..66d652287 --- /dev/null +++ b/test/integration/subscription.test.js @@ -0,0 +1,115 @@ +const chai = require("chai"); +const sinon = require("sinon"); +const app = require("../../server"); +const cookieName = config.get("userToken.cookieName"); +const { subscribedMessage, unSubscribedMessage, subscriptionData } = require("../fixtures/subscription/subscription"); +const addUser = require("../utils/addUser"); +const authService = require("../../services/authService"); +const chaiHttp = require("chai-http"); +chai.use(chaiHttp); +const nodemailer = require("nodemailer"); +const nodemailerMock = require("nodemailer-mock"); +const userData = require("../fixtures/user/user")(); +const { expect } = chai; +let userId = ""; +const superUser = userData[4]; +let superUserAuthToken = ""; +describe("/subscription email notifications", function () { + let jwt; + + beforeEach(async function () { + userId = await addUser(); + jwt = authService.generateAuthToken({ userId }); + }); + + it("Should return 401 if the user is not logged in", function (done) { + chai + .request(app) + .post("/subscription?dev=true") + .end((err, res) => { + if (err) { + return done(); + } + expect(res).to.have.status(401); + expect(res.body).to.be.a("object"); + expect(res.body.message).to.equal("Unauthenticated User"); + return done(); + }); + }); + + it("should add user's data and make them subscribe to us.", function (done) { + chai + .request(app) + .post(`/subscription?dev=true`) + .set("cookie", `${cookieName}=${jwt}`) + .send(subscriptionData) + .end((err, res) => { + if (err) { + return done(err); + } + expect(res).to.have.status(201); + expect(res.body).to.equal(subscribedMessage); + return done(); + }); + }); + + it("should unsubscribe the user", function (done) { + chai + .request(app) + .patch(`/subscription?dev=true`) + .set("cookie", `${cookieName}=${jwt}`) + .end((err, res) => { + if (err) { + return done(err); + } + expect(res).to.have.status(200); + expect(res.body).to.equal(unSubscribedMessage); + return done(); + }); + }); + + describe("/notify endpoint", function () { + beforeEach(async function () { + const superUserId = await addUser(superUser); + superUserAuthToken = authService.generateAuthToken({ userId: superUserId }); + sinon.stub(nodemailerMock, "createTransport").callsFake(nodemailerMock.createTransport); + }); + + afterEach(function () { + sinon.restore(); + nodemailerMock.mock.reset(); + }); + + it("Should return 401 if the super user is not logged in", function (done) { + chai + .request(app) + .get("/subscription/notify?dev=true") + .end((err, res) => { + if (err) { + return done(); + } + expect(res).to.have.status(401); + expect(res.body).to.be.a("object"); + expect(res.body.message).to.equal("Unauthenticated User"); + return done(); + }); + }); + + it("should handle errors if sending email fails", function (done) { + sinon.stub(nodemailer, "createTransport").callsFake(() => { + throw new Error("Transport error"); + }); + + chai + .request(app) + .get("/subscription/notify?dev=true") + .set("Cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + if (err) return done(err); + expect(res).to.have.status(500); + expect(res.body).to.have.property("message", "An internal server error occurred"); + return done(); + }); + }); + }); +}); diff --git a/test/unit/middlewares/devFlag.test.js b/test/unit/middlewares/devFlag.test.js new file mode 100644 index 000000000..bd692ec1b --- /dev/null +++ b/test/unit/middlewares/devFlag.test.js @@ -0,0 +1,58 @@ +const { expect } = require("chai"); +const { devFlagMiddleware } = require("../../../middlewares/devFlag"); +const sinon = require("sinon"); + +describe("devFlagMiddleware", function () { + let req; + let res; + let next; + + beforeEach(function () { + req = { + query: {}, + }; + res = { + boom: { + notFound: sinon.spy((message) => { + res.status = 404; + res.message = message; + }), + }, + }; + next = sinon.spy(); + }); + + it("should call next() if dev query parameter is true", function () { + req.query.dev = "true"; + devFlagMiddleware(req, res, next); + return expect(next.calledOnce).to.be.equal(true); + }); + + it("should return 404 if dev query parameter is not true", function () { + req.query.dev = "false"; + + devFlagMiddleware(req, res, next); + + expect(res.status).to.equal(404); + expect(res.message).to.equal("Route not found"); + return expect(next.notCalled).to.be.equal(true); + }); + + it("should return 404 if dev query parameter is missing", function () { + devFlagMiddleware(req, res, next); + + expect(res.status).to.equal(404); + expect(res.message).to.equal("Route not found"); + return expect(next.notCalled).to.be.equal(true); + }); + + it("should call next(err) if an error occurs", function () { + res.boom.notFound = sinon.stub().throws(new Error("Test error")); + + devFlagMiddleware(req, res, next); + + expect(next.calledOnce).to.be.equal(true); + expect(next.args[0][0]).to.be.instanceOf(Error); + return expect(next.args[0][0].message).to.equal("Test error"); + }); +}); diff --git a/test/unit/middlewares/subscription-validator.test.js b/test/unit/middlewares/subscription-validator.test.js new file mode 100644 index 000000000..74d2ed9df --- /dev/null +++ b/test/unit/middlewares/subscription-validator.test.js @@ -0,0 +1,121 @@ +const Sinon = require("sinon"); +const { expect } = require("chai"); +const { validateSubscribe } = require("../../../middlewares/validators/subscription"); + +describe("Middleware | Validators | Subscription", function () { + let req, res, nextSpy; + + beforeEach(function () { + req = { body: {} }; + res = { + status: Sinon.stub().returnsThis(), + json: Sinon.stub(), + }; + nextSpy = Sinon.spy(); + }); + + it("should call next function when a valid request body is passed", async function () { + req.body = { + phoneNumber: "+911234567890", + email: "test@example.com", + }; + + validateSubscribe(req, res, nextSpy); + + expect(nextSpy.calledOnce).to.be.equal(true); + expect(res.status.called).to.be.equal(false); + expect(res.json.called).to.be.equal(false); + }); + + it("should not return an error when phoneNumber is missing", async function () { + req.body = { + email: "test@example.com", + }; + + validateSubscribe(req, res, nextSpy); + expect(nextSpy.calledOnce).to.be.equal(true); + expect(res.status.called).to.be.equal(false); + expect(res.json.called).to.be.equal(false); + }); + + it("should return a 400 error when email is missing", async function () { + req.body = { + phoneNumber: "+911234567890", + }; + + validateSubscribe(req, res, nextSpy); + + expect(nextSpy.called).to.be.equal(false); + expect(res.status.calledOnceWith(400)).to.be.equal(true); + expect(res.json.calledOnce).to.be.equal(true); + expect(res.json.firstCall.args[0]).to.have.property("error").that.includes('"email" is required'); + }); + + it("should return a 400 error when both phoneNumber and email are missing", async function () { + req.body = {}; + + validateSubscribe(req, res, nextSpy); + expect(nextSpy.called).to.be.equal(false); + expect(res.status.calledOnceWith(400)).to.be.equal(true); + expect(res.json.calledOnce).to.be.equal(true); + expect(res.json.firstCall.args[0]).to.have.property("error").that.includes('"email" is required'); + }); + + it("should return a 400 error when email is not in correct format", async function () { + req.body = { + phoneNumber: "+911234567890", + email: "invalid-email", + }; + + validateSubscribe(req, res, nextSpy); + + expect(nextSpy.called).to.be.equal(false); + expect(res.status.calledOnceWith(400)).to.be.equal(true); + expect(res.json.calledOnce).to.be.equal(true); + expect(res.json.firstCall.args[0]) + .to.have.property("error") + .that.includes('"email" with value "invalid-email" fails to match the required pattern'); + }); + + it("should not return an error when phoneNumber is in correct format", async function () { + req.body = { + phoneNumber: "+911234567890", + email: "test@example.com", + }; + + validateSubscribe(req, res, nextSpy); + expect(nextSpy.calledOnce).to.be.equal(true); + expect(res.status.called).to.be.equal(false); + expect(res.json.called).to.be.equal(false); + }); + + it("should trim and validate phoneNumber if it contains leading or trailing spaces", async function () { + req.body = { + phoneNumber: " +911234567890 ", + email: "test@example.com", + }; + + validateSubscribe(req, res, nextSpy); + + expect(nextSpy.calledOnce).to.be.equal(true); + expect(res.status.called).to.be.equal(false); + expect(res.json.called).to.be.equal(false); + expect(req.body.phoneNumber).to.equal("+911234567890"); + }); + + it("should return a 400 error when phoneNumber is in incorrect format", async function () { + req.body = { + phoneNumber: "invalid-number", + email: "test@example.com", + }; + + validateSubscribe(req, res, nextSpy); + + expect(nextSpy.called).to.be.equal(false); + expect(res.status.calledOnceWith(400)).to.be.equal(true); + expect(res.json.calledOnce).to.be.equal(true); + expect(res.json.firstCall.args[0]) + .to.have.property("error") + .that.includes('"phoneNumber" with value "invalid-number" fails to match the required pattern'); + }); +}); diff --git a/types/global.d.ts b/types/global.d.ts index 6c6afa057..8f1046ada 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -36,6 +36,9 @@ export type userData = { status: string; username: string; updated_at: number; + isSubscribed: boolean; + phoneNumber: string | null; + email: string; }; export type CustomResponse = Response & { boom: Boom }; diff --git a/yarn.lock b/yarn.lock index 5781ddc3d..95acf1406 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1076,6 +1076,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.66.tgz#dd035d409df322acc83dff62a602f12a5783bbb3" integrity sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw== +"@types/nodemailer@^6.4.15": + version "6.4.15" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.15.tgz#494be695e11c438f7f5df738fb4ab740312a6ed2" + integrity sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ== + dependencies: + "@types/node" "*" + "@types/qs@*", "@types/qs@^6.2.31": version "6.9.15" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.15.tgz#adde8a060ec9c305a82de1babc1056e73bd64dce" @@ -5879,6 +5886,18 @@ node-releases@^2.0.14: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== +nodemailer-mock@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/nodemailer-mock/-/nodemailer-mock-2.0.6.tgz#0dd3e522df73682d47f4f2b1ee905aacd22e2c8e" + integrity sha512-9x/QN1AbKy4PJ7yIQnToly3c7gUCSGABeB10/c5jgO986fAOMghzVedbZe8UDsu2PEStCoOd+MayX09CduYSHQ== + dependencies: + debug "^4.3.4" + +nodemailer@^6.9.15: + version "6.9.15" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.15.tgz#57b79dc522be27e0e47ac16cc860aa0673e62e04" + integrity sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ== + nodemon@3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.3.tgz#dcce9ee0aa7d19cd4dcd576ae9a0456d9078b286"