feat: CSV import preview/apply endpoints and helpers
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
433
backend/index.js
433
backend/index.js
@@ -11,6 +11,7 @@ import { mkdirSync, unlinkSync, existsSync, readFileSync } from 'fs';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import multer from 'multer';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { parse as parseCsv } from 'csv-parse/sync';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = join(__dirname, 'data');
|
||||
@@ -45,6 +46,11 @@ const toInitials = (name = '') =>
|
||||
|
||||
const FEATURE_TYPES = ['idea', 'feature', 'fix', 'removal'];
|
||||
const FEATURE_STATUSES = ['backlog', 'planned', 'in-progress', 'shipped', 'dropped'];
|
||||
const PROJECT_STATUSES = ['planning', 'active', 'on-hold', 'completed'];
|
||||
const TASK_STATUSES = ['todo', 'in-progress', 'done'];
|
||||
const TASK_PRIORITIES = ['low', 'medium', 'high'];
|
||||
const TASK_RECURRENCES = ['none', 'daily', 'weekly', 'monthly'];
|
||||
const DEFAULT_PROJECT_COLOR = '#6366f1';
|
||||
|
||||
const normalizeFeatureEntry = (feature = {}, previousFeature = null) => {
|
||||
const now = new Date().toISOString();
|
||||
@@ -81,6 +87,365 @@ const ensureProjectShape = (project) => {
|
||||
project.features = project.features.map(feature => normalizeFeatureEntry(feature));
|
||||
};
|
||||
|
||||
const normalizeEnumValue = (value, allowedValues, fallbackValue) => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return allowedValues.includes(normalized) ? normalized : fallbackValue;
|
||||
};
|
||||
|
||||
const normalizeIsoDate = (value) => {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
const parsed = new Date(trimmed);
|
||||
if (Number.isNaN(parsed.getTime())) return '';
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const normalizeProjectColor = (value) => {
|
||||
const trimmed = String(value || '').trim();
|
||||
return /^#[0-9a-f]{6}$/i.test(trimmed) ? trimmed : DEFAULT_PROJECT_COLOR;
|
||||
};
|
||||
|
||||
const parseEstimatedHours = (value) => {
|
||||
if (value === undefined || value === null || String(value).trim() === '') return undefined;
|
||||
const hours = Number(value);
|
||||
return Number.isFinite(hours) && hours >= 0 ? hours : undefined;
|
||||
};
|
||||
|
||||
const parseDelimitedItems = (value) =>
|
||||
String(value || '')
|
||||
.split(/\r?\n|[|;]+/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normalizeMemberIdentity = ({ name = '', email = '' } = {}) => ({
|
||||
name: String(name || '').trim(),
|
||||
email: String(email || '').trim().toLowerCase(),
|
||||
});
|
||||
|
||||
const matchesMemberIdentity = (member, identity) => {
|
||||
const memberEmail = String(member?.email || '').trim().toLowerCase();
|
||||
const memberName = String(member?.name || '').trim().toLowerCase();
|
||||
if (identity.email && memberEmail && memberEmail === identity.email) return true;
|
||||
if (identity.name && memberName && memberName === identity.name.toLowerCase()) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const findMemberByIdentity = (members, identity) =>
|
||||
Array.isArray(members) ? members.find(member => matchesMemberIdentity(member, identity)) || null : null;
|
||||
|
||||
const buildTaskDuplicateKey = ({ title = '', dueDate = '', startDate = '', assignedTo = '' }) => [
|
||||
String(title || '').trim().toLowerCase(),
|
||||
String(startDate || '').trim(),
|
||||
String(dueDate || '').trim(),
|
||||
String(assignedTo || '').trim(),
|
||||
].join('::');
|
||||
|
||||
const buildProjectRecord = (draft = {}, fallbackName = 'Imported Project') => ({
|
||||
id: randomUUID(),
|
||||
name: String(draft.name || fallbackName).trim() || fallbackName,
|
||||
description: String(draft.description || '').trim(),
|
||||
status: normalizeEnumValue(draft.status, PROJECT_STATUSES, 'planning'),
|
||||
color: normalizeProjectColor(draft.color),
|
||||
startDate: normalizeIsoDate(draft.startDate),
|
||||
dueDate: normalizeIsoDate(draft.dueDate),
|
||||
members: [],
|
||||
tasks: [],
|
||||
milestones: [],
|
||||
features: [],
|
||||
});
|
||||
|
||||
const buildTaskRecord = (draft = {}, assignedTo = '') => ({
|
||||
id: randomUUID(),
|
||||
title: String(draft.title || '').trim() || 'Untitled Task',
|
||||
description: String(draft.description || '').trim(),
|
||||
status: normalizeEnumValue(draft.status, TASK_STATUSES, 'todo'),
|
||||
priority: normalizeEnumValue(draft.priority, TASK_PRIORITIES, 'medium'),
|
||||
dueDate: normalizeIsoDate(draft.dueDate),
|
||||
startDate: normalizeIsoDate(draft.startDate),
|
||||
assignedTo: assignedTo || '',
|
||||
estimatedHours: parseEstimatedHours(draft.estimatedHours),
|
||||
actualHours: draft.actualHours,
|
||||
recurrence: normalizeEnumValue(draft.recurrence, TASK_RECURRENCES, 'none'),
|
||||
subtasks: Array.isArray(draft.subtasks)
|
||||
? draft.subtasks
|
||||
.map(subtask => {
|
||||
const title = typeof subtask === 'string' ? subtask.trim() : String(subtask?.title || '').trim();
|
||||
if (!title) return null;
|
||||
return { id: randomUUID(), title, done: Boolean(subtask?.done) };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
attachments: Array.isArray(draft.attachments) ? draft.attachments : [],
|
||||
});
|
||||
|
||||
const resolveProjectMember = (project, memberDraft, createMissingMembers = true) => {
|
||||
ensureProjectShape(project);
|
||||
|
||||
const identity = normalizeMemberIdentity(memberDraft);
|
||||
if (!identity.name && !identity.email) return null;
|
||||
|
||||
const existingProjectMember = findMemberByIdentity(project.members, identity);
|
||||
if (existingProjectMember) return existingProjectMember;
|
||||
|
||||
let rosterMember = findMemberByIdentity(db.data.members, identity);
|
||||
if (!rosterMember && createMissingMembers) {
|
||||
rosterMember = {
|
||||
id: randomUUID(),
|
||||
name: identity.name || identity.email || 'Imported Member',
|
||||
role: String(memberDraft.role || '').trim() || 'Team Member',
|
||||
initials: toInitials(identity.name || identity.email || 'Imported Member'),
|
||||
email: identity.email,
|
||||
};
|
||||
db.data.members.push(rosterMember);
|
||||
}
|
||||
|
||||
if (!rosterMember) return null;
|
||||
|
||||
const projectMember = {
|
||||
...rosterMember,
|
||||
initials: rosterMember.initials || toInitials(rosterMember.name),
|
||||
};
|
||||
if (!project.members.some(member => member.id === projectMember.id)) {
|
||||
project.members.push(projectMember);
|
||||
}
|
||||
return projectMember;
|
||||
};
|
||||
|
||||
const readImportRows = (file) => {
|
||||
const extension = extname(file?.originalname || '').toLowerCase();
|
||||
if (extension !== '.csv') {
|
||||
throw new Error('Only CSV import is supported in this release');
|
||||
}
|
||||
|
||||
const raw = file?.buffer?.toString('utf8') || '';
|
||||
const rows = parseCsv(raw, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
bom: true,
|
||||
relax_quotes: true,
|
||||
});
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
throw new Error('The CSV file is empty');
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const buildCsvImportPreview = ({ file, mode, targetProjectId, createMissingMembers, duplicateStrategy }) => {
|
||||
const importMode = mode === 'existing-project' ? 'existing-project' : 'new-project';
|
||||
const allowMemberCreation = createMissingMembers !== 'false';
|
||||
const strategy = duplicateStrategy === 'create' ? 'create' : 'skip';
|
||||
const rows = readImportRows(file);
|
||||
const targetProject = importMode === 'existing-project'
|
||||
? db.data.projects.find(project => project.id === targetProjectId)
|
||||
: null;
|
||||
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
|
||||
if (importMode === 'existing-project' && !targetProject) {
|
||||
errors.push('Select an existing project before previewing this import');
|
||||
}
|
||||
|
||||
const firstProjectName = rows.find(row => String(row.project_name || '').trim())?.project_name;
|
||||
const fallbackProjectName = String(file.originalname || 'Imported Project').replace(/\.[^.]+$/, '') || 'Imported Project';
|
||||
const projectDraft = buildProjectRecord({
|
||||
name: firstProjectName || fallbackProjectName,
|
||||
description: rows.find(row => String(row.project_description || '').trim())?.project_description,
|
||||
status: rows.find(row => String(row.project_status || '').trim())?.project_status,
|
||||
color: rows.find(row => String(row.project_color || '').trim())?.project_color,
|
||||
startDate: rows.find(row => String(row.project_start_date || '').trim())?.project_start_date,
|
||||
dueDate: rows.find(row => String(row.project_due_date || '').trim())?.project_due_date,
|
||||
}, fallbackProjectName);
|
||||
|
||||
const existingDuplicateKeys = new Set(
|
||||
(targetProject?.tasks || []).map(task => buildTaskDuplicateKey(task))
|
||||
);
|
||||
const seenImportKeys = new Set();
|
||||
const memberPlans = new Map();
|
||||
|
||||
const tasks = rows.map((row, index) => {
|
||||
const title = String(row.title || row.task_title || row.task || '').trim();
|
||||
if (!title) {
|
||||
errors.push(`Row ${index + 2}: missing title`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignee = {
|
||||
name: row.assigned_to_name || row.assignee_name || row.assigned_to || '',
|
||||
email: row.assigned_to_email || row.assignee_email || '',
|
||||
role: row.assigned_to_role || row.assignee_role || '',
|
||||
};
|
||||
const assigneeIdentity = normalizeMemberIdentity(assignee);
|
||||
const rosterMatch = findMemberByIdentity(db.data.members, assigneeIdentity);
|
||||
const projectMatch = findMemberByIdentity(targetProject?.members || [], assigneeIdentity);
|
||||
const taskDraft = {
|
||||
title,
|
||||
description: row.description || '',
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
startDate: row.start_date || row.startDate || '',
|
||||
dueDate: row.due_date || row.dueDate || '',
|
||||
estimatedHours: row.estimated_hours || row.estimatedHours,
|
||||
recurrence: row.recurrence,
|
||||
subtasks: parseDelimitedItems(row.subtasks),
|
||||
};
|
||||
const duplicateKey = buildTaskDuplicateKey({
|
||||
...taskDraft,
|
||||
assignedTo: projectMatch?.id || rosterMatch?.id || `${assigneeIdentity.email}|${assigneeIdentity.name}`,
|
||||
});
|
||||
const duplicateInProject = existingDuplicateKeys.has(duplicateKey);
|
||||
const duplicateInFile = seenImportKeys.has(duplicateKey);
|
||||
seenImportKeys.add(duplicateKey);
|
||||
|
||||
let assignmentAction = 'none';
|
||||
if (assigneeIdentity.name || assigneeIdentity.email) {
|
||||
if (projectMatch) {
|
||||
assignmentAction = 'already-assigned';
|
||||
} else if (rosterMatch) {
|
||||
assignmentAction = 'assign-existing';
|
||||
} else if (allowMemberCreation) {
|
||||
assignmentAction = 'create-and-assign';
|
||||
} else {
|
||||
assignmentAction = 'unresolved';
|
||||
warnings.push(`Row ${index + 2}: ${title} will be imported unassigned because ${assigneeIdentity.email || assigneeIdentity.name} is not in the roster`);
|
||||
}
|
||||
|
||||
const memberKey = assigneeIdentity.email || assigneeIdentity.name.toLowerCase();
|
||||
if (memberKey && !memberPlans.has(memberKey)) {
|
||||
memberPlans.set(memberKey, {
|
||||
...assigneeIdentity,
|
||||
role: String(assignee.role || '').trim() || 'Team Member',
|
||||
action: assignmentAction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateInProject && strategy === 'skip') {
|
||||
warnings.push(`Row ${index + 2}: duplicate task "${title}" will be skipped`);
|
||||
}
|
||||
if (duplicateInFile) {
|
||||
warnings.push(`Row ${index + 2}: duplicate task "${title}" also appears elsewhere in this CSV`);
|
||||
}
|
||||
|
||||
return {
|
||||
rowNumber: index + 2,
|
||||
title,
|
||||
description: taskDraft.description,
|
||||
status: normalizeEnumValue(taskDraft.status, TASK_STATUSES, 'todo'),
|
||||
priority: normalizeEnumValue(taskDraft.priority, TASK_PRIORITIES, 'medium'),
|
||||
startDate: normalizeIsoDate(taskDraft.startDate),
|
||||
dueDate: normalizeIsoDate(taskDraft.dueDate),
|
||||
estimatedHours: parseEstimatedHours(taskDraft.estimatedHours),
|
||||
recurrence: normalizeEnumValue(taskDraft.recurrence, TASK_RECURRENCES, 'none'),
|
||||
subtasks: taskDraft.subtasks,
|
||||
assignee: assigneeIdentity,
|
||||
assignmentAction,
|
||||
duplicateInProject,
|
||||
duplicateInFile,
|
||||
duplicateKey,
|
||||
willImport: !(duplicateInProject && strategy === 'skip'),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
format: 'csv',
|
||||
mode: importMode,
|
||||
fileName: file.originalname,
|
||||
options: {
|
||||
targetProjectId: targetProject?.id || '',
|
||||
createMissingMembers: allowMemberCreation,
|
||||
duplicateStrategy: strategy,
|
||||
},
|
||||
project: importMode === 'existing-project'
|
||||
? {
|
||||
id: targetProject?.id || '',
|
||||
name: targetProject?.name || '',
|
||||
description: targetProject?.description || '',
|
||||
}
|
||||
: projectDraft,
|
||||
tasks,
|
||||
members: Array.from(memberPlans.values()),
|
||||
warnings: Array.from(new Set(warnings)),
|
||||
errors,
|
||||
summary: {
|
||||
rowCount: rows.length,
|
||||
taskCount: tasks.length,
|
||||
importableTaskCount: tasks.filter(task => task.willImport).length,
|
||||
duplicateCount: tasks.filter(task => task.duplicateInProject).length,
|
||||
memberCreateCount: Array.from(memberPlans.values()).filter(member => member.action === 'create-and-assign').length,
|
||||
memberAssignCount: Array.from(memberPlans.values()).filter(member => member.action === 'assign-existing').length,
|
||||
unresolvedAssignmentCount: tasks.filter(task => task.assignmentAction === 'unresolved').length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const applyCsvImportPreview = (preview) => {
|
||||
if (!preview || preview.format !== 'csv') {
|
||||
throw new Error('Invalid import payload');
|
||||
}
|
||||
if (Array.isArray(preview.errors) && preview.errors.length > 0) {
|
||||
throw new Error('Resolve preview errors before applying the import');
|
||||
}
|
||||
|
||||
const importMode = preview.mode === 'existing-project' ? 'existing-project' : 'new-project';
|
||||
const duplicateStrategy = preview?.options?.duplicateStrategy === 'create' ? 'create' : 'skip';
|
||||
const createMissingMembers = Boolean(preview?.options?.createMissingMembers);
|
||||
|
||||
let project = null;
|
||||
if (importMode === 'existing-project') {
|
||||
project = db.data.projects.find(existingProject => existingProject.id === preview?.project?.id || existingProject.id === preview?.options?.targetProjectId);
|
||||
if (!project) throw new Error('Target project no longer exists');
|
||||
} else {
|
||||
project = buildProjectRecord(preview.project, preview?.project?.name || 'Imported Project');
|
||||
db.data.projects.push(project);
|
||||
}
|
||||
|
||||
ensureProjectShape(project);
|
||||
|
||||
const duplicateKeys = new Set(project.tasks.map(task => buildTaskDuplicateKey(task)));
|
||||
let createdTasks = 0;
|
||||
let skippedTasks = 0;
|
||||
|
||||
for (const taskPreview of Array.isArray(preview.tasks) ? preview.tasks : []) {
|
||||
if (!taskPreview?.title) continue;
|
||||
|
||||
let assignedTo = '';
|
||||
if (taskPreview.assignee?.name || taskPreview.assignee?.email) {
|
||||
const member = resolveProjectMember(project, taskPreview.assignee, createMissingMembers);
|
||||
assignedTo = member?.id || '';
|
||||
}
|
||||
|
||||
const duplicateKey = buildTaskDuplicateKey({
|
||||
title: taskPreview.title,
|
||||
startDate: taskPreview.startDate,
|
||||
dueDate: taskPreview.dueDate,
|
||||
assignedTo,
|
||||
});
|
||||
if (duplicateStrategy === 'skip' && duplicateKeys.has(duplicateKey)) {
|
||||
skippedTasks += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
project.tasks.push(buildTaskRecord(taskPreview, assignedTo));
|
||||
duplicateKeys.add(duplicateKey);
|
||||
createdTasks += 1;
|
||||
}
|
||||
|
||||
ensureProjectShape(project);
|
||||
|
||||
return {
|
||||
project,
|
||||
summary: {
|
||||
createdTasks,
|
||||
skippedTasks,
|
||||
memberCount: project.members.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
await db.read();
|
||||
} catch (err) {
|
||||
@@ -176,6 +541,16 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
const importUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const extension = extname(file.originalname || '').toLowerCase();
|
||||
if (extension === '.csv') return cb(null, true);
|
||||
return cb(new Error('Only CSV files are supported right now'));
|
||||
},
|
||||
});
|
||||
|
||||
// ── Notification helper ────────────────────────────────────────────────────
|
||||
async function notify({ webhookUrl, notifyEmail, subject, text, payload }) {
|
||||
if (webhookUrl) {
|
||||
@@ -320,6 +695,40 @@ app.delete('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// CSV import preview/apply
|
||||
app.post('/api/import/preview', limiter, requireApiKey, (req, res) => {
|
||||
importUpload.single('file')(req, res, err => {
|
||||
if (err) return res.status(400).json({ error: err.message });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file provided' });
|
||||
|
||||
try {
|
||||
const preview = buildCsvImportPreview({
|
||||
file: req.file,
|
||||
mode: req.body.mode,
|
||||
targetProjectId: req.body.targetProjectId,
|
||||
createMissingMembers: req.body.createMissingMembers,
|
||||
duplicateStrategy: req.body.duplicateStrategy,
|
||||
});
|
||||
return res.json(preview);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error?.message || 'Failed to preview import' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/import/apply', limiter, requireApiKey, async (req, res) => {
|
||||
try {
|
||||
const result = applyCsvImportPreview(req.body?.preview);
|
||||
await db.write();
|
||||
return res.status(201).json({
|
||||
project: result.project,
|
||||
summary: result.summary,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error?.message || 'Failed to apply import' });
|
||||
}
|
||||
});
|
||||
|
||||
// Assign member to project
|
||||
app.post('/api/projects/:projectId/members/:memberId', limiter, requireApiKey, async (req, res) => {
|
||||
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||
@@ -347,21 +756,19 @@ app.post('/api/projects/:projectId/tasks', limiter, requireApiKey, async (req, r
|
||||
return res.status(400).json({ error: 'Member not assigned to project' });
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: randomUUID(),
|
||||
title: String(title || '').trim() || 'Untitled Task',
|
||||
description: description || '',
|
||||
status: req.body.status || 'todo',
|
||||
priority: req.body.priority || 'medium',
|
||||
dueDate: req.body.dueDate || '',
|
||||
startDate: req.body.startDate || '',
|
||||
assignedTo: assigneeId,
|
||||
const task = buildTaskRecord({
|
||||
title,
|
||||
description,
|
||||
status: req.body.status,
|
||||
priority: req.body.priority,
|
||||
dueDate: req.body.dueDate,
|
||||
startDate: req.body.startDate,
|
||||
estimatedHours: req.body.estimatedHours,
|
||||
actualHours: req.body.actualHours,
|
||||
recurrence: req.body.recurrence || 'none',
|
||||
subtasks: Array.isArray(req.body.subtasks) ? req.body.subtasks : [],
|
||||
attachments: Array.isArray(req.body.attachments) ? req.body.attachments : [],
|
||||
};
|
||||
recurrence: req.body.recurrence,
|
||||
subtasks: req.body.subtasks,
|
||||
attachments: req.body.attachments,
|
||||
}, assigneeId);
|
||||
|
||||
project.tasks.push(task);
|
||||
await db.write();
|
||||
|
||||
Reference in New Issue
Block a user