Files
Project-Manager/backend/index.js

524 lines
20 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';
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 ensureProjectShape = (project) => {
if (!Array.isArray(project.members)) project.members = [];
if (!Array.isArray(project.tasks)) project.tasks = [];
if (!Array.isArray(project.milestones)) project.milestones = [];
};
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'));
},
});
// ── 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 configuredOrigins = (process.env.CORS_ORIGIN || '')
.split(',')
.map(o => o.trim())
.filter(Boolean);
const allowedOrigins = configuredOrigins.length
? configuredOrigins
: ['http://localhost:5173', 'http://localhost:4000', '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: [], ...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();
});
// 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 = {
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,
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 : [],
};
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();
});
// ── 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:5173';
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 || 4000;
const HOST = process.env.HOST || '0.0.0.0';
app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`));