Skip to content

Commit dab3a53

Browse files
parth0025claude
andauthored
feat(tier1): add reactions, recents, auto-archive, burndown and relation alerts (#228)
Five Tier 1 enhancements on one branch: - emoji reactions on comments: Modules/Reactions with an allowlisted emoji set, toggle endpoint, socket emits; ReactionBar component with a viewport-clamped teleported picker - recently visited: new recentVisits collection with full plumbing, auto-recorded on task open, Recent dropdown in the board toolbar with direct navigation - auto-archive rules: per-project enabled/afterDays setting, get/set endpoints, nightly cron that archives completed tasks untouched for N days - children, sprint counters, history and socket emits handled - sprint burndown: read-only series endpoint computed from existing status history and estimates; toolbar button opens a modal with sprint picker and ApexCharts line chart - relation follow-ups: watcher notifications on link and unlink, and a warning toast when completing a task still blocked by open tasks Favorites and watchers were found already shipped end-to-end and were left untouched. Toolbar fitted to a single line; search block now shrinks to content. Tests: 63 passing across 8 suites. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0413d28 commit dab3a53

31 files changed

Lines changed: 1378 additions & 9 deletions

File tree

Config/collections.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const dbCollections = {
5656
REFERCODE: "refferalcodes",
5757
REFFERALMAPPING: "refferalmapping",
5858
GLOBALSETTING: "globalSetting",
59+
RECENTVISITS: "recentVisits",
5960
}
6061

6162
/** DOCUMENT ID'S NAME WHICH IS USED IN THE "SETTINGS" COLLECTION NAME **/

Config/schemaType.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const SCHEMA_TYPE = {
5555
REFERCODE: "refferalcodes",
5656
REFFERALMAPPING: "refferalmapping",
5757
GLOBALSETTING: "globalSetting",
58+
RECENTVISITS: "recentVisits",
5859
}
5960

6061
module.exports = {

Modules/Reactions/controller.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const { SCHEMA_TYPE } = require("../../Config/schemaType");
2+
const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries");
3+
const mongoose = require("mongoose");
4+
const logger = require("../../Config/loggerConfig");
5+
const socketEmitter = require('../../event/socketEventEmitter');
6+
const { validateReactionInput } = require('./helpers/reactionRules');
7+
8+
// Emoji reactions on tasks and comments. Reactions live as an embedded
9+
// array on the target document — `reactions: [{ emoji, userId, createdAt }]`,
10+
// one entry per user+emoji — so reads need no extra query and socket
11+
// updates carry them automatically. Toggle semantics: present → pull,
12+
// absent → push.
13+
14+
/**
15+
* POST /api/v2/reactions
16+
* body: { targetType: 'task'|'comment', targetId, emoji, userData, isProjectComment? }
17+
* companyId comes from the verified header (same convention as /api/v2/tasks/bulk).
18+
*/
19+
exports.toggleReaction = async (req, res) => {
20+
try {
21+
const companyId = req.headers['companyid'] || '';
22+
const { targetType, targetId, emoji, userData, isProjectComment = false } = req.body || {};
23+
const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
24+
25+
const check = validateReactionInput({ companyId, targetType, targetId, emoji, userId });
26+
if (!check.valid) {
27+
return res.send({ status: false, statusText: check.reason });
28+
}
29+
30+
const schemaType = targetType === 'task' ? SCHEMA_TYPE.TASKS : SCHEMA_TYPE.COMMENTS;
31+
const targetObjId = new mongoose.Types.ObjectId(targetId);
32+
33+
const doc = await MongoDbCrudOpration(companyId, { type: schemaType, data: [{ _id: targetObjId }] }, 'findOne');
34+
if (!doc) {
35+
return res.send({ status: false, statusText: 'Target not found.' });
36+
}
37+
38+
const alreadyReacted = (doc.reactions || []).some((reaction) => reaction.emoji === emoji && String(reaction.userId) === userId);
39+
const updateObj = alreadyReacted
40+
? { $pull: { reactions: { emoji, userId } } }
41+
: { $push: { reactions: { emoji, userId, createdAt: new Date() } } };
42+
43+
const updated = await MongoDbCrudOpration(companyId, {
44+
type: schemaType,
45+
data: [{ _id: targetObjId }, updateObj, { returnDocument: 'after' }],
46+
}, 'findOneAndUpdate');
47+
48+
const updatedFields = { reactions: updated?.reactions || [] };
49+
if (targetType === 'task') {
50+
socketEmitter.emit('update', { type: "update", data: updated, updatedFields, module: 'task' });
51+
} else {
52+
socketEmitter.emit('update', { type: "update", data: updated, updatedFields, module: isProjectComment ? 'comments_project' : 'comments' });
53+
}
54+
55+
return res.send({
56+
status: true,
57+
statusText: alreadyReacted ? 'Reaction removed.' : 'Reaction added.',
58+
data: { reactions: updated?.reactions || [] },
59+
});
60+
} catch (error) {
61+
logger.error(`ERROR in toggle reaction: ${error.message}`);
62+
return res.send({ status: false, statusText: error.message });
63+
}
64+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Emoji reaction rules. Pure data + validation — no I/O — shared by the
2+
// controller, the frontend allowlist, and the unit tests.
3+
4+
// Fixed GitHub-style reaction set. Free-form emoji input is deliberately
5+
// rejected: an allowlist validates cheaply, renders consistently across
6+
// platforms, and keeps junk strings out of the documents.
7+
const REACTION_EMOJIS = Object.freeze(['👍', '❤️', '😄', '🎉', '😮', '😢', '🚀', '👀']);
8+
9+
const TARGET_TYPES = Object.freeze(['task', 'comment']);
10+
11+
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
12+
13+
const isObjectIdString = (id) => OBJECT_ID_PATTERN.test(String(id || ''));
14+
15+
/* Validate a toggle-reaction request. Returns { valid, reason }. */
16+
const validateReactionInput = ({ companyId, targetType, targetId, emoji, userId }) => {
17+
if (!companyId) {
18+
return { valid: false, reason: 'companyId is required.' };
19+
}
20+
if (!TARGET_TYPES.includes(targetType)) {
21+
return { valid: false, reason: `targetType must be one of: ${TARGET_TYPES.join(', ')}.` };
22+
}
23+
if (!isObjectIdString(targetId)) {
24+
return { valid: false, reason: 'A valid targetId is required.' };
25+
}
26+
if (!REACTION_EMOJIS.includes(emoji)) {
27+
return { valid: false, reason: 'Unsupported reaction emoji.' };
28+
}
29+
if (!userId) {
30+
return { valid: false, reason: 'userId is required.' };
31+
}
32+
return { valid: true, reason: '' };
33+
};
34+
35+
module.exports = {
36+
REACTION_EMOJIS,
37+
TARGET_TYPES,
38+
isObjectIdString,
39+
validateReactionInput,
40+
};

Modules/Reactions/init.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const routes = require('./routes');
2+
3+
exports.init = (app) => {
4+
routes.init(app);
5+
}

Modules/Reactions/routes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const ctrl = require('./controller');
2+
3+
exports.init = (app) => {
4+
app.post('/api/v2/reactions', ctrl.toggleReaction);
5+
}

Modules/RecentVisits/controller.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
const { SCHEMA_TYPE } = require("../../Config/schemaType");
2+
const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries");
3+
const mongoose = require("mongoose");
4+
const logger = require("../../Config/loggerConfig");
5+
6+
// Per-user "recently visited" tracking. One document per user+entity,
7+
// upserted on every visit — the list endpoint returns the newest first,
8+
// enriched with task summaries so the dropdown renders without extra calls.
9+
10+
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
11+
const LIST_LIMIT = 15;
12+
13+
/**
14+
* POST /api/v2/recent-visits
15+
* body: { entityType: 'task', entityId, userData }
16+
*/
17+
exports.recordVisit = async (req, res) => {
18+
try {
19+
const companyId = req.headers['companyid'] || '';
20+
const { entityType, entityId, userData } = req.body || {};
21+
const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
22+
23+
if (!companyId || !userId) {
24+
return res.send({ status: false, statusText: 'companyId and userId are required.' });
25+
}
26+
if (entityType !== 'task' || !OBJECT_ID_PATTERN.test(String(entityId || ''))) {
27+
return res.send({ status: false, statusText: 'A valid task entity is required.' });
28+
}
29+
30+
await MongoDbCrudOpration(companyId, {
31+
type: SCHEMA_TYPE.RECENTVISITS,
32+
data: [
33+
{ userId, entityType, entityId: new mongoose.Types.ObjectId(entityId) },
34+
{ $set: { visitedAt: new Date() } },
35+
{ upsert: true },
36+
],
37+
}, 'updateOne');
38+
39+
return res.send({ status: true, statusText: 'Visit recorded.' });
40+
} catch (error) {
41+
logger.error(`ERROR in record recent visit: ${error.message}`);
42+
return res.send({ status: false, statusText: error.message });
43+
}
44+
};
45+
46+
/**
47+
* GET /api/v2/recent-visits?uid=<userId>
48+
* Returns the user's most recent task visits with task summaries; visits
49+
* whose task was deleted in the meantime are filtered out.
50+
*/
51+
exports.listVisits = async (req, res) => {
52+
try {
53+
const companyId = req.headers['companyid'] || '';
54+
const userId = String(req.query?.uid || '');
55+
if (!companyId || !userId) {
56+
return res.send({ status: false, statusText: 'companyId and uid are required.' });
57+
}
58+
59+
const visits = await MongoDbCrudOpration(companyId, {
60+
type: SCHEMA_TYPE.RECENTVISITS,
61+
data: [{ userId, entityType: 'task' }, null, { sort: { visitedAt: -1 }, limit: LIST_LIMIT * 2 }],
62+
}, 'find');
63+
64+
if (!visits || !visits.length) {
65+
return res.send({ status: true, statusText: 'No recent visits.', data: [] });
66+
}
67+
68+
const taskIds = visits.map((visit) => visit.entityId);
69+
const tasks = await MongoDbCrudOpration(companyId, {
70+
type: SCHEMA_TYPE.TASKS,
71+
data: [
72+
{ _id: { $in: taskIds }, deletedStatusKey: { $ne: 1 } },
73+
'TaskName TaskKey status statusType ProjectID sprintId folderObjId deletedStatusKey',
74+
],
75+
}, 'find');
76+
const taskById = new Map((tasks || []).map((task) => [String(task._id), task]));
77+
78+
const data = visits
79+
.map((visit) => ({ visitedAt: visit.visitedAt, task: taskById.get(String(visit.entityId)) || null }))
80+
.filter((item) => item.task)
81+
.slice(0, LIST_LIMIT);
82+
83+
return res.send({ status: true, statusText: 'Recent visits fetched.', data });
84+
} catch (error) {
85+
logger.error(`ERROR in list recent visits: ${error.message}`);
86+
return res.send({ status: false, statusText: error.message });
87+
}
88+
};

Modules/RecentVisits/init.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const routes = require('./routes');
2+
3+
exports.init = (app) => {
4+
routes.init(app);
5+
}

Modules/RecentVisits/routes.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const ctrl = require('./controller');
2+
3+
exports.init = (app) => {
4+
app.post('/api/v2/recent-visits', ctrl.recordVisit);
5+
app.get('/api/v2/recent-visits', ctrl.listVisits);
6+
}

Modules/Sprints/burndown.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
const { SCHEMA_TYPE } = require('../../Config/schemaType');
2+
const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries');
3+
const mongoose = require('mongoose');
4+
const logger = require('../../Config/loggerConfig');
5+
6+
// Sprint burndown. Completion is derived from the data that already exists:
7+
// a task counts as done when its current status type is 'close', and its
8+
// completion date is the createdAt of its latest Task_Status history entry
9+
// (fallback: the task's updatedAt). No new write paths — read-only endpoint.
10+
11+
const MAX_DAYS = 120;
12+
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
13+
14+
const endOfDay = (date) => {
15+
const d = new Date(date);
16+
d.setHours(23, 59, 59, 999);
17+
return d;
18+
};
19+
20+
const dayKey = (date) => {
21+
const d = new Date(date);
22+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
23+
};
24+
25+
/* POST /api/v2/sprints/burndown body: { sprintId } (companyId from header) */
26+
exports.getSprintBurndown = async (req, res) => {
27+
try {
28+
const companyId = req.headers['companyid'] || '';
29+
const { sprintId } = req.body || {};
30+
if (!companyId || !OBJECT_ID_PATTERN.test(String(sprintId || ''))) {
31+
return res.send({ status: false, statusText: 'companyId and a valid sprintId are required.' });
32+
}
33+
34+
const sprintObjId = new mongoose.Types.ObjectId(sprintId);
35+
const sprint = await MongoDbCrudOpration(companyId, {
36+
type: SCHEMA_TYPE.SPRINTS,
37+
data: [{ _id: sprintObjId }],
38+
}, 'findOne');
39+
if (!sprint) {
40+
return res.send({ status: false, statusText: 'Sprint not found.' });
41+
}
42+
43+
// Active + archived tasks count toward sprint scope; deleted don't.
44+
const tasks = await MongoDbCrudOpration(companyId, {
45+
type: SCHEMA_TYPE.TASKS,
46+
data: [
47+
{ sprintId: sprintObjId, deletedStatusKey: { $in: [0, 2, undefined] }, isParentTask: true },
48+
'_id TaskKey statusType totalEstimatedTime createdAt updatedAt',
49+
],
50+
}, 'find');
51+
52+
if (!tasks || !tasks.length) {
53+
return res.send({ status: true, statusText: 'No tasks in this sprint yet.', data: { sprintName: sprint.name || '', days: [] } });
54+
}
55+
56+
// Latest status-change date per task. History stores TaskId in
57+
// whichever form the writer passed, so match both string + ObjectId.
58+
const idStrings = tasks.map((task) => String(task._id));
59+
const idObjects = tasks.map((task) => task._id);
60+
const historyRows = await MongoDbCrudOpration(companyId, {
61+
type: SCHEMA_TYPE.HISTORY,
62+
data: [
63+
{ Key: 'Task_Status', TaskId: { $in: [...idStrings, ...idObjects] } },
64+
'TaskId createdAt',
65+
],
66+
}, 'find');
67+
68+
const lastStatusChange = new Map();
69+
(historyRows || []).forEach((row) => {
70+
const key = String(row.TaskId);
71+
const current = lastStatusChange.get(key);
72+
if (!current || new Date(row.createdAt) > current) {
73+
lastStatusChange.set(key, new Date(row.createdAt));
74+
}
75+
});
76+
77+
const enriched = tasks.map((task) => ({
78+
createdAt: new Date(task.createdAt),
79+
estimate: Number(task.totalEstimatedTime) || 0,
80+
completedAt: task.statusType === 'close'
81+
? (lastStatusChange.get(String(task._id)) || new Date(task.updatedAt))
82+
: null,
83+
}));
84+
85+
// Date range: sprint start (or earliest task) → today, capped.
86+
const earliestTask = enriched.reduce((min, task) => (task.createdAt < min ? task.createdAt : min), new Date());
87+
let rangeStart = sprint.startDate ? new Date(sprint.startDate) : (sprint.createdAt ? new Date(sprint.createdAt) : earliestTask);
88+
if (earliestTask < rangeStart) rangeStart = earliestTask;
89+
const rangeEnd = new Date();
90+
const totalDays = Math.min(MAX_DAYS, Math.max(1, Math.ceil((endOfDay(rangeEnd) - rangeStart) / 86400000)));
91+
92+
const totalCount = enriched.length;
93+
const totalEstimate = enriched.reduce((sum, task) => sum + task.estimate, 0);
94+
const days = [];
95+
for (let i = 0; i < totalDays; i++) {
96+
const cursor = endOfDay(new Date(rangeStart.getTime() + i * 86400000));
97+
const scoped = enriched.filter((task) => task.createdAt <= cursor);
98+
const completed = scoped.filter((task) => task.completedAt && task.completedAt <= cursor);
99+
const completedEstimate = completed.reduce((sum, task) => sum + task.estimate, 0);
100+
const scopedEstimate = scoped.reduce((sum, task) => sum + task.estimate, 0);
101+
days.push({
102+
date: dayKey(cursor),
103+
remainingCount: scoped.length - completed.length,
104+
remainingEstimate: Math.max(0, scopedEstimate - completedEstimate),
105+
// Straight line from full scope on day one to zero on the last day.
106+
ideal: Math.max(0, Math.round((totalCount * (totalDays - 1 - i)) / Math.max(1, totalDays - 1))),
107+
});
108+
}
109+
110+
return res.send({
111+
status: true,
112+
statusText: 'Burndown computed.',
113+
data: {
114+
sprintName: sprint.name || '',
115+
totalCount,
116+
totalEstimate,
117+
days,
118+
},
119+
});
120+
} catch (error) {
121+
logger.error(`ERROR in sprint burndown: ${error.message}`);
122+
return res.send({ status: false, statusText: error.message });
123+
}
124+
};

0 commit comments

Comments
 (0)