diff --git a/models/discordactions.js b/models/discordactions.js index 4a6085c70..721f823dd 100644 --- a/models/discordactions.js +++ b/models/discordactions.js @@ -8,7 +8,7 @@ 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 { 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"); @@ -680,26 +680,32 @@ const updateIdle7dUsersOnDiscord = async (dev) => { }); if (allUserStatus) { + const nowMs = Date.now(); 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); - } + const fullStatusDoc = await userStatusModel.doc(userStatus.id).get(); + const fullData = fullStatusDoc.exists ? fullStatusDoc.data() : {}; + const idleDays = computeIdleDaysExcludingOOO( + fullData.idleWindowStartedAt, + fullData.lastOooFrom, + fullData.lastOooUntil, + userStatus.currentStatus?.from, + nowMs + ); + if (idleDays <= 7) { + return; + } + 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); } } } diff --git a/models/userStatus.js b/models/userStatus.js index 85c8112b9..6070e0134 100644 --- a/models/userStatus.js +++ b/models/userStatus.js @@ -249,6 +249,12 @@ const updateUserStatus = async (userId, updatedStatusData) => { }); if (lastOooUntilUpdate !== undefined) { newStatusData.lastOooUntil = lastOooUntilUpdate; + if (previousState === userState.OOO && previousCurrentStatus.from != null) { + newStatusData.lastOooFrom = previousCurrentStatus.from; + } + } + if (requestedNextState === userState.IDLE && previousState === userState.ACTIVE) { + newStatusData.idleWindowStartedAt = newStatusData.currentStatus?.from ?? newStatusData.currentStatus?.updatedAt; } if ( userStatusData.currentStatus?.state === userState.IDLE && @@ -270,7 +276,11 @@ const updateUserStatus = async (userId, updatedStatusData) => { } } } - const { id } = await userStatusModel.add({ userId, lastOooUntil: null, ...newStatusData }); + const initialData = { userId, lastOooUntil: null, ...newStatusData }; + if (newStatusData.currentStatus?.state === userState.IDLE) { + initialData.idleWindowStartedAt = newStatusData.currentStatus.from ?? newStatusData.currentStatus.updatedAt; + } + const { id } = await userStatusModel.add(initialData); return { id, userStatusExists: false, data: newStatusData }; } } catch (error) { @@ -321,6 +331,9 @@ const updateAllUserStatus = async () => { }); if (lastOooUntilUpdate !== undefined) { newStatusData.lastOooUntil = lastOooUntilUpdate; + if (currentState === userState.OOO && currentStatus?.from != null) { + newStatusData.lastOooFrom = currentStatus.from; + } } toUpdate = !toUpdate; summary.oooUsersAltered++; @@ -345,6 +358,7 @@ const updateAllUserStatus = async () => { newStatusData.currentStatus = newCurrentStatus; newStatusData.futureStatus = newFutureStatus; newStatusData.lastOooUntil = null; + newStatusData.lastOooFrom = null; toUpdate = !toUpdate; summary.nonOooUsersAltered++; } else { @@ -574,10 +588,15 @@ const batchUpdateUsersStatus = async (users) => { nextState, fallbackTimestamp: currentTimeStamp, }); - batch.update(docRef, { + const currentStatusData = data?.currentStatus || {}; + const batchUpdateData = { currentStatus: statusToUpdate, - ...(lastOooUntilUpdate !== undefined && { lastOooUntil: lastOooUntilUpdate }), - }); + ...(lastOooUntilUpdate !== undefined && { + lastOooUntil: lastOooUntilUpdate, + ...(currentStatusData.from != null && { lastOooFrom: currentStatusData.from }), + }), + }; + batch.update(docRef, batchUpdateData); } else { const getNextDayAfterUntil = getNextDayTimeStamp(currentUntil); batch.update(docRef, { @@ -603,6 +622,9 @@ const batchUpdateUsersStatus = async (users) => { if (lastOooUntilUpdate !== undefined) { updatedStatusData.lastOooUntil = lastOooUntilUpdate; } + if (state === userState.IDLE && currentState === userState.ACTIVE) { + updatedStatusData.idleWindowStartedAt = statusToUpdate.from ?? currentTimeStamp; + } batch.update(docRef, updatedStatusData); } } @@ -713,6 +735,9 @@ const cancelOooStatus = async (userId) => { }); if (lastOooUntilUpdate !== undefined) { newStatusData.lastOooUntil = lastOooUntilUpdate; + if (docData.currentStatus?.from != null) { + newStatusData.lastOooFrom = docData.currentStatus.from; + } } if (futureStatus?.state) { newStatusData.futureStatus = {}; diff --git a/test/unit/utils/userStatus.test.js b/test/unit/utils/userStatus.test.js index 9b3457367..e7b845ad3 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,37 @@ 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, null, null, now); + expect(days).to.equal(10); + }); + + it("should exclude last OOO period from idle days", function () { + const windowStart = Date.now() - 15 * ONE_DAY_MS; + const oooFrom = Date.now() - 10 * ONE_DAY_MS; + const oooUntil = Date.now() - 5 * ONE_DAY_MS; + const now = Date.now(); + const days = computeIdleDaysExcludingOOO(windowStart, oooFrom, oooUntil, null, now); + expect(days).to.equal(10); + }); + + it("should fall back to currentStatusFrom when idleWindowStartedAt is missing", function () { + const currentStatusFrom = Date.now() - 8 * ONE_DAY_MS; + const now = Date.now(); + const days = computeIdleDaysExcludingOOO(null, null, 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, null, null, now); + expect(days).to.equal(0); + }); + }); }); diff --git a/utils/userStatus.js b/utils/userStatus.js index bc57b5bc4..a4095408f 100644 --- a/utils/userStatus.js +++ b/utils/userStatus.js @@ -60,6 +60,37 @@ const resolveLastOooUntil = ({ previousState, previousUntil, nextState, fallback return undefined; }; +const ONE_DAY_MS = 1000 * 60 * 60 * 24; + +/** + * Computes total idle days in the window [windowStart, now], excluding the last OOO period. + * Used for group-idle-7d+ so that OOO days are not counted toward the 7-day idle threshold. + * + * @param {number|string|admin.firestore.Timestamp|null|undefined} idleWindowStartedAt - When idle window started (e.g. task completed). + * @param {number|string|admin.firestore.Timestamp|null|undefined} lastOooFrom - Start of last OOO period. + * @param {number|string|admin.firestore.Timestamp|null|undefined} lastOooUntil - End of last OOO period. + * @param {number|string|admin.firestore.Timestamp|null|undefined} currentStatusFrom - Fallback window start (e.g. currentStatus.from). + * @param {number} nowMs - Reference "now" in milliseconds. + * @returns {number} Total idle days (excluding OOO) in the window. + */ +const computeIdleDaysExcludingOOO = (idleWindowStartedAt, lastOooFrom, lastOooUntil, currentStatusFrom, nowMs) => { + const windowStart = normalizeTimestamp(idleWindowStartedAt) ?? normalizeTimestamp(currentStatusFrom) ?? nowMs; + const windowEnd = nowMs; + let totalMs = Math.max(0, windowEnd - windowStart); + + const oooFrom = normalizeTimestamp(lastOooFrom); + const oooUntil = normalizeTimestamp(lastOooUntil); + if (oooFrom != null && oooUntil != null && oooFrom < oooUntil) { + const overlapStart = Math.max(windowStart, oooFrom); + const overlapEnd = Math.min(windowEnd, oooUntil); + if (overlapStart < overlapEnd) { + totalMs -= overlapEnd - overlapStart; + } + } + + return Math.floor(totalMs / ONE_DAY_MS); +}; + /* returns the User Id based on the route path * @param req {Object} : Express request object * @returns userId {Number | undefined} : the user id incase it exists @@ -441,4 +472,5 @@ module.exports = { convertTimestampsToUTC, normalizeTimestamp, resolveLastOooUntil, + computeIdleDaysExcludingOOO, };