diff --git a/Config/collections.js b/Config/collections.js index 970979b4..5dd48ade 100644 --- a/Config/collections.js +++ b/Config/collections.js @@ -56,6 +56,7 @@ const dbCollections = { REFERCODE: "refferalcodes", REFFERALMAPPING: "refferalmapping", GLOBALSETTING: "globalSetting", + RECENTVISITS: "recentVisits", } /** DOCUMENT ID'S NAME WHICH IS USED IN THE "SETTINGS" COLLECTION NAME **/ diff --git a/Config/schemaType.js b/Config/schemaType.js index ab9e14c2..48f39755 100644 --- a/Config/schemaType.js +++ b/Config/schemaType.js @@ -55,6 +55,7 @@ const SCHEMA_TYPE = { REFERCODE: "refferalcodes", REFFERALMAPPING: "refferalmapping", GLOBALSETTING: "globalSetting", + RECENTVISITS: "recentVisits", } module.exports = { diff --git a/Modules/Reactions/controller.js b/Modules/Reactions/controller.js new file mode 100644 index 00000000..9e9c6e17 --- /dev/null +++ b/Modules/Reactions/controller.js @@ -0,0 +1,64 @@ +const { SCHEMA_TYPE } = require("../../Config/schemaType"); +const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"); +const mongoose = require("mongoose"); +const logger = require("../../Config/loggerConfig"); +const socketEmitter = require('../../event/socketEventEmitter'); +const { validateReactionInput } = require('./helpers/reactionRules'); + +// Emoji reactions on tasks and comments. Reactions live as an embedded +// array on the target document — `reactions: [{ emoji, userId, createdAt }]`, +// one entry per user+emoji — so reads need no extra query and socket +// updates carry them automatically. Toggle semantics: present → pull, +// absent → push. + +/** + * POST /api/v2/reactions + * body: { targetType: 'task'|'comment', targetId, emoji, userData, isProjectComment? } + * companyId comes from the verified header (same convention as /api/v2/tasks/bulk). + */ +exports.toggleReaction = async (req, res) => { + try { + const companyId = req.headers['companyid'] || ''; + const { targetType, targetId, emoji, userData, isProjectComment = false } = req.body || {}; + const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : ''; + + const check = validateReactionInput({ companyId, targetType, targetId, emoji, userId }); + if (!check.valid) { + return res.send({ status: false, statusText: check.reason }); + } + + const schemaType = targetType === 'task' ? SCHEMA_TYPE.TASKS : SCHEMA_TYPE.COMMENTS; + const targetObjId = new mongoose.Types.ObjectId(targetId); + + const doc = await MongoDbCrudOpration(companyId, { type: schemaType, data: [{ _id: targetObjId }] }, 'findOne'); + if (!doc) { + return res.send({ status: false, statusText: 'Target not found.' }); + } + + const alreadyReacted = (doc.reactions || []).some((reaction) => reaction.emoji === emoji && String(reaction.userId) === userId); + const updateObj = alreadyReacted + ? { $pull: { reactions: { emoji, userId } } } + : { $push: { reactions: { emoji, userId, createdAt: new Date() } } }; + + const updated = await MongoDbCrudOpration(companyId, { + type: schemaType, + data: [{ _id: targetObjId }, updateObj, { returnDocument: 'after' }], + }, 'findOneAndUpdate'); + + const updatedFields = { reactions: updated?.reactions || [] }; + if (targetType === 'task') { + socketEmitter.emit('update', { type: "update", data: updated, updatedFields, module: 'task' }); + } else { + socketEmitter.emit('update', { type: "update", data: updated, updatedFields, module: isProjectComment ? 'comments_project' : 'comments' }); + } + + return res.send({ + status: true, + statusText: alreadyReacted ? 'Reaction removed.' : 'Reaction added.', + data: { reactions: updated?.reactions || [] }, + }); + } catch (error) { + logger.error(`ERROR in toggle reaction: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +}; diff --git a/Modules/Reactions/helpers/reactionRules.js b/Modules/Reactions/helpers/reactionRules.js new file mode 100644 index 00000000..214be53d --- /dev/null +++ b/Modules/Reactions/helpers/reactionRules.js @@ -0,0 +1,40 @@ +// Emoji reaction rules. Pure data + validation — no I/O — shared by the +// controller, the frontend allowlist, and the unit tests. + +// Fixed GitHub-style reaction set. Free-form emoji input is deliberately +// rejected: an allowlist validates cheaply, renders consistently across +// platforms, and keeps junk strings out of the documents. +const REACTION_EMOJIS = Object.freeze(['👍', '❤️', '😄', '🎉', '😮', '😢', '🚀', '👀']); + +const TARGET_TYPES = Object.freeze(['task', 'comment']); + +const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/; + +const isObjectIdString = (id) => OBJECT_ID_PATTERN.test(String(id || '')); + +/* Validate a toggle-reaction request. Returns { valid, reason }. */ +const validateReactionInput = ({ companyId, targetType, targetId, emoji, userId }) => { + if (!companyId) { + return { valid: false, reason: 'companyId is required.' }; + } + if (!TARGET_TYPES.includes(targetType)) { + return { valid: false, reason: `targetType must be one of: ${TARGET_TYPES.join(', ')}.` }; + } + if (!isObjectIdString(targetId)) { + return { valid: false, reason: 'A valid targetId is required.' }; + } + if (!REACTION_EMOJIS.includes(emoji)) { + return { valid: false, reason: 'Unsupported reaction emoji.' }; + } + if (!userId) { + return { valid: false, reason: 'userId is required.' }; + } + return { valid: true, reason: '' }; +}; + +module.exports = { + REACTION_EMOJIS, + TARGET_TYPES, + isObjectIdString, + validateReactionInput, +}; diff --git a/Modules/Reactions/init.js b/Modules/Reactions/init.js new file mode 100644 index 00000000..be6fc26a --- /dev/null +++ b/Modules/Reactions/init.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +exports.init = (app) => { + routes.init(app); +} diff --git a/Modules/Reactions/routes.js b/Modules/Reactions/routes.js new file mode 100644 index 00000000..55d8e1fc --- /dev/null +++ b/Modules/Reactions/routes.js @@ -0,0 +1,5 @@ +const ctrl = require('./controller'); + +exports.init = (app) => { + app.post('/api/v2/reactions', ctrl.toggleReaction); +} diff --git a/Modules/RecentVisits/controller.js b/Modules/RecentVisits/controller.js new file mode 100644 index 00000000..b1cda8ed --- /dev/null +++ b/Modules/RecentVisits/controller.js @@ -0,0 +1,88 @@ +const { SCHEMA_TYPE } = require("../../Config/schemaType"); +const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"); +const mongoose = require("mongoose"); +const logger = require("../../Config/loggerConfig"); + +// Per-user "recently visited" tracking. One document per user+entity, +// upserted on every visit — the list endpoint returns the newest first, +// enriched with task summaries so the dropdown renders without extra calls. + +const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/; +const LIST_LIMIT = 15; + +/** + * POST /api/v2/recent-visits + * body: { entityType: 'task', entityId, userData } + */ +exports.recordVisit = async (req, res) => { + try { + const companyId = req.headers['companyid'] || ''; + const { entityType, entityId, userData } = req.body || {}; + const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : ''; + + if (!companyId || !userId) { + return res.send({ status: false, statusText: 'companyId and userId are required.' }); + } + if (entityType !== 'task' || !OBJECT_ID_PATTERN.test(String(entityId || ''))) { + return res.send({ status: false, statusText: 'A valid task entity is required.' }); + } + + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.RECENTVISITS, + data: [ + { userId, entityType, entityId: new mongoose.Types.ObjectId(entityId) }, + { $set: { visitedAt: new Date() } }, + { upsert: true }, + ], + }, 'updateOne'); + + return res.send({ status: true, statusText: 'Visit recorded.' }); + } catch (error) { + logger.error(`ERROR in record recent visit: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +}; + +/** + * GET /api/v2/recent-visits?uid= + * Returns the user's most recent task visits with task summaries; visits + * whose task was deleted in the meantime are filtered out. + */ +exports.listVisits = async (req, res) => { + try { + const companyId = req.headers['companyid'] || ''; + const userId = String(req.query?.uid || ''); + if (!companyId || !userId) { + return res.send({ status: false, statusText: 'companyId and uid are required.' }); + } + + const visits = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.RECENTVISITS, + data: [{ userId, entityType: 'task' }, null, { sort: { visitedAt: -1 }, limit: LIST_LIMIT * 2 }], + }, 'find'); + + if (!visits || !visits.length) { + return res.send({ status: true, statusText: 'No recent visits.', data: [] }); + } + + const taskIds = visits.map((visit) => visit.entityId); + const tasks = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [ + { _id: { $in: taskIds }, deletedStatusKey: { $ne: 1 } }, + 'TaskName TaskKey status statusType ProjectID sprintId folderObjId deletedStatusKey', + ], + }, 'find'); + const taskById = new Map((tasks || []).map((task) => [String(task._id), task])); + + const data = visits + .map((visit) => ({ visitedAt: visit.visitedAt, task: taskById.get(String(visit.entityId)) || null })) + .filter((item) => item.task) + .slice(0, LIST_LIMIT); + + return res.send({ status: true, statusText: 'Recent visits fetched.', data }); + } catch (error) { + logger.error(`ERROR in list recent visits: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +}; diff --git a/Modules/RecentVisits/init.js b/Modules/RecentVisits/init.js new file mode 100644 index 00000000..be6fc26a --- /dev/null +++ b/Modules/RecentVisits/init.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +exports.init = (app) => { + routes.init(app); +} diff --git a/Modules/RecentVisits/routes.js b/Modules/RecentVisits/routes.js new file mode 100644 index 00000000..a3f0c4e7 --- /dev/null +++ b/Modules/RecentVisits/routes.js @@ -0,0 +1,6 @@ +const ctrl = require('./controller'); + +exports.init = (app) => { + app.post('/api/v2/recent-visits', ctrl.recordVisit); + app.get('/api/v2/recent-visits', ctrl.listVisits); +} diff --git a/Modules/Sprints/burndown.js b/Modules/Sprints/burndown.js new file mode 100644 index 00000000..e66c6ed2 --- /dev/null +++ b/Modules/Sprints/burndown.js @@ -0,0 +1,124 @@ +const { SCHEMA_TYPE } = require('../../Config/schemaType'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const mongoose = require('mongoose'); +const logger = require('../../Config/loggerConfig'); + +// Sprint burndown. Completion is derived from the data that already exists: +// a task counts as done when its current status type is 'close', and its +// completion date is the createdAt of its latest Task_Status history entry +// (fallback: the task's updatedAt). No new write paths — read-only endpoint. + +const MAX_DAYS = 120; +const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/; + +const endOfDay = (date) => { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +}; + +const dayKey = (date) => { + const d = new Date(date); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +/* POST /api/v2/sprints/burndown body: { sprintId } (companyId from header) */ +exports.getSprintBurndown = async (req, res) => { + try { + const companyId = req.headers['companyid'] || ''; + const { sprintId } = req.body || {}; + if (!companyId || !OBJECT_ID_PATTERN.test(String(sprintId || ''))) { + return res.send({ status: false, statusText: 'companyId and a valid sprintId are required.' }); + } + + const sprintObjId = new mongoose.Types.ObjectId(sprintId); + const sprint = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.SPRINTS, + data: [{ _id: sprintObjId }], + }, 'findOne'); + if (!sprint) { + return res.send({ status: false, statusText: 'Sprint not found.' }); + } + + // Active + archived tasks count toward sprint scope; deleted don't. + const tasks = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [ + { sprintId: sprintObjId, deletedStatusKey: { $in: [0, 2, undefined] }, isParentTask: true }, + '_id TaskKey statusType totalEstimatedTime createdAt updatedAt', + ], + }, 'find'); + + if (!tasks || !tasks.length) { + return res.send({ status: true, statusText: 'No tasks in this sprint yet.', data: { sprintName: sprint.name || '', days: [] } }); + } + + // Latest status-change date per task. History stores TaskId in + // whichever form the writer passed, so match both string + ObjectId. + const idStrings = tasks.map((task) => String(task._id)); + const idObjects = tasks.map((task) => task._id); + const historyRows = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.HISTORY, + data: [ + { Key: 'Task_Status', TaskId: { $in: [...idStrings, ...idObjects] } }, + 'TaskId createdAt', + ], + }, 'find'); + + const lastStatusChange = new Map(); + (historyRows || []).forEach((row) => { + const key = String(row.TaskId); + const current = lastStatusChange.get(key); + if (!current || new Date(row.createdAt) > current) { + lastStatusChange.set(key, new Date(row.createdAt)); + } + }); + + const enriched = tasks.map((task) => ({ + createdAt: new Date(task.createdAt), + estimate: Number(task.totalEstimatedTime) || 0, + completedAt: task.statusType === 'close' + ? (lastStatusChange.get(String(task._id)) || new Date(task.updatedAt)) + : null, + })); + + // Date range: sprint start (or earliest task) → today, capped. + const earliestTask = enriched.reduce((min, task) => (task.createdAt < min ? task.createdAt : min), new Date()); + let rangeStart = sprint.startDate ? new Date(sprint.startDate) : (sprint.createdAt ? new Date(sprint.createdAt) : earliestTask); + if (earliestTask < rangeStart) rangeStart = earliestTask; + const rangeEnd = new Date(); + const totalDays = Math.min(MAX_DAYS, Math.max(1, Math.ceil((endOfDay(rangeEnd) - rangeStart) / 86400000))); + + const totalCount = enriched.length; + const totalEstimate = enriched.reduce((sum, task) => sum + task.estimate, 0); + const days = []; + for (let i = 0; i < totalDays; i++) { + const cursor = endOfDay(new Date(rangeStart.getTime() + i * 86400000)); + const scoped = enriched.filter((task) => task.createdAt <= cursor); + const completed = scoped.filter((task) => task.completedAt && task.completedAt <= cursor); + const completedEstimate = completed.reduce((sum, task) => sum + task.estimate, 0); + const scopedEstimate = scoped.reduce((sum, task) => sum + task.estimate, 0); + days.push({ + date: dayKey(cursor), + remainingCount: scoped.length - completed.length, + remainingEstimate: Math.max(0, scopedEstimate - completedEstimate), + // Straight line from full scope on day one to zero on the last day. + ideal: Math.max(0, Math.round((totalCount * (totalDays - 1 - i)) / Math.max(1, totalDays - 1))), + }); + } + + return res.send({ + status: true, + statusText: 'Burndown computed.', + data: { + sprintName: sprint.name || '', + totalCount, + totalEstimate, + days, + }, + }); + } catch (error) { + logger.error(`ERROR in sprint burndown: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +}; diff --git a/Modules/Sprints/routes.js b/Modules/Sprints/routes.js index d3805210..3ffb1413 100644 --- a/Modules/Sprints/routes.js +++ b/Modules/Sprints/routes.js @@ -1,4 +1,5 @@ const ctrl = require('./controller'); +const burndown = require('./burndown'); // Whitelist of functions allowed to be called via PATCH /sprint/:id const ALLOWED_SPRINT_TYPES = ['editSprintName', 'updateSprint']; @@ -7,6 +8,9 @@ const ALLOWED_SPRINT_TYPES = ['editSprintName', 'updateSprint']; const ALLOWED_FOLDER_TYPES = ['editFolderName', 'updateFolder']; exports.init = (app) => { + // Read-only burndown series for a sprint (count + estimate based). + app.post('/api/v2/sprints/burndown', burndown.getSprintBurndown); + app.post('/api/v1/sprint', ctrl.addSprint); app.patch('/api/v1/sprint/:id', (req, res) => { if(!req?.body?.type) { diff --git a/Modules/Tasks/helpers/taskMongo/relations.js b/Modules/Tasks/helpers/taskMongo/relations.js index 8b5d1fd8..dba0ac48 100644 --- a/Modules/Tasks/helpers/taskMongo/relations.js +++ b/Modules/Tasks/helpers/taskMongo/relations.js @@ -4,6 +4,7 @@ const { MongoDbCrudOpration } = require("../../../../utils/mongo-handler/mongoQu const { default: mongoose } = require("mongoose"); const socketEmitter = require('../../../../event/socketEventEmitter'); const { HandleHistory } = require("../mongo_helper"); +const { HandleBothNotification } = require("../handleNotification"); const { INVERSE_RELATION, RELATION_LABELS, validateTaskRef, validateRelationPair, validateRelationInput } = require('./relationRules'); // Task-to-task relations (blocks / blocked_by / duplicates / duplicated_by / @@ -53,6 +54,9 @@ module.exports = { this.addRelationHistory({ companyId, task, otherKey: relatedTask.TaskKey, type, userData }); this.addRelationHistory({ companyId, task: relatedTask, otherKey: task.TaskKey, type: inverseType, userData }); + this.notifyRelationChange({ companyId, task, userData, message: `${userData?.Employee_Name || 'Someone'} linked this task — it now ${RELATION_LABELS[type]} ${relatedTask.TaskKey}.` }); + this.notifyRelationChange({ companyId, task: relatedTask, userData, message: `${userData?.Employee_Name || 'Someone'} linked this task — it now ${RELATION_LABELS[inverseType]} ${task.TaskKey}.` }); + resolve({ status: true, statusText: `${task.TaskKey} now ${RELATION_LABELS[type]} ${relatedTask.TaskKey}.`, @@ -108,6 +112,11 @@ module.exports = { this.removeRelationHistory({ companyId, task: relatedTask, otherKey: task.TaskKey, userData }); } + this.notifyRelationChange({ companyId, task, userData, message: `${userData?.Employee_Name || 'Someone'} removed the link with ${relatedTask ? relatedTask.TaskKey : 'a deleted task'}.` }); + if (relatedTask) { + this.notifyRelationChange({ companyId, task: relatedTask, userData, message: `${userData?.Employee_Name || 'Someone'} removed the link with ${task.TaskKey}.` }); + } + resolve({ status: true, statusText: 'Task link removed successfully.', @@ -213,6 +222,33 @@ module.exports = { }); }, + // In-app + email notification to the task's watchers (recipient filtering — + // watcher modes, creator — happens inside HandleBothNotification). A + // "No watchers" rejection is the normal empty case, not an error. + notifyRelationChange({ companyId, task, userData, message }) { + const notificationObject = { + message, + key: 'task_relation', + projectId: task.ProjectID, + taskId: task._id, + sprintId: task.sprintId, + }; + HandleBothNotification({ + type: 'tasks', + userData, + companyId, + projectId: task.ProjectID, + taskId: task._id, + folderId: task.folderObjId || "", + sprintId: task.sprintId, + object: notificationObject, + }).catch((error) => { + if (error?.message !== 'No watchers') { + logger.error(`ERROR in relation notification: ${error?.message || error}`); + } + }); + }, + removeRelationHistory({ companyId, task, otherKey, userData }) { const historyObj = { key: 'Task_Relation', diff --git a/Modules/projectSetting/autoArchive.js b/Modules/projectSetting/autoArchive.js new file mode 100644 index 00000000..b0138cba --- /dev/null +++ b/Modules/projectSetting/autoArchive.js @@ -0,0 +1,202 @@ +const { SCHEMA_TYPE } = require('../../Config/schemaType'); +const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries'); +const mongoose = require('mongoose'); +const logger = require('../../Config/loggerConfig'); +const socketEmitter = require('../../event/socketEventEmitter'); +const { HandleHistory } = require('../Tasks/helpers/mongo_helper'); + +// Per-project auto-archive rule: completed tasks (status type 'close') that +// haven't been touched for `afterDays` days get archived (deletedStatusKey 2, +// same lifecycle as manual archive) by a nightly cron. The rule lives on the +// project document as `autoArchive: { enabled, afterDays }`. + +const { normaliseRule, DEFAULT_AFTER_DAYS, MIN_AFTER_DAYS, MAX_AFTER_DAYS } = require('./autoArchiveRules'); + +const LOG_PREFIX = '[autoArchive]'; +const COMPANY_CONCURRENCY = 5; +// Per-project per-run cap — a backstop so a freshly-enabled rule on a huge +// backlog archives gradually instead of hammering one nightly run. +const MAX_TASKS_PER_PROJECT_RUN = 200; + +const SYSTEM_USER = { id: 'auto-archive', Employee_Name: 'Auto-archive' }; + +/* GET /api/v1/projectSetting/autoArchive/:pid */ +async function getAutoArchive(req, res) { + try { + const companyId = req.headers['companyid'] || ''; + const { pid } = req.params; + if (!companyId || !pid) { + return res.send({ status: false, statusText: 'companyId and projectId are required.' }); + } + const project = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, + data: [{ _id: new mongoose.Types.ObjectId(pid) }, 'autoArchive'], + }, 'findOne'); + if (!project) { + return res.send({ status: false, statusText: 'Project not found.' }); + } + return res.send({ status: true, statusText: 'Auto-archive rule fetched.', data: normaliseRule(project.autoArchive) }); + } catch (error) { + logger.error(`${LOG_PREFIX} get failed: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +} + +/* POST /api/v1/projectSetting/autoArchive body: { projectId, enabled, afterDays } */ +async function setAutoArchive(req, res) { + try { + const companyId = req.headers['companyid'] || ''; + const { projectId, enabled, afterDays } = req.body || {}; + if (!companyId || !projectId) { + return res.send({ status: false, statusText: 'companyId and projectId are required.' }); + } + const rule = normaliseRule({ enabled, afterDays }); + const updated = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, + data: [ + { _id: new mongoose.Types.ObjectId(projectId) }, + { $set: { autoArchive: rule } }, + { returnDocument: 'after' }, + ], + }, 'findOneAndUpdate'); + if (!updated) { + return res.send({ status: false, statusText: 'Project not found.' }); + } + return res.send({ status: true, statusText: rule.enabled ? `Completed tasks will auto-archive after ${rule.afterDays} days.` : 'Auto-archive disabled.', data: rule }); + } catch (error) { + logger.error(`${LOG_PREFIX} set failed: ${error.message}`); + return res.send({ status: false, statusText: error.message }); + } +} + +/* Archive one parent task: children first, then the task itself, then the + * sprint counters (same fields the manual archive flow maintains), history, + * and a socket emit so open boards drop the task live. */ +async function archiveOneTask(companyId, task, afterDays) { + const childCount = (task.subTasks || 0); + + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [ + { ParentTaskId: String(task._id), deletedStatusKey: 0 }, + { $set: { deletedStatusKey: 2 } }, + ], + }, 'updateMany'); + + const updated = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [ + { _id: task._id, deletedStatusKey: 0 }, + { $set: { deletedStatusKey: 2 } }, + { returnDocument: 'after' }, + ], + }, 'findOneAndUpdate'); + if (!updated) { + return false; + } + + // Sprint counters mirror the manual archive flow (structural.js): + // archived tasks move from `tasks` into `archiveTaskCount`. + if (task.sprintId) { + await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.SPRINTS, + data: [ + { _id: task.sprintId }, + { $inc: { archiveTaskCount: childCount + 1, tasks: -1 * (childCount + 1) } }, + ], + }, 'updateOne').catch((error) => { + logger.error(`${LOG_PREFIX} sprint counter update failed for task ${task._id}: ${error.message}`); + }); + } + + socketEmitter.emit('update', { type: "update", data: updated, updatedFields: { deletedStatusKey: 2 }, module: 'task' }); + + HandleHistory('task', companyId, task.ProjectID, task._id, { + key: 'Task_Archive', + message: `Auto-archive archived this task — completed and untouched for ${afterDays} days.`, + sprintId: task.sprintId, + }, SYSTEM_USER).catch((error) => { + logger.error(`${LOG_PREFIX} history failed for task ${task._id}: ${error.message}`); + }); + + return true; +} + +/* Run the rule for every opted-in project of one company. */ +async function runAutoArchiveForCompany(companyId) { + const projects = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.PROJECTS, + data: [{ 'autoArchive.enabled': true, deletedStatusKey: { $in: [0, undefined] } }, '_id ProjectName autoArchive'], + }, 'find'); + + let archivedTotal = 0; + for (const project of (projects || [])) { + const rule = normaliseRule(project.autoArchive); + if (!rule.enabled) continue; + const cutoff = new Date(Date.now() - rule.afterDays * 24 * 60 * 60 * 1000); + + const candidates = await MongoDbCrudOpration(companyId, { + type: SCHEMA_TYPE.TASKS, + data: [ + { + ProjectID: project._id, + isParentTask: true, + deletedStatusKey: 0, + statusType: 'close', + updatedAt: { $lt: cutoff }, + }, + '_id TaskKey ProjectID sprintId subTasks', + ], + }, 'find'); + + const slice = (candidates || []).slice(0, MAX_TASKS_PER_PROJECT_RUN); + for (const task of slice) { + // eslint-disable-next-line no-await-in-loop + const done = await archiveOneTask(companyId, task, rule.afterDays).catch((error) => { + logger.error(`${LOG_PREFIX} task ${task._id} failed: ${error.message}`); + return false; + }); + if (done) archivedTotal++; + } + } + if (archivedTotal > 0) { + logger.info(`${LOG_PREFIX} company ${companyId}: archived ${archivedTotal} tasks`); + } + return archivedTotal; +} + +/* Nightly entry point — iterates every company in bounded batches, one + * tenant's failure never poisons the rest (same pattern as + * ScreenshotRetention.runRetentionForAllCompanies). */ +async function runAutoArchiveForAllCompanies() { + const startedAt = Date.now(); + let companies = []; + try { + companies = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, { + type: SCHEMA_TYPE.COMPANIES, + data: [{}, '_id'], + }, 'find'); + } catch (error) { + logger.error(`${LOG_PREFIX} could not enumerate companies: ${error.message}`); + return; + } + + for (let i = 0; i < companies.length; i += COMPANY_CONCURRENCY) { + const slice = companies.slice(i, i + COMPANY_CONCURRENCY); + // eslint-disable-next-line no-await-in-loop + await Promise.allSettled(slice.map((company) => runAutoArchiveForCompany(String(company._id)))); + } + + logger.info(`${LOG_PREFIX} nightly run complete in ${Math.round((Date.now() - startedAt) / 1000)}s`); +} + +module.exports = { + normaliseRule, + getAutoArchive, + setAutoArchive, + runAutoArchiveForCompany, + runAutoArchiveForAllCompanies, + DEFAULT_AFTER_DAYS, + MIN_AFTER_DAYS, + MAX_AFTER_DAYS, +}; diff --git a/Modules/projectSetting/autoArchiveRules.js b/Modules/projectSetting/autoArchiveRules.js new file mode 100644 index 00000000..173a2729 --- /dev/null +++ b/Modules/projectSetting/autoArchiveRules.js @@ -0,0 +1,24 @@ +// Auto-archive rule normalisation. Pure — no I/O, no app imports — so the +// controller, the cron runner, and the unit tests share one source of truth. + +const DEFAULT_AFTER_DAYS = 30; +const MIN_AFTER_DAYS = 1; +const MAX_AFTER_DAYS = 365; + +/* Clamp arbitrary input into a safe rule shape. */ +function normaliseRule(input) { + const enabled = input?.enabled === true; + let afterDays = Number(input?.afterDays); + if (!Number.isFinite(afterDays)) { + afterDays = DEFAULT_AFTER_DAYS; + } + afterDays = Math.min(MAX_AFTER_DAYS, Math.max(MIN_AFTER_DAYS, Math.round(afterDays))); + return { enabled, afterDays }; +} + +module.exports = { + normaliseRule, + DEFAULT_AFTER_DAYS, + MIN_AFTER_DAYS, + MAX_AFTER_DAYS, +}; diff --git a/Modules/projectSetting/routes.js b/Modules/projectSetting/routes.js index 3835fb16..d7700d76 100644 --- a/Modules/projectSetting/routes.js +++ b/Modules/projectSetting/routes.js @@ -1,4 +1,5 @@ const ctrl = require('./controller'); +const autoArchive = require('./autoArchive'); exports.init = (app) => { /** @@ -97,4 +98,9 @@ exports.init = (app) => { */ app.post('/api/v1/projectSetting/taskStatus', ctrl.changeTaskStatus); app.post('/api/v1/projectSetting/migrateSprintsFun', ctrl.migrateSprintsFun); + + // Per-project auto-archive rule (completed tasks archive after N days — + // applied by the nightly cron in cron.js). + app.get('/api/v1/projectSetting/autoArchive/:pid', autoArchive.getAutoArchive); + app.post('/api/v1/projectSetting/autoArchive', autoArchive.setAutoArchive); } \ No newline at end of file diff --git a/cron.js b/cron.js index 04b439ed..e301ab2a 100644 --- a/cron.js +++ b/cron.js @@ -4,6 +4,7 @@ const taskIndexRef = require("./Modules/taskIndex/controller"); const { handleBucketSizeUpdateCron } = require(`./common-storage/common-${process.env.STORAGE_TYPE}.js`); const aiRef = require("./Modules/AI/controller") const screenshotRetention = require("./Modules/ScreenshotRetention/helper"); +const autoArchive = require("./Modules/projectSetting/autoArchive"); // BUG-035 / #89 — pin every cron to a known timezone so schedules don't // shift when the server's local tz changes (DST transition, container @@ -41,6 +42,18 @@ schedule.scheduleJob({ rule: '0 * * * *', tz: CRON_TZ }, async () => { taskIndexRef.createUnIndexTask(); }) +// Auto-archive — daily at 01:00 UTC (off-peak vs the midnight jobs). For +// every project with `autoArchive.enabled`, archives completed tasks +// (status type 'close') untouched for `afterDays` days. +schedule.scheduleJob({ rule: '0 1 * * *', tz: CRON_TZ }, async () => { + logger.info(`[Cron] autoArchive.runAutoArchiveForAllCompanies`); + try { + await autoArchive.runAutoArchiveForAllCompanies(); + } catch (err) { + logger.error(`[Cron] autoArchive failed: ${err && err.message ? err.message : err}`); + } +}) + // // This cron job executes daily at midnight (12 AM) and remove ai request count. schedule.scheduleJob({ rule: '0 0 * * *', tz: CRON_TZ }, async () => { aiRef.resetAiRequestCount(); diff --git a/frontend/src/components/atom/ReactionBar/ReactionBar.vue b/frontend/src/components/atom/ReactionBar/ReactionBar.vue new file mode 100644 index 00000000..17a6ec7b --- /dev/null +++ b/frontend/src/components/atom/ReactionBar/ReactionBar.vue @@ -0,0 +1,166 @@ + + + + + + + diff --git a/frontend/src/components/molecules/Burndown/BurndownModal.vue b/frontend/src/components/molecules/Burndown/BurndownModal.vue new file mode 100644 index 00000000..bef41dde --- /dev/null +++ b/frontend/src/components/molecules/Burndown/BurndownModal.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/components/molecules/RecentVisits/RecentVisitsDropdown.vue b/frontend/src/components/molecules/RecentVisits/RecentVisitsDropdown.vue new file mode 100644 index 00000000..2628b014 --- /dev/null +++ b/frontend/src/components/molecules/RecentVisits/RecentVisitsDropdown.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/components/molecules/TaskDetailTab/TaskDetailTab.vue b/frontend/src/components/molecules/TaskDetailTab/TaskDetailTab.vue index 0870ee27..973f8847 100644 --- a/frontend/src/components/molecules/TaskDetailTab/TaskDetailTab.vue +++ b/frontend/src/components/molecules/TaskDetailTab/TaskDetailTab.vue @@ -105,7 +105,7 @@ import Swal from 'sweetalert2'; import { useStore } from 'vuex'; import { useToast } from 'vue-toast-notification'; -import { computed, defineProps, inject, ref, nextTick,onMounted } from 'vue'; +import { computed, defineProps, inject, ref, nextTick,onMounted, watch } from 'vue'; // COMPONENTS import { dbCollections } from '@/utils/Collections'; @@ -187,6 +187,21 @@ const isSpinnerAi = ref(false); // inject const userId = inject('$userId'); + +// Recently-visited tracking — fire-and-forget on every task open. +function recordRecentVisit() { + if (!props.task?._id) return; + const user = getUser(userId.value); + apiRequest('post', '/api/v2/recent-visits', { + entityType: 'task', + entityId: props.task._id, + userData: { id: user.id }, + }).catch((error) => { + console.error('ERROR in record recent visit: ', error); + }); +} +onMounted(recordRecentVisit); +watch(() => props.task?._id, recordRecentVisit); const companyId = inject('$companyId'); const clientWidth = inject("$clientWidth"); const projectData = inject("selectedProject"); diff --git a/frontend/src/components/molecules/TaskFilter/style.css b/frontend/src/components/molecules/TaskFilter/style.css index 124f3d6b..5c25d29c 100644 --- a/frontend/src/components/molecules/TaskFilter/style.css +++ b/frontend/src/components/molecules/TaskFilter/style.css @@ -293,8 +293,10 @@ } .task-filtersearch { + /* Shrink to content (capped) so the action buttons fit on the same + line — a fixed 43% starved the right-side button group. */ max-width: 43%; - width: 43%; + width: auto; } .task-fitler-search .form-control{ font-size: 16px; diff --git a/frontend/src/components/organisms/Comment/Comment.vue b/frontend/src/components/organisms/Comment/Comment.vue index 6953c5d4..34e29399 100644 --- a/frontend/src/components/organisms/Comment/Comment.vue +++ b/frontend/src/components/organisms/Comment/Comment.vue @@ -125,6 +125,13 @@ ({{$t('Comments.edited')}}) + @@ -168,7 +175,7 @@