Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 38 additions & 19 deletions models/discordactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ const { findSubscribedGroupIds } = require("../utils/helper");
const { retrieveUsers } = require("../services/dataAccessLayer");
const { BATCH_SIZE_IN_CLAUSE } = require("../constants/firebase");
const { getAllUserStatus, getGroupRole, getUserStatus } = require("./userStatus");
const { normalizeTimestamp, checkIfUserHasLiveTasks } = require("../utils/userStatus");
const {
getApprovedOooPeriods,
normalizeTimestamp,
checkIfUserHasLiveTasks,
computeIdleDaysExcludingOOO,
} = require("../utils/userStatus");
const { userState, POST_OOO_GRACE_PERIOD_IN_DAYS } = require("../constants/userStatus");
const config = require("config");
const logger = require("../utils/logger");
Expand Down Expand Up @@ -658,8 +663,12 @@ const updateIdle7dUsersOnDiscord = async (dev) => {

try {
groupIdle7dRole = await getGroupRole("group-idle-7d+");
if (!groupIdle7dRole?.roleExists || !groupIdle7dRole?.role?.roleid) {
throw new Error(
"Idle 7d+ role does not exist or has no roleid. Ensure discord-roles has a document with rolename 'group-idle-7d+'."
);
}
groupIdle7dRoleId = groupIdle7dRole.role.roleid;
if (!groupIdle7dRole.roleExists) throw new Error("Idle Role does not exist");

const { allUserStatus } = await getAllUserStatus({ state: userState.IDLE });
const discordUsers = await getDiscordMembers();
Expand All @@ -679,27 +688,37 @@ const updateIdle7dUsersOnDiscord = async (dev) => {
}
});

const currentTime = Date.now();
if (allUserStatus) {
await Promise.all(
allUserStatus.map(async (userStatus) => {
const currentDate = new Date();
const lastDate = new Date(userStatus.currentStatus.from);
const ONE_DAY = 1000 * 60 * 60 * 24;
const timeDifference = currentDate.setUTCHours(0, 0, 0, 0) - lastDate.setUTCHours(0, 0, 0, 0);
const daysDifference = Math.floor(timeDifference / ONE_DAY);
try {
if (daysDifference > 7) {
const userData = await userModel.doc(userStatus.userId).get();
const isUserArchived = userData.data().roles.archived;
if (userData.exists) {
if (isUserArchived) {
totalArchivedUsers++;
} else if (dev === "true" && !allMavens.includes(userData.data().discordId)) {
const shouldAdd = await shouldAddIdleUser(userStatus, tasksModel);
if (shouldAdd) {
userStatus.userid = userData.data().discordId;
allIdle7dUsers.push(userStatus);
}
if (!userStatus?.userId) {
logger.warn("updateIdle7dUsersOnDiscord: skipping user status with missing userId");
return;
}
const windowStart = normalizeTimestamp(userStatus.idleFrom) ?? currentTime;
const oooPeriods = await getApprovedOooPeriods(userStatus.userId, windowStart, currentTime);
const idleDays = computeIdleDaysExcludingOOO(
userStatus.idleFrom,
userStatus.currentStatus?.from,
currentTime,
oooPeriods
);
if (idleDays < 7) {
return;
}
const userData = await userModel.doc(userStatus.userId).get();
const userPayload = userData?.data?.();
const isUserArchived = userPayload?.roles?.archived;
if (userData?.exists) {
if (isUserArchived) {
totalArchivedUsers++;
} else if (dev === "true" && !allMavens.includes(userPayload?.discordId)) {
const shouldAdd = await shouldAddIdleUser(userStatus, tasksModel);
if (shouldAdd) {
userStatus.userid = userPayload?.discordId;
allIdle7dUsers.push(userStatus);
}
}
}
Expand Down
17 changes: 10 additions & 7 deletions models/userStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,13 @@ const getAllUserStatus = async (query) => {
.get();
}
data.forEach((doc) => {
const docData = doc.data();
const currentUserStatus = {
id: doc.id,
userId: doc.data().userId,
currentStatus: doc.data().currentStatus,
monthlyHours: doc.data().monthlyHours,
userId: docData.userId,
currentStatus: docData.currentStatus,
monthlyHours: docData.monthlyHours,
idleFrom: docData.idleFrom ?? null,
};
allUserStatus.push(currentUserStatus);
});
Expand Down Expand Up @@ -270,7 +272,8 @@ const updateUserStatus = async (userId, updatedStatusData) => {
}
}
}
const { id } = await userStatusModel.add({ userId, lastOooUntil: null, ...newStatusData });
const initialData = { userId, lastOooUntil: null, ...newStatusData };
const { id } = await userStatusModel.add(initialData);
return { id, userStatusExists: false, data: newStatusData };
}
} catch (error) {
Expand Down Expand Up @@ -541,7 +544,6 @@ const batchUpdateUsersStatus = async (users) => {
const currentStatusData = data?.currentStatus || {};
const currentState = currentStatusData.state;
const currentUntil = currentStatusData.until;
const nextState = state;
if (currentState === state) {
currentState === userState.ACTIVE ? summary.activeUsersUnaltered++ : summary.idleUsersUnaltered++;
continue;
Expand Down Expand Up @@ -571,7 +573,7 @@ const batchUpdateUsersStatus = async (users) => {
const lastOooUntilUpdate = resolveLastOooUntil({
previousState: currentState,
previousUntil: currentUntil,
nextState,
nextState: state,
fallbackTimestamp: currentTimeStamp,
});
batch.update(docRef, {
Expand All @@ -597,7 +599,7 @@ const batchUpdateUsersStatus = async (users) => {
const lastOooUntilUpdate = resolveLastOooUntil({
previousState: currentState,
previousUntil: currentUntil,
nextState,
nextState: state,
fallbackTimestamp: currentTimeStamp,
});
if (lastOooUntilUpdate !== undefined) {
Expand Down Expand Up @@ -718,6 +720,7 @@ const cancelOooStatus = async (userId) => {
newStatusData.futureStatus = {};
}
await userStatusModel.doc(docId).update(newStatusData);

if (!isActive) {
await addGroupIdleRoleToDiscordUser(userId);
}
Expand Down
89 changes: 87 additions & 2 deletions test/integration/taskBasedStatusUpdate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ describe("Task Based Status Updates", function () {
.send(reqBody);
expect(res.status).to.equal(204);
const userStatus002Data = (await userStatusModel.doc("userStatusDoc001").get()).data();
expect(userStatus002Data).to.have.keys(["userId", "currentStatus"]);
expect(userStatus002Data).to.include.keys("userId", "currentStatus");
expect(userStatus002Data.currentStatus.state).to.equal(userState.IDLE);
});

Expand All @@ -582,8 +582,93 @@ describe("Task Based Status Updates", function () {
.send(reqBody);
expect(res.status).to.equal(204);
const userStatus002Data = (await userStatusModel.doc("userStatusDoc001").get()).data();
expect(userStatus002Data).to.have.keys(["userId", "currentStatus"]);
expect(userStatus002Data).to.include.keys("userId", "currentStatus");
expect(userStatus002Data.currentStatus.state).to.equal(userState.ACTIVE);
});
});

describe("idleFrom field lifecycle", function () {
let userId;
let superUserId;
let userJwt;
let taskArr;

beforeEach(async function () {
userId = await addUser(userData[6]);
superUserId = await addUser(userData[4]);
userJwt = authService.generateAuthToken({ userId });
taskArr = allTasks();
const sampleTask1 = taskArr[0];
sampleTask1.assignee = userId;
sampleTask1.createdBy = superUserId;
await firestore.collection("tasks").doc("taskid-idle-window-1").set(sampleTask1);
});

afterEach(async function () {
await cleanDb();
});

it("should set idleFrom when user transitions ACTIVE → IDLE (task completed)", async function () {
const activeStatusData = generateStatusDataForState(userId, userState.ACTIVE);
await firestore.collection("usersStatus").doc("userStatusIdleWindow").set(activeStatusData);

const beforeMs = Date.now();
const res = await chai
.request(app)
.patch(`/tasks/self/taskid-idle-window-1`)
.set("cookie", `${cookieName}=${userJwt}`)
.send({ status: "COMPLETED", percentCompleted: 100 });

expect(res.body.userStatus.data.currentStatus).to.equal(userState.IDLE);

const doc = await firestore.collection("usersStatus").doc("userStatusIdleWindow").get();
const idleFrom = doc.data().idleFrom;
expect(idleFrom).to.be.a("number");
expect(idleFrom).to.be.at.least(beforeMs);
});

it("should clear idleFrom when user transitions IDLE → ACTIVE (new task assigned)", async function () {
const idleStatusData = {
...generateStatusDataForState(userId, userState.IDLE),
idleFrom: Date.now() - 5 * 24 * 60 * 60 * 1000, // 5 days ago
};
await firestore.collection("usersStatus").doc("userStatusIdleWindow").set(idleStatusData);

const sampleTask2 = taskArr[1];
sampleTask2.assignee = userId;
sampleTask2.createdBy = superUserId;
await firestore.collection("tasks").doc("taskid-idle-window-2").set(sampleTask2);

const superUserJwt = authService.generateAuthToken({ userId: superUserId });
await chai
.request(app)
.patch(`/tasks/taskid-idle-window-1`)
.set("cookie", `${cookieName}=${superUserJwt}`)
.send({ assignee: userData[6].username });

const doc = await firestore.collection("usersStatus").doc("userStatusIdleWindow").get();
expect(doc.data().currentStatus.state).to.equal(userState.ACTIVE);
expect(doc.data().idleFrom).to.equal(null);
});

it("should NOT update idleFrom when user is already IDLE (no duplicate reset)", async function () {
const existingIdleWindowTs = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago
const alreadyIdleData = {
...generateStatusDataForState(userId, userState.IDLE),
idleFrom: existingIdleWindowTs,
};
await firestore.collection("usersStatus").doc("userStatusIdleWindow").set(alreadyIdleData);

const res = await chai
.request(app)
.patch(`/tasks/self/taskid-idle-window-1`)
.set("cookie", `${cookieName}=${userJwt}`)
.send({ status: "COMPLETED", percentCompleted: 100 });

expect(res.body.userStatus.message).to.equal("The status is already IDLE");

const doc = await firestore.collection("usersStatus").doc("userStatusIdleWindow").get();
expect(doc.data().idleFrom).to.equal(existingIdleWindowTs);
});
});
});
84 changes: 83 additions & 1 deletion test/unit/utils/userStatus.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
const chai = require("chai");
const { expect } = chai;
const { generateNewStatus, checkIfUserHasLiveTasks, convertTimestampsToUTC } = require("../../../utils/userStatus");
const {
generateNewStatus,
checkIfUserHasLiveTasks,
convertTimestampsToUTC,
computeIdleDaysExcludingOOO,
} = require("../../../utils/userStatus");
const { userState } = require("../../../constants/userStatus");
const {
OutputFixtureForFnConvertTimestampsToUTC,
Expand Down Expand Up @@ -102,4 +107,81 @@ describe("User Status Functions", function () {
expect(result).to.deep.equal(OutputFixtureForFnConvertTimestampsToUTC);
});
});

describe("computeIdleDaysExcludingOOO", function () {
const ONE_DAY_MS = 1000 * 60 * 60 * 24;

it("should return total idle days when no OOO period", function () {
const windowStart = Date.now() - 10 * ONE_DAY_MS;
const now = Date.now();
const days = computeIdleDaysExcludingOOO(windowStart, null, now);
expect(days).to.equal(10);
});

it("should exclude OOO periods from idle days", function () {
const now = Date.now();
const windowStart = now - 15 * ONE_DAY_MS;
const oooPeriods = [{ from: now - 10 * ONE_DAY_MS, until: now - 5 * ONE_DAY_MS }];
const days = computeIdleDaysExcludingOOO(windowStart, null, now, oooPeriods);
expect(days).to.equal(10);
});

it("should fall back to currentStatusFrom when idleFrom is missing", function () {
const currentStatusFrom = Date.now() - 8 * ONE_DAY_MS;
const now = Date.now();
const days = computeIdleDaysExcludingOOO(null, currentStatusFrom, now);
expect(days).to.equal(8);
});

it("should return 0 when window has no span", function () {
const now = Date.now();
const days = computeIdleDaysExcludingOOO(now, null, now);
expect(days).to.equal(0);
});

it("should return 0 when window start is in the future (edge case)", function () {
const now = Date.now();
const futureStart = now + 5 * ONE_DAY_MS;
const days = computeIdleDaysExcludingOOO(futureStart, null, now);
expect(days).to.equal(0);
});

it("should subtract multiple OOO periods", function () {
const now = Date.now();
const windowStart = now - 20 * ONE_DAY_MS;
const oooPeriods = [
{ from: now - 18 * ONE_DAY_MS, until: now - 16 * ONE_DAY_MS }, // 2 days
{ from: now - 10 * ONE_DAY_MS, until: now - 7 * ONE_DAY_MS }, // 3 days
];
const days = computeIdleDaysExcludingOOO(windowStart, null, now, oooPeriods);
expect(days).to.equal(15);
});

it("should handle overlapping OOO periods without double subtracting", function () {
const now = Date.now();
const windowStart = now - 20 * ONE_DAY_MS;
const oooPeriods = [
{ from: now - 12 * ONE_DAY_MS, until: now - 8 * ONE_DAY_MS },
{ from: now - 10 * ONE_DAY_MS, until: now - 6 * ONE_DAY_MS },
];
const days = computeIdleDaysExcludingOOO(windowStart, null, now, oooPeriods);
expect(days).to.equal(12);
});

it("should handle OOO period partially outside window", function () {
const now = Date.now();
const windowStart = now - 10 * ONE_DAY_MS;
const oooPeriods = [{ from: now - 15 * ONE_DAY_MS, until: now - 7 * ONE_DAY_MS }];
const days = computeIdleDaysExcludingOOO(windowStart, null, now, oooPeriods);
expect(days).to.equal(7);
});

it("should return full idle days when OOO period is outside window", function () {
const now = Date.now();
const windowStart = now - 10 * ONE_DAY_MS;
const oooPeriods = [{ from: now - 20 * ONE_DAY_MS, until: now - 15 * ONE_DAY_MS }];
const days = computeIdleDaysExcludingOOO(windowStart, null, now, oooPeriods);
expect(days).to.equal(10);
});
});
});
Loading
Loading