Skip to content

Commit c1b7d7a

Browse files
authored
Merge pull request #253 from aliansoftwareteam/feat/sprint3-implementation
feat(sprint3): story points, estimation scale, Trello rich import, @mention notifications
2 parents 1ffb9e5 + 0fdab95 commit c1b7d7a

21 files changed

Lines changed: 728 additions & 3 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "Sample Trello Board (rich)",
3+
"lists": [
4+
{ "id": "list-todo", "name": "To Do", "closed": false },
5+
{ "id": "list-hold", "name": "On Hold", "closed": false },
6+
{ "id": "list-done", "name": "Complete", "closed": false }
7+
],
8+
"members": [
9+
{ "id": "m1", "fullName": "Parth Detroja", "username": "parth", "email": "harmit.mendapara@aliansoftware.net" }
10+
],
11+
"cards": [
12+
{
13+
"id": "c1", "name": "Set up project repo", "desc": "Init repo and CI pipeline",
14+
"due": "2026-06-24T10:00:00.000Z", "idList": "list-todo", "closed": false,
15+
"idMembers": ["m1"],
16+
"labels": [{ "name": "Backend", "color": "blue" }, { "name": "P0", "color": "red" }],
17+
"attachments": [{ "name": "spec.pdf", "url": "https://trello.com/attach/spec.pdf", "bytes": 20480 }]
18+
},
19+
{
20+
"id": "c2", "name": "Build landing page", "desc": "Hero, features and pricing sections",
21+
"due": null, "idList": "list-hold", "closed": false,
22+
"idMembers": [],
23+
"labels": [{ "name": "Frontend", "color": "green" }],
24+
"attachments": []
25+
},
26+
{
27+
"id": "c3", "name": "Archived card should be skipped", "desc": "this card is closed",
28+
"due": null, "idList": "list-done", "closed": true
29+
}
30+
],
31+
"checklists": [
32+
{
33+
"id": "cl1", "idCard": "c1", "name": "Acceptance criteria",
34+
"checkItems": [
35+
{ "name": "Repo created", "state": "complete", "pos": 100 },
36+
{ "name": "CI pipeline green", "state": "incomplete", "pos": 200 }
37+
]
38+
}
39+
],
40+
"actions": [
41+
{ "type": "commentCard", "date": "2026-06-18T09:00:00.000Z", "data": { "text": "Use the org template for CI.", "card": { "id": "c1" } }, "memberCreator": { "fullName": "Parth Detroja" } },
42+
{ "type": "commentCard", "date": "2026-06-18T11:00:00.000Z", "data": { "text": "Landing copy is in the brief.", "card": { "id": "c2" } }, "memberCreator": { "fullName": "Jane Doe" } }
43+
]
44+
}

Config/permissionGuard.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const TASK_ACTION_PERMISSION = {
6161
updateTaskType: 'task.task_type',
6262
updateDescription: 'task.task_description',
6363
updateTaskTotalEstimate: 'task.task_estimated_hours',
64+
updatePoints: 'task.task_estimated_hours',
6465
};
6566

6667
/** Resolve a user's roleType within a company (cached 60s). null if not a member. */

Modules/Comments/controller.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,59 @@ const { handleTaskAttachmentsDuplicateFunctionality } = require(`../../common-st
66
const { replaceObjectKey } = require("../Auth/helper");
77
const socketEmitter = require('../../event/socketEventEmitter');
88
const { escapeRegex } = require("../../utils/escapeRegex");
9+
const { parseMentionIds } = require("./helpers/parseMentions");
10+
const { handleNotificationtFun } = require("../notification/prepare-notification-data/controllerV2");
11+
12+
/* @mention delivery: record the mention (feeds the in-app "mentions" tab, which
13+
* queries the mentions collection by mentionIds) and fire the notification
14+
* pipeline (in-app + push + email) targeted at the mentioned users — NOT the
15+
* task watchers. Best-effort: never throws, so it can't break comment save. */
16+
const notifyMentions = async (companyId, comment, mentionIds) => {
17+
const mentionerId = String(comment.userId || comment.createdBy || '');
18+
const taskId = comment.taskId && String(comment.taskId) !== 'default' ? String(comment.taskId) : '';
19+
const projectId = String(comment.projectId || '');
20+
const sprintId = comment.sprintId ? String(comment.sprintId) : '';
21+
22+
await MongoDbCrudOpration(companyId, {
23+
type: SCHEMA_TYPE.MENTIONS,
24+
data: {
25+
comment_id: String(comment._id),
26+
comment_message: comment.message || '',
27+
comment_type: taskId ? 'task' : 'project',
28+
mentionIds,
29+
userId: mentionerId,
30+
projectId,
31+
taskId,
32+
sprintId,
33+
type: 'mention',
34+
notSeen: mentionIds,
35+
},
36+
}, 'save').catch((err) => logger.error(`[mentions] record save failed: ${err.message}`));
37+
38+
try {
39+
await handleNotificationtFun({ body: {
40+
createdAt: new Date(),
41+
key: "comments_I'm_@mentioned_in",
42+
message: comment.message || '',
43+
projectId,
44+
taskId,
45+
type: 'task',
46+
userId: mentionerId,
47+
assigneeUsers: mentionIds,
48+
folderId: comment.folderId ? String(comment.folderId) : '',
49+
isSelected: false,
50+
notSeen: mentionIds,
51+
sprintId,
52+
updatedAt: new Date(),
53+
companyId,
54+
changeType: 'mention',
55+
comments_id: String(comment._id),
56+
} });
57+
} catch (err) {
58+
logger.error(`[mentions] notification dispatch failed: ${err.message}`);
59+
}
60+
};
61+
962
/**
1063
* This endpoint is used to save data in comments collection
1164
* @param {*} req
@@ -16,10 +69,14 @@ exports.save = async (req, res) => {
1669
try {
1770
const { data } = req.body
1871
const convertData = replaceObjectKey(data, ["objId"]);
72+
// @mentions: extract the [Name](userId) tokens the editor inserts so the
73+
// comment records who was mentioned (drives the mention notification).
74+
const mentionIds = parseMentionIds(convertData.message);
1975
const query = {
2076
type: SCHEMA_TYPE.COMMENTS,
2177
data: {
2278
...convertData,
79+
...(mentionIds.length ? { mentionIds } : {}),
2380
...(convertData.taskId !== 'default' ? { taskId: convertData.taskId } : {})
2481
}
2582
}
@@ -33,6 +90,10 @@ exports.save = async (req, res) => {
3390
else {
3491
socketEmitter.emit('insert', { type: "insert", data: response , updatedFields: {}, module: 'comments_project' });
3592
}
93+
if (mentionIds.length && response && response._id) {
94+
notifyMentions(req.headers['companyid'], response, mentionIds)
95+
.catch((err) => logger.error(`[mentions] notify failed: ${err.message}`));
96+
}
3697
if (response) {
3798
return res.status(200).json({ status: true, data: response || {} });
3899
} else {
@@ -73,6 +134,7 @@ exports.update = async (req, res) => {
73134
{
74135
$set: {
75136
...data,
137+
...(data.message !== undefined ? { mentionIds: parseMentionIds(data.message) } : {}),
76138
...((data.taskId && data.taskId !== 'default') ? { taskId: new mongoose.Types.ObjectId(data.taskId) } : {})
77139
}
78140
},
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Extract mentioned user ids from a comment body. The comment editor inserts a
2+
// mention as the markdown-style token "[Display Name](userId)" where userId is
3+
// the user's 24-hex id (see the frontend CommentInput `addMention`). Only 24-hex
4+
// ids count as mentions, so an ordinary link like "[docs](https://…)" is never
5+
// mistaken for one. Pure — no I/O — shared by the controller and the tests.
6+
7+
const OBJECT_ID = /^[0-9a-fA-F]{24}$/;
8+
9+
const parseMentionIds = (message) => {
10+
if (!message || typeof message !== 'string') return [];
11+
const re = /\[[^\]]*\]\(([^)]*)\)/g;
12+
const ids = new Set();
13+
let match;
14+
while ((match = re.exec(message)) !== null) {
15+
const id = (match[1] || '').trim();
16+
if (OBJECT_ID.test(id)) ids.add(id);
17+
}
18+
return Array.from(ids);
19+
};
20+
21+
module.exports = { parseMentionIds };

Modules/Importers/controller.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,90 @@ const loadImportContext = async (companyId, projectId) => {
9393
return { project, statusArray };
9494
};
9595

96+
/* Map the parser's rich card data onto each task into the shapes the task
97+
* create path persists (S3-01): resolve member emails → assignees, build
98+
* checklistArray + attachment link-references, and fold Trello labels into the
99+
* description (there is no programmatic tag-create path). Mutates `tasks`; a
100+
* no-op for CSV/Jira tasks, which carry none of these fields. */
101+
const enrichImportTasks = async (companyId, tasks) => {
102+
const emails = Array.from(new Set(
103+
tasks.flatMap((t) => (Array.isArray(t.memberEmails) ? t.memberEmails : [])).filter(Boolean),
104+
));
105+
const emailToId = {};
106+
if (emails.length) {
107+
try {
108+
const users = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, {
109+
type: SCHEMA_TYPE.USERS,
110+
data: [{ Employee_Email: { $in: emails } }, { _id: 1, Employee_Email: 1 }],
111+
}, 'find');
112+
(users || []).forEach((u) => {
113+
if (u && u.Employee_Email) emailToId[String(u.Employee_Email).toLowerCase()] = String(u._id);
114+
});
115+
} catch (error) {
116+
logger.error(`[importers] member email resolve failed: ${error.message}`);
117+
}
118+
}
119+
120+
tasks.forEach((task) => {
121+
if (Array.isArray(task.memberEmails) && task.memberEmails.length) {
122+
const ids = task.memberEmails.map((e) => emailToId[String(e).toLowerCase()]).filter(Boolean);
123+
if (ids.length) task.AssigneeUserId = Array.from(new Set([...(task.AssigneeUserId || []), ...ids]));
124+
}
125+
if (Array.isArray(task.checklists) && task.checklists.length) {
126+
task.checklistArray = task.checklists.map((cl) => ({
127+
id: new mongoose.Types.ObjectId().toString(),
128+
name: cl.name || 'Checklist',
129+
items: (Array.isArray(cl.items) ? cl.items : []).map((it) => ({ name: it.name, isChecked: !!it.isChecked })),
130+
}));
131+
}
132+
if (Array.isArray(task.attachments) && task.attachments.length) {
133+
task.attachments = task.attachments.map((att) => ({
134+
id: new mongoose.Types.ObjectId().toString(),
135+
filename: att.name || att.url,
136+
mediaURL: att.url,
137+
size: att.bytes || 0,
138+
type: 'link',
139+
}));
140+
}
141+
if (Array.isArray(task.labels) && task.labels.length) {
142+
const names = task.labels.map((l) => l && l.name).filter(Boolean);
143+
if (names.length) {
144+
task.rawDescription = `${task.rawDescription || ''}${task.rawDescription ? '\n\n' : ''}Labels: ${names.join(', ')}`.slice(0, 10000);
145+
}
146+
}
147+
});
148+
};
149+
150+
/* Create the parsed Trello comments on the freshly-created tasks. Each input
151+
* task was stamped with `createdTaskId` by createMultipleTasks. Best-effort:
152+
* a failed comment never fails the import. */
153+
const createImportComments = async (companyId, projectData, sprintId, folderId, tasks, userId) => {
154+
for (const task of tasks) {
155+
if (!task.createdTaskId || !Array.isArray(task.comments) || !task.comments.length) continue;
156+
for (const c of task.comments) {
157+
if (!c || !c.text) continue;
158+
const body = `${c.author ? c.author + ': ' : ''}${c.text}`.slice(0, 10000);
159+
try {
160+
await MongoDbCrudOpration(companyId, {
161+
type: SCHEMA_TYPE.COMMENTS,
162+
data: {
163+
message: body,
164+
userId,
165+
type: 'text',
166+
projectId: new mongoose.Types.ObjectId(projectData._id),
167+
taskId: new mongoose.Types.ObjectId(task.createdTaskId),
168+
sprintId: new mongoose.Types.ObjectId(sprintId),
169+
project: false,
170+
...(folderId ? { folderId: new mongoose.Types.ObjectId(folderId) } : {}),
171+
},
172+
}, 'save');
173+
} catch (error) {
174+
logger.error(`[importers] comment import failed for task ${task.createdTaskId}: ${error.message}`);
175+
}
176+
}
177+
}
178+
};
179+
96180
/* Record the job, feed the bulk-create pipeline, update the job. Returns the
97181
* response envelope. Identical create path to the Jira importer. */
98182
const finishImport = async (companyId, { source, project, sprintId, sprintName, folderId, folderName, userData, statusArray, tasks, skipped }) => {
@@ -124,6 +208,9 @@ const finishImport = async (companyId, { source, project, sprintId, sprintName,
124208
sprint.folderId = folderId;
125209
sprint.folderName = folderName || '';
126210
}
211+
// S3-01: fold Trello rich data (checklists, attachments, members, labels)
212+
// onto each task before creation; comments are added after (they need ids).
213+
await enrichImportTasks(companyId, tasks);
127214
const tasksWithSprint = tasks.map((task) => ({ ...task, sprintId, sprintArray: sprint }));
128215

129216
try {
@@ -135,6 +222,8 @@ const finishImport = async (companyId, { source, project, sprintId, sprintName,
135222
statusArray,
136223
sprint,
137224
});
225+
await createImportComments(companyId, projectData, sprintId, folderId, tasksWithSprint, userId)
226+
.catch((commentErr) => logger.error(`[importers] comment import error: ${commentErr.message}`));
138227
const createdCount = Array.isArray(result?.data) ? result.data.length : tasks.length;
139228
await MongoDbCrudOpration(companyId, {
140229
type: SCHEMA_TYPE.IMPORT_JOBS,

Modules/Importers/helpers/trelloRules.js

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,70 @@ const validateTrelloInput = ({ companyId, projectId, sprintId, board, userId })
2323

2424
/* Parse a Trello board export → { tasks, skipped, listNames }.
2525
* Closed lists/cards and nameless cards are skipped; each card's list name is
26-
* mapped onto an existing project status (falls back to the first status). */
26+
* mapped onto an existing project status (falls back to the first status).
27+
*
28+
* Each task also carries the rich card data the importer enriches it with after
29+
* creation — checklists, comments, labels, attachments (as link references) and
30+
* member emails. Note: Trello JSON exports usually OMIT member emails (privacy)
31+
* and host attachment URLs behind auth, so member-mapping is best-effort and
32+
* attachments are kept as links rather than downloaded. Pure — no I/O. */
2733
const parseTrelloBoard = ({ board, statusNames, leaderId }) => {
2834
const lists = Array.isArray(board.lists) ? board.lists : [];
2935
const listById = {};
3036
lists.filter((list) => list && !list.closed).forEach((list) => {
3137
listById[String(list.id)] = String(list.name || '');
3238
});
3339

40+
// card id → [{ name, items: [{ name, isChecked }] }]
41+
const checklistsByCard = {};
42+
(Array.isArray(board.checklists) ? board.checklists : []).forEach((cl) => {
43+
if (!cl || !cl.idCard) return;
44+
const items = (Array.isArray(cl.checkItems) ? cl.checkItems : [])
45+
.slice()
46+
.sort((a, b) => ((a && a.pos) || 0) - ((b && b.pos) || 0))
47+
.map((ci) => ({ name: String((ci && ci.name) || '').trim().slice(0, 500), isChecked: !!(ci && ci.state === 'complete') }))
48+
.filter((ci) => ci.name);
49+
if (!items.length && !cl.name) return;
50+
(checklistsByCard[String(cl.idCard)] = checklistsByCard[String(cl.idCard)] || [])
51+
.push({ name: String(cl.name || 'Checklist').trim().slice(0, 200), items });
52+
});
53+
54+
// card id → [{ text, author, date }] (Trello stores comments as commentCard actions)
55+
const commentsByCard = {};
56+
(Array.isArray(board.actions) ? board.actions : []).forEach((action) => {
57+
if (!action || action.type !== 'commentCard' || !action.data || !action.data.card) return;
58+
const cardId = String(action.data.card.id || '');
59+
const text = String((action.data && action.data.text) || '').trim();
60+
if (!cardId || !text) return;
61+
const author = (action.memberCreator && (action.memberCreator.fullName || action.memberCreator.username)) || '';
62+
(commentsByCard[cardId] = commentsByCard[cardId] || [])
63+
.push({ text: text.slice(0, 10000), author: String(author), date: action.date || null });
64+
});
65+
66+
// member id → email (usually absent in JSON exports → best-effort assignee mapping)
67+
const emailByMemberId = {};
68+
(Array.isArray(board.members) ? board.members : []).forEach((member) => {
69+
if (member && member.id && member.email) {
70+
emailByMemberId[String(member.id)] = String(member.email).trim().toLowerCase();
71+
}
72+
});
73+
3474
const tasks = [];
3575
let skipped = 0;
3676
(board.cards || []).forEach((card) => {
3777
if (!card || card.closed || !String(card.name || '').trim()) { skipped += 1; return; }
78+
const cardId = String(card.id || '');
3879
const listName = listById[String(card.idList)] || '';
3980
const due = card.due ? new Date(card.due) : null;
81+
const labels = (Array.isArray(card.labels) ? card.labels : [])
82+
.map((label) => ({ name: String((label && label.name) || '').trim(), color: String((label && label.color) || '').trim() }))
83+
.filter((label) => label.name || label.color);
84+
const attachments = (Array.isArray(card.attachments) ? card.attachments : [])
85+
.map((att) => ({ name: String((att && (att.name || att.url)) || '').slice(0, 300), url: String((att && att.url) || ''), bytes: (att && Number(att.bytes)) || 0 }))
86+
.filter((att) => att.url);
87+
const memberEmails = (Array.isArray(card.idMembers) ? card.idMembers : [])
88+
.map((id) => emailByMemberId[String(id)])
89+
.filter(Boolean);
4090
tasks.push({
4191
TaskName: String(card.name).trim().slice(0, 500),
4292
status: mapStatusName(listName, statusNames),
@@ -48,6 +98,12 @@ const parseTrelloBoard = ({ board, statusNames, leaderId }) => {
4898
DueDate: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
4999
rawDescription: String(card.desc || '').slice(0, 10000),
50100
ParentTaskId: '',
101+
// Rich card data, enriched onto the task after creation (S3-01).
102+
checklists: checklistsByCard[cardId] || [],
103+
comments: commentsByCard[cardId] || [],
104+
labels,
105+
attachments,
106+
memberEmails,
51107
});
52108
});
53109
return { tasks, skipped, listNames: Object.values(listById) };

Modules/Tasks/helpers/taskMongo/create.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ module.exports = {
262262
'sprintArray': sprint,
263263
'customField': task.customField || {},
264264
'descriptionBlock': task.descriptionBlock || {},
265+
'rawDescription': task.rawDescription || '',
266+
'checklistArray': task.checklistArray || [],
267+
'attachments': task.attachments || [],
265268
};
266269
if(sprint.folderId) {
267270
parentTaskObj.folderObjId = sprint.folderId;
@@ -275,6 +278,7 @@ module.exports = {
275278
setNotif: true
276279
}).then(taskResult => {
277280
idMapping[task._id] = taskResult.id;
281+
task.createdTaskId = taskResult.id;
278282
completedTasks++;
279283
updateProgress();
280284
})

0 commit comments

Comments
 (0)