diff --git a/backend/index.js b/backend/index.js index e1457b2..a37f12b 100644 --- a/backend/index.js +++ b/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(); diff --git a/backend/package-lock.json b/backend/package-lock.json index 3ebe13f..78ef7f6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "cors": "^2.8.5", + "csv-parse": "^6.2.0", "express": "^4.18.2", "express-rate-limit": "^6.8.0", "helmet": "^6.0.1", @@ -299,6 +300,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/csv-parse": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.0.tgz", + "integrity": "sha512-Zv8KRHccD1q3BJlK4VcQiEn/+suOOp++89g/fpqOxB2U2tU66uC3yM+ZwU6nQQEJp8AqBIiNqB+pUTKNz4QzKg==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/backend/package.json b/backend/package.json index 8bc79e0..f58ced8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "cors": "^2.8.5", + "csv-parse": "^6.2.0", "express": "^4.18.2", "express-rate-limit": "^6.8.0", "helmet": "^6.0.1",