Implement wiring hardening, runtime API config, smoke tests, and build scripts
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
RUN apk add --no-cache curl && npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:4000/health || exit 1
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
479
backend/index.js
479
backend/index.js
@@ -1,45 +1,254 @@
|
||||
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 } from 'path';
|
||||
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');
|
||||
import { mkdirSync } from 'fs';
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const dbFile = join(dataDir, 'db.json');
|
||||
|
||||
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: [] };
|
||||
const defaultData = { members: [], projects: [], tasks: [], invites: [] };
|
||||
const db = new Low(adapter, defaultData);
|
||||
|
||||
await db.read();
|
||||
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(cors());
|
||||
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', (req, res) => {
|
||||
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);
|
||||
db.write();
|
||||
await db.write();
|
||||
res.status(201).json(member);
|
||||
});
|
||||
|
||||
app.delete('/api/members/:id', (req, res) => {
|
||||
app.delete('/api/members/:id', limiter, requireApiKey, async (req, res) => {
|
||||
db.data.members = db.data.members.filter(m => m.id !== req.params.id);
|
||||
db.write();
|
||||
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', async (req, res) => {
|
||||
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 };
|
||||
@@ -52,55 +261,263 @@ app.get('/api/projects', (req, res) => {
|
||||
res.json(db.data.projects);
|
||||
});
|
||||
|
||||
app.post('/api/projects', (req, res) => {
|
||||
const project = { id: Date.now().toString(), members: [], ...req.body };
|
||||
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);
|
||||
db.write();
|
||||
await db.write();
|
||||
res.status(201).json(project);
|
||||
});
|
||||
|
||||
app.put('/api/projects/:id', async (req, res) => {
|
||||
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', (req, res) => {
|
||||
app.delete('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
|
||||
db.data.projects = db.data.projects.filter(p => p.id !== req.params.id);
|
||||
db.write();
|
||||
await db.write();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Assign member to project
|
||||
app.post('/api/projects/:projectId/members/:memberId', (req, res) => {
|
||||
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' });
|
||||
if (!project.members.includes(req.params.memberId)) {
|
||||
project.members.push(req.params.memberId);
|
||||
db.write();
|
||||
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', (req, res) => {
|
||||
const { title, description, memberId } = req.body;
|
||||
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' });
|
||||
if (!project.members.includes(memberId)) return res.status(400).json({ error: 'Member not assigned to project' });
|
||||
const task = { id: Date.now().toString(), projectId: project.id, memberId, title, description, status: 'todo' };
|
||||
db.data.tasks.push(task);
|
||||
db.write();
|
||||
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 tasks = db.data.tasks.filter(t => t.projectId === req.params.projectId);
|
||||
res.json(tasks);
|
||||
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;
|
||||
app.listen(PORT, () => console.log(`Backend running on port ${PORT}`));
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`));
|
||||
|
||||
192
backend/package-lock.json
generated
192
backend/package-lock.json
generated
@@ -10,7 +10,12 @@
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"lowdb": "^6.0.1"
|
||||
"express-rate-limit": "^6.8.0",
|
||||
"helmet": "^6.0.1",
|
||||
"lowdb": "^6.0.1",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
@@ -43,6 +48,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -59,6 +70,24 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/basic-auth/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -122,6 +151,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -185,6 +231,21 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -386,6 +447,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "6.11.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz",
|
||||
"integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4 || ^5"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -555,6 +628,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-6.2.0.tgz",
|
||||
"integrity": "sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -755,12 +837,59 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "~2.0.1",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-finished": "~2.3.0",
|
||||
"on-headers": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan/node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -770,6 +899,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz",
|
||||
"integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
@@ -867,6 +1005,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -954,6 +1101,20 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -1163,6 +1324,23 @@
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@@ -1221,6 +1399,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -1237,6 +1421,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@@ -5,12 +5,18 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
"dev": "nodemon index.js",
|
||||
"build": "node --check index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.8.0",
|
||||
"helmet": "^6.0.1",
|
||||
"lowdb": "^6.0.1",
|
||||
"cors": "^2.8.5"
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
||||
Reference in New Issue
Block a user