Skip to content

Commit 26ec1df

Browse files
VinuB-Devbyt3quester
authored andcommitted
Feat: Implement APIs for Tracking Departed Users with Assigned Tasks (Real-Dev-Squad#2268)
* feat: Departed users api changes. * feat: Added test cases for departed users api changes. * Refactor: Update fetchIncompleteTasksByUserIds to use batch queries and filter in-memory - Replaced Firestore query with batch query to fetch incomplete tasks for multiple users at once. - Filter tasks by user IDs and completed statuses in-memory for improved efficiency. - Updated return structure to return an array directly instead of an object with `docs` property. - Updated test cases related to the same.
1 parent c32ff5f commit 26ec1df

File tree

9 files changed

+273
-48
lines changed

9 files changed

+273
-48
lines changed

controllers/users.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const { addLog } = require("../models/logs");
3030
const { getUserStatus } = require("../models/userStatus");
3131
const config = require("config");
3232
const { generateUniqueUsername } = require("../services/users");
33+
const userService = require("../services/users");
3334
const discordDeveloperRoleId = config.get("discordDeveloperRoleId");
3435

3536
const verifyUser = async (req, res) => {
@@ -191,6 +192,30 @@ const getUsers = async (req, res) => {
191192
}
192193
}
193194

195+
const isDeparted = req.query.departed === "true";
196+
197+
if (isDeparted) {
198+
if (!dev) {
199+
return res.boom.notFound("Route not found");
200+
}
201+
try {
202+
const result = await dataAccess.retrieveUsers({ query: req.query });
203+
const departedUsers = await userService.getUsersWithIncompleteTasks(result.users);
204+
if (departedUsers.length === 0) return res.status(204).send();
205+
return res.json({
206+
message: "Users with abandoned tasks fetched successfully",
207+
users: departedUsers,
208+
links: {
209+
next: result.nextId ? getPaginationLink(req.query, "next", result.nextId) : "",
210+
prev: result.prevId ? getPaginationLink(req.query, "prev", result.prevId) : "",
211+
},
212+
});
213+
} catch (error) {
214+
logger.error("Error when fetching users who abandoned tasks:", error);
215+
return res.boom.badImplementation(INTERNAL_SERVER_ERROR);
216+
}
217+
}
218+
194219
if (transformedQuery?.filterBy === OVERDUE_TASKS) {
195220
try {
196221
const tasksData = await getOverdueTasks(days);

middlewares/validators/user.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ async function getUsers(req, res, next) {
200200
filterBy: joi.string().optional(),
201201
days: joi.string().optional(),
202202
dev: joi.string().optional(),
203+
departed: joi.string().optional(),
203204
roles: joi.optional().custom((value, helpers) => {
204205
if (value !== "member") {
205206
return helpers.message("only member role is supported");

models/tasks.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -752,9 +752,9 @@ const fetchIncompleteTasksByUserIds = async (userIds) => {
752752
try {
753753
const incompleteTasksQuery = await tasksModel.where("assigneeId", "in", userIds).get();
754754

755-
const incompleteTaskForUsers = incompleteTasksQuery.docs.filter((task) => {
756-
return !COMPLETED_STATUSES.includes(task.data().status);
757-
});
755+
const incompleteTaskForUsers = incompleteTasksQuery.docs.filter(
756+
(task) => !COMPLETED_STATUSES.includes(task.data().status)
757+
);
758758

759759
return incompleteTaskForUsers;
760760
} catch (error) {

models/users.js

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ const firestore = require("../utils/firestore");
88
const { fetchWallet, createWallet } = require("../models/wallets");
99
const { updateUserStatus } = require("../models/userStatus");
1010
const { arraysHaveCommonItem, chunks } = require("../utils/array");
11-
const { archiveUsers } = require("../services/users");
12-
const { ALLOWED_FILTER_PARAMS, FIRESTORE_IN_CLAUSE_SIZE } = require("../constants/users");
11+
const {
12+
ALLOWED_FILTER_PARAMS,
13+
FIRESTORE_IN_CLAUSE_SIZE,
14+
USERS_PATCH_HANDLER_SUCCESS_MESSAGES,
15+
USERS_PATCH_HANDLER_ERROR_MESSAGES,
16+
} = require("../constants/users");
1317
const { DOCUMENT_WRITE_SIZE } = require("../constants/constants");
1418
const { userState } = require("../constants/userStatus");
1519
const { BATCH_SIZE_IN_CLAUSE } = require("../constants/firebase");
@@ -27,6 +31,52 @@ const { formatUsername } = require("../utils/username");
2731
const { logType } = require("../constants/logs");
2832
const { addLog } = require("../services/logService");
2933

34+
/**
35+
* Archive users by setting the roles.archived field to true.
36+
* This function commits the write in batches to avoid reaching the maximum number of writes per batch.
37+
* @param {Array} usersData - An array of user objects with the following properties: id, first_name, last_name
38+
* @returns {Promise} - A promise that resolves with a summary object containing the number of users updated and failed, and an array of updated and failed user details.
39+
*/
40+
const archiveUsers = async (usersData) => {
41+
const batch = firestore.batch();
42+
const usersBatch = [];
43+
const summary = {
44+
totalUsersArchived: 0,
45+
totalOperationsFailed: 0,
46+
updatedUserDetails: [],
47+
failedUserDetails: [],
48+
};
49+
50+
usersData.forEach((user) => {
51+
const { id, first_name: firstName, last_name: lastName } = user;
52+
const updatedUserData = {
53+
...user,
54+
roles: {
55+
...user.roles,
56+
archived: true,
57+
},
58+
updated_at: Date.now(),
59+
};
60+
batch.update(userModel.doc(id), updatedUserData);
61+
usersBatch.push({ id, firstName, lastName });
62+
});
63+
64+
try {
65+
await batch.commit();
66+
summary.totalUsersArchived += usersData.length;
67+
summary.updatedUserDetails = [...usersBatch];
68+
return {
69+
message: USERS_PATCH_HANDLER_SUCCESS_MESSAGES.ARCHIVE_USERS.SUCCESSFULLY_COMPLETED_BATCH_UPDATES,
70+
...summary,
71+
};
72+
} catch (err) {
73+
logger.error("Firebase batch Operation Failed!");
74+
summary.totalOperationsFailed += usersData.length;
75+
summary.failedUserDetails = [...usersBatch];
76+
return { message: USERS_PATCH_HANDLER_ERROR_MESSAGES.ARCHIVE_USERS.BATCH_DATA_UPDATED_FAILED, ...summary };
77+
}
78+
};
79+
3080
/**
3181
* Adds or updates the user data
3282
*
@@ -218,11 +268,11 @@ const getSuggestedUsers = async (skill) => {
218268
*/
219269
const fetchPaginatedUsers = async (query) => {
220270
const isDevMode = query.dev === "true";
221-
222271
try {
223272
const size = parseInt(query.size) || 100;
224273
const doc = (query.next || query.prev) && (await userModel.doc(query.next || query.prev).get());
225274

275+
const isArchived = query.departed === "true";
226276
let dbQuery;
227277
/**
228278
* !!NOTE : At the time of writing we only support member in the role query
@@ -231,9 +281,9 @@ const fetchPaginatedUsers = async (query) => {
231281
* if you're making changes to this code remove the archived check in the role query, example: role=archived,member
232282
*/
233283
if (query.roles === "member") {
234-
dbQuery = userModel.where("roles.archived", "==", false).where("roles.member", "==", true);
284+
dbQuery = userModel.where("roles.archived", "==", isArchived).where("roles.member", "==", true);
235285
} else {
236-
dbQuery = userModel.where("roles.archived", "==", false).orderBy("username");
286+
dbQuery = userModel.where("roles.archived", "==", isArchived).orderBy("username");
237287
}
238288

239289
let compositeQuery = [dbQuery];
@@ -253,6 +303,10 @@ const fetchPaginatedUsers = async (query) => {
253303
}
254304

255305
if (Object.keys(query).length) {
306+
if (query.departed) {
307+
compositeQuery = compositeQuery.map((query) => query.where("roles.in_discord", "==", false));
308+
dbQuery = dbQuery.where("roles.in_discord", "==", false);
309+
}
256310
if (query.search) {
257311
const searchValue = query.search.toLowerCase().trim();
258312
dbQuery = dbQuery.startAt(searchValue).endAt(searchValue + "\uf8ff");
@@ -1092,6 +1146,7 @@ const fetchUsersNotInDiscordServer = async () => {
10921146
};
10931147

10941148
module.exports = {
1149+
archiveUsers,
10951150
addOrUpdate,
10961151
fetchPaginatedUsers,
10971152
fetchUser,

services/users.js

Lines changed: 20 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,28 @@
1-
const { USERS_PATCH_HANDLER_SUCCESS_MESSAGES, USERS_PATCH_HANDLER_ERROR_MESSAGES } = require("../constants/users");
21
const firestore = require("../utils/firestore");
32
const { formatUsername } = require("../utils/username");
43
const userModel = firestore.collection("users");
5-
const archiveUsers = async (usersData) => {
6-
const batch = firestore.batch();
7-
const usersBatch = [];
8-
const summary = {
9-
totalUsersArchived: 0,
10-
totalOperationsFailed: 0,
11-
updatedUserDetails: [],
12-
failedUserDetails: [],
13-
};
4+
const tasksModel = require("../models/tasks");
145

15-
usersData.forEach((user) => {
16-
const { id, first_name: firstName, last_name: lastName } = user;
17-
const updatedUserData = {
18-
...user,
19-
roles: {
20-
...user.roles,
21-
archived: true,
22-
},
23-
updated_at: Date.now(),
24-
};
25-
batch.update(userModel.doc(id), updatedUserData);
26-
usersBatch.push({ id, firstName, lastName });
27-
});
6+
const getUsersWithIncompleteTasks = async (users) => {
7+
if (users.length === 0) return [];
288

299
try {
30-
await batch.commit();
31-
summary.totalUsersArchived += usersData.length;
32-
summary.updatedUserDetails = [...usersBatch];
33-
return {
34-
message: USERS_PATCH_HANDLER_SUCCESS_MESSAGES.ARCHIVE_USERS.SUCCESSFULLY_COMPLETED_BATCH_UPDATES,
35-
...summary,
36-
};
37-
} catch (err) {
38-
logger.error("Firebase batch Operation Failed!");
39-
summary.totalOperationsFailed += usersData.length;
40-
summary.failedUserDetails = [...usersBatch];
41-
return { message: USERS_PATCH_HANDLER_ERROR_MESSAGES.ARCHIVE_USERS.BATCH_DATA_UPDATED_FAILED, ...summary };
10+
const userIds = users.map((user) => user.id);
11+
12+
const abandonedTasksQuerySnapshot = await tasksModel.fetchIncompleteTasksByUserIds(userIds);
13+
14+
if (abandonedTasksQuerySnapshot.empty) {
15+
return [];
16+
}
17+
18+
const userIdsWithIncompleteTasks = new Set(abandonedTasksQuerySnapshot.map((doc) => doc.data().assigneeId));
19+
20+
const eligibleUsersWithTasks = users.filter((user) => userIdsWithIncompleteTasks.has(user.id));
21+
22+
return eligibleUsersWithTasks;
23+
} catch (error) {
24+
logger.error(`Error in getting users who abandoned tasks: ${error}`);
25+
throw error;
4226
}
4327
};
4428

@@ -63,6 +47,6 @@ const generateUniqueUsername = async (firstName, lastName) => {
6347
};
6448

6549
module.exports = {
66-
archiveUsers,
6750
generateUniqueUsername,
51+
getUsersWithIncompleteTasks,
6852
};

test/integration/users.test.js

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ const { userPhotoVerificationData } = require("../fixtures/user/photo-verificati
4141
const Sinon = require("sinon");
4242
const { INTERNAL_SERVER_ERROR, SOMETHING_WENT_WRONG } = require("../../constants/errorMessages");
4343
const photoVerificationModel = firestore.collection("photo-verification");
44-
44+
const userModel = firestore.collection("users");
45+
const taskModel = firestore.collection("tasks");
46+
const {
47+
usersData: abandonedUsersData,
48+
tasksData: abandonedTasksData,
49+
} = require("../fixtures/abandoned-tasks/departed-users");
50+
const userService = require("../../services/users");
4551
chai.use(chaiHttp);
4652

4753
describe("Users", function () {
@@ -1441,6 +1447,56 @@ describe("Users", function () {
14411447
});
14421448
});
14431449

1450+
describe("GET /users?departed", function () {
1451+
beforeEach(async function () {
1452+
await cleanDb();
1453+
const userPromises = abandonedUsersData.map((user) => userModel.doc(user.id).set(user));
1454+
await Promise.all(userPromises);
1455+
1456+
const taskPromises = abandonedTasksData.map((task) => taskModel.add(task));
1457+
await Promise.all(taskPromises);
1458+
});
1459+
1460+
afterEach(async function () {
1461+
Sinon.restore();
1462+
await cleanDb();
1463+
});
1464+
1465+
it("should return a list of users with abandoned tasks", async function () {
1466+
const res = await chai.request(app).get("/users?dev=true&departed=true");
1467+
expect(res).to.have.status(200);
1468+
expect(res.body).to.have.property("message").that.equals("Users with abandoned tasks fetched successfully");
1469+
expect(res.body).to.have.property("users").to.be.an("array").with.lengthOf(2);
1470+
});
1471+
1472+
it("should return an empty array when no users have abandoned tasks", async function () {
1473+
await cleanDb();
1474+
const user = abandonedUsersData[2];
1475+
await userModel.add(user);
1476+
1477+
const task = abandonedTasksData[3];
1478+
await taskModel.add(task);
1479+
const res = await chai.request(app).get("/users?dev=true&departed=true");
1480+
1481+
expect(res).to.have.status(204);
1482+
});
1483+
1484+
it("should fail if dev flag is not passed", async function () {
1485+
const res = await chai.request(app).get("/users?departed=true");
1486+
expect(res).to.have.status(404);
1487+
expect(res.body.message).to.be.equal("Route not found");
1488+
});
1489+
1490+
it("should handle errors gracefully if getUsersWithIncompleteTasks fails", async function () {
1491+
Sinon.stub(userService, "getUsersWithIncompleteTasks").rejects(new Error(INTERNAL_SERVER_ERROR));
1492+
1493+
const res = await chai.request(app).get("/users?departed=true&dev=true");
1494+
1495+
expect(res).to.have.status(500);
1496+
expect(res.body.message).to.be.equal(INTERNAL_SERVER_ERROR);
1497+
});
1498+
});
1499+
14441500
describe("PUT /users/self/intro", function () {
14451501
let userStatusData;
14461502

test/unit/models/tasks.test.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,6 @@ describe("tasks", function () {
378378

379379
it("should return an empty array if there are no tasks incomplete for the user", async function () {
380380
await cleanDb();
381-
382381
const activeUser = abandonedUsersData[2];
383382
const incompleteTasks = await tasks.fetchIncompleteTasksByUserIds([activeUser.id]);
384383
expect(incompleteTasks.length).to.be.equal(0);

test/unit/models/users.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,50 @@ describe("users", function () {
529529
});
530530
});
531531

532+
describe("fetchPaginatedUsers - Departed Users", function () {
533+
beforeEach(async function () {
534+
await cleanDb();
535+
536+
const userPromises = abandonedUsersData.map((user) => userModel.add(user));
537+
await Promise.all(userPromises);
538+
});
539+
540+
afterEach(async function () {
541+
await cleanDb();
542+
sinon.restore();
543+
});
544+
545+
it("should fetch users not in discord server", async function () {
546+
const result = await users.fetchPaginatedUsers({ departed: "true" });
547+
expect(result.allUsers.length).to.be.equal(2);
548+
});
549+
550+
it("should return no users if departed flag is false", async function () {
551+
const result = await users.fetchPaginatedUsers({ departed: "false" });
552+
expect(result.allUsers.length).to.be.equal(0);
553+
});
554+
555+
it("should return an empty array if there are no departed users in the database", async function () {
556+
await cleanDb();
557+
const activeUser = abandonedUsersData[2];
558+
await userModel.add(activeUser);
559+
560+
const result = await users.fetchPaginatedUsers({ departed: "true" });
561+
expect(result.allUsers.length).to.be.equal(0);
562+
});
563+
564+
it("should handle errors gracefully if the database query fails", async function () {
565+
sinon.stub(users, "fetchPaginatedUsers").throws(new Error("Database query failed"));
566+
567+
try {
568+
await users.fetchPaginatedUsers();
569+
expect.fail("Expected function to throw an error");
570+
} catch (error) {
571+
expect(error.message).to.equal("Database query failed");
572+
}
573+
});
574+
});
575+
532576
describe("fetchUsersNotInDiscordServer", function () {
533577
beforeEach(async function () {
534578
await cleanDb();

0 commit comments

Comments
 (0)