- Add dedicated REST endpoints for feature CRUD (GET/POST/PUT/DELETE /api/projects/:id/features) - Add normalizeFeatureEntry() with updatedAt, updatedBy, shippedAt lifecycle - Auto-set shippedAt when status transitions to 'shipped' - Frontend api.js: getFeatures, createFeature, updateFeature, deleteFeature methods - App.jsx: optimistic CRUD handlers using dedicated feature endpoints - Project list: feature-status filter dropdown + 5-way sort (feature activity, feature status, project status, due date, name) - Project cards: feature log preview (latest entry + in-flight count) - FeaturesTab: filter by status, sort by updatedAt desc - FeatureRow: show updatedAt, updatedBy, shippedAt metadata; updatedBy edit field
608 lines
24 KiB
JavaScript
608 lines
24 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 FEATURE_TYPES = ['idea', 'feature', 'fix', 'removal'];
|
|
const FEATURE_STATUSES = ['backlog', 'planned', 'in-progress', 'shipped', 'dropped'];
|
|
|
|
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));
|
|
};
|
|
|
|
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: [], 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();
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
|
|
// 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: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}`));
|