1018 lines
39 KiB
JavaScript
1018 lines
39 KiB
JavaScript
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}`));
|