diff --git a/config/default.js b/config/default.js index ab0b02852..91f44377c 100644 --- a/config/default.js +++ b/config/default.js @@ -14,6 +14,7 @@ module.exports = { discordUnverifiedRoleId: "", discordDeveloperRoleId: "", discordMavenRoleId: "", + discordMissedUpdatesRoleId: "", githubApi: { baseUrl: "https://api.github.com", org: "Real-Dev-Squad", diff --git a/config/production.js b/config/production.js index 56cf7a28c..cb21e9ac7 100644 --- a/config/production.js +++ b/config/production.js @@ -6,6 +6,7 @@ module.exports = { discordUnverifiedRoleId: "1103047289330745386", discordDeveloperRoleId: "915490782939582485", discordMavenRoleId: "875564640438997043", + discordMissedUpdatesRoleId: "1183553844811153458", userToken: { cookieName: "rds-session", }, diff --git a/config/staging.js b/config/staging.js index fea453041..8ba88722c 100644 --- a/config/staging.js +++ b/config/staging.js @@ -6,6 +6,7 @@ module.exports = { discordUnverifiedRoleId: "1120875993771544687", discordDeveloperRoleId: "1121445071213056071", discordMavenRoleId: "1152361736456896586", + discordMissedUpdatesRoleId: "1184201657404362772", enableFileLogs: false, enableConsoleLogs: true, diff --git a/constants/constants.ts b/constants/constants.ts index 692eec686..9d36b9094 100644 --- a/constants/constants.ts +++ b/constants/constants.ts @@ -1,5 +1,15 @@ const DOCUMENT_WRITE_SIZE = 500; +const daysOfWeek = { + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6, +}; module.exports = { - DOCUMENT_WRITE_SIZE, + DOCUMENT_WRITE_SIZE, + daysOfWeek, }; diff --git a/constants/tasks.ts b/constants/tasks.ts index 24ec9db14..ba29fc2ee 100644 --- a/constants/tasks.ts +++ b/constants/tasks.ts @@ -37,6 +37,24 @@ const MAPPED_TASK_STATUS = { UNASSIGNED: "AVAILABLE", }; +const COMPLETED_TASK_STATUS = { + VERIFIED: "VERIFIED", + DONE: "DONE", + COMPLETED: "COMPLETED", +}; const TASK_SIZE = 5; -module.exports = { TASK_TYPE, TASK_STATUS, TASK_STATUS_OLD, MAPPED_TASK_STATUS, TASK_SIZE, DEFAULT_TASK_PRIORITY }; +const tasksUsersStatus = { + MISSED_UPDATES: "missed-updates", +}; + +module.exports = { + TASK_TYPE, + TASK_STATUS, + TASK_STATUS_OLD, + MAPPED_TASK_STATUS, + TASK_SIZE, + DEFAULT_TASK_PRIORITY, + COMPLETED_TASK_STATUS, + tasksUsersStatus, +}; diff --git a/controllers/tasks.js b/controllers/tasks.js index 9c743d688..45238d807 100644 --- a/controllers/tasks.js +++ b/controllers/tasks.js @@ -1,5 +1,5 @@ const tasks = require("../models/tasks"); -const { TASK_STATUS, TASK_STATUS_OLD } = require("../constants/tasks"); +const { TASK_STATUS, TASK_STATUS_OLD, tasksUsersStatus } = require("../constants/tasks"); const { addLog } = require("../models/logs"); const { USER_STATUS } = require("../constants/users"); const { addOrUpdate, getRdsUserInfoByGitHubUsername } = require("../models/users"); @@ -13,6 +13,9 @@ const { updateUserStatusOnTaskUpdate, updateStatusOnTaskCompletion } = require(" const dataAccess = require("../services/dataAccessLayer"); const { parseSearchQuery } = require("../utils/tasks"); const { addTaskCreatedAtAndUpdatedAtFields } = require("../services/tasks"); +const { RQLQueryParser } = require("../utils/RQLParser"); +const { getMissedProgressUpdatesUsers } = require("../models/discordactions"); +const { daysOfWeek } = require("../constants/constants"); /** * Creates new task * @@ -475,6 +478,38 @@ const updateStatus = async (req, res) => { } }; +const getUsersHandler = async (req, res) => { + try { + const { size, cursor, q: queryString } = req.query; + const rqlParser = new RQLQueryParser(queryString); + const { "days-count": daysGap, weekday, date, status } = rqlParser.getFilterQueries(); + if (!!status && status.length === 1 && status[0].value === tasksUsersStatus.MISSED_UPDATES) { + if (daysGap && daysGap > 1) { + return res.boom.badRequest("number of days gap provided cannot be greater than 1"); + } + const response = await getMissedProgressUpdatesUsers({ + cursor: cursor, + size: size && Number.parseInt(size), + excludedDates: date?.map((date) => Number.parseInt(date.value)), + excludedDays: weekday?.map((day) => daysOfWeek[day.value]), + dateGap: !!daysGap && daysGap.length === 1 ? Number.parseInt(daysGap[0].value) : null, + }); + + if (response.error) { + return res.boom.badRequest(response.message); + } + return res + .status(200) + .json({ message: "Discord details of users with status missed updates fetched successfully", data: response }); + } else { + return res.boom.badRequest("Unknown type and query"); + } + } catch (error) { + logger.error("Error in fetching users details of tasks", error); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; + module.exports = { addNewTask, fetchTasks, @@ -486,4 +521,5 @@ module.exports = { overdueTasks, assignTask, updateStatus, + getUsersHandler, }; diff --git a/middlewares/validators/tasks.js b/middlewares/validators/tasks.js index e3367d03b..c6bbe6dfe 100644 --- a/middlewares/validators/tasks.js +++ b/middlewares/validators/tasks.js @@ -1,7 +1,10 @@ const joi = require("joi"); +const { BadRequest } = require("http-errors"); const { DINERO, NEELAM } = require("../../constants/wallets"); -const { TASK_STATUS, TASK_STATUS_OLD, MAPPED_TASK_STATUS } = require("../../constants/tasks"); - +const { TASK_STATUS, TASK_STATUS_OLD, MAPPED_TASK_STATUS, tasksUsersStatus } = require("../../constants/tasks"); +const { RQLQueryParser } = require("../../utils/RQLParser"); +const { Operators } = require("../../typeDefinitions/rqlParser"); +const { daysOfWeek } = require("../../constants/constants"); const TASK_STATUS_ENUM = Object.values(TASK_STATUS); const MAPPED_TASK_STATUS_ENUM = Object.keys(MAPPED_TASK_STATUS); @@ -116,14 +119,18 @@ const updateTask = async (req, res, next) => { }; const updateSelfTask = async (req, res, next) => { + const validStatus = [...TASK_STATUS_ENUM, ...Object.values(TASK_STATUS_OLD)].filter( + (item) => item !== TASK_STATUS.AVAILABLE + ); const schema = joi .object() .strict() .keys({ status: joi .string() - .valid(...TASK_STATUS_ENUM, ...Object.values(TASK_STATUS_OLD)) - .optional(), + .valid(...validStatus) + .optional() + .error(new BadRequest(`The value for the 'status' field is invalid.`)), percentCompleted: joi.number().integer().min(0).max(100).optional(), }); try { @@ -131,7 +138,11 @@ const updateSelfTask = async (req, res, next) => { next(); } catch (error) { logger.error(`Error validating updateSelfTask payload : ${error}`); - res.boom.badRequest(error.details[0].message); + if (error instanceof BadRequest) { + res.boom.badRequest(error.message); + } else { + res.boom.badRequest(error.details[0].message); + } } }; @@ -190,10 +201,68 @@ const getTasksValidator = async (req, res, next) => { res.boom.badRequest(error.details[0].message); } }; +const getUsersValidator = async (req, res, next) => { + const queryParamsSchema = joi.object().keys({ + cursor: joi.string().optional(), + q: joi.string().optional(), + size: joi.number().integer().min(1).max(2013), + }); + const filtersSchema = joi.object().keys({ + status: joi + .array() + .items( + joi.object().keys({ + value: joi.string().valid(...Object.values(tasksUsersStatus)), + operator: joi.string().valid(Operators.INCLUDE), + }) + ) + .required(), + "days-count": joi + .array() + .items( + joi.object().keys({ + value: joi.number().integer().min(1).max(10), + operator: joi.string().valid(Operators.EXCLUDE), + }) + ) + .optional(), + weekday: joi + .array() + .items( + joi.object().keys({ + value: joi.string().valid(...Object.keys(daysOfWeek)), + operator: joi.string().valid(Operators.EXCLUDE), + }) + ) + .optional(), + date: joi + .array() + .items( + joi.object().keys({ + value: joi.date().timestamp(), + operator: joi.string().valid(Operators.EXCLUDE), + }) + ) + .optional(), + }); + try { + const { q: queryString } = req.query; + const rqlQueryParser = new RQLQueryParser(queryString); + await Promise.all([ + queryParamsSchema.validateAsync(req.query), + filtersSchema.validateAsync(rqlQueryParser.getFilterQueries()), + ]); + next(); + } catch (error) { + logger.error(`Error validating get tasks for users query : ${error}`); + res.boom.badRequest(error.details[0].message); + } +}; module.exports = { createTask, updateTask, updateSelfTask, getTasksValidator, + getUsersValidator, }; diff --git a/models/discordactions.js b/models/discordactions.js index 17c31c436..8adb45990 100644 --- a/models/discordactions.js +++ b/models/discordactions.js @@ -16,9 +16,18 @@ const dataAccess = require("../services/dataAccessLayer"); const { getDiscordMembers, addRoleToUser, removeRoleFromUser } = require("../services/discordService"); const discordDeveloperRoleId = config.get("discordDeveloperRoleId"); const discordMavenRoleId = config.get("discordMavenRoleId"); +const discordMissedUpdatesRoleId = config.get("discordMissedUpdatesRoleId"); + const userStatusModel = firestore.collection("usersStatus"); const usersUtils = require("../utils/users"); const { getUsersBasedOnFilter, fetchUser } = require("./users"); +const { convertDaysToMilliseconds, convertMillisToSeconds } = require("../utils/time"); +const { chunks } = require("../utils/array"); +const tasksModel = firestore.collection("tasks"); +const { FIRESTORE_IN_CLAUSE_SIZE } = require("../constants/users"); +const discordService = require("../services/discordService"); +const { buildTasksQueryForMissedUpdates } = require("../utils/tasks"); +const { buildProgressQueryForMissedUpdates } = require("../utils/progresses"); /** * @@ -470,7 +479,7 @@ const updateUsersNicknameStatus = async (lastNicknameUpdate) => { const today = new Date().getTime(); - const nicknameUpdatePromises = []; + let successfulUpdates = 0; const nicknameUpdateBatches = []; const totalUsersStatus = usersStatusDocs.length; @@ -505,14 +514,19 @@ const updateUsersNicknameStatus = async (lastNicknameUpdate) => { } }); - const settledPromises = await Promise.all(promises); - nicknameUpdatePromises.push(...settledPromises); + const settledPromises = await Promise.allSettled(promises); + + settledPromises.forEach((result) => { + if (result.status === "fulfilled" && !!result.value) { + successfulUpdates++; + } else { + logger.error(`Error while updating nickname: ${result.reason}`); + } + }); await new Promise((resolve) => setTimeout(resolve, 5000)); } - const successfulUpdates = nicknameUpdatePromises.length; - const res = { totalUsersStatus, successfulNicknameUpdates: successfulUpdates, @@ -831,6 +845,175 @@ const updateUsersWith31DaysPlusOnboarding = async () => { } }; +const getMissedProgressUpdatesUsers = async (options = {}) => { + const { cursor, size = 500, excludedDates = [], excludedDays = [0], dateGap = 3 } = options; + const stats = { + tasks: 0, + missedUpdatesTasks: 0, + }; + try { + const discordUsersPromise = discordService.getDiscordMembers(); + const missedUpdatesRoleId = discordMissedUpdatesRoleId; + + let gapWindowStart = Date.now() - convertDaysToMilliseconds(dateGap); + const gapWindowEnd = Date.now(); + excludedDates.forEach((timestamp) => { + if (timestamp > gapWindowStart && timestamp < gapWindowEnd) { + gapWindowStart -= convertDaysToMilliseconds(1); + } + }); + + if (excludedDays.length === 7) { + return { usersToAddRole: [], ...stats }; + } + + for (let i = gapWindowEnd; i >= gapWindowStart; i -= convertDaysToMilliseconds(1)) { + const day = new Date(i).getDay(); + if (excludedDays.includes(day)) { + gapWindowStart -= convertDaysToMilliseconds(1); + } + } + + let taskQuery = buildTasksQueryForMissedUpdates(size); + + if (cursor) { + const data = await tasksModel.doc(cursor).get(); + if (!data.data()) { + return { + error: "Bad Request", + message: `Invalid cursor: ${cursor}`, + }; + } + taskQuery = taskQuery.startAfter(data); + } + + const usersMap = new Map(); + const progressCountPromise = []; + const tasksQuerySnapshot = await taskQuery.get(); + + stats.tasks = tasksQuerySnapshot.size; + tasksQuerySnapshot.forEach((doc) => { + const { assignee: taskAssignee, startedOn: taskStartedOn } = doc.data(); + if (!taskAssignee || taskStartedOn >= convertMillisToSeconds(gapWindowStart)) return; + + const taskId = doc.id; + + if (usersMap.has(taskAssignee)) { + const userData = usersMap.get(taskAssignee); + userData.tasksCount++; + } else { + usersMap.set(taskAssignee, { + tasksCount: 1, + latestProgressCount: dateGap + 1, + isActive: false, + }); + } + const updateTasksIdMap = async () => { + const progressQuery = buildProgressQueryForMissedUpdates(taskId, gapWindowStart, gapWindowEnd); + const progressSnapshot = await progressQuery.get(); + const userData = usersMap.get(taskAssignee); + userData.latestProgressCount = Math.min(progressSnapshot.data().count, userData.latestProgressCount); + + if (userData.latestProgressCount === 0) { + stats.missedUpdatesTasks++; + } + }; + progressCountPromise.push(updateTasksIdMap()); + }); + + const userIdChunks = chunks(Array.from(usersMap.keys()), FIRESTORE_IN_CLAUSE_SIZE); + const userStatusSnapshotPromise = userIdChunks.map( + async (userIdList) => + await userStatusModel + .where("currentStatus.state", "==", userState.ACTIVE) + .where("userId", "in", userIdList) + .get() + ); + const userDetailsPromise = userIdChunks.map( + async (userIdList) => + await userModel + .where("roles.archived", "==", false) + .where(admin.firestore.FieldPath.documentId(), "in", userIdList) + .get() + ); + + const userStatusChunks = await Promise.all(userStatusSnapshotPromise); + + userStatusChunks.forEach((userStatusList) => + userStatusList.forEach((doc) => { + usersMap.get(doc.data().userId).isActive = true; + }) + ); + + const userDetailsListChunks = await Promise.all(userDetailsPromise); + userDetailsListChunks.forEach((userList) => { + userList.forEach((doc) => { + const userData = usersMap.get(doc.id); + userData.discordId = doc.data().discordId; + }); + }); + + const discordUserList = await discordUsersPromise; + const discordUserMap = new Map(); + discordUserList.forEach((discordUser) => { + const discordUserData = { isBot: !!discordUser.user.bot }; + discordUser.roles.forEach((roleId) => { + switch (roleId) { + case discordDeveloperRoleId: { + discordUserData.isDeveloper = true; + break; + } + case discordMavenRoleId: { + discordUserData.isMaven = true; + break; + } + case missedUpdatesRoleId: { + discordUserData.hasMissedUpdatesRole = true; + break; + } + } + }); + discordUserMap.set(discordUser.user.id, discordUserData); + }); + + await Promise.all(progressCountPromise); + + for (const [userId, userData] of usersMap.entries()) { + const discordUserData = discordUserMap.get(userData.discordId); + const isDiscordMember = !!discordUserData; + const shouldAddRole = + userData.latestProgressCount === 0 && + userData.isActive && + isDiscordMember && + discordUserData.isDeveloper && + !discordUserData.isMaven && + !discordUserData.isBot && + !discordUserData.hasMissedUpdatesRole; + + if (!shouldAddRole) { + usersMap.delete(userId); + } + } + + const usersToAddRole = []; + for (const userData of usersMap.values()) { + usersToAddRole.push(userData.discordId); + } + const resultDataLength = tasksQuerySnapshot.docs.length; + const isLast = size && resultDataLength === size; + const lastVisible = isLast && tasksQuerySnapshot.docs[resultDataLength - 1]; + + if (lastVisible) { + stats.cursor = lastVisible.id; + } + + return { usersToAddRole, ...stats }; + } catch (err) { + logger.error("Error while running the add missed roles script", err); + throw err; + } +}; + const addInviteToInviteModel = async (inviteObject) => { try { const invite = await discordInvitesModel.add(inviteObject); @@ -873,6 +1056,7 @@ module.exports = { updateUsersNicknameStatus, updateIdle7dUsersOnDiscord, updateUsersWith31DaysPlusOnboarding, + getMissedProgressUpdatesUsers, getUserDiscordInvite, addInviteToInviteModel, }; diff --git a/routes/tasks.js b/routes/tasks.js index b62fc2478..862100fff 100644 --- a/routes/tasks.js +++ b/routes/tasks.js @@ -2,12 +2,19 @@ const express = require("express"); const router = express.Router(); const authenticate = require("../middlewares/authenticate"); const tasks = require("../controllers/tasks"); -const { createTask, updateTask, updateSelfTask, getTasksValidator } = require("../middlewares/validators/tasks"); +const { + createTask, + updateTask, + updateSelfTask, + getTasksValidator, + getUsersValidator, +} = require("../middlewares/validators/tasks"); const authorizeRoles = require("../middlewares/authorizeRoles"); const { APPOWNER, SUPERUSER } = require("../constants/roles"); const assignTask = require("../middlewares/assignTask"); const { cacheResponse, invalidateCache } = require("../utils/cache"); const { ALL_TASKS } = require("../constants/cacheKeys"); +const { verifyCronJob } = require("../middlewares/authorizeBot"); router.get("/", getTasksValidator, cacheResponse({ invalidationKey: ALL_TASKS, expiry: 10 }), tasks.fetchTasks); router.get("/self", authenticate, tasks.getSelfTasks); @@ -40,6 +47,8 @@ router.patch( ); router.patch("/assign/self", authenticate, invalidateCache({ invalidationKeys: [ALL_TASKS] }), tasks.assignTask); +router.get("/users/discord", verifyCronJob, getUsersValidator, tasks.getUsersHandler); + router.post("/migration", authenticate, authorizeRoles([SUPERUSER]), tasks.updateStatus); module.exports = router; diff --git a/test/integration/discordactions.test.js b/test/integration/discordactions.test.js index 57d6a933f..c0ad7f066 100644 --- a/test/integration/discordactions.test.js +++ b/test/integration/discordactions.test.js @@ -40,7 +40,7 @@ const { updateUserStatus } = require("../../models/userStatus"); const { generateUserStatusData } = require("../fixtures/userStatus/userStatus"); const { getDiscordMembers } = require("../fixtures/discordResponse/discord-response"); const { getOnboarding31DPlusMembers } = require("../fixtures/discordResponse/discord-response"); - +const discordRolesModel = require("../../models/discordactions"); chai.use(chaiHttp); const { userStatusDataForOooState } = require("../fixtures/userStatus/userStatus"); const { generateCronJobToken } = require("../utils/generateBotToken"); @@ -392,7 +392,12 @@ describe("Discord actions", function () { describe("POST /discord-actions/nickname/status", function () { let jwtToken; beforeEach(async function () { - const { id } = await userModel.add({ ...userData[0] }); + const userData2 = { ...userData[1] }; + delete userData2.discordId; + const [{ id }, { id: userId2 }] = await Promise.all([ + userModel.add({ ...userData[0] }), + userModel.add(userData2), + ]); const statusData = { ...userStatusDataForOooState, futureStatus: { @@ -402,7 +407,17 @@ describe("Discord actions", function () { }, userId: id, }; - await userStatusModel.add(statusData); + const statusData2 = { + ...userStatusDataForOooState, + futureStatus: { + state: "ACTIVE", + updatedAt: 1668211200000, + from: 1668709800000, + }, + userId: userId2, + }; + await Promise.all([userStatusModel.add(statusData), userStatusModel.add(statusData2)]); + jwtToken = generateCronJobToken({ name: CRON_JOB_HANDLER }); }); @@ -435,9 +450,9 @@ describe("Discord actions", function () { expect(res.body).to.deep.equal({ message: "Updated discord users nickname based on status", data: { - totalUsersStatus: 1, + totalUsersStatus: 2, successfulNicknameUpdates: 1, - unsuccessfulNicknameUpdates: 0, + unsuccessfulNicknameUpdates: 1, }, }); return done(); @@ -445,9 +460,9 @@ describe("Discord actions", function () { }).timeout(10000); it("should return object with 0 successful updates when user nickname changes", function (done) { - const response = "Error occurred while updating user's nickname"; - fetchStub.returns(Promise.reject(response)); + sinon.stub(discordRolesModel, "updateUsersNicknameStatus").throws(new Error()); + sinon.stub(); chai .request(app) .post("/discord-actions/nickname/status") @@ -464,7 +479,7 @@ describe("Discord actions", function () { expect(res.body.message).to.equal("An internal server error occurred"); return done(); }); - }); + }).timeout(10000); }); describe("POST /discord-actions/discord-roles", function () { before(async function () { diff --git a/test/integration/taskRequests.test.js b/test/integration/taskRequests.test.js index bcfce3fb2..63faaebc9 100644 --- a/test/integration/taskRequests.test.js +++ b/test/integration/taskRequests.test.js @@ -961,7 +961,6 @@ describe("Task Requests", function () { }); }); }); - describe("POST /taskRequests", function () { let fetchIssuesByIdStub; let fetchTaskStub; diff --git a/test/integration/tasks.test.js b/test/integration/tasks.test.js index 0720e3503..57e009a6c 100644 --- a/test/integration/tasks.test.js +++ b/test/integration/tasks.test.js @@ -9,20 +9,30 @@ const tasks = require("../../models/tasks"); const authService = require("../../services/authService"); const addUser = require("../utils/addUser"); const userModel = require("../../models/users"); +const userStatusModel = require("../../models/userStatus"); const config = require("config"); const cookieName = config.get("userToken.cookieName"); const userData = require("../fixtures/user/user")(); const tasksData = require("../fixtures/tasks/tasks")(); const { DINERO, NEELAM } = require("../../constants/wallets"); const cleanDb = require("../utils/cleanDb"); -const { TASK_STATUS } = require("../../constants/tasks"); +const { TASK_STATUS, tasksUsersStatus } = require("../../constants/tasks"); const updateTaskStatus = require("../fixtures/tasks/tasks1")(); +const userStatusData = require("../fixtures/userStatus/userStatus"); +const tasksModel = firestore.collection("tasks"); +const discordService = require("../../services/discordService"); +const { CRON_JOB_HANDLER } = require("../../constants/bot"); chai.use(chaiHttp); const appOwner = userData[3]; const superUser = userData[4]; let jwt, superUserJwt; +const { createProgressDocument } = require("../../models/progresses"); +const { stubbedModelTaskProgressData } = require("../fixtures/progress/progresses"); +const { convertDaysToMilliseconds } = require("../../utils/time"); +const { getDiscordMembers } = require("../fixtures/discordResponse/discord-response"); +const { generateCronJobToken } = require("../utils/generateBotToken"); const taskData = [ { @@ -911,7 +921,26 @@ describe("Tasks", function () { isNoteworthy: true, }; + it("Should throw 400 Bad Request if the user tries to update the status of a task to AVAILABLE", function (done) { + chai + .request(app) + .patch(`/tasks/self/${taskId1}`) + .set("cookie", `${cookieName}=${jwt}`) + .send(taskStatusData) + .end((err, res) => { + if (err) { + return done(err); + } + expect(res).to.have.status(400); + expect(res.body).to.be.a("object"); + expect(res.body.error).to.equal("Bad Request"); + expect(res.body.message).to.equal("The value for the 'status' field is invalid."); + return done(); + }); + }); + it("Should update the task status for given self taskid", function (done) { + taskStatusData.status = "IN_PROGRESS"; chai .request(app) .patch(`/tasks/self/${taskId1}`) @@ -992,6 +1021,7 @@ describe("Tasks", function () { }); it("Should return 404 if task doesnt exist", function (done) { + taskStatusData.status = "IN_PROGRESS"; chai .request(app) .patch("/tasks/self/wrongtaskId") @@ -1033,6 +1063,7 @@ describe("Tasks", function () { }); it("Should give 403 if status is already 'VERIFIED' ", async function () { + taskStatusData.status = "IN_PROGRESS"; taskId = (await tasks.updateTask({ ...taskData, assignee: appOwner.username })).taskId; const res = await chai .request(app) @@ -1293,6 +1324,123 @@ describe("Tasks", function () { }); }); + describe("GET /tasks/users", function () { + let activeUserWithProgressUpdates; + let idleUser; + let userNotInDiscord; + let jwtToken; + beforeEach(async function () { + await cleanDb(); + idleUser = { ...userData[9], discordId: getDiscordMembers[0].user.id }; + activeUserWithProgressUpdates = { ...userData[10], discordId: getDiscordMembers[1].user.id }; + const activeUserWithNoUpdates = { ...userData[0], discordId: getDiscordMembers[2].user.id }; + userNotInDiscord = { ...userData[4], discordId: "Not in discord" }; + const { + idleStatus: idleUserStatus, + activeStatus: activeUserStatus, + userStatusDataForOooState: oooUserStatus, + } = userStatusData; + const userIdList = await Promise.all([ + await addUser(idleUser), // idle user with no task progress updates + await addUser(activeUserWithProgressUpdates), // active user with task progress updates + await addUser(activeUserWithNoUpdates), // active user with no task progress updates + await addUser(userNotInDiscord), // OOO user with + ]); + await Promise.all([ + await userStatusModel.updateUserStatus(userIdList[0], idleUserStatus), + await userStatusModel.updateUserStatus(userIdList[1], activeUserStatus), + await userStatusModel.updateUserStatus(userIdList[2], activeUserStatus), + await userStatusModel.updateUserStatus(userIdList[3], oooUserStatus), + ]); + + const tasksPromise = []; + + for (let index = 0; index < 4; index++) { + const task = tasksData[index]; + const validTask = { + ...task, + assignee: userIdList[index], + startedOn: (new Date().getTime() - convertDaysToMilliseconds(7)) / 1000, + endsOn: (new Date().getTime() + convertDaysToMilliseconds(4)) / 1000, + status: TASK_STATUS.IN_PROGRESS, + }; + + tasksPromise.push(tasksModel.add(validTask)); + } + const taskIdList = (await Promise.all(tasksPromise)).map((tasksDoc) => tasksDoc.id); + const progressDataList = []; + + const date = new Date(); + date.setDate(date.getDate() - 1); + const progressData = stubbedModelTaskProgressData(null, taskIdList[2], date.getTime(), date.valueOf()); + progressDataList.push(progressData); + + await Promise.all(progressDataList.map(async (progress) => await createProgressDocument(progress))); + const discordMembers = [...getDiscordMembers].map((user) => { + return { ...user }; + }); + const roles1 = [...discordMembers[0].roles, "9876543210"]; + const roles2 = [...discordMembers[1].roles, "9876543210"]; + discordMembers[0].roles = roles1; + discordMembers[1].roles = roles2; + sinon.stub(discordService, "getDiscordMembers").returns(discordMembers); + jwtToken = generateCronJobToken({ name: CRON_JOB_HANDLER }); + }); + afterEach(async function () { + sinon.restore(); + await cleanDb(); + }); + it("should return successful response with user id list", async function () { + const response = await chai + .request(app) + .get("/tasks/users/discord") + .query({ q: `status:${tasksUsersStatus.MISSED_UPDATES}` }) + .set("Authorization", `Bearer ${jwtToken}`); + expect(response.body).to.be.deep.equal({ + message: "Discord details of users with status missed updates fetched successfully", + data: { + usersToAddRole: [activeUserWithProgressUpdates.discordId], + tasks: 4, + missedUpdatesTasks: 3, + }, + }); + expect(response.status).to.be.equal(200); + }); + it("should return successful response with user id when all params are passed", async function () { + const response = await chai + .request(app) + .get("/tasks/users/discord") + .query({ + size: 5, + q: `status:${tasksUsersStatus.MISSED_UPDATES} -weekday:sun -weekday:mon -weekday:tue -weekday:wed -weekday:thu -weekday:fri -date:231423432 -days-count:4`, + }) + .set("Authorization", `Bearer ${jwtToken}`); + expect(response.body).to.be.deep.equal({ + message: "Discord details of users with status missed updates fetched successfully", + data: { + usersToAddRole: [], + tasks: 4, + missedUpdatesTasks: 0, + }, + }); + expect(response.status).to.be.equal(200); + }); + + it("should return bad request error when status is not passed", async function () { + const response = await chai + .request(app) + .get("/tasks/users/discord") + .query({}) + .set("Authorization", `Bearer ${jwtToken}`); + expect(response.body).to.be.deep.equal({ + error: "Bad Request", + message: '"status" is required', + statusCode: 400, + }); + expect(response.status).to.be.equal(400); + }); + }); + describe("PATCH /tasks/:id should update the tasks by SuperUser", function () { beforeEach(async function () { const superUserId = await addUser(superUser); diff --git a/test/unit/middlewares/tasks-validator.test.js b/test/unit/middlewares/tasks-validator.test.js index 38ded1ebc..43faf5655 100644 --- a/test/unit/middlewares/tasks-validator.test.js +++ b/test/unit/middlewares/tasks-validator.test.js @@ -2,10 +2,12 @@ const Sinon = require("sinon"); const { getTasksValidator, createTask, + updateSelfTask, + getUsersValidator, updateTask: updateTaskValidator, } = require("../../../middlewares/validators/tasks"); const { expect } = require("chai"); -const { TASK_STATUS } = require("../../../constants/tasks"); +const { TASK_STATUS, tasksUsersStatus } = require("../../../constants/tasks"); describe("getTasks validator", function () { it("should pass the request when no values for query params dev or status is passed", async function () { @@ -615,4 +617,112 @@ describe("getTasks validator", function () { await updateTaskValidator(req, res, nextMiddlewareSpy); expect(nextMiddlewareSpy.callCount).to.be.equal(0); }); + describe("getUsersValidator | Validator", function () { + it("should pass the request when valid query parameters are provided", async function () { + const req = { + query: { + size: 10, + cursor: "someCursor", + q: `status:${tasksUsersStatus.MISSED_UPDATES} -days-count:2 -date:123423432 -weekday:sun`, + }, + }; + const res = {}; + const nextMiddlewareSpy = Sinon.spy(); + await getUsersValidator(req, res, nextMiddlewareSpy); + expect(nextMiddlewareSpy.callCount).to.be.equal(1); + }); + it("should pass the request when multiple valid query parameters are provided", async function () { + const req = { + query: { + size: 10, + cursor: "someCursor", + q: `status:${tasksUsersStatus.MISSED_UPDATES} -days-count:2 -date:123423432 -weekday:sun -weekday:mon`, + }, + }; + const res = {}; + const nextMiddlewareSpy = Sinon.spy(); + await getUsersValidator(req, res, nextMiddlewareSpy); + expect(nextMiddlewareSpy.callCount).to.be.equal(1); + }); + it("should pass the request when only required query parameters are provided", async function () { + const req = { + query: { + q: `status:${tasksUsersStatus.MISSED_UPDATES}`, + }, + }; + const res = {}; + const nextMiddlewareSpy = Sinon.spy(); + await getUsersValidator(req, res, nextMiddlewareSpy); + expect(nextMiddlewareSpy.callCount).to.be.equal(1); + }); + + it("should not pass validation when invalid query parameters are provided", async function () { + const req = { + query: { + invalidParam: "someValue", + }, + }; + const res = { + boom: { + badRequest: Sinon.spy(), + }, + }; + const nextMiddlewareSpy = Sinon.spy(); + await getUsersValidator(req, res, nextMiddlewareSpy); + expect(nextMiddlewareSpy.callCount).to.be.equal(0); + expect(res.boom.badRequest.callCount).to.be.equal(1); + }); + + it("should not pass validation when required parameters are missing", async function () { + const req = { + query: { + size: "someQuery", + }, + }; + const res = { + boom: { + badRequest: Sinon.spy(), + }, + }; + const nextMiddlewareSpy = Sinon.spy(); + await getUsersValidator(req, res, nextMiddlewareSpy); + expect(nextMiddlewareSpy.callCount).to.be.equal(0); + expect(res.boom.badRequest.callCount).to.be.equal(1); + }); + + it("should not pass validation when invalid filter parameters are provided", async function () { + const req = { + query: { + q: "date:invalidOperator:2023-01-01", + }, + }; + const res = { + boom: { + badRequest: Sinon.spy(), + }, + }; + const nextMiddlewareSpy = Sinon.spy(); + await getUsersValidator(req, res, nextMiddlewareSpy); + expect(nextMiddlewareSpy.callCount).to.be.equal(0); + expect(res.boom.badRequest.callCount).to.be.equal(1); + }); + }); + + describe("updateSelfTask Validator", function () { + it("should not pass the request when status is AVAILABLE", async function () { + const req = { + body: { + status: "AVAILABLE", + }, + }; + const res = { + boom: { + badRequest: Sinon.spy(), + }, + }; + const nextMiddlewareSpy = Sinon.spy(); + await updateSelfTask(req, res, nextMiddlewareSpy); + expect(nextMiddlewareSpy.callCount).to.be.equal(0); + }); + }); }); diff --git a/test/unit/models/discordactions.test.js b/test/unit/models/discordactions.test.js index ce70375a7..2042f224c 100644 --- a/test/unit/models/discordactions.test.js +++ b/test/unit/models/discordactions.test.js @@ -4,9 +4,18 @@ const sinon = require("sinon"); const firestore = require("../../../utils/firestore"); const photoVerificationModel = firestore.collection("photo-verification"); const discordRoleModel = firestore.collection("discord-roles"); +const userStatusCollection = firestore.collection("usersStatus"); const memberRoleModel = firestore.collection("member-group-roles"); const userModel = firestore.collection("users"); const admin = require("firebase-admin"); +const tasksData = require("../../fixtures/tasks/tasks")(); + +const addUser = require("../../utils/addUser"); +const userStatusData = require("../../fixtures/userStatus/userStatus"); +const { getDiscordMembers } = require("../../fixtures/discordResponse/discord-response"); +const discordService = require("../../../services/discordService"); +const { TASK_STATUS } = require("../../../constants/tasks"); +const tasksModel = firestore.collection("tasks"); const { createNewRole, @@ -18,6 +27,7 @@ const { enrichGroupDataWithMembershipInfo, fetchGroupToUserMapping, updateUsersNicknameStatus, + getMissedProgressUpdatesUsers, addInviteToInviteModel, getUserDiscordInvite, } = require("../../../models/discordactions"); @@ -25,11 +35,14 @@ const { groupData, roleData, existingRole, memberGroupData } = require("../../fi const cleanDb = require("../../utils/cleanDb"); const { userPhotoVerificationData } = require("../../fixtures/user/photo-verification"); const userData = require("../../fixtures/user/user")(); -const userStatusModel = firestore.collection("usersStatus"); +const userStatusModel = require("../../../models/userStatus"); const { getStatusData } = require("../../fixtures/userStatus/userStatus"); const usersStatusData = getStatusData(); const dataAccessLayer = require("../../../services/dataAccessLayer"); const { ONE_DAY_IN_MS } = require("../../../constants/users"); +const { createProgressDocument } = require("../../../models/progresses"); +const { stubbedModelTaskProgressData } = require("../../fixtures/progress/progresses"); +const { convertDaysToMilliseconds } = require("../../../utils/time"); chai.should(); @@ -448,7 +461,6 @@ describe("discordactions", function () { beforeEach(async function () { fetchStub = sinon.stub(global, "fetch"); dataAccessLayerStub = sinon.stub(dataAccessLayer, "retrieveUsers"); - addedUers.forEach(({ username, discordId, id }) => { dataAccessLayerStub.withArgs(sinon.match({ id })).resolves({ user: { @@ -477,7 +489,7 @@ describe("discordactions", function () { const addedUsersStatusPromise = usersStatusData.map(async (data, index) => { const { id } = addedUers[index]; const statusData = { ...data, userId: id }; - const { id: userStatusId } = await userStatusModel.add(statusData); + const { id: userStatusId } = await userStatusCollection.add(statusData); return { ...statusData, id: userStatusId }; }); @@ -578,6 +590,155 @@ describe("discordactions", function () { }).timeout(10000); }); + describe("getMissedProgressUpdatesUsers", function () { + let activeUserWithProgressUpdates; + let idleUser; + let userNotInDiscord; + let activeUserId; + beforeEach(async function () { + idleUser = { ...userData[9], discordId: getDiscordMembers[0].user.id }; + activeUserWithProgressUpdates = { ...userData[10], discordId: getDiscordMembers[1].user.id }; + const activeUserWithNoUpdates = { ...userData[0], discordId: getDiscordMembers[2].user.id }; + userNotInDiscord = { ...userData[4], discordId: "Not in discord" }; + const { + idleStatus: idleUserStatus, + activeStatus: activeUserStatus, + userStatusDataForOooState: oooUserStatus, + } = userStatusData; + const userIdList = await Promise.all([ + await addUser(idleUser), // idle user with no task progress updates + await addUser(activeUserWithProgressUpdates), // active user with task progress updates + await addUser(activeUserWithNoUpdates), // active user with no task progress updates + await addUser(userNotInDiscord), // OOO user with no task progress updates + ]); + activeUserId = userIdList[2]; + await Promise.all([ + await userStatusModel.updateUserStatus(userIdList[0], idleUserStatus), + await userStatusModel.updateUserStatus(userIdList[1], activeUserStatus), + await userStatusModel.updateUserStatus(userIdList[2], activeUserStatus), + await userStatusModel.updateUserStatus(userIdList[3], oooUserStatus), + ]); + + const tasksPromise = []; + + for (let index = 0; index < 4; index++) { + const task = tasksData[index]; + const validTask = { + ...task, + assignee: userIdList[index], + startedOn: (new Date().getTime() - convertDaysToMilliseconds(7)) / 1000, + endsOn: (new Date().getTime() + convertDaysToMilliseconds(4)) / 1000, + status: TASK_STATUS.IN_PROGRESS, + }; + + tasksPromise.push(tasksModel.add(validTask)); + } + const taskIdList = (await Promise.all(tasksPromise)).map((tasksDoc) => tasksDoc.id); + const progressDataList = []; + + const date = new Date(); + date.setDate(date.getDate() - 1); + const progressData = stubbedModelTaskProgressData(null, taskIdList[2], date.getTime(), date.valueOf()); + progressDataList.push(progressData); + + await Promise.all(progressDataList.map(async (progress) => await createProgressDocument(progress))); + const discordMembers = [...getDiscordMembers]; + discordMembers[0].roles.push("9876543210"); + discordMembers[1].roles.push("9876543210"); + sinon.stub(discordService, "getDiscordMembers").returns(discordMembers); + }); + afterEach(async function () { + sinon.restore(); + await cleanDb(); + }); + it("should list of users who missed updating progress", async function () { + const result = await getMissedProgressUpdatesUsers(); + expect(result).to.be.an("object"); + expect(result).to.be.deep.equal({ + tasks: 4, + missedUpdatesTasks: 3, + usersToAddRole: [activeUserWithProgressUpdates.discordId], + }); + }); + + it("should not list of users who are not active and who missed updating progress", async function () { + const result = await getMissedProgressUpdatesUsers(); + expect(result).to.be.an("object"); + expect(result.usersToAddRole).to.not.contain(idleUser.discordId); + expect(result.usersToAddRole).to.not.contain(userNotInDiscord.discordId); + }); + + it("should not list of users when exception days are added", async function () { + const date = new Date(); + date.setDate(date.getDate() - 1); + const date2 = new Date(); + date2.setDate(date2.getDate() - 2); + const date3 = new Date(); + date3.setDate(date3.getDate() - 3); + const date4 = new Date(); + date4.setDate(date4.getDate() - 4); + const result = await getMissedProgressUpdatesUsers({ + excludedDates: [date.valueOf(), date2.valueOf(), date3.valueOf(), date4.valueOf()], + }); + expect(result).to.be.an("object"); + expect(result).to.be.deep.equal({ + tasks: 4, + missedUpdatesTasks: 0, + usersToAddRole: [], + }); + }); + + it("should not list of users when all days of week are excluded", async function () { + const result = await getMissedProgressUpdatesUsers({ + excludedDays: [0, 1, 2, 3, 4, 5, 6], + }); + expect(result).to.be.an("object"); + expect(result).to.be.deep.equal({ + tasks: 0, + missedUpdatesTasks: 0, + usersToAddRole: [], + }); + }); + it("should not list any users since 5 days of weeks are excluded", async function () { + const oneMonthOldTask = { ...tasksData[0] }; + oneMonthOldTask.assignee = activeUserId; + oneMonthOldTask.startedOn = (new Date().getTime() - convertDaysToMilliseconds(30)) / 1000; + oneMonthOldTask.endsOn = (new Date().getTime() + convertDaysToMilliseconds(4)) / 1000; + const taskId = (await tasksModel.add(oneMonthOldTask)).id; + const date = new Date(); + date.setDate(date.getDate() - 29); + const progressData = stubbedModelTaskProgressData(null, taskId, date.getTime(), date.valueOf()); + await createProgressDocument(progressData); + + const result = await getMissedProgressUpdatesUsers({ + excludedDays: [0, 1, 2, 3, 4, 5], + dateGap: 3, + }); + expect(result).to.be.an("object"); + expect(result).to.be.deep.equal({ + tasks: 5, + missedUpdatesTasks: 0, + usersToAddRole: [], + }); + }); + + it("should process only 1 task when size is passed as 1", async function () { + const result = await getMissedProgressUpdatesUsers({ size: 1 }); + + expect(result).to.be.an("object"); + expect(result.tasks).to.be.equal(1); + }); + it("should fetch process tasks when cursor is passed", async function () { + const result = await getMissedProgressUpdatesUsers({ size: 4 }); + + expect(result).to.be.an("object"); + expect(result).to.haveOwnProperty("cursor"); + const nextResult = await getMissedProgressUpdatesUsers({ size: 4, cursor: result.cursor }); + expect(nextResult).to.be.an("object"); + expect(nextResult).to.not.haveOwnProperty("cursor"); + }); + }); + describe("addInviteToInviteModel", function () { it("should add invite in the invite model for user", async function () { const inviteObject = { userId: "kfjkasdfl", inviteLink: "discord.gg/xyz" }; diff --git a/utils/progresses.js b/utils/progresses.js index e2ae033e0..62a6b67b9 100644 --- a/utils/progresses.js +++ b/utils/progresses.js @@ -2,11 +2,14 @@ const { NotFound } = require("http-errors"); const { fetchTask } = require("../models/tasks"); const { fetchUser } = require("../models/users"); const fireStore = require("../utils/firestore"); +const progressesModel = fireStore.collection("progresses"); + const { PROGRESSES_RESPONSE_MESSAGES: { PROGRESS_DOCUMENT_NOT_FOUND }, MILLISECONDS_IN_DAY, PROGRESS_VALID_SORT_FIELDS, } = require("../constants/progresses"); +const { convertTimestampToUTCStartOrEndOfDay } = require("./time"); const progressesCollection = fireStore.collection("progresses"); /** @@ -211,6 +214,14 @@ const buildQueryToSearchProgressByDay = (pathParams) => { return query; }; +const buildProgressQueryForMissedUpdates = (taskId, startTimestamp, endTimestamp) => { + return progressesModel + .where("type", "==", "task") + .where("taskId", "==", taskId) + .where("date", ">=", convertTimestampToUTCStartOrEndOfDay(startTimestamp)) + .where("date", "<=", convertTimestampToUTCStartOrEndOfDay(endTimestamp, true)) + .count(); +}; module.exports = { getProgressDateTimestamp, buildQueryForPostingProgress, @@ -222,4 +233,5 @@ module.exports = { buildRangeProgressQuery, getProgressRecords, buildQueryToSearchProgressByDay, + buildProgressQueryForMissedUpdates, }; diff --git a/utils/tasks.js b/utils/tasks.js index a2c14615b..f1b4616c6 100644 --- a/utils/tasks.js +++ b/utils/tasks.js @@ -1,5 +1,7 @@ const { getUsername, getUserId, getParticipantUsernames, getParticipantUserIds } = require("./users"); -const { TASK_TYPE, MAPPED_TASK_STATUS } = require("../constants/tasks"); +const { TASK_TYPE, MAPPED_TASK_STATUS, COMPLETED_TASK_STATUS, TASK_STATUS } = require("../constants/tasks"); +const fireStore = require("../utils/firestore"); +const tasksModel = fireStore.collection("tasks"); const fromFirestoreData = async (task) => { if (!task) { @@ -110,10 +112,20 @@ const parseSearchQuery = (queryString) => { return searchParams; }; +const buildTasksQueryForMissedUpdates = (size) => { + const completedTasksStatusList = Object.values(COMPLETED_TASK_STATUS); + return tasksModel + .where("status", "not-in", [...completedTasksStatusList, TASK_STATUS.AVAILABLE]) + .orderBy("status") + .orderBy("assignee") + .limit(size); +}; + module.exports = { fromFirestoreData, toFirestoreData, buildTasks, transformQuery, parseSearchQuery, + buildTasksQueryForMissedUpdates, }; diff --git a/utils/time.js b/utils/time.js index 50c5374f2..5910a7bdc 100644 --- a/utils/time.js +++ b/utils/time.js @@ -24,7 +24,15 @@ const convertHoursToMilliseconds = (hours) => { const convertDaysToMilliseconds = (days) => { return days * 24 * 60 * 60 * 1000; }; - +/** + * Converts milliseconds to seconds + * @param milliseconds {number} : to be converted + * @returns {number} : seconds + */ +const convertMillisToSeconds = (milliseconds) => { + if (typeof milliseconds !== "number") throw Error("Not a number"); + return Math.round(milliseconds / 1000); +}; /** * Returns time in seconds of timestamp after given duration * @param timestamp {integer} : base time in milliseconds @@ -82,4 +90,5 @@ module.exports = { getTimeInSecondAfter, getBeforeHourTime, convertTimestampToUTCStartOrEndOfDay, + convertMillisToSeconds, }; diff --git a/utils/users.js b/utils/users.js index 0b02bfe68..239801b09 100644 --- a/utils/users.js +++ b/utils/users.js @@ -4,7 +4,7 @@ const userModel = firestore.collection("users"); const { months, discordNicknameLength } = require("../constants/users"); const dataAccessLayer = require("../services/dataAccessLayer"); const discordService = require("../services/discordService"); - +const ROLES = require("../constants/roles"); const addUserToDBForTest = async (userData) => { await userModel.add(userData); }; @@ -271,10 +271,15 @@ const generateOOONickname = (username = "", from, until) => { */ const updateNickname = async (userId, status = {}) => { try { - const { user: { discordId, username } = {} } = await dataAccessLayer.retrieveUsers({ id: userId }); - if (!discordId || !username) { - throw new Error("Username or discordId unavailable"); + const { + user: { discordId, username, roles = {} }, + discordJoinedAt = {}, + } = await dataAccessLayer.retrieveUsers({ id: userId }); + + if (!discordId || !username || !discordJoinedAt || roles[ROLES.ARCHIVED]) { + throw new Error("User details unavailable"); } + try { const nickname = generateOOONickname(username, status.from, status.until);