Skip to content

Commit

Permalink
feat: collect email for subscription (#2124)
Browse files Browse the repository at this point in the history
* feat: collect email for subscription

* feat: add API to send email & test locally

* feat: write test cases for subscription APIs

* refactor: add comment

* feat: add test for send-email API

* feat: validating email and add contants

* refactor: change file extension

* feat: add test for invalid email

* feat: add feature flag

* refactor: change config details

* refactor: change test email

* fix: change phone number to optional

* fix: make phoneNumber optional

* fix: change API name

* fix: change API name

* fix: try-catch Indain phone numbers controllers

* fix: put to patch

* feat: add devFlagMiddleware

* fix: comments

* fix: return message

* remove: comments

* fix: failing test case

* fix: add test cases

* fix: remove comment

* fix: custom environment variables

* rename: emailSubscriptonCrenderials

---------

Co-authored-by: Vinit khandal <[email protected]>
  • Loading branch information
tejaskh3 and vinit717 authored Oct 16, 2024
1 parent 37aea6e commit 9a5e799
Show file tree
Hide file tree
Showing 16 changed files with 478 additions and 0 deletions.
10 changes: 10 additions & 0 deletions config/custom-environment-variables.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ module.exports = {
clientSecret: "<clientSecret>",
},

emailServiceConfig: {
email: "<RDS_EMAIL>",
password: "<EMAIL PASSWORD GENERATED AFTER 2FA>",
host: "<smtp host>",
port: "<number>",
},

firestore: `{
"type": "service_account",
"project_id": "<project-name>",
Expand Down
2 changes: 2 additions & 0 deletions constants/subscription-validator.ts
Original file line number Diff line number Diff line change
@@ -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}$/;
74 changes: 74 additions & 0 deletions controllers/subscription.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
subject: "Hello local, Testing in progress.",
text: "working for notification feature",
html: "<b>Hello world!</b>",
});

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);
}
};
15 changes: 15 additions & 0 deletions middlewares/devFlag.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
23 changes: 23 additions & 0 deletions middlewares/validators/subscription.ts
Original file line number Diff line number Diff line change
@@ -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();
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions routes/index.ts
Original file line number Diff line number Diff line change
@@ -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"));
Expand Down Expand Up @@ -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;
12 changes: 12 additions & 0 deletions routes/subscription.ts
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions test/config/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<RDS_EMAIL>",
password: "<EMAIL PASSWORD GENERATED AFTER 2FA>",
host: "<smtp host>",
port: "<number>",
},
services: {
rdsApi: {
baseUrl: `http://localhost:${port}`,
Expand Down
7 changes: 7 additions & 0 deletions test/fixtures/subscription/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const subscribedMessage = "User subscribed successfully";
export const unSubscribedMessage = "User unsubscribed successfully";
export const subscriptionData = {
phoneNumber: "+911234567890",
email: "[email protected]",
};

115 changes: 115 additions & 0 deletions test/integration/subscription.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
});
58 changes: 58 additions & 0 deletions test/unit/middlewares/devFlag.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading

0 comments on commit 9a5e799

Please sign in to comment.