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
233 changes: 151 additions & 82 deletions Modules/Importers/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const mongoose = require("mongoose");
const logger = require("../../Config/loggerConfig");
const { taskMongo } = require('../Tasks/helpers/task_class_Mongo');
const { validateImportInput, transformJiraRows } = require('./helpers/jiraRules');
const { validateCsvInput, transformCsvRows } = require('./helpers/csvRules');
const { validateTrelloInput, parseTrelloBoard } = require('./helpers/trelloRules');

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

const [project, statusDocs] = await Promise.all([
MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.PROJECTS,
data: [{ _id: new mongoose.Types.ObjectId(projectId) }],
}, 'findOne'),
MongoDbCrudOpration(companyId, {
type: dbCollections.SETTINGS,
data: [{ name: settingsCollectionDocs.TASK_STATUS }],
}, 'find'),
]);
if (!project) {
return res.send({ status: false, statusText: 'Project not found.' });
}
const statusArray = ((statusDocs && statusDocs[0] && statusDocs[0].settings) || [])
.map((status) => ({ name: status.name, key: status.key, type: status.type }))
.filter((status) => status.name !== undefined);
if (!statusArray.length) {
return res.send({ status: false, statusText: 'No task statuses configured for this company.' });
}
const ctx = await loadImportContext(companyId, projectId);
if (ctx.error) return res.send({ status: false, statusText: ctx.error });

const { tasks, skipped } = transformJiraRows({
rows,
statusNames: statusArray.map((status) => status.name),
leaderId: userId,
});
if (!tasks.length) {
return res.send({ status: false, statusText: 'No importable rows found (a Summary column is required).' });
}
const { tasks, skipped } = transformJiraRows({ rows, statusNames: ctx.statusArray.map((status) => status.name), leaderId: userId });
if (!tasks.length) return res.send({ status: false, statusText: 'No importable rows found (a Summary column is required).' });

const job = await MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.IMPORT_JOBS,
data: {
userId,
source: 'jira',
projectId: new mongoose.Types.ObjectId(projectId),
sprintId: new mongoose.Types.ObjectId(sprintId),
status: 'processing',
total: tasks.length,
processed: 0,
created: 0,
errorList: [],
},
}, 'save');

const projectData = {
_id: project._id,
CompanyId: companyId,
ProjectName: project.ProjectName,
ProjectCode: project.ProjectCode,
lastTaskId: project.lastTaskId,
};
const sprint = { id: sprintId, name: sprintName || '' };
if (folderId) {
sprint.folderId = folderId;
sprint.folderName = folderName || '';
}
const tasksWithSprint = tasks.map((task) => ({ ...task, sprintId, sprintArray: sprint }));

try {
const result = await taskMongo.createMultipleTasks({
tasks: tasksWithSprint,
userData: { id: userId, Employee_Name: userData.Employee_Name || '', companyOwnerId: userData.companyOwnerId || '' },
projectData,
indexObj: {},
statusArray,
sprint,
});
const createdCount = Array.isArray(result?.data) ? result.data.length : tasks.length;
await MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.IMPORT_JOBS,
data: [{ _id: job._id }, { $set: { status: 'done', processed: tasks.length, created: createdCount } }],
}, 'updateOne');
return res.send({ status: true, statusText: `Imported ${createdCount} tasks from Jira (${skipped} rows skipped).`, data: { jobId: job._id, created: createdCount, skipped } });
} catch (creationError) {
logger.error(`[importers] jira job ${job._id} failed: ${creationError.message}`);
await MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.IMPORT_JOBS,
data: [{ _id: job._id }, { $set: { status: 'failed', errorList: [String(creationError.message || creationError).slice(0, 300)] } }],
}, 'updateOne').catch(() => {});
return res.send({ status: false, statusText: `Import failed: ${creationError.message}` });
}
const out = await finishImport(companyId, { source: 'jira', project: ctx.project, sprintId, sprintName, folderId, folderName, userData, statusArray: ctx.statusArray, tasks, skipped });
return res.send(out);
} catch (error) {
logger.error(`ERROR in jira import: ${error.message}`);
return res.send({ status: false, statusText: error.message });
Expand All @@ -129,3 +56,145 @@ exports.listImports = async (req, res) => {
return res.send({ status: false, statusText: error.message });
}
};

// ── Shared pipeline for the CSV + Trello importers (mirrors importFromJira) ──

const STATUS_FALLBACK_TYPE = 'default_active';

/* Load the project + a usable task-status list. Prefer the PROJECT's own
* taskStatusData (it matches the board, including any custom statuses); fall back
* to the company task-status template only if the project has none. Every entry
* is normalized so it always carries a type/key — otherwise createMultipleTasks
* builds a task with an empty statusType and the task schema (statusType is
* required) rejects the whole import. Returns { project, statusArray } or { error }. */
const loadImportContext = async (companyId, projectId) => {
const project = await MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.PROJECTS,
data: [{ _id: new mongoose.Types.ObjectId(projectId) }],
}, 'findOne');
if (!project) return { error: 'Project not found.' };

let source = Array.isArray(project.taskStatusData) ? project.taskStatusData : [];
if (!source.length) {
const statusDocs = await MongoDbCrudOpration(companyId, {
type: dbCollections.SETTINGS,
data: [{ name: settingsCollectionDocs.TASK_STATUS }],
}, 'find');
source = (statusDocs && statusDocs[0] && statusDocs[0].settings) || [];
}
const statusArray = source
.filter((status) => status && status.name !== undefined && status.name !== null && String(status.name).trim() !== '')
.map((status, idx) => ({
name: status.name,
key: (status.key !== undefined && status.key !== null) ? status.key : idx + 1,
type: status.type || STATUS_FALLBACK_TYPE,
}));
if (!statusArray.length) return { error: 'No task statuses configured for this project.' };
return { project, statusArray };
};

/* Record the job, feed the bulk-create pipeline, update the job. Returns the
* response envelope. Identical create path to the Jira importer. */
const finishImport = async (companyId, { source, project, sprintId, sprintName, folderId, folderName, userData, statusArray, tasks, skipped }) => {
const userId = String((userData && (userData.id || userData._id)) || '');
const job = await MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.IMPORT_JOBS,
data: {
userId,
source,
projectId: project._id,
sprintId: new mongoose.Types.ObjectId(sprintId),
status: 'processing',
total: tasks.length,
processed: 0,
created: 0,
errorList: [],
},
}, 'save');

const projectData = {
_id: project._id,
CompanyId: companyId,
ProjectName: project.ProjectName,
ProjectCode: project.ProjectCode,
lastTaskId: project.lastTaskId,
};
const sprint = { id: sprintId, name: sprintName || '' };
if (folderId) {
sprint.folderId = folderId;
sprint.folderName = folderName || '';
}
const tasksWithSprint = tasks.map((task) => ({ ...task, sprintId, sprintArray: sprint }));

try {
const result = await taskMongo.createMultipleTasks({
tasks: tasksWithSprint,
userData: { id: userId, Employee_Name: (userData && userData.Employee_Name) || '', companyOwnerId: (userData && userData.companyOwnerId) || '' },
projectData,
indexObj: {},
statusArray,
sprint,
});
const createdCount = Array.isArray(result?.data) ? result.data.length : tasks.length;
await MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.IMPORT_JOBS,
data: [{ _id: job._id }, { $set: { status: 'done', processed: tasks.length, created: createdCount } }],
}, 'updateOne');
return { status: true, statusText: `Imported ${createdCount} tasks from ${source} (${skipped} skipped).`, data: { jobId: job._id, created: createdCount, skipped } };
} catch (creationError) {
logger.error(`[importers] ${source} job ${job._id} failed: ${creationError.message}`);
await MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.IMPORT_JOBS,
data: [{ _id: job._id }, { $set: { status: 'failed', errorList: [String(creationError.message || creationError).slice(0, 300)] } }],
}, 'updateOne').catch(() => {});
return { status: false, statusText: `Import failed: ${creationError.message}` };
}
};

/* POST /api/v2/imports/csv
* body: { rows, mapping?, projectId, sprintId, sprintName?, folderId?, folderName?, userData } */
exports.importFromCsv = async (req, res) => {
try {
const companyId = req.headers['companyid'] || '';
const { rows, mapping, projectId, sprintId, sprintName, folderId, folderName, userData } = req.body || {};
const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
const check = validateCsvInput({ companyId, projectId, sprintId, rows, userId });
if (!check.valid) return res.send({ status: false, statusText: check.reason });

const ctx = await loadImportContext(companyId, projectId);
if (ctx.error) return res.send({ status: false, statusText: ctx.error });

const { tasks, skipped } = transformCsvRows({ rows, mapping, statusNames: ctx.statusArray.map((status) => status.name), leaderId: userId });
if (!tasks.length) return res.send({ status: false, statusText: 'No importable rows found (a task-name column is required).' });

const out = await finishImport(companyId, { source: 'csv', project: ctx.project, sprintId, sprintName, folderId, folderName, userData, statusArray: ctx.statusArray, tasks, skipped });
return res.send(out);
} catch (error) {
logger.error(`ERROR in csv import: ${error.message}`);
return res.send({ status: false, statusText: error.message });
}
};

/* POST /api/v2/imports/trello
* body: { board, projectId, sprintId, sprintName?, folderId?, folderName?, userData } */
exports.importFromTrello = async (req, res) => {
try {
const companyId = req.headers['companyid'] || '';
const { board, projectId, sprintId, sprintName, folderId, folderName, userData } = req.body || {};
const userId = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
const check = validateTrelloInput({ companyId, projectId, sprintId, board, userId });
if (!check.valid) return res.send({ status: false, statusText: check.reason });

const ctx = await loadImportContext(companyId, projectId);
if (ctx.error) return res.send({ status: false, statusText: ctx.error });

const { tasks, skipped } = parseTrelloBoard({ board, statusNames: ctx.statusArray.map((status) => status.name), leaderId: userId });
if (!tasks.length) return res.send({ status: false, statusText: 'No importable cards found.' });

const out = await finishImport(companyId, { source: 'trello', project: ctx.project, sprintId, sprintName, folderId, folderName, userData, statusArray: ctx.statusArray, tasks, skipped });
return res.send(out);
} catch (error) {
logger.error(`ERROR in trello import: ${error.message}`);
return res.send({ status: false, statusText: error.message });
}
};
57 changes: 57 additions & 0 deletions Modules/Importers/helpers/csvRules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// CSV-import rules. Pure — no I/O — shared by the controller and the tests.
// Input: rows parsed client-side from a CSV/XLSX export (each row a
// { header: value } map), plus an optional column `mapping`
// ({ taskName, status, priority, dueDate, description } → source column name).
// Without a mapping, common column names are auto-detected (same heuristics as
// the Jira importer). Output rows match the shape createMultipleTasks expects.
const { isObjectIdString, fieldOf, mapPriority, mapStatusName } = require('./jiraRules');

const MAX_ROWS = 2000;

// Resolve a canonical field: prefer the user's explicit column mapping, else
// fall back to auto-detecting one of the candidate header names.
const valueFor = (row, mapping, field, candidates) => {
const col = mapping && mapping[field];
if (col && row && row[col] !== undefined && row[col] !== null && String(row[col]).trim() !== '') {
return String(row[col]).trim();
}
return fieldOf(row, candidates);
};

const validateCsvInput = ({ companyId, projectId, sprintId, rows, userId }) => {
if (!companyId) return { valid: false, reason: 'companyId is required.' };
if (!userId) return { valid: false, reason: 'userId is required.' };
if (!isObjectIdString(projectId)) return { valid: false, reason: 'A valid projectId is required.' };
if (!isObjectIdString(sprintId)) return { valid: false, reason: 'A valid sprintId is required.' };
if (!Array.isArray(rows) || !rows.length) return { valid: false, reason: 'rows must be a non-empty array.' };
if (rows.length > MAX_ROWS) return { valid: false, reason: `At most ${MAX_ROWS} rows per import.` };
return { valid: true, reason: '' };
};

/* Transform parsed CSV rows into createMultipleTasks input.
* Returns { tasks, skipped } — rows without a task name are skipped. */
const transformCsvRows = ({ rows, mapping = {}, statusNames, leaderId }) => {
const tasks = [];
let skipped = 0;
(rows || []).forEach((row) => {
const name = valueFor(row, mapping, 'taskName', ['task name', 'summary', 'title', 'name']);
if (!name) { skipped += 1; return; }
const dueRaw = valueFor(row, mapping, 'dueDate', ['due date', 'duedate', 'due', 'end date']);
const due = dueRaw ? new Date(dueRaw) : null;
tasks.push({
TaskName: name.slice(0, 500),
status: mapStatusName(valueFor(row, mapping, 'status', ['status', 'state']), statusNames),
Task_Priority: mapPriority(valueFor(row, mapping, 'priority', ['priority'])),
TaskType: 'task',
TaskTypeKey: 1,
Task_Leader: leaderId,
AssigneeUserId: [],
DueDate: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
rawDescription: valueFor(row, mapping, 'description', ['description', 'desc', 'notes']).slice(0, 10000),
ParentTaskId: '',
});
});
return { tasks, skipped };
};

module.exports = { MAX_ROWS, valueFor, validateCsvInput, transformCsvRows };
56 changes: 56 additions & 0 deletions Modules/Importers/helpers/trelloRules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Trello-import rules. Pure — no I/O — shared by the controller and the tests.
// Input: a Trello board JSON export (Menu → Print/Export → Export as JSON):
// { name, lists: [{ id, name, closed }], cards: [{ name, desc, due, idList, closed }] }
// Lists become the source status names (mapped onto the project's existing
// statuses); open cards become tasks in the shape createMultipleTasks expects.
const { isObjectIdString, mapStatusName } = require('./jiraRules');

const MAX_CARDS = 2000;

const validateTrelloInput = ({ companyId, projectId, sprintId, board, userId }) => {
if (!companyId) return { valid: false, reason: 'companyId is required.' };
if (!userId) return { valid: false, reason: 'userId is required.' };
if (!isObjectIdString(projectId)) return { valid: false, reason: 'A valid projectId is required.' };
if (!isObjectIdString(sprintId)) return { valid: false, reason: 'A valid sprintId is required.' };
if (!board || typeof board !== 'object' || !Array.isArray(board.cards)) {
return { valid: false, reason: 'A Trello board export with a cards array is required.' };
}
const openCards = board.cards.filter((card) => card && !card.closed);
if (!openCards.length) return { valid: false, reason: 'No open cards found in the board export.' };
if (openCards.length > MAX_CARDS) return { valid: false, reason: `At most ${MAX_CARDS} cards per import.` };
return { valid: true, reason: '' };
};

/* Parse a Trello board export → { tasks, skipped, listNames }.
* Closed lists/cards and nameless cards are skipped; each card's list name is
* mapped onto an existing project status (falls back to the first status). */
const parseTrelloBoard = ({ board, statusNames, leaderId }) => {
const lists = Array.isArray(board.lists) ? board.lists : [];
const listById = {};
lists.filter((list) => list && !list.closed).forEach((list) => {
listById[String(list.id)] = String(list.name || '');
});

const tasks = [];
let skipped = 0;
(board.cards || []).forEach((card) => {
if (!card || card.closed || !String(card.name || '').trim()) { skipped += 1; return; }
const listName = listById[String(card.idList)] || '';
const due = card.due ? new Date(card.due) : null;
tasks.push({
TaskName: String(card.name).trim().slice(0, 500),
status: mapStatusName(listName, statusNames),
Task_Priority: 'Normal',
TaskType: 'task',
TaskTypeKey: 1,
Task_Leader: leaderId,
AssigneeUserId: [],
DueDate: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
rawDescription: String(card.desc || '').slice(0, 10000),
ParentTaskId: '',
});
});
return { tasks, skipped, listNames: Object.values(listById) };
};

module.exports = { MAX_CARDS, validateTrelloInput, parseTrelloBoard };
2 changes: 2 additions & 0 deletions Modules/Importers/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ const ctrl = require('./controller');

exports.init = (app) => {
app.post('/api/v2/imports/jira', ctrl.importFromJira);
app.post('/api/v2/imports/csv', ctrl.importFromCsv);
app.post('/api/v2/imports/trello', ctrl.importFromTrello);
app.get('/api/v2/imports', ctrl.listImports);
}
Loading
Loading