diff --git a/config/development.js b/config/development.js index 63109b346..0e4d3bd44 100644 --- a/config/development.js +++ b/config/development.js @@ -10,6 +10,7 @@ module.exports = { port: port, enableFileLogs: false, enableConsoleLogs: true, + discordNewComersChannelId: "709080951824842783", services: { rdsApi: { diff --git a/config/test.js b/config/test.js index 569ebb0d0..935e9731a 100644 --- a/config/test.js +++ b/config/test.js @@ -11,6 +11,7 @@ module.exports = { enableConsoleLogs: true, discordUnverifiedRoleId: "1234567890", discordDeveloperRoleId: "9876543210", + discordNewComersChannelId: "709080951824842783", discordMavenRoleId: "1212121212", githubOauth: { clientId: "clientId", diff --git a/controllers/discordactions.js b/controllers/discordactions.js index a5f67e55f..f5b0d5568 100644 --- a/controllers/discordactions.js +++ b/controllers/discordactions.js @@ -383,6 +383,75 @@ const setRoleToUsersWith31DaysPlusOnboarding = async (req, res) => { } }; +const generateInviteForUser = async (req, res) => { + try { + const { userId } = req.query; + const userIdForInvite = userId || req.userData.id; + + const modelResponse = await discordRolesModel.getUserDiscordInvite(userIdForInvite); + + if (!modelResponse.notFound) { + return res.status(409).json({ + message: "User invite is already present!", + }); + } + + const channelId = config.get("discordNewComersChannelId"); + const authToken = jwt.sign({}, config.get("rdsServerlessBot.rdsServerLessPrivateKey"), { + algorithm: "RS256", + expiresIn: config.get("rdsServerlessBot.ttl"), + }); + + const inviteOptions = { + channelId: channelId, + }; + const response = await fetch(`${DISCORD_BASE_URL}/invite`, { + method: "POST", + body: JSON.stringify(inviteOptions), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${authToken}` }, + }); + const discordInviteResponse = await response.json(); + + const inviteCode = discordInviteResponse.data.code; + const inviteLink = `discord.gg/${inviteCode}`; + + await discordRolesModel.addInviteToInviteModel({ userId: userIdForInvite, inviteLink }); + + return res.status(201).json({ + message: "invite generated successfully", + inviteLink, + }); + } catch (err) { + logger.error(`Error in generating invite for user: ${err}`); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; + +const getUserDiscordInvite = async (req, res) => { + try { + const { userId } = req.query; + const isSuperUser = req.userData.roles.super_user; + + if (userId && !isSuperUser) return res.boom.forbidden("User should be super user to get link for other users"); + + const userIdForInvite = userId || req.userData.id; + + const invite = await discordRolesModel.getUserDiscordInvite(userIdForInvite); + + if (invite.notFound) { + return res.boom.notFound("User invite doesn't exist"); + } + + return res.json({ + message: "Invite returned successfully", + inviteLink: invite?.inviteLink, + }); + } catch (err) { + logger.error(`Error in fetching user invite: ${err}`); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; + module.exports = { getGroupsRoleId, createGroupRole, @@ -396,4 +465,6 @@ module.exports = { updateUsersNicknameStatus, syncDiscordGroupRolesInFirestore, setRoleToUsersWith31DaysPlusOnboarding, + getUserDiscordInvite, + generateInviteForUser, }; diff --git a/middlewares/checkCanGenerateDiscordLink.ts b/middlewares/checkCanGenerateDiscordLink.ts new file mode 100644 index 000000000..0578e695d --- /dev/null +++ b/middlewares/checkCanGenerateDiscordLink.ts @@ -0,0 +1,28 @@ +import { NextFunction } from "express"; +import { CustomRequest, CustomResponse } from "../types/global"; + +const checkCanGenerateDiscordLink = async (req: CustomRequest, res: CustomResponse, next: NextFunction) => { + const { discordId, roles, id: userId, profileStatus } = req.userData; + const isSuperUser = roles.super_user; + const userIdInQuery = req.query.userId; + + if (userIdInQuery && userIdInQuery !== userId && !isSuperUser) { + return res.boom.forbidden("User should be super user to generate link for other users"); + } + + if (discordId) { + return res.boom.forbidden("Only users who have never joined discord can generate invite link"); + } + + if (roles.archived) { + return res.boom.forbidden("Archived users cannot generate invite"); + } + + if (!roles.maven && !roles.designer && !roles.product_manager && profileStatus !== "VERIFIED") { + return res.boom.forbidden("Only selected roles can generate discord link directly"); + } + + return next(); +}; + +module.exports = checkCanGenerateDiscordLink; diff --git a/models/discordactions.js b/models/discordactions.js index 17b54766a..288e7e97b 100644 --- a/models/discordactions.js +++ b/models/discordactions.js @@ -2,6 +2,7 @@ const { generateDiscordProfileImageUrl } = require("../utils/discord-actions"); const firestore = require("../utils/firestore"); const discordRoleModel = firestore.collection("discord-roles"); const memberRoleModel = firestore.collection("member-group-roles"); +const discordInvitesModel = firestore.collection("discord-invites"); const admin = require("firebase-admin"); const { findSubscribedGroupIds } = require("../utils/helper"); const { retrieveUsers } = require("../services/dataAccessLayer"); @@ -817,6 +818,31 @@ const updateUsersWith31DaysPlusOnboarding = async () => { } }; +const addInviteToInviteModel = async (inviteObject) => { + try { + const invite = await discordInvitesModel.add(inviteObject); + return invite.id; + } catch (err) { + logger.error("Error in adding invite", err); + throw err; + } +}; + +const getUserDiscordInvite = async (userId) => { + try { + const invite = await discordInvitesModel.where("userId", "==", userId).get(); + const [inviteDoc] = invite.docs; + if (inviteDoc) { + return { id: inviteDoc.id, ...inviteDoc.data(), notFound: false }; + } else { + return { notFound: true }; + } + } catch (err) { + logger.log("error in getting user invite", err); + throw err; + } +}; + module.exports = { createNewRole, removeMemberGroup, @@ -834,4 +860,6 @@ module.exports = { updateUsersNicknameStatus, updateIdle7dUsersOnDiscord, updateUsersWith31DaysPlusOnboarding, + getUserDiscordInvite, + addInviteToInviteModel, }; diff --git a/routes/discordactions.js b/routes/discordactions.js index c9d391ab2..e2cd44ee5 100644 --- a/routes/discordactions.js +++ b/routes/discordactions.js @@ -8,6 +8,8 @@ const { deleteRole, updateDiscordImageForVerification, setRoleIdleToIdleUsers, + getUserDiscordInvite, + generateInviteForUser, setRoleIdle7DToIdleUsers, updateDiscordNicknames, updateUsersNicknameStatus, @@ -20,6 +22,7 @@ const { validateUpdateUsersNicknameStatusBody, } = require("../middlewares/validators/discordactions"); const checkIsVerifiedDiscord = require("../middlewares/verifydiscord"); +const checkCanGenerateDiscordLink = require("../middlewares/checkCanGenerateDiscordLink"); const { SUPERUSER } = require("../constants/roles"); const authorizeRoles = require("../middlewares/authorizeRoles"); const { verifyCronJob } = require("../middlewares/authorizeBot"); @@ -29,6 +32,8 @@ const router = express.Router(); router.post("/groups", authenticate, checkIsVerifiedDiscord, validateGroupRoleBody, createGroupRole); router.get("/groups", authenticate, checkIsVerifiedDiscord, getAllGroupRoles); router.post("/roles", authenticate, checkIsVerifiedDiscord, validateMemberRoleBody, addGroupRoleToMember); +router.get("/invite", authenticate, getUserDiscordInvite); +router.post("/invite", authenticate, checkCanGenerateDiscordLink, generateInviteForUser); router.delete("/roles", authenticate, checkIsVerifiedDiscord, deleteRole); router.get("/roles", authenticate, checkIsVerifiedDiscord, getGroupsRoleId); router.patch( diff --git a/test/fixtures/user/user.js b/test/fixtures/user/user.js index c7f5e4cb9..ebf0977a1 100644 --- a/test/fixtures/user/user.js +++ b/test/fixtures/user/user.js @@ -217,6 +217,7 @@ module.exports = () => { archived: false, member: true, in_discord: true, + designer: true, }, twitter_id: "RitvikJamwal4u", linkedin_id: "ritvik-jamwal4u", @@ -239,6 +240,7 @@ module.exports = () => { member: true, archived: false, in_discord: true, + product_manager: true, }, picture: { publicId: "profile/mtS4DhUvNYsKqI7oCWVB/aenklfhtjldc5ytei3ar", @@ -261,6 +263,7 @@ module.exports = () => { }, roles: { member: true, + maven: true, }, picture: { publicId: "profile/mtS4DhUvNYsKqI7oCWVB/aenklfhtjldc5ytei3ar", @@ -411,5 +414,31 @@ module.exports = () => { updated_at: Date.now(), created_at: Date.now(), }, + { + username: "Vinayak", + first_name: "Vinayak", + last_name: "Trivedi", + yoe: 2, + img: "./img.png", + linkedin_id: "_", + github_id: "xfasrfsd", + github_display_name: "vinayak-trivedi", + discordJoinedAt: "2023-04-06T01:47:34.488000+00:00", + phone: "1234567890", + email: "abc@gmail.com", + status: "active", + tokens: { + githubAccessToken: "githubAccessToken", + }, + roles: { + restricted: false, + app_owner: true, + archived: true, + }, + picture: { + publicId: "profile/mtS4DhUvNYsKqI7oCWVB/aenklfhtjldc5ytei3ar", + url: "https://res.cloudinary.com/realdevsquad/image/upload/v1667685133/profile/mtS4DhUvNYsKqI7oCWVB/aenklfhtjldc5ytei3ar.jpg", + }, + }, ]; }; diff --git a/test/integration/discordactions.test.js b/test/integration/discordactions.test.js index 87e084f68..542fcc748 100644 --- a/test/integration/discordactions.test.js +++ b/test/integration/discordactions.test.js @@ -10,6 +10,11 @@ const cleanDb = require("../utils/cleanDb"); const userData = require("../fixtures/user/user")(); const usersInDiscord = require("../fixtures/user/inDiscord"); const superUser = userData[4]; +const archievedUser = userData[19]; +const developerUserWithoutApprovedProfileStatus = userData[6]; +const designerUser = userData[8]; +const productManagerUser = userData[9]; +const mavenUser = userData[10]; const config = require("config"); const sinon = require("sinon"); @@ -30,7 +35,7 @@ const { groupOnboarding31dPlus, } = require("../fixtures/discordactions/discordactions"); const discordServices = require("../../services/discordService"); -const { addGroupRoleToMember } = require("../../models/discordactions"); +const { addGroupRoleToMember, addInviteToInviteModel } = require("../../models/discordactions"); const { updateUserStatus } = require("../../models/userStatus"); const { generateUserStatusData } = require("../fixtures/userStatus/userStatus"); const { getDiscordMembers } = require("../fixtures/discordResponse/discord-response"); @@ -43,7 +48,18 @@ const { CRON_JOB_HANDLER } = require("../../constants/bot"); describe("Discord actions", function () { let superUserId; + let archievedUserId; + let designerUserId; + let mavenUserId; + let productManagerUserId; + let developerUserWithoutApprovedProfileStatusId; let superUserAuthToken; + let userAuthToken; + let developerUserWithoutApprovedProfileStatusToken; + let designerAuthToken; + let mavenAuthToken; + let productManagerAuthToken; + let archievedUserToken; let userId = ""; let discordId = ""; let fetchStub; @@ -53,6 +69,7 @@ describe("Discord actions", function () { userId = await addUser(); superUserId = await addUser(superUser); superUserAuthToken = authService.generateAuthToken({ userId: superUserId }); + userAuthToken = authService.generateAuthToken({ userId: userId }); jwt = authService.generateAuthToken({ userId }); discordId = "12345"; @@ -529,4 +546,153 @@ describe("Discord actions", function () { }); }); }); + + describe("GET /discord-actions/invite", function () { + it("should return the invite for the user if no userId is provided in the params and the invite exists", async function () { + await addInviteToInviteModel({ userId: superUserId, inviteLink: "discord.gg/apQYT7HB" }); + + const res = await chai + .request(app) + .get("/discord-actions/invite") + .set("cookie", `${cookieName}=${superUserAuthToken}`); + expect(res).to.have.status(200); + expect(res.body).to.be.a("object"); + expect(res.body).to.deep.equal({ + message: "Invite returned successfully", + inviteLink: "discord.gg/apQYT7HB", + }); + }); + + it("Should return the invite for other user if the userId is provided in the query and the user is super user", async function () { + await addInviteToInviteModel({ userId: userId, inviteLink: "discord.gg/apQYT7HA" }); + const res = await chai + .request(app) + .get(`/discord-actions/invite?userId=${userId}`) + .set("cookie", `${cookieName}=${superUserAuthToken}`); + expect(res).to.have.status(200); + expect(res.body).to.be.a("object"); + expect(res.body).to.deep.equal({ + message: "Invite returned successfully", + inviteLink: "discord.gg/apQYT7HA", + }); + }); + + it("should return 403 if the userId in the query param is not equal to the userId of the user and user is not a super user", async function () { + const res = await chai + .request(app) + .get(`/discord-actions/invite?userId=${superUserId}`) + .set("cookie", `${cookieName}=${userAuthToken}`); + expect(res).to.have.status(403); + expect(res.body).to.be.a("object"); + expect(res.body.message).to.be.equal("User should be super user to get link for other users"); + }); + }); + + describe("POST /discord-actions/invite", function () { + it("should return 403 if the userId in the query param is not equal to the userId of the user and user is not a super user", async function () { + const res = await chai + .request(app) + .post(`/discord-actions/invite?userId=${superUserId}`) + .set("cookie", `${cookieName}=${userAuthToken}`); + expect(res).to.have.status(403); + expect(res.body).to.be.a("object"); + expect(res.body.message).to.be.equal("User should be super user to generate link for other users"); + }); + + it("should return 403 if the user has discord id in their user object, which means user is already in discord", async function () { + const res = await chai + .request(app) + .post(`/discord-actions/invite`) + .set("cookie", `${cookieName}=${userAuthToken}`); + expect(res).to.have.status(403); + expect(res.body).to.be.a("object"); + expect(res.body.message).to.be.equal("Only users who have never joined discord can generate invite link"); + }); + + it("should return 403 if user has role archieved", async function () { + archievedUserId = await addUser(archievedUser); + archievedUserToken = authService.generateAuthToken({ userId: archievedUserId }); + const res = await chai + .request(app) + .post(`/discord-actions/invite`) + .set("cookie", `${cookieName}=${archievedUserToken}`); + expect(res).to.have.status(403); + expect(res.body).to.be.a("object"); + expect(res.body.message).to.be.equal("Archived users cannot generate invite"); + }); + + it("should return 403 if the user doesn't have role designer, product_manager, or mavens", async function () { + developerUserWithoutApprovedProfileStatusId = await addUser(developerUserWithoutApprovedProfileStatus); + developerUserWithoutApprovedProfileStatusToken = authService.generateAuthToken({ + userId: developerUserWithoutApprovedProfileStatusId, + }); + const res = await chai + .request(app) + .post(`/discord-actions/invite`) + .set("cookie", `${cookieName}=${developerUserWithoutApprovedProfileStatusToken}`); + expect(res).to.have.status(403); + expect(res.body).to.be.a("object"); + expect(res.body.message).to.be.equal("Only selected roles can generate discord link directly"); + }); + + it("should generate discord link if user is a product mananger", async function () { + fetchStub.returns( + Promise.resolve({ + status: 201, + json: () => Promise.resolve({ data: { code: "xyz" } }), + }) + ); + + productManagerUserId = await addUser(productManagerUser); + productManagerAuthToken = authService.generateAuthToken({ userId: productManagerUserId }); + const res = await chai + .request(app) + .post(`/discord-actions/invite`) + .set("cookie", `${cookieName}=${productManagerAuthToken}`); + + expect(res).to.have.status(201); + expect(res.body.message).to.be.equal("invite generated successfully"); + expect(res.body.inviteLink).to.be.equal("discord.gg/xyz"); + }); + + it("should generate discord link if user is a designer", async function () { + fetchStub.returns( + Promise.resolve({ + status: 201, + json: () => Promise.resolve({ data: { code: "zlmfasd" } }), + }) + ); + + designerUserId = await addUser(designerUser); + designerAuthToken = authService.generateAuthToken({ userId: designerUserId }); + const res = await chai + .request(app) + .post(`/discord-actions/invite`) + .set("cookie", `${cookieName}=${designerAuthToken}`); + + expect(res).to.have.status(201); + expect(res.body.message).to.be.equal("invite generated successfully"); + expect(res.body.inviteLink).to.be.equal("discord.gg/zlmfasd"); + }); + + it("should generate discord link if user is a maven", async function () { + fetchStub.returns( + Promise.resolve({ + status: 201, + json: () => Promise.resolve({ data: { code: "asdfdsfsd" } }), + }) + ); + + mavenUserId = await addUser(mavenUser); + mavenAuthToken = authService.generateAuthToken({ userId: mavenUserId }); + const res = await chai + .request(app) + .post(`/discord-actions/invite`) + .set("cookie", `${cookieName}=${mavenAuthToken}`); + + expect(res).to.have.status(201); + expect(res.body.message).to.be.equal("invite generated successfully"); + expect(res.body.inviteLink).to.be.equal("discord.gg/asdfdsfsd"); + }); + }); }); diff --git a/test/unit/models/discordactions.test.js b/test/unit/models/discordactions.test.js index 86e0837de..be9389fc9 100644 --- a/test/unit/models/discordactions.test.js +++ b/test/unit/models/discordactions.test.js @@ -18,6 +18,8 @@ const { enrichGroupDataWithMembershipInfo, fetchGroupToUserMapping, updateUsersNicknameStatus, + addInviteToInviteModel, + getUserDiscordInvite, } = require("../../../models/discordactions"); const { groupData, roleData, existingRole, memberGroupData } = require("../../fixtures/discordactions/discordactions"); const cleanDb = require("../../utils/cleanDb"); @@ -567,4 +569,32 @@ describe("discordactions", function () { } }).timeout(10000); }); + + describe("addInviteToInviteModel", function () { + it("should add invite in the invite model for user", async function () { + const inviteObject = { userId: "kfjkasdfl", inviteLink: "discord.gg/xyz" }; + const inviteId = await addInviteToInviteModel(inviteObject); + expect(inviteId).to.exist; // eslint-disable-line no-unused-expressions + }); + }); + + describe("getUserDiscordInvite", function () { + before(async function () { + const inviteObject = { userId: "kfjkasdfl", inviteLink: "discord.gg/xyz" }; + await addInviteToInviteModel(inviteObject); + }); + + it("should return invite for the user when the userId of a user is passed at it exists in the db", async function () { + const invite = await getUserDiscordInvite("kfjkasdfl"); + expect(invite).to.have.property("id"); + expect(invite.notFound).to.be.equal(false); + expect(invite.userId).to.be.equal("kfjkasdfl"); + expect(invite.inviteLink).to.be.equal("discord.gg/xyz"); + }); + + it("should return notFound true, if the invite for user doesn't exist", async function () { + const invite = await getUserDiscordInvite("kfjkasdafdfdsfl"); + expect(invite.notFound).to.be.equal(true); + }); + }); }); diff --git a/test/unit/services/users.test.js b/test/unit/services/users.test.js index 021c9d826..59384b0a7 100644 --- a/test/unit/services/users.test.js +++ b/test/unit/services/users.test.js @@ -53,7 +53,7 @@ describe("Users services", function () { expect(res).to.deep.equal({ message: "Successfully completed batch updates", - totalUsersArchived: 19, + totalUsersArchived: 20, totalOperationsFailed: 0, updatedUserDetails: userDetails, failedUserDetails: [], @@ -74,7 +74,7 @@ describe("Users services", function () { expect(res).to.deep.equal({ message: "Firebase batch operation failed", totalUsersArchived: 0, - totalOperationsFailed: 19, + totalOperationsFailed: 20, updatedUserDetails: [], failedUserDetails: userDetails, }); diff --git a/types/global.d.ts b/types/global.d.ts new file mode 100644 index 000000000..6333142f8 --- /dev/null +++ b/types/global.d.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from "express"; +import { Boom } from "express-boom"; + +export type userData = { + id: string; + profileURL: string; + discordJoinedAt: string; + roles: { + archived: boolean; + in_discord?: boolean; + member?: boolean; + maven?: boolean; + designer?: boolean; + product_manager?: boolean; + }; + profileStatus: string; + created_at: number; + yoe: number; + github_created_at: number; + company: string; + twitter_id: string; + first_name: string; + incompleteUserDetails: boolean; + discordId: string; + last_name: string; + linkedin_id: string; + picture?: { + url?: string; + publicId?: string; + }; + instagram_id: string; + github_display_name: string; + github_id: string; + designation: string; + status: string; + username: string; + updated_at: number; +}; + +export type CustomResponse = Response & { boom: Boom }; +export type CustomRequest = Request & { userData };