import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; import rateLimit from 'express-rate-limit'; import { Low } from 'lowdb'; import { JSONFile } from 'lowdb/node'; import { join, dirname, isAbsolute, extname } from 'path'; import { fileURLToPath } from 'url'; 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'); const dbFileEnv = process.env.DB_FILE; const dbFile = dbFileEnv ? (isAbsolute(dbFileEnv) ? dbFileEnv : join(dataDir, dbFileEnv)) : join(dataDir, 'db.json'); const legacyDbFile = join(__dirname, 'db.json'); mkdirSync(dirname(dbFile), { recursive: true }); const adapter = new JSONFile(dbFile); const defaultData = { members: [], projects: [], tasks: [], invites: [] }; const db = new Low(adapter, defaultData); const safeReadJson = (filePath) => { try { if (!existsSync(filePath)) return null; const raw = readFileSync(filePath, 'utf8').trim(); if (!raw) return null; return JSON.parse(raw); } catch { return null; } }; const toInitials = (name = '') => String(name) .split(' ') .filter(Boolean) .map(w => w[0]) .join('') .toUpperCase() .slice(0, 2); 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(); const title = String(feature?.title || previousFeature?.title || '').trim() || 'Untitled entry'; const type = FEATURE_TYPES.includes(feature?.type) ? feature.type : (FEATURE_TYPES.includes(previousFeature?.type) ? previousFeature.type : 'idea'); const status = FEATURE_STATUSES.includes(feature?.status) ? feature.status : (FEATURE_STATUSES.includes(previousFeature?.status) ? previousFeature.status : 'backlog'); const createdAt = feature?.createdAt || previousFeature?.createdAt || now; const updatedAt = feature?.updatedAt || previousFeature?.updatedAt || feature?.createdAt || previousFeature?.createdAt || now; const updatedBy = typeof feature?.updatedBy === 'string' ? feature.updatedBy.trim() : typeof previousFeature?.updatedBy === 'string' ? previousFeature.updatedBy.trim() : ''; const shippedAt = feature?.shippedAt || previousFeature?.shippedAt || (status === 'shipped' ? (updatedAt || createdAt || now) : ''); return { id: feature?.id || previousFeature?.id || randomUUID(), title, type, status, note: feature?.note || feature?.description || previousFeature?.note || '', createdAt, updatedAt, updatedBy, shippedAt, }; }; const ensureProjectShape = (project) => { if (!Array.isArray(project.members)) project.members = []; if (!Array.isArray(project.tasks)) project.tasks = []; if (!Array.isArray(project.milestones)) project.milestones = []; if (!Array.isArray(project.features)) project.features = []; 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) { console.warn('Primary db file was unreadable, reinitializing with defaults.', err?.message || err); db.data = { ...defaultData }; await db.write(); } if (!db.data) db.data = defaultData; if (!db.data.invites) db.data.invites = []; if (!db.data.members) db.data.members = []; if (!db.data.projects) db.data.projects = []; if (!db.data.tasks) db.data.tasks = []; // One-time bootstrap from legacy backend/db.json when the runtime db is empty. const runtimeHasData = (db.data.members.length + db.data.projects.length + db.data.invites.length) > 0; if (!runtimeHasData && dbFile !== legacyDbFile) { const legacy = safeReadJson(legacyDbFile); if (legacy && typeof legacy === 'object') { db.data.members = Array.isArray(legacy.members) ? legacy.members : []; db.data.projects = Array.isArray(legacy.projects) ? legacy.projects : []; db.data.invites = Array.isArray(legacy.invites) ? legacy.invites : []; db.data.tasks = Array.isArray(legacy.tasks) ? legacy.tasks : []; } } // Normalize projects and migrate any legacy flat tasks into nested project.tasks. for (const project of db.data.projects) ensureProjectShape(project); for (const task of db.data.tasks) { const project = db.data.projects.find(p => p.id === task.projectId); if (!project) continue; ensureProjectShape(project); const existing = project.tasks.find(t => t.id === task.id); if (existing) continue; project.tasks.push({ id: task.id || randomUUID(), title: task.title || 'Untitled Task', description: task.description || '', status: task.status || 'todo', priority: task.priority || 'medium', dueDate: task.dueDate || '', startDate: task.startDate || '', assignedTo: task.assignedTo || task.memberId || '', estimatedHours: task.estimatedHours, actualHours: task.actualHours, recurrence: task.recurrence || 'none', subtasks: Array.isArray(task.subtasks) ? task.subtasks : [], attachments: Array.isArray(task.attachments) ? task.attachments : [], }); } db.data.tasks = []; for (const project of db.data.projects) { ensureProjectShape(project); project.members = project.members .map(member => { if (member && typeof member === 'object') { return { ...member, initials: member.initials || toInitials(member.name), }; } const roster = db.data.members.find(m => m.id === member); if (!roster) return null; return { ...roster, initials: roster.initials || toInitials(roster.name) }; }) .filter(Boolean); } await db.write(); // ── File uploads (multer) ─────────────────────────────────────────────────── const UPLOAD_DIR = join(__dirname, 'uploads'); mkdirSync(UPLOAD_DIR, { recursive: true }); const ALLOWED_MIME = new Set([ 'image/jpeg','image/png','image/gif','image/webp','image/svg+xml', 'application/pdf','text/plain','text/csv','text/markdown', 'application/json', 'application/zip','application/x-zip-compressed', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx ]); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, UPLOAD_DIR), filename: (req, file, cb) => cb(null, randomUUID() + extname(file.originalname).toLowerCase()), }); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB fileFilter: (req, file, cb) => { if (ALLOWED_MIME.has(file.mimetype)) cb(null, true); else cb(new Error('File type not allowed')); }, }); 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) { try { await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || { subject, text }), signal: AbortSignal.timeout(5000), }); } catch (err) { console.warn('Webhook notification failed', err?.message || err); } } if (notifyEmail && process.env.SMTP_HOST) { try { const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT) || 587, secure: process.env.SMTP_SECURE === 'true', auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined, }); await transporter.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@projecthub.local', to: notifyEmail, subject, text, }); } catch (err) { console.warn('Email notification failed', err?.message || err); } } } const app = express(); app.use(helmet()); app.use(morgan(process.env.MORGAN_FORMAT || 'combined')); const defaultBackendPort = String(process.env.PORT || process.env.PORT_BACKEND || '4000'); const defaultFrontendPort = String(process.env.PORT_FRONTEND_DEV || '5173'); const configuredOrigins = (process.env.CORS_ORIGIN || '') .split(',') .map(o => o.trim()) .filter(Boolean); const allowedOrigins = configuredOrigins.length ? configuredOrigins : [`http://localhost:${defaultFrontendPort}`, `http://localhost:${defaultBackendPort}`, 'null']; const corsOptions = { credentials: true, origin: (origin, callback) => { if (!origin) return callback(null, true); if (origin === 'null' || origin.startsWith('file://')) return callback(null, true); if (allowedOrigins.includes(origin)) return callback(null, true); return callback(new Error('Not allowed by CORS')); }, }; app.use(cors(corsOptions)); app.use(express.json()); const limiter = rateLimit({ windowMs: Number(process.env.RATE_WINDOW_MS) || 15 * 60 * 1000, max: Number(process.env.RATE_MAX) || 200, }); const requireApiKey = (req, res, next) => { const key = process.env.WRITE_API_KEY; if (!key) return next(); const provided = req.get('x-api-key') || req.query.api_key; if (provided === key) return next(); return res.status(401).json({ error: 'Unauthorized' }); }; // Health app.get('/health', async (req, res) => { try { await db.read(); return res.json({ status: 'ok', timestamp: Date.now() }); } catch (err) { return res.status(500).json({ status: 'error' }); } }); // Member CRUD app.get('/api/members', (req, res) => { res.json(db.data.members); }); app.post('/api/members', limiter, requireApiKey, async (req, res) => { const member = { id: Date.now().toString(), ...req.body }; member.initials = member.initials || toInitials(member.name); db.data.members.push(member); await db.write(); res.status(201).json(member); }); app.delete('/api/members/:id', limiter, requireApiKey, async (req, res) => { db.data.members = db.data.members.filter(m => m.id !== req.params.id); for (const project of db.data.projects) { ensureProjectShape(project); project.members = project.members.filter(m => m.id !== req.params.id); } await db.write(); res.status(204).end(); }); app.put('/api/members/:id', limiter, requireApiKey, async (req, res) => { const idx = db.data.members.findIndex(m => m.id === req.params.id); if (idx === -1) return res.status(404).json({ error: 'Member not found' }); db.data.members[idx] = { ...db.data.members[idx], ...req.body, id: req.params.id }; await db.write(); res.json(db.data.members[idx]); }); // Project CRUD app.get('/api/projects', (req, res) => { res.json(db.data.projects); }); app.post('/api/projects', limiter, requireApiKey, async (req, res) => { const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], features: [], ...req.body }; ensureProjectShape(project); db.data.projects.push(project); await db.write(); res.status(201).json(project); }); app.put('/api/projects/:id', limiter, requireApiKey, async (req, res) => { const idx = db.data.projects.findIndex(p => p.id === req.params.id); const updated = { ...req.body, id: req.params.id }; ensureProjectShape(updated); if (idx === -1) db.data.projects.push(updated); else db.data.projects[idx] = updated; await db.write(); res.json(updated); }); app.delete('/api/projects/:id', limiter, requireApiKey, async (req, res) => { db.data.projects = db.data.projects.filter(p => p.id !== req.params.id); await db.write(); 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); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); const member = db.data.members.find(m => m.id === req.params.memberId); if (!member) return res.status(404).json({ error: 'Member not found' }); const alreadyAssigned = project.members.some(m => m.id === member.id); if (!alreadyAssigned) { project.members.push({ ...member, initials: member.initials || toInitials(member.name) }); await db.write(); } res.json(project); }); // Tasks (assign to member in project) app.post('/api/projects/:projectId/tasks', limiter, requireApiKey, async (req, res) => { const { title, description, memberId, assignedTo } = req.body; const assigneeId = assignedTo || memberId || ''; const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); if (assigneeId && !project.members.some(m => m.id === assigneeId)) { return res.status(400).json({ error: 'Member not assigned to project' }); } 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, subtasks: req.body.subtasks, attachments: req.body.attachments, }, assigneeId); project.tasks.push(task); await db.write(); res.status(201).json(task); }); app.get('/api/projects/:projectId/tasks', (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); res.json(project.tasks); }); app.put('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); const idx = project.tasks.findIndex(t => t.id === req.params.taskId); if (idx === -1) return res.status(404).json({ error: 'Task not found' }); const assigneeId = req.body.assignedTo || req.body.memberId || project.tasks[idx].assignedTo || ''; if (assigneeId && !project.members.some(m => m.id === assigneeId)) { return res.status(400).json({ error: 'Member not assigned to project' }); } project.tasks[idx] = { ...project.tasks[idx], ...req.body, id: req.params.taskId, assignedTo: assigneeId, }; await db.write(); res.json(project.tasks[idx]); }); app.delete('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); const idx = project.tasks.findIndex(t => t.id === req.params.taskId); if (idx === -1) return res.status(404).json({ error: 'Task not found' }); project.tasks.splice(idx, 1); await db.write(); res.status(204).end(); }); // Feature log CRUD app.get('/api/projects/:projectId/features', (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); res.json(project.features); }); app.post('/api/projects/:projectId/features', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); const feature = normalizeFeatureEntry(req.body); project.features.push(feature); await db.write(); res.status(201).json(feature); }); app.put('/api/projects/:projectId/features/:featureId', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); const idx = project.features.findIndex(feature => feature.id === req.params.featureId); if (idx === -1) return res.status(404).json({ error: 'Feature entry not found' }); const current = project.features[idx]; project.features[idx] = normalizeFeatureEntry({ ...current, ...req.body, id: req.params.featureId, createdAt: current.createdAt, shippedAt: req.body.status === 'shipped' ? (req.body.shippedAt || current.shippedAt || new Date().toISOString()) : (req.body.status && req.body.status !== 'shipped' ? '' : current.shippedAt), }, current); await db.write(); res.json(project.features[idx]); }); app.delete('/api/projects/:projectId/features/:featureId', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); const idx = project.features.findIndex(feature => feature.id === req.params.featureId); if (idx === -1) return res.status(404).json({ error: 'Feature entry not found' }); project.features.splice(idx, 1); await db.write(); res.status(204).end(); }); // ── Static uploads ────────────────────────────────────────────────────────── app.use('/uploads', express.static(UPLOAD_DIR)); // ── File attachments ──────────────────────────────────────────────────────── // Attachments are stored inline in project.tasks[].attachments[] app.post('/api/projects/:projectId/tasks/:taskId/attachments', limiter, requireApiKey, (req, res, next) => { upload.single('file')(req, res, async err => { if (err) return res.status(400).json({ error: err.message }); if (!req.file) return res.status(400).json({ error: 'No file provided' }); const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); const task = project.tasks.find(t => t.id === req.params.taskId); if (!task) return res.status(404).json({ error: 'Task not found' }); if (!task.attachments) task.attachments = []; const attachment = { id: randomUUID(), filename: req.file.filename, originalName: req.file.originalname, mimetype: req.file.mimetype, size: req.file.size, url: `/uploads/${req.file.filename}`, uploadedAt: new Date().toISOString(), }; task.attachments.push(attachment); await db.write(); res.status(201).json(attachment); }); }); app.delete('/api/projects/:projectId/tasks/:taskId/attachments/:attachmentId', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.projectId); if (!project) return res.status(404).json({ error: 'Project not found' }); ensureProjectShape(project); const task = project.tasks.find(t => t.id === req.params.taskId); if (!task) return res.status(404).json({ error: 'Task not found' }); const idx = task.attachments?.findIndex(a => a.id === req.params.attachmentId); if (idx === undefined || idx === -1) return res.status(404).json({ error: 'Attachment not found' }); const [removed] = task.attachments.splice(idx, 1); // Delete file from disk const filePath = join(UPLOAD_DIR, removed.filename); if (existsSync(filePath)) { try { unlinkSync(filePath); } catch {} } await db.write(); res.status(204).end(); }); // ── Webhook/notification settings on project ──────────────────────────────── // Projects can have optional webhookUrl and notifyEmail; these are just // stored as part of the project doc. The POST /api/projects/:id/notify endpoint // fires a test notification. app.post('/api/projects/:id/notify', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.id); if (!project) return res.status(404).json({ error: 'Project not found' }); const { subject = 'Project Hub notification', text = `Update for project: ${project.name}` } = req.body; notify({ webhookUrl: project.webhookUrl, notifyEmail: project.notifyEmail, subject, text, payload: { projectId: project.id, projectName: project.name, subject, text } }); res.json({ sent: true }); }); // ── Invite system ──────────────────────────────────────────────────────────── // Generate an invite token for a project app.post('/api/projects/:id/invites', limiter, requireApiKey, async (req, res) => { const project = db.data.projects.find(p => p.id === req.params.id); if (!project) return res.status(404).json({ error: 'Project not found' }); const token = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days const invite = { id: randomUUID(), token, projectId: project.id, projectName: project.name, email: req.body.email || null, createdAt: new Date().toISOString(), expiresAt: expiresAt.toISOString(), acceptedAt: null, }; db.data.invites.push(invite); await db.write(); // Fire email if SMTP configured and email provided if (req.body.email) { const appUrl = process.env.APP_URL || `http://localhost:${defaultFrontendPort}`; notify({ notifyEmail: req.body.email, subject: `You've been invited to "${project.name}"`, text: `You've been invited to join the project "${project.name}".\n\nAccept your invitation:\n${appUrl}/invite/${token}\n\nThis link expires in 7 days.`, }); } res.status(201).json({ token, expiresAt: invite.expiresAt, inviteId: invite.id }); }); // Validate an invite token (public) app.get('/api/invites/:token', (req, res) => { const invite = db.data.invites.find(i => i.token === req.params.token); if (!invite) return res.status(404).json({ error: 'Invite not found or expired' }); if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' }); if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' }); res.json({ projectId: invite.projectId, projectName: invite.projectName, email: invite.email, expiresAt: invite.expiresAt }); }); // Accept an invite (attaches provided member name to project if given) app.post('/api/invites/:token/accept', limiter, async (req, res) => { const invite = db.data.invites.find(i => i.token === req.params.token); if (!invite) return res.status(404).json({ error: 'Invite not found' }); if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' }); if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' }); invite.acceptedAt = new Date().toISOString(); // Optionally add the member to the project if (req.body.name) { const project = db.data.projects.find(p => p.id === invite.projectId); if (project) { ensureProjectShape(project); const initials = req.body.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); const email = (req.body.email || invite.email || '').trim(); let rosterMember = db.data.members.find(m => email && m.email === email); if (!rosterMember) { rosterMember = { id: randomUUID(), name: req.body.name, role: req.body.role || 'Team Member', initials, email, }; db.data.members.push(rosterMember); } if (!project.members.some(m => m.id === rosterMember.id)) { project.members.push({ ...rosterMember, initials: rosterMember.initials || toInitials(rosterMember.name) }); } } } await db.write(); res.json({ projectId: invite.projectId, projectName: invite.projectName }); }); const PORT = process.env.PORT || process.env.PORT_BACKEND || 4000; const HOST = process.env.HOST || '0.0.0.0'; app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`));