diff --git a/models/discordactions.js b/models/discordactions.js index 4a6085c70..57a9f8058 100644 --- a/models/discordactions.js +++ b/models/discordactions.js @@ -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"); @@ -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(); @@ -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); } } } diff --git a/models/userStatus.js b/models/userStatus.js index 85c8112b9..9a868791b 100644 --- a/models/userStatus.js +++ b/models/userStatus.js @@ -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); }); @@ -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) { @@ -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; @@ -571,7 +573,7 @@ const batchUpdateUsersStatus = async (users) => { const lastOooUntilUpdate = resolveLastOooUntil({ previousState: currentState, previousUntil: currentUntil, - nextState, + nextState: state, fallbackTimestamp: currentTimeStamp, }); batch.update(docRef, { @@ -597,7 +599,7 @@ const batchUpdateUsersStatus = async (users) => { const lastOooUntilUpdate = resolveLastOooUntil({ previousState: currentState, previousUntil: currentUntil, - nextState, + nextState: state, fallbackTimestamp: currentTimeStamp, }); if (lastOooUntilUpdate !== undefined) { @@ -718,6 +720,7 @@ const cancelOooStatus = async (userId) => { newStatusData.futureStatus = {}; } await userStatusModel.doc(docId).update(newStatusData); + if (!isActive) { await addGroupIdleRoleToDiscordUser(userId); } diff --git a/test/integration/taskBasedStatusUpdate.test.js b/test/integration/taskBasedStatusUpdate.test.js index 86b353617..b7ade6d0d 100644 --- a/test/integration/taskBasedStatusUpdate.test.js +++ b/test/integration/taskBasedStatusUpdate.test.js @@ -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); }); @@ -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); + }); + }); }); diff --git a/test/unit/utils/userStatus.test.js b/test/unit/utils/userStatus.test.js index 9b3457367..a9d1e6525 100644 --- a/test/unit/utils/userStatus.test.js +++ b/test/unit/utils/userStatus.test.js @@ -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, @@ -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); + }); + }); }); diff --git a/utils/userStatus.js b/utils/userStatus.js index bc57b5bc4..6fa9df3cf 100644 --- a/utils/userStatus.js +++ b/utils/userStatus.js @@ -1,6 +1,10 @@ const { NotFound } = require("http-errors"); const { userState } = require("../constants/userStatus"); +const { REQUEST_STATE } = require("../constants/requests"); const { convertTimestampToUTCStartOrEndOfDay } = require("./time"); +const firestore = require("./firestore"); +const requestsModel = firestore.collection("requests"); +const logger = require("./logger"); /** * Normalizes various timestamp representations into a millisecond number. @@ -51,13 +55,86 @@ const resolveLastOooUntil = ({ previousState, previousUntil, nextState, fallback if (nextState === userState.OOO) { return null; } + const isLeavingOoo = previousState === userState.OOO && nextState !== userState.OOO; + if (!isLeavingOoo) return undefined; + const normalized = normalizeTimestamp(previousUntil); + return normalized ?? fallbackTimestamp ?? null; +}; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +const mergeOooPeriods = (periods) => { + if (!periods.length) return []; + const sorted = [...periods].sort((a, b) => a.from - b.from); + const merged = []; + for (const period of sorted) { + if (!merged.length || period.from > merged[merged.length - 1].until) { + merged.push({ ...period }); + continue; + } + merged[merged.length - 1].until = Math.max(merged[merged.length - 1].until, period.until); + } + return merged; +}; + +/** + * @param {string} userId - The user's ID (requestedBy in requests collection) + * @param {number} windowStart - Start of the idle window in ms + * @param {number} windowEnd - End of the window (typically Date.now()) in ms + * @returns {Promise>} Merged OOO periods overlapping the window + */ +const getApprovedOooPeriods = async (userId, windowStart, windowEnd) => { + try { + const snapshot = await requestsModel + .where("requestedBy", "==", userId) + .where("type", "==", "OOO") + .where("state", "==", REQUEST_STATE.APPROVED) + .where("until", ">=", windowStart) + .get(); + + const periods = []; + snapshot.forEach((doc) => { + const data = doc.data(); + + const from = normalizeTimestamp(data.from); + const until = normalizeTimestamp(data.until); + + if (from >= until || from >= windowEnd) return; - const isLeavingOOO = previousState === userState.OOO && nextState !== undefined && nextState !== userState.OOO; - if (isLeavingOOO) { - return normalizeTimestamp(previousUntil) ?? normalizeTimestamp(fallbackTimestamp) ?? Date.now(); + periods.push({ from, until }); + }); + + return mergeOooPeriods(periods); + } catch (error) { + logger.error(`Error fetching approved OOO periods for user ${userId}: ${error.message}`); + return []; } +}; - return undefined; +/** + * Computes total idle days in [windowStart, now], excluding OOO durations. + * + * @param {number|string|admin.firestore.Timestamp|null|undefined} idleFrom + * @param {number|string|admin.firestore.Timestamp|null|undefined} currentStatusFrom + * @param {number} nowMs + * @param {Array<{from:number,until:number}>} oooPeriods - Pre-queried and merged OOO periods + * @returns {number} + */ +const computeIdleDaysExcludingOOO = (idleFrom, currentStatusFrom, nowMs, oooPeriods = []) => { + const rawWindowStart = normalizeTimestamp(idleFrom) ?? normalizeTimestamp(currentStatusFrom) ?? nowMs; + const windowStart = Math.min(rawWindowStart, nowMs); + const windowEnd = nowMs; + let totalMs = Math.max(0, windowEnd - windowStart); + + for (const period of oooPeriods) { + const overlapStart = Math.max(windowStart, period.from); + const overlapEnd = Math.min(windowEnd, period.until); + if (overlapStart < overlapEnd) { + totalMs = Math.max(0, totalMs - (overlapEnd - overlapStart)); + } + } + + return Math.floor(totalMs / ONE_DAY_MS); }; /* returns the User Id based on the route path @@ -200,6 +277,11 @@ const updateCurrentStatusToState = async (collection, latestStatusData, newState if (lastOooUntilUpdate !== undefined) { updatedStatusData.lastOooUntil = lastOooUntilUpdate; } + if (newState === userState.IDLE && previousState === userState.ACTIVE) { + updatedStatusData.idleFrom = currentTimeStamp; + } else if (newState === userState.ACTIVE) { + updatedStatusData.idleFrom = null; + } try { await collection.doc(id).update(updatedStatusData); } catch (err) { @@ -281,9 +363,8 @@ const updateFutureStatusToState = async (collection, latestStatusData, newState) const createUserStatusWithState = async (userId, collection, state) => { const currentTimeStamp = new Date().getTime(); try { - await collection.add({ + const payload = { userId, - lastOooUntil: null, currentStatus: { state, message: "", @@ -291,7 +372,11 @@ const createUserStatusWithState = async (userId, collection, state) => { until: "", updatedAt: currentTimeStamp, }, - }); + }; + if (state === userState.IDLE) { + payload.idleFrom = currentTimeStamp; + } + await collection.add(payload); } catch (err) { logger.error(`error creating the current status for user id ${userId} - ${err.message}`); throw new Error("Status Creation Failed."); @@ -441,4 +526,6 @@ module.exports = { convertTimestampsToUTC, normalizeTimestamp, resolveLastOooUntil, + getApprovedOooPeriods, + computeIdleDaysExcludingOOO, };