Skip to content

Commit 1ffb9e5

Browse files
authored
Merge pull request #252 from aliansoftwareteam/feat/sprint2-implementation
feat(integrations): sprint 2 — webhooks UI, CSV/Trello importers, action-aware notifications
2 parents 43761e7 + 8399ce8 commit 1ffb9e5

19 files changed

Lines changed: 1703 additions & 123 deletions

File tree

Modules/Importers/controller.js

Lines changed: 151 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const mongoose = require("mongoose");
55
const logger = require("../../Config/loggerConfig");
66
const { taskMongo } = require('../Tasks/helpers/task_class_Mongo');
77
const { validateImportInput, transformJiraRows } = require('./helpers/jiraRules');
8+
const { validateCsvInput, transformCsvRows } = require('./helpers/csvRules');
9+
const { validateTrelloInput, parseTrelloBoard } = require('./helpers/trelloRules');
810

911
// Jira importer. The client parses the Jira CSV export (the xlsx lib reads
1012
// CSV) and posts plain rows; the server maps statuses/priorities and feeds
@@ -20,91 +22,16 @@ exports.importFromJira = async (req, res) => {
2022
const { rows, projectId, sprintId, sprintName, folderId, folderName, userData } = req.body || {};
2123
const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
2224
const check = validateImportInput({ companyId, projectId, sprintId, rows, userId });
23-
if (!check.valid) {
24-
return res.send({ status: false, statusText: check.reason });
25-
}
25+
if (!check.valid) return res.send({ status: false, statusText: check.reason });
2626

27-
const [project, statusDocs] = await Promise.all([
28-
MongoDbCrudOpration(companyId, {
29-
type: SCHEMA_TYPE.PROJECTS,
30-
data: [{ _id: new mongoose.Types.ObjectId(projectId) }],
31-
}, 'findOne'),
32-
MongoDbCrudOpration(companyId, {
33-
type: dbCollections.SETTINGS,
34-
data: [{ name: settingsCollectionDocs.TASK_STATUS }],
35-
}, 'find'),
36-
]);
37-
if (!project) {
38-
return res.send({ status: false, statusText: 'Project not found.' });
39-
}
40-
const statusArray = ((statusDocs && statusDocs[0] && statusDocs[0].settings) || [])
41-
.map((status) => ({ name: status.name, key: status.key, type: status.type }))
42-
.filter((status) => status.name !== undefined);
43-
if (!statusArray.length) {
44-
return res.send({ status: false, statusText: 'No task statuses configured for this company.' });
45-
}
27+
const ctx = await loadImportContext(companyId, projectId);
28+
if (ctx.error) return res.send({ status: false, statusText: ctx.error });
4629

47-
const { tasks, skipped } = transformJiraRows({
48-
rows,
49-
statusNames: statusArray.map((status) => status.name),
50-
leaderId: userId,
51-
});
52-
if (!tasks.length) {
53-
return res.send({ status: false, statusText: 'No importable rows found (a Summary column is required).' });
54-
}
30+
const { tasks, skipped } = transformJiraRows({ rows, statusNames: ctx.statusArray.map((status) => status.name), leaderId: userId });
31+
if (!tasks.length) return res.send({ status: false, statusText: 'No importable rows found (a Summary column is required).' });
5532

56-
const job = await MongoDbCrudOpration(companyId, {
57-
type: SCHEMA_TYPE.IMPORT_JOBS,
58-
data: {
59-
userId,
60-
source: 'jira',
61-
projectId: new mongoose.Types.ObjectId(projectId),
62-
sprintId: new mongoose.Types.ObjectId(sprintId),
63-
status: 'processing',
64-
total: tasks.length,
65-
processed: 0,
66-
created: 0,
67-
errorList: [],
68-
},
69-
}, 'save');
70-
71-
const projectData = {
72-
_id: project._id,
73-
CompanyId: companyId,
74-
ProjectName: project.ProjectName,
75-
ProjectCode: project.ProjectCode,
76-
lastTaskId: project.lastTaskId,
77-
};
78-
const sprint = { id: sprintId, name: sprintName || '' };
79-
if (folderId) {
80-
sprint.folderId = folderId;
81-
sprint.folderName = folderName || '';
82-
}
83-
const tasksWithSprint = tasks.map((task) => ({ ...task, sprintId, sprintArray: sprint }));
84-
85-
try {
86-
const result = await taskMongo.createMultipleTasks({
87-
tasks: tasksWithSprint,
88-
userData: { id: userId, Employee_Name: userData.Employee_Name || '', companyOwnerId: userData.companyOwnerId || '' },
89-
projectData,
90-
indexObj: {},
91-
statusArray,
92-
sprint,
93-
});
94-
const createdCount = Array.isArray(result?.data) ? result.data.length : tasks.length;
95-
await MongoDbCrudOpration(companyId, {
96-
type: SCHEMA_TYPE.IMPORT_JOBS,
97-
data: [{ _id: job._id }, { $set: { status: 'done', processed: tasks.length, created: createdCount } }],
98-
}, 'updateOne');
99-
return res.send({ status: true, statusText: `Imported ${createdCount} tasks from Jira (${skipped} rows skipped).`, data: { jobId: job._id, created: createdCount, skipped } });
100-
} catch (creationError) {
101-
logger.error(`[importers] jira job ${job._id} failed: ${creationError.message}`);
102-
await MongoDbCrudOpration(companyId, {
103-
type: SCHEMA_TYPE.IMPORT_JOBS,
104-
data: [{ _id: job._id }, { $set: { status: 'failed', errorList: [String(creationError.message || creationError).slice(0, 300)] } }],
105-
}, 'updateOne').catch(() => {});
106-
return res.send({ status: false, statusText: `Import failed: ${creationError.message}` });
107-
}
33+
const out = await finishImport(companyId, { source: 'jira', project: ctx.project, sprintId, sprintName, folderId, folderName, userData, statusArray: ctx.statusArray, tasks, skipped });
34+
return res.send(out);
10835
} catch (error) {
10936
logger.error(`ERROR in jira import: ${error.message}`);
11037
return res.send({ status: false, statusText: error.message });
@@ -129,3 +56,145 @@ exports.listImports = async (req, res) => {
12956
return res.send({ status: false, statusText: error.message });
13057
}
13158
};
59+
60+
// ── Shared pipeline for the CSV + Trello importers (mirrors importFromJira) ──
61+
62+
const STATUS_FALLBACK_TYPE = 'default_active';
63+
64+
/* Load the project + a usable task-status list. Prefer the PROJECT's own
65+
* taskStatusData (it matches the board, including any custom statuses); fall back
66+
* to the company task-status template only if the project has none. Every entry
67+
* is normalized so it always carries a type/key — otherwise createMultipleTasks
68+
* builds a task with an empty statusType and the task schema (statusType is
69+
* required) rejects the whole import. Returns { project, statusArray } or { error }. */
70+
const loadImportContext = async (companyId, projectId) => {
71+
const project = await MongoDbCrudOpration(companyId, {
72+
type: SCHEMA_TYPE.PROJECTS,
73+
data: [{ _id: new mongoose.Types.ObjectId(projectId) }],
74+
}, 'findOne');
75+
if (!project) return { error: 'Project not found.' };
76+
77+
let source = Array.isArray(project.taskStatusData) ? project.taskStatusData : [];
78+
if (!source.length) {
79+
const statusDocs = await MongoDbCrudOpration(companyId, {
80+
type: dbCollections.SETTINGS,
81+
data: [{ name: settingsCollectionDocs.TASK_STATUS }],
82+
}, 'find');
83+
source = (statusDocs && statusDocs[0] && statusDocs[0].settings) || [];
84+
}
85+
const statusArray = source
86+
.filter((status) => status && status.name !== undefined && status.name !== null && String(status.name).trim() !== '')
87+
.map((status, idx) => ({
88+
name: status.name,
89+
key: (status.key !== undefined && status.key !== null) ? status.key : idx + 1,
90+
type: status.type || STATUS_FALLBACK_TYPE,
91+
}));
92+
if (!statusArray.length) return { error: 'No task statuses configured for this project.' };
93+
return { project, statusArray };
94+
};
95+
96+
/* Record the job, feed the bulk-create pipeline, update the job. Returns the
97+
* response envelope. Identical create path to the Jira importer. */
98+
const finishImport = async (companyId, { source, project, sprintId, sprintName, folderId, folderName, userData, statusArray, tasks, skipped }) => {
99+
const userId = String((userData && (userData.id || userData._id)) || '');
100+
const job = await MongoDbCrudOpration(companyId, {
101+
type: SCHEMA_TYPE.IMPORT_JOBS,
102+
data: {
103+
userId,
104+
source,
105+
projectId: project._id,
106+
sprintId: new mongoose.Types.ObjectId(sprintId),
107+
status: 'processing',
108+
total: tasks.length,
109+
processed: 0,
110+
created: 0,
111+
errorList: [],
112+
},
113+
}, 'save');
114+
115+
const projectData = {
116+
_id: project._id,
117+
CompanyId: companyId,
118+
ProjectName: project.ProjectName,
119+
ProjectCode: project.ProjectCode,
120+
lastTaskId: project.lastTaskId,
121+
};
122+
const sprint = { id: sprintId, name: sprintName || '' };
123+
if (folderId) {
124+
sprint.folderId = folderId;
125+
sprint.folderName = folderName || '';
126+
}
127+
const tasksWithSprint = tasks.map((task) => ({ ...task, sprintId, sprintArray: sprint }));
128+
129+
try {
130+
const result = await taskMongo.createMultipleTasks({
131+
tasks: tasksWithSprint,
132+
userData: { id: userId, Employee_Name: (userData && userData.Employee_Name) || '', companyOwnerId: (userData && userData.companyOwnerId) || '' },
133+
projectData,
134+
indexObj: {},
135+
statusArray,
136+
sprint,
137+
});
138+
const createdCount = Array.isArray(result?.data) ? result.data.length : tasks.length;
139+
await MongoDbCrudOpration(companyId, {
140+
type: SCHEMA_TYPE.IMPORT_JOBS,
141+
data: [{ _id: job._id }, { $set: { status: 'done', processed: tasks.length, created: createdCount } }],
142+
}, 'updateOne');
143+
return { status: true, statusText: `Imported ${createdCount} tasks from ${source} (${skipped} skipped).`, data: { jobId: job._id, created: createdCount, skipped } };
144+
} catch (creationError) {
145+
logger.error(`[importers] ${source} job ${job._id} failed: ${creationError.message}`);
146+
await MongoDbCrudOpration(companyId, {
147+
type: SCHEMA_TYPE.IMPORT_JOBS,
148+
data: [{ _id: job._id }, { $set: { status: 'failed', errorList: [String(creationError.message || creationError).slice(0, 300)] } }],
149+
}, 'updateOne').catch(() => {});
150+
return { status: false, statusText: `Import failed: ${creationError.message}` };
151+
}
152+
};
153+
154+
/* POST /api/v2/imports/csv
155+
* body: { rows, mapping?, projectId, sprintId, sprintName?, folderId?, folderName?, userData } */
156+
exports.importFromCsv = async (req, res) => {
157+
try {
158+
const companyId = req.headers['companyid'] || '';
159+
const { rows, mapping, projectId, sprintId, sprintName, folderId, folderName, userData } = req.body || {};
160+
const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
161+
const check = validateCsvInput({ companyId, projectId, sprintId, rows, userId });
162+
if (!check.valid) return res.send({ status: false, statusText: check.reason });
163+
164+
const ctx = await loadImportContext(companyId, projectId);
165+
if (ctx.error) return res.send({ status: false, statusText: ctx.error });
166+
167+
const { tasks, skipped } = transformCsvRows({ rows, mapping, statusNames: ctx.statusArray.map((status) => status.name), leaderId: userId });
168+
if (!tasks.length) return res.send({ status: false, statusText: 'No importable rows found (a task-name column is required).' });
169+
170+
const out = await finishImport(companyId, { source: 'csv', project: ctx.project, sprintId, sprintName, folderId, folderName, userData, statusArray: ctx.statusArray, tasks, skipped });
171+
return res.send(out);
172+
} catch (error) {
173+
logger.error(`ERROR in csv import: ${error.message}`);
174+
return res.send({ status: false, statusText: error.message });
175+
}
176+
};
177+
178+
/* POST /api/v2/imports/trello
179+
* body: { board, projectId, sprintId, sprintName?, folderId?, folderName?, userData } */
180+
exports.importFromTrello = async (req, res) => {
181+
try {
182+
const companyId = req.headers['companyid'] || '';
183+
const { board, projectId, sprintId, sprintName, folderId, folderName, userData } = req.body || {};
184+
const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
185+
const check = validateTrelloInput({ companyId, projectId, sprintId, board, userId });
186+
if (!check.valid) return res.send({ status: false, statusText: check.reason });
187+
188+
const ctx = await loadImportContext(companyId, projectId);
189+
if (ctx.error) return res.send({ status: false, statusText: ctx.error });
190+
191+
const { tasks, skipped } = parseTrelloBoard({ board, statusNames: ctx.statusArray.map((status) => status.name), leaderId: userId });
192+
if (!tasks.length) return res.send({ status: false, statusText: 'No importable cards found.' });
193+
194+
const out = await finishImport(companyId, { source: 'trello', project: ctx.project, sprintId, sprintName, folderId, folderName, userData, statusArray: ctx.statusArray, tasks, skipped });
195+
return res.send(out);
196+
} catch (error) {
197+
logger.error(`ERROR in trello import: ${error.message}`);
198+
return res.send({ status: false, statusText: error.message });
199+
}
200+
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// CSV-import rules. Pure — no I/O — shared by the controller and the tests.
2+
// Input: rows parsed client-side from a CSV/XLSX export (each row a
3+
// { header: value } map), plus an optional column `mapping`
4+
// ({ taskName, status, priority, dueDate, description } → source column name).
5+
// Without a mapping, common column names are auto-detected (same heuristics as
6+
// the Jira importer). Output rows match the shape createMultipleTasks expects.
7+
const { isObjectIdString, fieldOf, mapPriority, mapStatusName } = require('./jiraRules');
8+
9+
const MAX_ROWS = 2000;
10+
11+
// Resolve a canonical field: prefer the user's explicit column mapping, else
12+
// fall back to auto-detecting one of the candidate header names.
13+
const valueFor = (row, mapping, field, candidates) => {
14+
const col = mapping && mapping[field];
15+
if (col && row && row[col] !== undefined && row[col] !== null && String(row[col]).trim() !== '') {
16+
return String(row[col]).trim();
17+
}
18+
return fieldOf(row, candidates);
19+
};
20+
21+
const validateCsvInput = ({ companyId, projectId, sprintId, rows, userId }) => {
22+
if (!companyId) return { valid: false, reason: 'companyId is required.' };
23+
if (!userId) return { valid: false, reason: 'userId is required.' };
24+
if (!isObjectIdString(projectId)) return { valid: false, reason: 'A valid projectId is required.' };
25+
if (!isObjectIdString(sprintId)) return { valid: false, reason: 'A valid sprintId is required.' };
26+
if (!Array.isArray(rows) || !rows.length) return { valid: false, reason: 'rows must be a non-empty array.' };
27+
if (rows.length > MAX_ROWS) return { valid: false, reason: `At most ${MAX_ROWS} rows per import.` };
28+
return { valid: true, reason: '' };
29+
};
30+
31+
/* Transform parsed CSV rows into createMultipleTasks input.
32+
* Returns { tasks, skipped } — rows without a task name are skipped. */
33+
const transformCsvRows = ({ rows, mapping = {}, statusNames, leaderId }) => {
34+
const tasks = [];
35+
let skipped = 0;
36+
(rows || []).forEach((row) => {
37+
const name = valueFor(row, mapping, 'taskName', ['task name', 'summary', 'title', 'name']);
38+
if (!name) { skipped += 1; return; }
39+
const dueRaw = valueFor(row, mapping, 'dueDate', ['due date', 'duedate', 'due', 'end date']);
40+
const due = dueRaw ? new Date(dueRaw) : null;
41+
tasks.push({
42+
TaskName: name.slice(0, 500),
43+
status: mapStatusName(valueFor(row, mapping, 'status', ['status', 'state']), statusNames),
44+
Task_Priority: mapPriority(valueFor(row, mapping, 'priority', ['priority'])),
45+
TaskType: 'task',
46+
TaskTypeKey: 1,
47+
Task_Leader: leaderId,
48+
AssigneeUserId: [],
49+
DueDate: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
50+
rawDescription: valueFor(row, mapping, 'description', ['description', 'desc', 'notes']).slice(0, 10000),
51+
ParentTaskId: '',
52+
});
53+
});
54+
return { tasks, skipped };
55+
};
56+
57+
module.exports = { MAX_ROWS, valueFor, validateCsvInput, transformCsvRows };
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Trello-import rules. Pure — no I/O — shared by the controller and the tests.
2+
// Input: a Trello board JSON export (Menu → Print/Export → Export as JSON):
3+
// { name, lists: [{ id, name, closed }], cards: [{ name, desc, due, idList, closed }] }
4+
// Lists become the source status names (mapped onto the project's existing
5+
// statuses); open cards become tasks in the shape createMultipleTasks expects.
6+
const { isObjectIdString, mapStatusName } = require('./jiraRules');
7+
8+
const MAX_CARDS = 2000;
9+
10+
const validateTrelloInput = ({ companyId, projectId, sprintId, board, userId }) => {
11+
if (!companyId) return { valid: false, reason: 'companyId is required.' };
12+
if (!userId) return { valid: false, reason: 'userId is required.' };
13+
if (!isObjectIdString(projectId)) return { valid: false, reason: 'A valid projectId is required.' };
14+
if (!isObjectIdString(sprintId)) return { valid: false, reason: 'A valid sprintId is required.' };
15+
if (!board || typeof board !== 'object' || !Array.isArray(board.cards)) {
16+
return { valid: false, reason: 'A Trello board export with a cards array is required.' };
17+
}
18+
const openCards = board.cards.filter((card) => card && !card.closed);
19+
if (!openCards.length) return { valid: false, reason: 'No open cards found in the board export.' };
20+
if (openCards.length > MAX_CARDS) return { valid: false, reason: `At most ${MAX_CARDS} cards per import.` };
21+
return { valid: true, reason: '' };
22+
};
23+
24+
/* Parse a Trello board export → { tasks, skipped, listNames }.
25+
* 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). */
27+
const parseTrelloBoard = ({ board, statusNames, leaderId }) => {
28+
const lists = Array.isArray(board.lists) ? board.lists : [];
29+
const listById = {};
30+
lists.filter((list) => list && !list.closed).forEach((list) => {
31+
listById[String(list.id)] = String(list.name || '');
32+
});
33+
34+
const tasks = [];
35+
let skipped = 0;
36+
(board.cards || []).forEach((card) => {
37+
if (!card || card.closed || !String(card.name || '').trim()) { skipped += 1; return; }
38+
const listName = listById[String(card.idList)] || '';
39+
const due = card.due ? new Date(card.due) : null;
40+
tasks.push({
41+
TaskName: String(card.name).trim().slice(0, 500),
42+
status: mapStatusName(listName, statusNames),
43+
Task_Priority: 'Normal',
44+
TaskType: 'task',
45+
TaskTypeKey: 1,
46+
Task_Leader: leaderId,
47+
AssigneeUserId: [],
48+
DueDate: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
49+
rawDescription: String(card.desc || '').slice(0, 10000),
50+
ParentTaskId: '',
51+
});
52+
});
53+
return { tasks, skipped, listNames: Object.values(listById) };
54+
};
55+
56+
module.exports = { MAX_CARDS, validateTrelloInput, parseTrelloBoard };

Modules/Importers/routes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@ const ctrl = require('./controller');
22

33
exports.init = (app) => {
44
app.post('/api/v2/imports/jira', ctrl.importFromJira);
5+
app.post('/api/v2/imports/csv', ctrl.importFromCsv);
6+
app.post('/api/v2/imports/trello', ctrl.importFromTrello);
57
app.get('/api/v2/imports', ctrl.listImports);
68
}

0 commit comments

Comments
 (0)