diff --git a/controllers/tasks.js b/controllers/tasks.js index 9e36c639c..41af73f4f 100644 --- a/controllers/tasks.js +++ b/controllers/tasks.js @@ -532,6 +532,17 @@ const getUsersHandler = async (req, res) => { } }; +const getOrphanedTasks = async (req, res) => { + try { + const data = await tasks.fetchOrphanedTasks(); + + return res.status(200).json({ message: "Orphan tasks fetched successfully", data }); + } catch (error) { + logger.error("Error in getting tasks which were abandoned", error); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; + module.exports = { addNewTask, fetchTasks, @@ -545,4 +556,5 @@ module.exports = { updateStatus, getUsersHandler, orphanTasks, + getOrphanedTasks, }; diff --git a/controllers/users.js b/controllers/users.js index 1fa81c282..ba7c608a8 100644 --- a/controllers/users.js +++ b/controllers/users.js @@ -1029,6 +1029,16 @@ const updateUsernames = async (req, res) => { } }; +const getUsersWithAbandonedTasks = async (req, res) => { + try { + const data = await userQuery.fetchUsersWithAbandonedTasks(); + return res.status(200).json({ message: "Users with abandoned tasks fetched successfully", data }); + } catch (error) { + logger.error("Error in getting user who abandoned tasks:", error); + return res.boom.badImplementation(INTERNAL_SERVER_ERROR); + } +}; + module.exports = { verifyUser, generateChaincode, @@ -1061,4 +1071,5 @@ module.exports = { isDeveloper, getIdentityStats, updateUsernames, + getUsersWithAbandonedTasks, }; diff --git a/models/tasks.js b/models/tasks.js index 8b0754b1b..f30606247 100644 --- a/models/tasks.js +++ b/models/tasks.js @@ -701,6 +701,35 @@ const markUnDoneTasksOfArchivedUsersBacklog = async (users) => { } }; +const fetchOrphanedTasks = async () => { + try { + const COMPLETED_STATUSES = [DONE, COMPLETED]; + const abandonedTasks = []; + + const userSnapshot = await userModel + .where("roles.archived", "==", true) + .where("roles.in_discord", "==", false) + .get(); + + for (const userDoc of userSnapshot.docs) { + const user = userDoc.data(); + const abandonedTasksQuerySnapshot = await tasksModel + .where("assigneeId", "==", user.id || "") + .where("status", "not-in", COMPLETED_STATUSES) + .get(); + + // Check if the user has any tasks with status not in [Done, Complete] + if (!abandonedTasksQuerySnapshot.empty) { + abandonedTasks.push(...abandonedTasksQuerySnapshot.docs.map((doc) => doc.data())); + } + } + return abandonedTasks; + } catch (error) { + logger.error(`Error in getting tasks abandoned by users: ${error}`); + throw error; + } +}; + module.exports = { updateTask, fetchTasks, @@ -720,4 +749,5 @@ module.exports = { updateTaskStatus, updateOrphanTasksStatus, markUnDoneTasksOfArchivedUsersBacklog, + fetchOrphanedTasks, }; diff --git a/models/users.js b/models/users.js index 5d9f84fd2..ab45b45f6 100644 --- a/models/users.js +++ b/models/users.js @@ -24,7 +24,9 @@ const admin = require("firebase-admin"); const { INTERNAL_SERVER_ERROR } = require("../constants/errorMessages"); const { AUTHORITIES } = require("../constants/authorities"); const { formatUsername } = require("../utils/username"); - +const tasksModel = firestore.collection("tasks"); +const { TASK_STATUS } = require("../constants/tasks"); +const { COMPLETED, DONE } = TASK_STATUS; /** * Adds or updates the user data * @@ -1013,6 +1015,36 @@ const updateUsersWithNewUsernames = async () => { } }; +const fetchUsersWithAbandonedTasks = async () => { + try { + const COMPLETED_STATUSES = [DONE, COMPLETED]; + const eligibleUsersWithTasks = []; + + const userSnapshot = await userModel + .where("roles.archived", "==", true) + .where("roles.in_discord", "==", false) + .get(); + + for (const userDoc of userSnapshot.docs) { + const user = userDoc.data(); + + // Check if the user has any tasks with status not in [Done, Complete] + const abandonedTasksQuerySnapshot = await tasksModel + .where("assigneeId", "==", user.id) + .where("status", "not-in", COMPLETED_STATUSES) + .get(); + + if (!abandonedTasksQuerySnapshot.empty) { + eligibleUsersWithTasks.push(user); + } + } + return eligibleUsersWithTasks; + } catch (error) { + logger.error(`Error in getting users who abandoned tasks: ${error}`); + throw error; + } +}; + module.exports = { addOrUpdate, fetchPaginatedUsers, @@ -1042,4 +1074,5 @@ module.exports = { fetchUserForKeyValue, getNonNickNameSyncedUsers, updateUsersWithNewUsernames, + fetchUsersWithAbandonedTasks, }; diff --git a/routes/tasks.js b/routes/tasks.js index 5596f982c..57095e82c 100644 --- a/routes/tasks.js +++ b/routes/tasks.js @@ -33,6 +33,7 @@ const enableDevModeMiddleware = (req, res, next) => { } }; +router.get("/orphaned-tasks", tasks.getOrphanedTasks); router.get("/", getTasksValidator, cacheResponse({ invalidationKey: ALL_TASKS, expiry: 10 }), tasks.fetchTasks); router.get("/self", authenticate, tasks.getSelfTasks); router.get("/overdue", authenticate, authorizeRoles([SUPERUSER]), tasks.overdueTasks); diff --git a/routes/users.js b/routes/users.js index 94d301cda..e8d19573d 100644 --- a/routes/users.js +++ b/routes/users.js @@ -13,6 +13,7 @@ const ROLES = require("../constants/roles"); const { Services } = require("../constants/bot"); const authenticateProfile = require("../middlewares/authenticateProfile"); +router.get("/departed-users", users.getUsersWithAbandonedTasks); router.post("/", authorizeAndAuthenticate([ROLES.SUPERUSER], [Services.CRON_JOB_HANDLER]), users.markUnverified); router.post("/update-in-discord", authenticate, authorizeRoles([SUPERUSER]), users.setInDiscordScript); router.post("/verify", authenticate, users.verifyUser); @@ -67,6 +68,7 @@ router.patch("/profileURL", authenticate, userValidator.updateProfileURL, users. router.patch("/rejectDiff", authenticate, authorizeRoles([SUPERUSER]), users.rejectProfileDiff); router.patch("/:userId", authenticate, authorizeRoles([SUPERUSER]), users.updateUser); router.get("/suggestedUsers/:skillId", authenticate, authorizeRoles([SUPERUSER]), users.getSuggestedUsers); + module.exports = router; router.post("/batch-username-update", authenticate, authorizeRoles([SUPERUSER]), users.updateUsernames); module.exports = router; diff --git a/test/fixtures/tasks/tasks.js b/test/fixtures/tasks/tasks.js index 1d5efa978..1768c6cd6 100644 --- a/test/fixtures/tasks/tasks.js +++ b/test/fixtures/tasks/tasks.js @@ -140,5 +140,89 @@ module.exports = () => { createdAt: 1644753600, updatedAt: 1644753600, }, + { + id: "task1_id", + title: "Abandoned Task 1", + type: "feature", + status: "IN_PROGRESS", + priority: "HIGH", + percentCompleted: 50, + createdAt: 1727027666, + updatedAt: 1727027999, + startedOn: 1727027777, + endsOn: 1731542400, + assignee: "archived_user1", + assigneeId: "user1_id", + github: { + issue: { + html_url: "https://github.com/org/repo/issues/1", + url: "https://api.github.com/repos/org/repo/issues/1", + }, + }, + dependsOn: [], + }, + { + id: "task2_id", + title: "Abandoned Task 2", + type: "bug", + status: "BLOCKED", + priority: "MEDIUM", + percentCompleted: 30, + createdAt: 1727027666, + updatedAt: 1727027999, + startedOn: 1727027777, + endsOn: 1731542400, + assignee: "archived_user2", + assigneeId: "user2_id", + github: { + issue: { + html_url: "https://github.com/org/repo/issues/2", + url: "https://api.github.com/repos/org/repo/issues/2", + }, + }, + dependsOn: [], + }, + { + id: "task3_id", + title: "Completed Archived Task", + type: "feature", + status: "DONE", + priority: "LOW", + percentCompleted: 100, + createdAt: 1727027666, + updatedAt: 1727027999, + startedOn: 1727027777, + endsOn: 1731542400, + assignee: "archived_user1", + assigneeId: "user1_id", + github: { + issue: { + html_url: "https://github.com/org/repo/issues/3", + url: "https://api.github.com/repos/org/repo/issues/3", + }, + }, + dependsOn: [], + }, + { + id: "task4_id", + title: "Active User Task", + type: "feature", + status: "IN_PROGRESS", + priority: "HIGH", + percentCompleted: 75, + createdAt: 1727027666, + updatedAt: 1727027999, + startedOn: 1727027777, + endsOn: 1731542400, + assignee: "active_user", + assigneeId: "user3_id", + github: { + issue: { + html_url: "https://github.com/org/repo/issues/4", + url: "https://api.github.com/repos/org/repo/issues/4", + }, + }, + dependsOn: [], + }, ]; }; diff --git a/test/fixtures/user/user.js b/test/fixtures/user/user.js index 485255efc..674718d61 100644 --- a/test/fixtures/user/user.js +++ b/test/fixtures/user/user.js @@ -442,5 +442,68 @@ module.exports = () => { url: "https://res.cloudinary.com/realdevsquad/image/upload/v1667685133/profile/mtS4DhUvNYsKqI7oCWVB/aenklfhtjldc5ytei3ar.jpg", }, }, + { + id: "user1_id", + discordId: "123456789", + github_id: "github_user1", + username: "archived_user1", + first_name: "Archived", + last_name: "User One", + linkedin_id: "archived_user1", + github_display_name: "archived-user-1", + phone: "1234567890", + email: "archived1@test.com", + roles: { + archived: true, + in_discord: false, + }, + discordJoinedAt: "2024-01-01T00:00:00.000Z", + picture: { + publicId: "profile/user1", + url: "https://example.com/user1.jpg", + }, + }, + { + id: "user2_id", + discordId: "987654321", + github_id: "github_user2", + username: "archived_user2", + first_name: "Archived", + last_name: "User Two", + linkedin_id: "archived_user2", + github_display_name: "archived-user-2", + phone: "0987654321", + email: "archived2@test.com", + roles: { + archived: true, + in_discord: false, + }, + discordJoinedAt: "2024-01-02T00:00:00.000Z", + picture: { + publicId: "profile/user2", + url: "https://example.com/user2.jpg", + }, + }, + { + id: "user3_id", + discordId: "555555555", + github_id: "github_user3", + username: "active_user", + first_name: "Active", + last_name: "User", + linkedin_id: "active_user", + github_display_name: "active-user", + phone: "5555555555", + email: "active@test.com", + roles: { + archived: false, + in_discord: true, + }, + discordJoinedAt: "2024-01-03T00:00:00.000Z", + picture: { + publicId: "profile/user3", + url: "https://example.com/user3.jpg", + }, + }, ]; }; diff --git a/test/integration/tasks.test.js b/test/integration/tasks.test.js index 6a8442875..e142958bb 100644 --- a/test/integration/tasks.test.js +++ b/test/integration/tasks.test.js @@ -1633,4 +1633,62 @@ describe("Tasks", function () { }); }); }); + + describe("fetchOrphanedTasks", function () { + beforeEach(async function () { + // Clean the database + await cleanDb(); + + // Add test users to the database + const userPromises = userData.map((user) => userDBModel.add(user)); + await Promise.all(userPromises); + + // Add test tasks to the database + const taskPromises = tasksData.map((task) => tasksModel.add(task)); + await Promise.all(taskPromises); + }); + + afterEach(async function () { + await cleanDb(); + }); + + it("should fetch tasks assigned to archived and non-discord users", async function () { + const abandonedTasks = await tasks.fetchOrphanedTasks(); + + expect(abandonedTasks).to.be.an("array"); + expect(abandonedTasks).to.have.lengthOf(2); // Two tasks abandoned by users + }); + + it("should not include completed or done tasks", async function () { + const abandonedTasks = await tasks.fetchOrphanedTasks(); + + abandonedTasks.forEach((task) => { + expect(task.status).to.not.be.oneOf(["DONE", "COMPLETED"]); + }); + }); + + it("should not include tasks from active users", async function () { + const abandonedTasks = await tasks.fetchOrphanedTasks(); + + abandonedTasks.forEach((task) => { + expect(task.assignee).to.not.equal("active_user"); + }); + }); + + it("should handle case when no users are archived", async function () { + await cleanDb(); + + // Add only active users + const activeUser = userData[11]; // Using the active user from our test data + await userDBModel.add(activeUser); + + // Add a task assigned to the active user + const activeTask = tasksData[11]; // Using the active user's task + await tasksModel.add(activeTask); + + const abandonedTasks = await tasks.fetchOrphanedTasks(); + expect(abandonedTasks).to.be.an("array"); + expect(abandonedTasks).to.have.lengthOf(0); + }); + }); }); diff --git a/test/integration/users.test.js b/test/integration/users.test.js index 708b87d2f..cda75c3d7 100644 --- a/test/integration/users.test.js +++ b/test/integration/users.test.js @@ -23,9 +23,12 @@ const { userStatusDataAfterSignup, userStatusDataAfterFillingJoinSection, } = require("../fixtures/userStatus/userStatus"); -const { addJoinData, addOrUpdate } = require("../../models/users"); +const { addJoinData, addOrUpdate, fetchUsersWithAbandonedTasks } = require("../../models/users"); const userStatusModel = require("../../models/userStatus"); const { MAX_USERNAME_LENGTH } = require("../../constants/users.ts"); +const tasksData = require("../fixtures/tasks/tasks")(); +const tasksModel = firestore.collection("tasks"); +const userDBModel = firestore.collection("users"); const userRoleUpdate = userData[4]; const userRoleUnArchived = userData[13]; @@ -2646,4 +2649,54 @@ describe("Users", function () { expect(res).to.have.status(401); }); }); + + describe("fetchUsersWithAbandonedTasks", function () { + beforeEach(async function () { + // Clean the database + await cleanDb(); + + // Add test users to the database + const userPromises = userData.map((user) => userDBModel.add(user)); + await Promise.all(userPromises); + + // Add test tasks to the database + const taskPromises = tasksData.map((task) => tasksModel.add(task)); + await Promise.all(taskPromises); + }); + + afterEach(async function () { + await cleanDb(); + }); + + it("should fetch users with abandoned tasks", async function () { + const usersWithAbandonedTasks = await fetchUsersWithAbandonedTasks(); + + expect(usersWithAbandonedTasks).to.be.an("array"); + expect(usersWithAbandonedTasks).to.have.lengthOf(2); // Two users with abandoned tasks + }); + + it("should not include user who are present in discord or not archived", async function () { + const usersWithAbandonedTasks = await fetchUsersWithAbandonedTasks(); + + usersWithAbandonedTasks.forEach((user) => { + expect(user.roles.in_discord).to.not.equal(true); + expect(user.roles.archived).to.not.equal(false); + }); + }); + + it("should return an empty array if there are no users with abandoned tasks", async function () { + await cleanDb(); + + // Add only active users + const activeUser = userData[11]; // Using the active user from our test data + await userDBModel.add(activeUser); + + // Add a task assigned to the active user + const activeTask = tasksData[11]; // Using the active user's task + await tasksModel.add(activeTask); + const usersWithAbandonedTasks = await fetchUsersWithAbandonedTasks(); + expect(usersWithAbandonedTasks).to.be.an("array"); + expect(usersWithAbandonedTasks).to.have.lengthOf(0); + }); + }); }); diff --git a/test/unit/models/tasks.test.js b/test/unit/models/tasks.test.js index 44a560fd5..db9ccd15e 100644 --- a/test/unit/models/tasks.test.js +++ b/test/unit/models/tasks.test.js @@ -303,12 +303,12 @@ describe("tasks", function () { overdueTask.endsOn = Date.now() / 1000 + 24 * 60 * 60 * 7; await tasks.updateTask(overdueTask); const usersWithOverdueTasks = await tasks.getOverdueTasks(days); - expect(usersWithOverdueTasks.length).to.be.equal(5); + expect(usersWithOverdueTasks.length).to.be.equal(8); }); it("should return all users which have overdue tasks if days is not passed", async function () { const usersWithOverdueTasks = await tasks.getOverdueTasks(); - expect(usersWithOverdueTasks.length).to.be.equal(4); + expect(usersWithOverdueTasks.length).to.be.equal(7); }); }); @@ -332,8 +332,8 @@ describe("tasks", function () { it("Should update task status COMPLETED to DONE", async function () { const res = await tasks.updateTaskStatus(); - expect(res.totalTasks).to.be.equal(8); - expect(res.totalUpdatedStatus).to.be.equal(8); + expect(res.totalTasks).to.be.equal(12); + expect(res.totalUpdatedStatus).to.be.equal(12); }); it("should throw an error if firebase batch operation fails", async function () { diff --git a/test/unit/services/tasks.test.js b/test/unit/services/tasks.test.js index 02d424134..8e675958c 100644 --- a/test/unit/services/tasks.test.js +++ b/test/unit/services/tasks.test.js @@ -46,7 +46,7 @@ describe("Tasks services", function () { const res = await updateTaskStatusToDone(tasks); expect(res).to.deep.equal({ - totalUpdatedStatus: 8, + totalUpdatedStatus: 12, totalOperationsFailed: 0, updatedTaskDetails: taskDetails, failedTaskDetails: [], @@ -66,7 +66,7 @@ describe("Tasks services", function () { expect(res).to.deep.equal({ totalUpdatedStatus: 0, - totalOperationsFailed: 8, + totalOperationsFailed: 12, updatedTaskDetails: [], failedTaskDetails: taskDetails, }); diff --git a/test/unit/services/users.test.js b/test/unit/services/users.test.js index 60cf04c9d..611b1e64c 100644 --- a/test/unit/services/users.test.js +++ b/test/unit/services/users.test.js @@ -55,7 +55,7 @@ describe("Users services", function () { expect(res).to.deep.equal({ message: "Successfully completed batch updates", - totalUsersArchived: 20, + totalUsersArchived: 23, totalOperationsFailed: 0, updatedUserDetails: userDetails, failedUserDetails: [], @@ -76,7 +76,7 @@ describe("Users services", function () { expect(res).to.deep.equal({ message: "Firebase batch operation failed", totalUsersArchived: 0, - totalOperationsFailed: 20, + totalOperationsFailed: 23, updatedUserDetails: [], failedUserDetails: userDetails, });