diff --git a/controllers/fcmToken.js b/controllers/fcmToken.js new file mode 100644 index 000000000..a60521ab5 --- /dev/null +++ b/controllers/fcmToken.js @@ -0,0 +1,29 @@ +const { saveFcmToken } = require("../models/fcmToken"); +const { Conflict } = require("http-errors"); + +/** + * Route used to get the health status of teh server + * + * @param req {Object} - Express request object + * @param res {Object} - Express response object + */ +const fcmTokenController = async (req, res) => { + try { + const { fcmToken } = req.body; + + const fcmTokenId = await saveFcmToken({ userId: req.userData.id, fcmToken }); + if (fcmTokenId) res.status(200).json({ status: 200, message: "Device registered successfully" }); + } catch (error) { + if (error instanceof Conflict) { + return res.status(409).json({ + message: error.message, + }); + } + res.status(500).send("Something went wrong, please contact admin"); + } + return res.status(500).send("Internal server error"); +}; + +module.exports = { + fcmTokenController, +}; diff --git a/controllers/notify.js b/controllers/notify.js new file mode 100644 index 000000000..ee0bcdaa4 --- /dev/null +++ b/controllers/notify.js @@ -0,0 +1,83 @@ +const admin = require("firebase-admin"); +const { getFcmTokenFromUserId } = require("../services/getFcmTokenFromUserId"); +const { getUserIdsFromRoleId } = require("../services/getUserIdsFromRoleId"); + +/** + * Route used to get the health status of teh server + * + * @param req {Object} - Express request object + * @param res {Object} - Express response object + */ +const notifyController = async (req, res) => { + const { title, body, userId, groupRoleId } = req.body; + let fcmTokens = []; + if (userId) { + const fcmTokensFromUserId = await getFcmTokenFromUserId(userId); + fcmTokens = [...fcmTokens, ...fcmTokensFromUserId]; + } + + let userIdsFromRoleId = []; + let fcmTokensFromUserId; + if (groupRoleId) { + try { + userIdsFromRoleId = await getUserIdsFromRoleId(groupRoleId); + } catch (error) { + logger.error("error ", error); + throw error; + } + + const fcmTokensPromiseArray = userIdsFromRoleId.map(async (userId) => { + try { + fcmTokensFromUserId = await getFcmTokenFromUserId(userId); + } catch (error) { + logger.error("error ", error); + throw error; + } + fcmTokens = [...fcmTokens, ...fcmTokensFromUserId]; + }); + try { + await Promise.all(fcmTokensPromiseArray); + } catch (error) { + logger.error("error", error); + throw error; + } + } + + const setOfFcmTokens = new Set(fcmTokens); + + const message = { + notification: { + title: title || "Notification Title", + body: body || "Notification Body", + }, + data: { + key1: "value1", + key2: "value2", + }, + tokens: Array.from(setOfFcmTokens), + }; + function calculateMessageSize(message) { + const byteArray = new TextEncoder().encode(message); + + const byteLength = byteArray.length; + + const kilobytes = byteLength / 1024; + + return kilobytes; + } + if (calculateMessageSize(message) >= 2) { + res.error(401).send("Message length exceeds"); + } + admin + .messaging() + .sendMulticast(message) + .then(() => res.status(200).json({ status: 200, message: "User notified successfully" })) + .catch((error) => { + logger.error("Error sending message:", error); + res.status(500).json({ status: 500, message: "Internal server error" }); + }); +}; + +module.exports = { + notifyController, +}; diff --git a/middlewares/validators/fcmToken.js b/middlewares/validators/fcmToken.js new file mode 100644 index 000000000..bbed1c755 --- /dev/null +++ b/middlewares/validators/fcmToken.js @@ -0,0 +1,14 @@ +const joi = require("joi"); + +export const fcmTokenValidator = async (req, res, next) => { + const schema = joi.object().strict().keys({ + fcmToken: joi.string().required(), + }); + try { + await schema.validateAsync(req.body); + next(); + } catch (error) { + logger.error(`Bad request body : ${error}`); + res.boom.badRequest(error.details[0].message); + } +}; diff --git a/middlewares/validators/notify.js b/middlewares/validators/notify.js new file mode 100644 index 000000000..f960fea83 --- /dev/null +++ b/middlewares/validators/notify.js @@ -0,0 +1,24 @@ +const joi = require("joi"); + +export const notifyValidator = async (req, res, next) => { + const MAX_TITLE_LENGTH = 512; + const MAX_BODY_LENGTH = 1536; + + const schema = joi + .object() + .strict() + .keys({ + title: joi.string().required().max(MAX_TITLE_LENGTH).required(), + body: joi.string().required().max(MAX_BODY_LENGTH).required(), + userId: joi.string(), + groupRoleId: joi.string(), + }) + .xor("userId", "groupRoleId"); + try { + await schema.validateAsync(req.body); + next(); + } catch (error) { + logger.error(`Bad request body : ${error}`); + res.boom.badRequest(error.details[0].message); + } +}; diff --git a/models/fcmToken.js b/models/fcmToken.js new file mode 100644 index 000000000..7c8258b9b --- /dev/null +++ b/models/fcmToken.js @@ -0,0 +1,40 @@ +const firestore = require("../utils/firestore"); +const { Conflict } = require("http-errors"); + +const fcmTokenModel = firestore.collection("fcmToken"); + +const saveFcmToken = async (fcmTokenData) => { + try { + const fcmTokenSnapshot = await fcmTokenModel.where("userId", "==", fcmTokenData.userId).limit(1).get(); + + if (fcmTokenSnapshot.empty) { + const fcmToken = await fcmTokenModel.add({ + userId: fcmTokenData.userId, + fcmTokens: [fcmTokenData.fcmToken], + }); + return fcmToken.id; + } else { + let fcmTokenObj = {}; + fcmTokenSnapshot.forEach((fcmToken) => { + fcmTokenObj = { + id: fcmToken.id, + ...fcmToken.data(), + }; + }); + if (!fcmTokenObj.fcmTokens.includes(fcmTokenData.fcmToken)) { + fcmTokenObj.fcmTokens.push(fcmTokenData.fcmToken); + await fcmTokenModel.doc(fcmTokenObj.id).update({ + fcmTokens: fcmTokenObj.fcmTokens, + }); + return fcmTokenObj.id; + } else { + throw new Conflict("Device Already Registered"); + } + } + } catch (err) { + logger.error("Error in adding fcm token", err); + throw err; + } +}; + +module.exports = { saveFcmToken }; diff --git a/routes/fcmToken.js b/routes/fcmToken.js new file mode 100644 index 000000000..7c09d144a --- /dev/null +++ b/routes/fcmToken.js @@ -0,0 +1,9 @@ +const express = require("express"); +const router = express.Router(); + +const authenticate = require("../middlewares/authenticate"); +const { fcmTokenController } = require("../controllers/fcmToken"); +const { fcmTokenValidator } = require("../middlewares/validators/fcmToken"); + +router.post("/", authenticate, fcmTokenValidator, fcmTokenController); +module.exports = router; diff --git a/routes/index.js b/routes/index.js index c95dfef07..9b1054953 100644 --- a/routes/index.js +++ b/routes/index.js @@ -31,5 +31,7 @@ app.use("/issues", require("./issues.js")); app.use("/progresses", require("./progresses.js")); app.use("/monitor", require("./monitor.js")); app.use("/staging", require("./staging.js")); +app.use("/v1/fcm-tokens", require("./fcmToken.js")); +app.use("/v1/notifications", require("./notify.js")); app.use("/goals", require("./goals.js")); module.exports = app; diff --git a/routes/notify.js b/routes/notify.js new file mode 100644 index 000000000..3bbb58aae --- /dev/null +++ b/routes/notify.js @@ -0,0 +1,9 @@ +const express = require("express"); +const router = express.Router(); + +const authenticate = require("../middlewares/authenticate"); +const { notifyController } = require("../controllers/notify"); +const { notifyValidator } = require("../middlewares/validators/notify"); + +router.post("/", authenticate, notifyValidator, notifyController); +module.exports = router; diff --git a/services/getFcmTokenFromUserId.js b/services/getFcmTokenFromUserId.js new file mode 100644 index 000000000..16217b8c9 --- /dev/null +++ b/services/getFcmTokenFromUserId.js @@ -0,0 +1,12 @@ +const firestore = require("../utils/firestore"); + +const fcmTokenModel = firestore.collection("fcmToken"); + +export const getFcmTokenFromUserId = async (userId) => { + if (!userId) return []; + const fcmTokenSnapshot = await fcmTokenModel.where("userId", "==", userId).limit(1).get(); + if (!fcmTokenSnapshot.empty) { + return fcmTokenSnapshot.docs[0].data().fcmTokens; + } + return []; +}; diff --git a/services/getUserIdsFromRoleId.js b/services/getUserIdsFromRoleId.js new file mode 100644 index 000000000..3014bf18a --- /dev/null +++ b/services/getUserIdsFromRoleId.js @@ -0,0 +1,19 @@ +const firestore = require("../utils/firestore"); +const memberRoleModel = firestore.collection("member-group-roles"); + +export const getUserIdsFromRoleId = async (roleId) => { + let userIds = []; + try { + const querySnapshot = await memberRoleModel.where("roleid", "==", roleId).get(); + if (querySnapshot.empty) { + return []; + } + if (!querySnapshot.empty) { + userIds = querySnapshot.docs.map((doc) => doc.data().userid); + } + return userIds; + } catch (error) { + logger.error("error", error); + throw error; + } +}; diff --git a/test/integration/fcmToken.test.js b/test/integration/fcmToken.test.js new file mode 100644 index 000000000..3ae21c04a --- /dev/null +++ b/test/integration/fcmToken.test.js @@ -0,0 +1,100 @@ +const chai = require("chai"); +const { expect } = chai; +const app = require("../../server"); +const cleanDb = require("../utils/cleanDb"); +const addUser = require("../utils/addUser"); +const userData = require("../fixtures/user/user")(); + +const userData0 = userData[0]; +const authService = require("../../services/authService"); + +const cookieName = config.get("userToken.cookieName"); + +describe("Fcm Token Test", function () { + let userId0, userIdToken0; + + beforeEach(async function () { + userId0 = await addUser(userData0); + userIdToken0 = authService.generateAuthToken({ userId: userId0 }); + }); + afterEach(async function () { + await cleanDb(); + }); + + describe("POST call to save the fcm", function () { + it("should save the fcm token", async function () { + const fcmTokenData = { fcmToken: "iedsijdsdj" }; + + const response = await chai + .request(app) + .post("/v1/fcm-tokens") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...fcmTokenData, + }); + + expect(response).to.have.status(200); + expect(response.body.message).equals("Device registered successfully"); + }); + + it("should not duplicate fcm token", async function () { + const fcmTokenData = { fcmToken: "iedsijdsdj" }; + await chai + .request(app) + .post("/v1/fcm-tokens") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...fcmTokenData, + }); + + const response = await chai + .request(app) + .post("/v1/fcm-tokens") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...fcmTokenData, + }); + + expect(response).to.have.status(409); + expect(response.body.message).equals("Device Already Registered"); + }); + it("should have fcm token", async function () { + const fcmTokenData = {}; + const response = await chai + .request(app) + .post("/v1/fcm-tokens") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...fcmTokenData, + }); + expect(response).to.have.status(400); + expect(response.body.message).equals('"fcmToken" is required'); + }); + it("should have user token", async function () { + const fcmTokenData = { fcmToken: "iedsijdsdj" }; + + const response = await chai + .request(app) + .post("/v1/fcm-tokens") + .send({ + ...fcmTokenData, + }); + + expect(response).to.have.status(401); + expect(response.body.message).equals("Unauthenticated User"); + }); + + it("should have user token and fcm token", async function () { + const fcmTokenData = {}; + + const response = await chai + .request(app) + .post("/v1/fcm-tokens") + .send({ + ...fcmTokenData, + }); + expect(response).to.have.status(401); + expect(response.body.message).equals("Unauthenticated User"); + }); + }); +}); diff --git a/test/integration/notify.test.js b/test/integration/notify.test.js new file mode 100644 index 000000000..dd2d1810d --- /dev/null +++ b/test/integration/notify.test.js @@ -0,0 +1,125 @@ +const chai = require("chai"); +const { expect } = chai; +const app = require("../../server"); +const cleanDb = require("../utils/cleanDb"); +const addUser = require("../utils/addUser"); +const userData = require("../fixtures/user/user")(); + +const userData0 = userData[0]; +const authService = require("../../services/authService"); + +const cookieName = config.get("userToken.cookieName"); + +describe("Notify Test", function () { + let userId0, userIdToken0; + + beforeEach(async function () { + userId0 = await addUser(userData0); + userIdToken0 = authService.generateAuthToken({ userId: userId0 }); + }); + afterEach(async function () { + await cleanDb(); + }); + + describe("POST call to notify", function () { + // eslint-disable-next-line mocha/no-skipped-tests + it.skip("should send message to specified users", async function () { + // skipping the test because it connects with firebase cloud messaging service which we are unable to mock. + + const notifyData = { title: "some title", body: "some body", userId: userId0 }; + + const fcmTokenData = { fcmToken: "iedsijdsdj" }; + + await chai + .request(app) + .post("/v1/fcm-tokens") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...fcmTokenData, + }); + + const response = await chai + .request(app) + .post("/v1/notifications") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...notifyData, + }); + expect(response).to.have.status(200); + expect(response.body.message).equals("User notified successfully"); + }); + + it("should have title in body ", async function () { + const notifyData = { body: "some body", userId: userId0 }; + + const fcmTokenData = { fcmToken: "iedsijdsdj" }; + + await chai + .request(app) + .post("/v1/fcm-tokens") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...fcmTokenData, + }); + + const response = await chai + .request(app) + .post("/v1/notifications") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...notifyData, + }); + + expect(response).to.have.status(400); + expect(response.body.message).equals('"title" is required'); + }); + + it("should have message in body ", async function () { + const notifyData = { title: "some title", userId: userId0 }; + + const fcmTokenData = { fcmToken: "iedsijdsdj" }; + + await chai + .request(app) + .post("/v1/fcm-tokens") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...fcmTokenData, + }); + + const response = await chai + .request(app) + .post("/v1/notifications") + .set("cookie", `${cookieName}=${userIdToken0}`) + .send({ + ...notifyData, + }); + + expect(response).to.have.status(400); + expect(response.body.message).equals('"body" is required'); + }); + + it("should user token exist ", async function () { + const notifyData = { title: "some title", body: "some body" }; + + const fcmTokenData = { fcmToken: "iedsijdsdj" }; + + await chai + .request(app) + .post("/v1/fcm-tokens") + .send({ + ...fcmTokenData, + }); + + const response = await chai + .request(app) + .post("/v1/notifications") + .send({ + ...notifyData, + }); + + expect(response).to.have.status(401); + expect(response.body.message).equals("Unauthenticated User"); + }); + }); +}); diff --git a/test/unit/middlewares/fcmToken-validator.test.js b/test/unit/middlewares/fcmToken-validator.test.js new file mode 100644 index 000000000..d7fe19f6a --- /dev/null +++ b/test/unit/middlewares/fcmToken-validator.test.js @@ -0,0 +1,34 @@ +const Sinon = require("sinon"); + +const { expect } = require("chai"); +const { fcmTokenValidator } = require("../../../middlewares/validators/fcmToken"); + +describe("Test the fcmToken validator", function () { + it("Allows the request to pass", async function () { + const req = { + body: { + fcmToken: "some token", + }, + }; + const res = {}; + const nextSpy = Sinon.spy(); + await fcmTokenValidator(req, res, nextSpy); + expect(nextSpy.callCount).to.be.equal(1); + }); + + it("Stops the request to propogate to next", async function () { + const req = { + body: { + "": "", + }, + }; + const res = { + boom: { + badRequest: () => {}, + }, + }; + const nextSpy = Sinon.spy(); + await fcmTokenValidator(req, res, nextSpy); + expect(nextSpy.callCount).to.be.equal(0); + }); +}); diff --git a/test/unit/middlewares/notify-validator.test.js b/test/unit/middlewares/notify-validator.test.js new file mode 100644 index 000000000..c79a7baf9 --- /dev/null +++ b/test/unit/middlewares/notify-validator.test.js @@ -0,0 +1,68 @@ +const Sinon = require("sinon"); + +const { expect } = require("chai"); +const { notifyValidator } = require("../../../middlewares/validators/notify"); + +describe("Test the notify validator", function () { + it("Allows the request to pass with only user id", async function () { + const req = { + body: { + title: "some title", + body: "some body", + userId: "user id", + }, + }; + const res = {}; + const nextSpy = Sinon.spy(); + await notifyValidator(req, res, nextSpy); + expect(nextSpy.callCount).to.be.equal(1); + }); + + it("Allows the request to pass with only role id", async function () { + const req = { + body: { + title: "some title", + body: "some body", + groupRoleId: "group role id", + }, + }; + const res = {}; + const nextSpy = Sinon.spy(); + await notifyValidator(req, res, nextSpy); + expect(nextSpy.callCount).to.be.equal(1); + }); + it("Stops the request if both user and role id are pass", async function () { + const req = { + body: { + title: "some title", + body: "some body", + userId: "user id", + groupRoleId: "some role id", + }, + }; + const res = { + boom: { + badRequest: () => {}, + }, + }; + const nextSpy = Sinon.spy(); + await notifyValidator(req, res, nextSpy); + expect(nextSpy.callCount).to.be.equal(0); + }); + + it("Stops the request to propogate to next", async function () { + const req = { + body: { + "": "", + }, + }; + const res = { + boom: { + badRequest: () => {}, + }, + }; + const nextSpy = Sinon.spy(); + await notifyValidator(req, res, nextSpy); + expect(nextSpy.callCount).to.be.equal(0); + }); +}); diff --git a/test/unit/models/fcmToken.test.js b/test/unit/models/fcmToken.test.js new file mode 100644 index 000000000..9e1b0e01f --- /dev/null +++ b/test/unit/models/fcmToken.test.js @@ -0,0 +1,30 @@ +const chai = require("chai"); +const expect = chai.expect; +const firestore = require("../../../utils/firestore"); +const { saveFcmToken } = require("../../../models/fcmToken"); +const cleanDb = require("../../utils/cleanDb"); +const fcmTokenModel = firestore.collection("fcmToken"); + +describe("FCM token", function () { + describe("Save FCM Token", function () { + afterEach(async function () { + await cleanDb(); + }); + it("it should save FCM token", async function () { + const fcmTokenData = { userId: "jkkshdsjkh", fcmToken: "iedsijdsdj" }; + await saveFcmToken(fcmTokenData); + const queryResponse = await fcmTokenModel.where("userId", "==", fcmTokenData.userId).get(); + expect(queryResponse.docs[0].data().fcmTokens).includes(fcmTokenData.fcmToken); + }); + it("it should store another FCM token in same user-id", async function () { + const fcmTokenData1 = { userId: "jkkshdsjkh", fcmToken: "sdjagkjsd" }; + const fcmTokenData2 = { userId: "jkkshdsjkh", fcmToken: "sdsnkj" }; + + await saveFcmToken(fcmTokenData1); + await saveFcmToken(fcmTokenData2); + + const queryResponse = await fcmTokenModel.where("userId", "==", fcmTokenData1.userId).get(); + expect(queryResponse.docs[0].data().fcmTokens.length).equals(2); + }); + }); +}); diff --git a/test/unit/services/getFcmTokenFromUserId.test.js b/test/unit/services/getFcmTokenFromUserId.test.js new file mode 100644 index 000000000..1b0a68e42 --- /dev/null +++ b/test/unit/services/getFcmTokenFromUserId.test.js @@ -0,0 +1,27 @@ +const chai = require("chai"); +const expect = chai.expect; +const { saveFcmToken } = require("../../../models/fcmToken"); +const cleanDb = require("../../utils/cleanDb"); +const { getFcmTokenFromUserId } = require("../../../services/getFcmTokenFromUserId"); + +describe("FCM token services", function () { + describe("Get FCM token from user id", function () { + beforeEach(async function () { + const fcmTokenData = { userId: "jkkshdsjkh", fcmToken: "iedsijdsdj" }; + + await saveFcmToken(fcmTokenData); + }); + afterEach(async function () { + await cleanDb(); + }); + it("Get FCM token from user id", async function () { + const fcmToken = await getFcmTokenFromUserId("jkkshdsjkh"); + expect(fcmToken[0]).equals("iedsijdsdj"); + }); + + it("will return blank array for invalid user id", async function () { + const fcmToken = await getFcmTokenFromUserId("sdkfskf"); + expect(fcmToken.length).equals(0); + }); + }); +}); diff --git a/test/unit/services/getUserIdsFromRoleId.test.js b/test/unit/services/getUserIdsFromRoleId.test.js new file mode 100644 index 000000000..5c1e7a07c --- /dev/null +++ b/test/unit/services/getUserIdsFromRoleId.test.js @@ -0,0 +1,37 @@ +const chai = require("chai"); +const expect = chai.expect; +const cleanDb = require("../../utils/cleanDb"); +const { addGroupRoleToMember } = require("../../../models/discordactions"); +const { getUserIdsFromRoleId } = require("../../../services/getUserIdsFromRoleId"); + +describe("FCM token services", function () { + describe("get user id from role id", function () { + beforeEach(async function () {}); + afterEach(async function () { + await cleanDb(); + }); + }); + it("Should get user id's from role id", async function () { + const memberRoleModelData = { + roleid: "1147354535342383104", + userid: "jskdhaskjhdkasjh", + }; + await addGroupRoleToMember(memberRoleModelData); + const memberRoleModelData2 = { + roleid: "1147354535342383104", + userid: "EFEGFHERIUGHIUER", + }; + await addGroupRoleToMember(memberRoleModelData2); + + const res = await getUserIdsFromRoleId("1147354535342383104"); + + expect(res.length).equals(2); + expect(res).includes("EFEGFHERIUGHIUER"); + expect(res).includes("jskdhaskjhdkasjh"); + }); + + it("will return blank array for invalid role id", async function () { + const userId = await getUserIdsFromRoleId("sdkfskf"); + expect(userId.length).equals(0); + }); +});