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
1 change: 1 addition & 0 deletions Config/collections.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 **/
Expand Down
1 change: 1 addition & 0 deletions Config/schemaType.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const SCHEMA_TYPE = {
REFERCODE: "refferalcodes",
REFFERALMAPPING: "refferalmapping",
GLOBALSETTING: "globalSetting",
RECENTVISITS: "recentVisits",
}

module.exports = {
Expand Down
64 changes: 64 additions & 0 deletions Modules/Reactions/controller.js
Original file line number Diff line number Diff line change
@@ -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 });
}
};
40 changes: 40 additions & 0 deletions Modules/Reactions/helpers/reactionRules.js
Original file line number Diff line number Diff line change
@@ -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,
};
5 changes: 5 additions & 0 deletions Modules/Reactions/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const routes = require('./routes');

exports.init = (app) => {
routes.init(app);
}
5 changes: 5 additions & 0 deletions Modules/Reactions/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const ctrl = require('./controller');

exports.init = (app) => {
app.post('/api/v2/reactions', ctrl.toggleReaction);
}
88 changes: 88 additions & 0 deletions Modules/RecentVisits/controller.js
Original file line number Diff line number Diff line change
@@ -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=<userId>
* 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 });
}
};
5 changes: 5 additions & 0 deletions Modules/RecentVisits/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const routes = require('./routes');

exports.init = (app) => {
routes.init(app);
}
6 changes: 6 additions & 0 deletions Modules/RecentVisits/routes.js
Original file line number Diff line number Diff line change
@@ -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);
}
124 changes: 124 additions & 0 deletions Modules/Sprints/burndown.js
Original file line number Diff line number Diff line change
@@ -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 });
}
};
4 changes: 4 additions & 0 deletions Modules/Sprints/routes.js
Original file line number Diff line number Diff line change
@@ -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'];
Expand All @@ -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) {
Expand Down
Loading
Loading