Files
marketing-app/server/server.js
2026-02-08 22:51:42 +03:00

1454 lines
60 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const bcrypt = require('bcrypt');
const session = require('express-session');
const SqliteStore = require('connect-sqlite3')(session);
const { db, initialize } = require('./db');
const app = express();
const PORT = 3001;
// ─── SHARED HELPERS ─────────────────────────────────────────────
// Builds a dynamic UPDATE clause from request body fields.
// Returns { clause, values } where clause is "field1 = ?, field2 = ?" and values is the corresponding array.
// `jsonFields` are serialized with JSON.stringify before binding.
// `extraClauses` are appended as-is (e.g., 'updated_at = CURRENT_TIMESTAMP').
function buildUpdate(body, allowedFields, { jsonFields = [], extraClauses = [] } = {}) {
const clauses = [...extraClauses];
const values = [];
for (const field of allowedFields) {
if (body[field] !== undefined) {
clauses.push(`${field} = ?`);
values.push(jsonFields.includes(field) ? JSON.stringify(body[field]) : body[field]);
}
}
return { clauses, values, hasUpdates: clauses.length > 0 };
}
// Reusable SQL fragments for joined queries
const POST_SELECT_SQL = `SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name
FROM posts p
LEFT JOIN brands b ON p.brand_id = b.id
LEFT JOIN team_members t ON p.assigned_to = t.id
LEFT JOIN campaigns c ON p.campaign_id = c.id
LEFT JOIN users u ON p.created_by_user_id = u.id`;
const TASK_SELECT_SQL = `SELECT t.*,
p.name as project_name,
a.name as assigned_name,
c.name as creator_name,
u.name as creator_user_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN team_members a ON t.assigned_to = a.id
LEFT JOIN team_members c ON t.created_by = c.id
LEFT JOIN users u ON t.created_by_user_id = u.id`;
const PROJECT_SELECT_SQL = `SELECT p.*, b.name as brand_name, t.name as owner_name
FROM projects p
LEFT JOIN brands b ON p.brand_id = b.id
LEFT JOIN team_members t ON p.owner_id = t.id`;
const CAMPAIGN_SELECT_SQL = `SELECT c.*, b.name as brand_name
FROM campaigns c
LEFT JOIN brands b ON c.brand_id = b.id`;
function parsePostJson(post) {
return {
...post,
platforms: JSON.parse(post.platforms || '[]'),
publication_links: JSON.parse(post.publication_links || '[]'),
};
}
// Ensure uploads directory exists
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Middleware
app.use(cors({
origin: function(origin, callback) {
// Allow requests with no origin (mobile apps, curl, etc)
if (!origin) return callback(null, true);
// Allow localhost and any IP on port 5173
if (origin.match(/^https?:\/\/(localhost|127\.0\.0\.1|(\d+\.){3}\d+):5173$/)) {
return callback(null, true);
}
callback(null, true); // Allow all for dev
},
credentials: true
}));
app.use(express.json());
// Session middleware
app.use(session({
store: new SqliteStore({
db: 'sessions.db',
dir: __dirname
}),
secret: 'samaya-marketing-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // set to true in production with HTTPS
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
}
}));
// Serve uploaded files
app.use('/api/uploads', express.static(uploadsDir));
// Multer config
const decodeOriginalName = (name) => Buffer.from(name, 'latin1').toString('utf8');
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => {
file.originalname = decodeOriginalName(file.originalname);
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`;
cb(null, uniqueName);
}
});
const upload = multer({ storage, limits: { fileSize: 50 * 1024 * 1024 } }); // 50MB limit
// Initialize database
initialize();
// ─── AUTH MIDDLEWARE ────────────────────────────────────────────
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
function requireRole(...roles) {
return (req, res, next) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.session.userRole)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Ownership check: contributors can only modify their own resources (or resources assigned to them)
const VALID_OWNER_TABLES = new Set(['posts', 'tasks']);
function requireOwnerOrRole(table, ...allowedRoles) {
if (!VALID_OWNER_TABLES.has(table)) {
throw new Error(`requireOwnerOrRole: invalid table "${table}"`);
}
return (req, res, next) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
// Managers and superadmins bypass ownership check
if (allowedRoles.includes(req.session.userRole)) {
return next();
}
// Contributors must own the resource or be assigned to it
const row = db.prepare(`SELECT created_by_user_id, assigned_to FROM ${table} WHERE id = ?`).get(req.params.id);
if (!row) {
return res.status(404).json({ error: 'Not found' });
}
// Check if user owns the resource
if (row.created_by_user_id === req.session.userId) {
return next();
}
// Check if resource is assigned to user's team member
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
if (currentUser?.team_member_id && row.assigned_to === currentUser.team_member_id) {
return next();
}
return res.status(403).json({ error: 'You can only modify your own items' });
};
}
// ─── AUTH ROUTES ────────────────────────────────────────────────
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
try {
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// Create session
req.session.userId = user.id;
req.session.userEmail = user.email;
req.session.userRole = user.role;
req.session.userName = user.name;
req.session.teamMemberId = user.team_member_id;
res.json({
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
team_member_id: user.team_member_id,
},
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Login failed' });
}
});
app.post('/api/auth/logout', (req, res) => {
req.session.destroy(err => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
app.get('/api/auth/me', requireAuth, (req, res) => {
const user = db.prepare('SELECT id, name, email, role, avatar, team_member_id, team_role, brands, phone, tutorial_completed, created_at FROM users WHERE id = ?').get(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Check if profile is complete
const profileComplete = !!(user.team_role && user.brands);
res.json({ ...user, profileComplete, brands: JSON.parse(user.brands || '[]') });
});
// ─── SELF-SERVICE PROFILE ENDPOINTS ─────────────────────────────
app.get('/api/users/me/profile', requireAuth, (req, res) => {
const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, tutorial_completed FROM users WHERE id = ?').get(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ ...user, brands: JSON.parse(user.brands || '[]') });
});
app.patch('/api/users/me/profile', requireAuth, async (req, res) => {
const { clauses, values, hasUpdates } = buildUpdate(
req.body, ['name', 'team_role', 'phone', 'brands'], { jsonFields: ['brands'] }
);
if (!hasUpdates) {
return res.status(400).json({ error: 'No fields to update' });
}
try {
values.push(req.session.userId);
db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
if (req.body.name !== undefined) {
req.session.userName = req.body.name;
}
const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, tutorial_completed FROM users WHERE id = ?').get(req.session.userId);
res.json({ ...user, brands: JSON.parse(user.brands || '[]') });
} catch (err) {
console.error('Update profile error:', err);
res.status(500).json({ error: 'Failed to update profile' });
}
});
app.patch('/api/users/me/tutorial', requireAuth, (req, res) => {
const { completed } = req.body;
try {
db.prepare('UPDATE users SET tutorial_completed = ? WHERE id = ?').run(completed ? 1 : 0, req.session.userId);
res.json({ success: true, tutorial_completed: completed ? 1 : 0 });
} catch (err) {
console.error('Update tutorial error:', err);
res.status(500).json({ error: 'Failed to update tutorial status' });
}
});
// ─── USER MANAGEMENT (Superadmin only) ──────────────────────────
app.get('/api/users', requireAuth, requireRole('superadmin'), (req, res) => {
const users = db.prepare('SELECT id, name, email, role, avatar, team_member_id, created_at FROM users ORDER BY created_at DESC').all();
res.json(users);
});
app.post('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
const { name, email, password, role, avatar, team_member_id } = req.body;
if (!name || !email || !password || !role) {
return res.status(400).json({ error: 'Name, email, password, and role are required' });
}
if (!['superadmin', 'manager', 'contributor'].includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
try {
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) {
return res.status(409).json({ error: 'Email already exists' });
}
const passwordHash = await bcrypt.hash(password, 10);
const result = db.prepare(`
INSERT INTO users (name, email, password_hash, role, avatar, team_member_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(name, email, passwordHash, role, avatar || null, team_member_id || null);
const user = db.prepare('SELECT id, name, email, role, avatar, team_member_id, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(user);
} catch (err) {
console.error('Create user error:', err);
res.status(500).json({ error: 'Failed to create user' });
}
});
app.patch('/api/users/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!existing) {
return res.status(404).json({ error: 'User not found' });
}
if (req.body.role !== undefined && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) {
return res.status(400).json({ error: 'Invalid role' });
}
const { clauses, values, hasUpdates } = buildUpdate(
req.body, ['name', 'email', 'role', 'avatar', 'team_member_id']
);
if (req.body.password) {
clauses.push('password_hash = ?');
values.push(await bcrypt.hash(req.body.password, 10));
}
if (!hasUpdates && !req.body.password) {
return res.status(400).json({ error: 'No fields to update' });
}
try {
values.push(id);
db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const user = db.prepare('SELECT id, name, email, role, avatar, team_member_id, created_at FROM users WHERE id = ?').get(id);
res.json(user);
} catch (err) {
console.error('Update user error:', err);
res.status(500).json({ error: 'Failed to update user' });
}
});
app.delete('/api/users/:id', requireAuth, requireRole('superadmin'), (req, res) => {
const { id } = req.params;
// Prevent deleting yourself
if (Number(id) === req.session.userId) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id);
if (result.changes === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ success: true });
});
// ─── PERMISSIONS HELPER (for client-side checks) ────────────────
app.get('/api/auth/permissions', requireAuth, (req, res) => {
const role = req.session.userRole;
const canManage = role === 'superadmin' || role === 'manager';
res.json({
role,
canCreateCampaigns: canManage,
canEditCampaigns: canManage,
canDeleteCampaigns: canManage,
canCreateProjects: canManage,
canEditProjects: canManage,
canDeleteProjects: canManage,
canManageFinance: canManage,
canManageTeam: canManage,
canManageUsers: role === 'superadmin',
// Posts & tasks: everyone can create, but only own (for contributors)
canCreatePosts: true,
canCreateTasks: true,
canEditAnyPost: canManage,
canDeleteAnyPost: canManage,
canEditAnyTask: canManage,
canDeleteAnyTask: canManage,
});
});
// ─── ASSIGNABLE USERS (no brand filtering) ──────────────────────
app.get('/api/users/assignable', requireAuth, (req, res) => {
const users = db.prepare(`
SELECT u.id, u.name, u.team_role, u.avatar, u.team_member_id
FROM users u
WHERE u.team_member_id IS NOT NULL
ORDER BY u.name
`).all();
res.json(users.map(u => ({ ...u, _id: u.team_member_id })));
});
// ─── TEAM (Users with team info) ────────────────────────────────
app.get('/api/users/team', requireAuth, (req, res) => {
const users = db.prepare(`
SELECT id, name, email, role, team_role, brands, phone, avatar, team_member_id, created_at
FROM users
WHERE team_member_id IS NOT NULL
ORDER BY name
`).all();
// Filter based on brand overlap for non-superadmins
let filteredUsers = users;
if (req.session.userRole !== 'superadmin') {
const currentUser = db.prepare('SELECT brands FROM users WHERE id = ?').get(req.session.userId);
const myBrands = JSON.parse(currentUser?.brands || '[]');
filteredUsers = users.filter(u => {
const theirBrands = JSON.parse(u.brands || '[]');
// Always include self, or if there's brand overlap
return u.id === req.session.userId || theirBrands.some(b => myBrands.includes(b));
});
}
// Return with both id and _id for compatibility
res.json(filteredUsers.map(u => ({
...u,
_id: u.id,
brands: JSON.parse(u.brands || '[]')
})));
});
app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const { name, email, password, team_role, brands, phone, role } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!email) return res.status(400).json({ error: 'Email is required' });
// Managers can only create contributors
let userRole = role || 'contributor';
if (req.session.userRole === 'manager') {
if (userRole !== 'contributor') {
return res.status(403).json({ error: 'Managers can only create users with contributor role' });
}
userRole = 'contributor';
}
try {
// Check if email already exists
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) {
return res.status(409).json({ error: 'Email already exists' });
}
// First create team_member for backward compatibility
const tmResult = db.prepare(
'INSERT INTO team_members (name, email, role, brands, phone) VALUES (?, ?, ?, ?, ?)'
).run(name, email, team_role || null, JSON.stringify(brands || []), phone || null);
const teamMemberId = tmResult.lastInsertRowid;
// Then create user account
const defaultPassword = password || 'changeme123';
const passwordHash = await bcrypt.hash(defaultPassword, 10);
const userResult = db.prepare(`
INSERT INTO users (name, email, password_hash, role, team_role, brands, phone, team_member_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, email, passwordHash, userRole, team_role || null, JSON.stringify(brands || []), phone || null, teamMemberId);
const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, team_member_id FROM users WHERE id = ?').get(userResult.lastInsertRowid);
res.status(201).json({ ...user, _id: user.id, brands: JSON.parse(user.brands || '[]') });
} catch (err) {
console.error('Create team member error:', err);
res.status(500).json({ error: 'Failed to create team member' });
}
});
app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'User not found' });
const { clauses, values, hasUpdates } = buildUpdate(
req.body, ['name', 'email', 'team_role', 'phone', 'brands'], { jsonFields: ['brands'] }
);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
// Also update team_members table for backward compatibility
if (existing.team_member_id) {
// Map user fields to team_member fields (team_role -> role)
const tmBody = { ...req.body };
if (tmBody.team_role !== undefined) { tmBody.role = tmBody.team_role; delete tmBody.team_role; }
const tm = buildUpdate(tmBody, ['name', 'email', 'role', 'phone', 'brands'], { jsonFields: ['brands'] });
if (tm.hasUpdates) {
tm.values.push(existing.team_member_id);
db.prepare(`UPDATE team_members SET ${tm.clauses.join(', ')} WHERE id = ?`).run(...tm.values);
}
}
values.push(id);
db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, team_member_id FROM users WHERE id = ?').get(id);
res.json({ ...user, _id: user.id, brands: JSON.parse(user.brands || '[]') });
});
app.delete('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const existing = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.params.id);
// Delete user
const result = db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'User not found' });
// Also delete from team_members if exists
if (existing?.team_member_id) {
db.prepare('DELETE FROM team_members WHERE id = ?').run(existing.team_member_id);
}
res.json({ success: true });
});
// ─── TEAM MEMBERS ───────────────────────────────────────────────
app.get('/api/team', requireAuth, (req, res) => {
const members = db.prepare('SELECT * FROM team_members ORDER BY name').all();
res.json(members.map(m => ({ ...m, brands: JSON.parse(m.brands || '[]') })));
});
app.post('/api/team', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { name, email, role, avatar_url, brands } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare(
'INSERT INTO team_members (name, email, role, avatar_url, brands) VALUES (?, ?, ?, ?, ?)'
).run(name, email || null, role || null, avatar_url || null, JSON.stringify(brands || []));
const member = db.prepare('SELECT * FROM team_members WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ ...member, brands: JSON.parse(member.brands || '[]') });
});
app.patch('/api/team/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM team_members WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Member not found' });
const { clauses, values, hasUpdates } = buildUpdate(
req.body, ['name', 'email', 'role', 'avatar_url', 'brands'], { jsonFields: ['brands'] }
);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
values.push(id);
db.prepare(`UPDATE team_members SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const member = db.prepare('SELECT * FROM team_members WHERE id = ?').get(id);
res.json({ ...member, brands: JSON.parse(member.brands || '[]') });
});
app.delete('/api/team/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const result = db.prepare('DELETE FROM team_members WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Member not found' });
res.json({ success: true });
});
// ─── BRANDS ─────────────────────────────────────────────────────
// Sync brands from NocoDB
app.post('/api/brands/sync-nocodb', async (req, res) => {
try {
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8090';
const NOCODB_TOKEN = process.env.NOCODB_TOKEN || 'By-wCdkUm6N9JdfmNpGH2jd6LqEejwOXER7FMkgr';
const TABLE_ID = 'm9389nkt7ti11w3'; // Brands table in Samaya Digital Marketing base
const resp = await fetch(`${NOCODB_URL}/api/v2/tables/${TABLE_ID}/records?limit=100`, {
headers: { 'xc-token': NOCODB_TOKEN }
});
if (!resp.ok) throw new Error(`NocoDB returned ${resp.status}`);
const data = await resp.json();
const nocoBrands = data.list || [];
const findByName = db.prepare('SELECT id FROM brands WHERE name = ?');
const insertBrand = db.prepare('INSERT INTO brands (name, priority, color, icon) VALUES (?, ?, ?, ?)');
const updatePriority = db.prepare('UPDATE brands SET priority = ? WHERE id = ?');
const inserted = [];
const tx = db.transaction(() => {
for (const b of nocoBrands) {
const name = b.name_ar || b.Name || b.name;
if (!name) continue;
const priority = b.priority || 2;
const existing = findByName.get(name);
if (existing) {
updatePriority.run(priority, existing.id);
} else {
insertBrand.run(name, priority, null, null);
}
inserted.push(name);
}
});
tx();
res.json({ synced: inserted.length, brands: inserted });
} catch (err) {
console.error('NocoDB sync error:', err);
res.status(500).json({ error: err.message });
}
});
app.get('/api/brands', requireAuth, (req, res) => {
const brands = db.prepare('SELECT * FROM brands ORDER BY priority, name').all();
res.json(brands);
});
app.post('/api/brands', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { name, priority, color, icon } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare(
'INSERT INTO brands (name, priority, color, icon) VALUES (?, ?, ?, ?)'
).run(name, priority || 2, color || null, icon || null);
const brand = db.prepare('SELECT * FROM brands WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(brand);
});
app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM brands WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Brand not found' });
const { clauses, values, hasUpdates } = buildUpdate(req.body, ['name', 'priority', 'color', 'icon']);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
values.push(id);
db.prepare(`UPDATE brands SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const brand = db.prepare('SELECT * FROM brands WHERE id = ?').get(id);
res.json(brand);
});
// ─── POSTS ──────────────────────────────────────────────────────
app.get('/api/posts/stats', requireAuth, (req, res) => {
const stats = db.prepare(`
SELECT status, COUNT(*) as count FROM posts GROUP BY status
`).all();
const total = db.prepare('SELECT COUNT(*) as count FROM posts').get().count;
const result = { total };
for (const s of stats) {
result[s.status] = s.count;
}
res.json(result);
});
app.get('/api/posts', requireAuth, (req, res) => {
const { status, brand_id, assigned_to, platform } = req.query;
let sql = POST_SELECT_SQL;
const conditions = [];
const values = [];
if (status) { conditions.push('p.status = ?'); values.push(status); }
if (brand_id) { conditions.push('p.brand_id = ?'); values.push(brand_id); }
if (assigned_to) { conditions.push('p.assigned_to = ?'); values.push(assigned_to); }
if (platform) { conditions.push('p.platform = ?'); values.push(platform); }
if (req.query.campaign_id) { conditions.push('p.campaign_id = ?'); values.push(req.query.campaign_id); }
// Visibility filtering: contributors only see their own posts or posts assigned to them
if (req.session.userRole === 'contributor') {
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
const teamMemberId = currentUser?.team_member_id;
if (teamMemberId) {
conditions.push('(p.created_by_user_id = ? OR p.assigned_to = ?)');
values.push(req.session.userId, teamMemberId);
} else {
conditions.push('p.created_by_user_id = ?');
values.push(req.session.userId);
}
}
if (conditions.length > 0) sql += ' WHERE ' + conditions.join(' AND ');
sql += ' ORDER BY p.updated_at DESC';
const posts = db.prepare(sql).all(...values);
const thumbnailStmt = db.prepare("SELECT url FROM post_attachments WHERE post_id = ? AND mime_type LIKE 'image/%' ORDER BY created_at ASC LIMIT 1");
const postsWithThumbs = posts.map(p => ({
...parsePostJson(p),
thumbnail_url: thumbnailStmt.get(p.id)?.url || null,
}));
res.json(postsWithThumbs);
});
app.post('/api/posts', requireAuth, (req, res) => {
const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
// Support both single platform and platforms array
const platformsArr = platforms || (platform ? [platform] : []);
const result = db.prepare(`
INSERT INTO posts (title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(title, description || null, brand_id || null, assigned_to || null, status || 'draft', platformsArr[0] || null, JSON.stringify(platformsArr), content_type || null, scheduled_date || null, notes || null, campaign_id || null, req.session.userId);
const post = db.prepare(`${POST_SELECT_SQL} WHERE p.id = ?`).get(result.lastInsertRowid);
res.status(201).json(parsePostJson(post));
});
app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Post not found' });
const postFields = ['title', 'description', 'brand_id', 'assigned_to', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'campaign_id'];
const { clauses, values } = buildUpdate(req.body, postFields, {
jsonFields: ['platforms', 'publication_links'],
extraClauses: ['updated_at = CURRENT_TIMESTAMP'],
});
// Handle JSON array fields
if (req.body.platforms !== undefined) {
clauses.push('platforms = ?');
values.push(JSON.stringify(req.body.platforms));
if (!req.body.platform) {
clauses.push('platform = ?');
values.push(req.body.platforms[0] || null);
}
}
if (req.body.publication_links !== undefined) {
clauses.push('publication_links = ?');
values.push(JSON.stringify(req.body.publication_links));
}
// Validate publication links when publishing
if (req.body.status === 'published') {
const currentPlatforms = req.body.platforms || JSON.parse(existing.platforms || '[]');
const currentLinks = req.body.publication_links || JSON.parse(existing.publication_links || '[]');
const missingPlatforms = currentPlatforms.filter(platform => {
const link = currentLinks.find(l => l.platform === platform);
return !link || !link.url || !link.url.trim();
});
if (missingPlatforms.length > 0) {
return res.status(400).json({
error: `Cannot publish: missing publication links for: ${missingPlatforms.join(', ')}`,
missingPlatforms,
});
}
if (!req.body.published_date) {
clauses.push('published_date = CURRENT_TIMESTAMP');
}
}
values.push(id);
db.prepare(`UPDATE posts SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const post = db.prepare(`${POST_SELECT_SQL} WHERE p.id = ?`).get(id);
res.json(parsePostJson(post));
});
app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), (req, res) => {
const result = db.prepare('DELETE FROM posts WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Post not found' });
res.json({ success: true });
});
// ─── POST ATTACHMENTS ───────────────────────────────────────────
app.get('/api/posts/:id/attachments', requireAuth, (req, res) => {
const attachments = db.prepare('SELECT * FROM post_attachments WHERE post_id = ? ORDER BY created_at DESC').all(req.params.id);
res.json(attachments);
});
app.post('/api/posts/:id/attachments', requireAuth, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const postId = req.params.id;
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!post) return res.status(404).json({ error: 'Post not found' });
// Contributors can only add to their own posts
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId) {
// Also check if assigned to them
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
if (!currentUser?.team_member_id || post.assigned_to !== currentUser.team_member_id) {
fs.unlinkSync(path.join(uploadsDir, req.file.filename));
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
}
}
const url = `/api/uploads/${req.file.filename}`;
const result = db.prepare(`
INSERT INTO post_attachments (post_id, filename, original_name, mime_type, size, url)
VALUES (?, ?, ?, ?, ?, ?)
`).run(postId, req.file.filename, req.file.originalname, req.file.mimetype, req.file.size, url);
const attachment = db.prepare('SELECT * FROM post_attachments WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(attachment);
});
app.post('/api/posts/:id/attachments/from-asset', requireAuth, (req, res) => {
const { asset_id } = req.body;
if (!asset_id) return res.status(400).json({ error: 'asset_id is required' });
const postId = req.params.id;
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!post) return res.status(404).json({ error: 'Post not found' });
// Contributors can only add to their own posts
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId) {
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
if (!currentUser?.team_member_id || post.assigned_to !== currentUser.team_member_id) {
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
}
}
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(asset_id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
const url = `/api/uploads/${asset.filename}`;
const result = db.prepare(`
INSERT INTO post_attachments (post_id, filename, original_name, mime_type, size, url)
VALUES (?, ?, ?, ?, ?, ?)
`).run(postId, asset.filename, asset.original_name, asset.mime_type, asset.size, url);
const attachment = db.prepare('SELECT * FROM post_attachments WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(attachment);
});
app.delete('/api/attachments/:id', requireAuth, (req, res) => {
const attachment = db.prepare('SELECT pa.*, p.created_by_user_id, p.assigned_to FROM post_attachments pa JOIN posts p ON pa.post_id = p.id WHERE pa.id = ?').get(req.params.id);
if (!attachment) return res.status(404).json({ error: 'Attachment not found' });
// Contributors can only delete from their own posts
if (req.session.userRole === 'contributor' && attachment.created_by_user_id !== req.session.userId) {
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
if (!currentUser?.team_member_id || attachment.assigned_to !== currentUser.team_member_id) {
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
}
}
// Only delete file from disk if no asset or other attachment references it
const otherRefs = db.prepare('SELECT COUNT(*) as cnt FROM post_attachments WHERE filename = ? AND id != ?').get(attachment.filename, req.params.id);
const assetRef = db.prepare('SELECT COUNT(*) as cnt FROM assets WHERE filename = ?').get(attachment.filename);
if (otherRefs.cnt === 0 && assetRef.cnt === 0) {
const filePath = path.join(uploadsDir, attachment.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
db.prepare('DELETE FROM post_attachments WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ─── ASSETS ─────────────────────────────────────────────────────
app.get('/api/assets', requireAuth, (req, res) => {
const { brand_id, campaign_id, folder, tags } = req.query;
let sql = 'SELECT a.*, b.name as brand_name, c.name as campaign_name, t.name as uploader_name FROM assets a LEFT JOIN brands b ON a.brand_id = b.id LEFT JOIN campaigns c ON a.campaign_id = c.id LEFT JOIN team_members t ON a.uploaded_by = t.id';
const conditions = [];
const values = [];
if (brand_id) { conditions.push('a.brand_id = ?'); values.push(brand_id); }
if (campaign_id) { conditions.push('a.campaign_id = ?'); values.push(campaign_id); }
if (folder) { conditions.push('a.folder = ?'); values.push(folder); }
if (tags) { conditions.push('a.tags LIKE ?'); values.push(`%${tags}%`); }
if (conditions.length > 0) sql += ' WHERE ' + conditions.join(' AND ');
sql += ' ORDER BY a.created_at DESC';
const assets = db.prepare(sql).all(...values);
res.json(assets.map(a => ({ ...a, tags: JSON.parse(a.tags || '[]') })));
});
app.post('/api/assets/upload', requireAuth, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const { brand_id, campaign_id, uploaded_by, folder, tags } = req.body;
const result = db.prepare(`
INSERT INTO assets (filename, original_name, mime_type, size, tags, brand_id, campaign_id, uploaded_by, folder)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.file.filename,
req.file.originalname,
req.file.mimetype,
req.file.size,
tags || '[]',
brand_id || null,
campaign_id || null,
uploaded_by || null,
folder || 'general'
);
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ ...asset, tags: JSON.parse(asset.tags || '[]') });
});
app.delete('/api/assets/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
// Only delete file from disk if no post attachment references it
const attachmentRef = db.prepare('SELECT COUNT(*) as cnt FROM post_attachments WHERE filename = ?').get(asset.filename);
if (attachmentRef.cnt === 0) {
const filePath = path.join(uploadsDir, asset.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
db.prepare('DELETE FROM assets WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ─── CAMPAIGNS ──────────────────────────────────────────────────
app.get('/api/campaigns', requireAuth, (req, res) => {
const { brand_id, status, start_date, end_date } = req.query;
let sql = CAMPAIGN_SELECT_SQL;
const conditions = [];
const values = [];
if (brand_id) { conditions.push('c.brand_id = ?'); values.push(brand_id); }
if (status) { conditions.push('c.status = ?'); values.push(status); }
if (start_date) { conditions.push('c.end_date >= ?'); values.push(start_date); }
if (end_date) { conditions.push('c.start_date <= ?'); values.push(end_date); }
if (conditions.length > 0) sql += ' WHERE ' + conditions.join(' AND ');
sql += ' ORDER BY c.start_date DESC';
const campaigns = db.prepare(sql).all(...values);
res.json(campaigns.map(c => ({ ...c, platforms: JSON.parse(c.platforms || '[]') })));
});
app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' });
const result = db.prepare(`
INSERT INTO campaigns (name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, description || null, brand_id || null, start_date, end_date, status || 'planning', color || null, budget || null, goals || null, JSON.stringify(platforms || []), req.session.userId);
const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(result.lastInsertRowid);
res.status(201).json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
});
app.patch('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM campaigns WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Campaign not found' });
const campaignFields = [
'name', 'description', 'brand_id', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals',
'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes', 'platforms',
];
const { clauses, values, hasUpdates } = buildUpdate(req.body, campaignFields, { jsonFields: ['platforms'] });
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
values.push(id);
db.prepare(`UPDATE campaigns SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(id);
res.json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
});
app.delete('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const { id } = req.params;
try {
// Delete associated posts first (cascade)
db.prepare('DELETE FROM posts WHERE campaign_id = ?').run(id);
// Delete campaign tracks
db.prepare('DELETE FROM campaign_tracks WHERE campaign_id = ?').run(id);
// Unlink assets (set campaign_id to null instead of deleting the files)
db.prepare('UPDATE assets SET campaign_id = NULL WHERE campaign_id = ?').run(id);
// Delete the campaign
const result = db.prepare('DELETE FROM campaigns WHERE id = ?').run(id);
if (result.changes === 0) return res.status(404).json({ error: 'Campaign not found' });
res.json({ success: true });
} catch (err) {
console.error('Delete campaign error:', err);
res.status(500).json({ error: 'Failed to delete campaign' });
}
});
// ─── BUDGET ENTRIES ─────────────────────────────────────────────
app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const entries = db.prepare(`
SELECT be.*, c.name as campaign_name
FROM budget_entries be
LEFT JOIN campaigns c ON be.campaign_id = c.id
ORDER BY be.date_received DESC
`).all();
res.json(entries);
});
app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { label, amount, source, campaign_id, category, date_received, notes } = req.body;
if (!label || !amount || !date_received) return res.status(400).json({ error: 'Label, amount, and date are required' });
const result = db.prepare(`
INSERT INTO budget_entries (label, amount, source, campaign_id, category, date_received, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(label, amount, source || null, campaign_id || null, category || 'marketing', date_received, notes || '');
const entry = db.prepare('SELECT be.*, c.name as campaign_name FROM budget_entries be LEFT JOIN campaigns c ON be.campaign_id = c.id WHERE be.id = ?').get(result.lastInsertRowid);
res.status(201).json(entry);
});
app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const existing = db.prepare('SELECT * FROM budget_entries WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Budget entry not found' });
const { clauses, values, hasUpdates } = buildUpdate(
req.body, ['label', 'amount', 'source', 'campaign_id', 'category', 'date_received', 'notes']
);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
values.push(req.params.id);
db.prepare(`UPDATE budget_entries SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const entry = db.prepare('SELECT be.*, c.name as campaign_name FROM budget_entries be LEFT JOIN campaigns c ON be.campaign_id = c.id WHERE be.id = ?').get(req.params.id);
res.json(entry);
});
app.delete('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const result = db.prepare('DELETE FROM budget_entries WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Entry not found' });
res.json({ success: true });
});
// Finance summary — aggregates across all campaigns & tracks
app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const totalReceived = db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM budget_entries').get().total;
const campaignStats = db.prepare(`
SELECT
c.id, c.name, c.budget, c.status,
COALESCE(SUM(ct.budget_allocated), 0) as tracks_allocated,
COALESCE(SUM(ct.budget_spent), 0) as tracks_spent,
COALESCE(SUM(ct.revenue), 0) as tracks_revenue,
COALESCE(SUM(ct.impressions), 0) as tracks_impressions,
COALESCE(SUM(ct.clicks), 0) as tracks_clicks,
COALESCE(SUM(ct.conversions), 0) as tracks_conversions
FROM campaigns c
LEFT JOIN campaign_tracks ct ON ct.campaign_id = c.id
GROUP BY c.id
ORDER BY c.start_date DESC
`).all();
const totals = campaignStats.reduce((acc, c) => ({
allocated: acc.allocated + c.tracks_allocated,
spent: acc.spent + c.tracks_spent,
revenue: acc.revenue + c.tracks_revenue,
impressions: acc.impressions + c.tracks_impressions,
clicks: acc.clicks + c.tracks_clicks,
conversions: acc.conversions + c.tracks_conversions,
}), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 });
res.json({
totalReceived,
...totals,
remaining: totalReceived - totals.spent,
roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0,
campaigns: campaignStats,
});
});
// ─── CAMPAIGN TRACKS ────────────────────────────────────────────
app.get('/api/campaigns/:id/tracks', requireAuth, (req, res) => {
const tracks = db.prepare('SELECT * FROM campaign_tracks WHERE campaign_id = ? ORDER BY created_at').all(req.params.id);
res.json(tracks);
});
app.post('/api/campaigns/:id/tracks', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { name, type, platform, budget_allocated, status, notes } = req.body;
const campaign = db.prepare('SELECT id FROM campaigns WHERE id = ?').get(req.params.id);
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
const result = db.prepare(`
INSERT INTO campaign_tracks (campaign_id, name, type, platform, budget_allocated, status, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(req.params.id, name || null, type || 'organic_social', platform || null, budget_allocated || 0, status || 'planned', notes || '');
const track = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(track);
});
app.patch('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const existing = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Track not found' });
const { clauses, values, hasUpdates } = buildUpdate(req.body, [
'name', 'type', 'platform', 'budget_allocated', 'budget_spent', 'revenue',
'impressions', 'clicks', 'conversions', 'notes', 'status',
]);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
values.push(req.params.id);
db.prepare(`UPDATE campaign_tracks SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const track = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(req.params.id);
res.json(track);
});
app.delete('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const result = db.prepare('DELETE FROM campaign_tracks WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Track not found' });
res.json({ success: true });
});
// Get posts linked to a campaign (across all tracks)
app.get('/api/campaigns/:id/posts', requireAuth, (req, res) => {
const posts = db.prepare(`
SELECT p.*, t.name as assigned_name, b.name as brand_name, ct.name as track_name, ct.type as track_type
FROM posts p
LEFT JOIN team_members t ON p.assigned_to = t.id
LEFT JOIN brands b ON p.brand_id = b.id
LEFT JOIN campaign_tracks ct ON p.track_id = ct.id
WHERE p.campaign_id = ?
ORDER BY p.created_at DESC
`).all(req.params.id);
res.json(posts.map(parsePostJson));
});
// ─── PROJECTS ───────────────────────────────────────────────────
app.get('/api/projects', requireAuth, (req, res) => {
const { brand_id, owner_id, status } = req.query;
let sql = PROJECT_SELECT_SQL;
const conditions = [];
const values = [];
if (brand_id) { conditions.push('p.brand_id = ?'); values.push(brand_id); }
if (owner_id) { conditions.push('p.owner_id = ?'); values.push(owner_id); }
if (status) { conditions.push('p.status = ?'); values.push(status); }
if (conditions.length > 0) sql += ' WHERE ' + conditions.join(' AND ');
sql += ' ORDER BY p.created_at DESC';
res.json(db.prepare(sql).all(...values));
});
app.get('/api/projects/:id', requireAuth, (req, res) => {
const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(req.params.id);
if (!project) return res.status(404).json({ error: 'Project not found' });
res.json(project);
});
app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { name, description, brand_id, owner_id, status, priority, due_date } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare(`
INSERT INTO projects (name, description, brand_id, owner_id, status, priority, due_date, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, description || null, brand_id || null, owner_id || null, status || 'active', priority || 'medium', due_date || null, req.session.userId);
const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(result.lastInsertRowid);
res.status(201).json(project);
});
app.patch('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Project not found' });
const { clauses, values, hasUpdates } = buildUpdate(
req.body, ['name', 'description', 'brand_id', 'owner_id', 'status', 'priority', 'due_date']
);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
values.push(id);
db.prepare(`UPDATE projects SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(id);
res.json(project);
});
app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const result = db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Project not found' });
res.json({ success: true });
});
// ─── TASKS ──────────────────────────────────────────────────────
app.get('/api/tasks', requireAuth, (req, res) => {
const { project_id, assigned_to, status, is_personal } = req.query;
let sql = TASK_SELECT_SQL;
const conditions = [];
const values = [];
if (project_id) { conditions.push('t.project_id = ?'); values.push(project_id); }
if (assigned_to) { conditions.push('t.assigned_to = ?'); values.push(assigned_to); }
if (status) { conditions.push('t.status = ?'); values.push(status); }
if (is_personal !== undefined) { conditions.push('t.is_personal = ?'); values.push(is_personal === 'true' || is_personal === '1' ? 1 : 0); }
// Visibility filtering: contributors only see tasks they created or are assigned to
if (req.session.userRole === 'contributor') {
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
const teamMemberId = currentUser?.team_member_id;
if (teamMemberId) {
conditions.push('(t.created_by_user_id = ? OR t.assigned_to = ?)');
values.push(req.session.userId, teamMemberId);
} else {
conditions.push('t.created_by_user_id = ?');
values.push(req.session.userId);
}
}
if (conditions.length > 0) sql += ' WHERE ' + conditions.join(' AND ');
sql += ' ORDER BY t.created_at DESC';
res.json(db.prepare(sql).all(...values));
});
app.get('/api/tasks/my/:memberId', requireAuth, (req, res) => {
const tasks = db.prepare(`
SELECT t.*, p.name as project_name, u.name as creator_user_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN users u ON t.created_by_user_id = u.id
WHERE t.assigned_to = ? AND t.is_personal = 1
ORDER BY t.due_date ASC, t.priority DESC
`).all(req.params.memberId);
res.json(tasks);
});
app.post('/api/tasks', requireAuth, (req, res) => {
const { title, description, project_id, assigned_to, created_by, status, priority, due_date, is_personal } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(`
INSERT INTO tasks (title, description, project_id, assigned_to, created_by, status, priority, due_date, is_personal, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(title, description || null, project_id || null, assigned_to || null, created_by || null, status || 'todo', priority || 'medium', due_date || null, is_personal ? 1 : 0, req.session.userId);
const task = db.prepare(`${TASK_SELECT_SQL} WHERE t.id = ?`).get(result.lastInsertRowid);
res.status(201).json(task);
});
app.patch('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Task not found' });
// Pre-process is_personal to a SQLite-compatible integer
const body = { ...req.body };
if (body.is_personal !== undefined) {
body.is_personal = body.is_personal ? 1 : 0;
}
const extraClauses = [];
if (body.status === 'done' && existing.status !== 'done') {
extraClauses.push('completed_at = CURRENT_TIMESTAMP');
} else if (body.status && body.status !== 'done' && existing.status === 'done') {
extraClauses.push('completed_at = NULL');
}
const { clauses, values, hasUpdates } = buildUpdate(
body,
['title', 'description', 'project_id', 'assigned_to', 'created_by', 'status', 'priority', 'due_date', 'is_personal'],
{ extraClauses }
);
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
values.push(id);
db.prepare(`UPDATE tasks SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
const task = db.prepare(`${TASK_SELECT_SQL} WHERE t.id = ?`).get(id);
res.json(task);
});
app.delete('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin', 'manager'), (req, res) => {
const result = db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Task not found' });
res.json({ success: true });
});
// ─── DASHBOARD ──────────────────────────────────────────────────
app.get('/api/dashboard', requireAuth, (req, res) => {
// Post counts by status
const postsByStatus = db.prepare('SELECT status, COUNT(*) as count FROM posts GROUP BY status').all();
const totalPosts = db.prepare('SELECT COUNT(*) as count FROM posts').get().count;
// Active campaigns
const activeCampaigns = db.prepare("SELECT COUNT(*) as count FROM campaigns WHERE status = 'active'").get().count;
const totalCampaigns = db.prepare('SELECT COUNT(*) as count FROM campaigns').get().count;
// Overdue tasks
const overdueTasks = db.prepare(`
SELECT COUNT(*) as count FROM tasks
WHERE due_date < date('now') AND status != 'done'
`).get().count;
// Total tasks by status
const tasksByStatus = db.prepare('SELECT status, COUNT(*) as count FROM tasks GROUP BY status').all();
// Team workload (tasks assigned per member)
const teamWorkload = db.prepare(`
SELECT t.id, t.name, t.role,
COUNT(CASE WHEN tk.status != 'done' THEN 1 END) as active_tasks,
COUNT(CASE WHEN tk.status = 'done' THEN 1 END) as completed_tasks,
COUNT(CASE WHEN p.status NOT IN ('published', 'rejected') THEN 1 END) as active_posts
FROM team_members t
LEFT JOIN tasks tk ON tk.assigned_to = t.id
LEFT JOIN posts p ON p.assigned_to = t.id
GROUP BY t.id
ORDER BY active_tasks DESC
`).all();
// Active projects
const activeProjects = db.prepare("SELECT COUNT(*) as count FROM projects WHERE status = 'active'").get().count;
// Recent posts
const recentPosts = db.prepare(`
SELECT p.*, b.name as brand_name, t.name as assigned_name
FROM posts p
LEFT JOIN brands b ON p.brand_id = b.id
LEFT JOIN team_members t ON p.assigned_to = t.id
ORDER BY p.updated_at DESC LIMIT 5
`).all();
// Upcoming campaigns
const upcomingCampaigns = db.prepare(`
SELECT c.*, b.name as brand_name
FROM campaigns c
LEFT JOIN brands b ON c.brand_id = b.id
WHERE c.end_date >= date('now')
ORDER BY c.start_date ASC LIMIT 5
`).all();
res.json({
posts: {
total: totalPosts,
byStatus: postsByStatus.reduce((acc, s) => { acc[s.status] = s.count; return acc; }, {})
},
campaigns: {
total: totalCampaigns,
active: activeCampaigns
},
tasks: {
overdue: overdueTasks,
byStatus: tasksByStatus.reduce((acc, s) => { acc[s.status] = s.count; return acc; }, {})
},
projects: {
active: activeProjects
},
teamWorkload,
recentPosts,
upcomingCampaigns
});
});
// ─── COMMENTS / DISCUSSIONS ─────────────────────────────────
const COMMENT_ENTITY_TYPES = new Set(['post', 'task', 'project', 'campaign', 'asset']);
app.get('/api/comments/:entityType/:entityId', requireAuth, (req, res) => {
const { entityType, entityId } = req.params;
if (!COMMENT_ENTITY_TYPES.has(entityType)) {
return res.status(400).json({ error: 'Invalid entity type' });
}
const comments = db.prepare(`
SELECT c.*, u.name as user_name, u.avatar as user_avatar
FROM comments c
LEFT JOIN users u ON c.user_id = u.id
WHERE c.entity_type = ? AND c.entity_id = ?
ORDER BY c.created_at ASC
`).all(entityType, entityId);
res.json(comments);
});
app.post('/api/comments/:entityType/:entityId', requireAuth, (req, res) => {
const { entityType, entityId } = req.params;
const { content } = req.body;
if (!COMMENT_ENTITY_TYPES.has(entityType)) {
return res.status(400).json({ error: 'Invalid entity type' });
}
if (!content || !content.trim()) {
return res.status(400).json({ error: 'Content is required' });
}
const result = db.prepare(
'INSERT INTO comments (entity_type, entity_id, user_id, content) VALUES (?, ?, ?, ?)'
).run(entityType, entityId, req.session.userId, content.trim());
const comment = db.prepare(`
SELECT c.*, u.name as user_name, u.avatar as user_avatar
FROM comments c
LEFT JOIN users u ON c.user_id = u.id
WHERE c.id = ?
`).get(result.lastInsertRowid);
res.status(201).json(comment);
});
app.delete('/api/comments/:id', requireAuth, (req, res) => {
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(req.params.id);
if (!comment) return res.status(404).json({ error: 'Comment not found' });
// Only the comment author, managers, or superadmins can delete
if (comment.user_id !== req.session.userId && req.session.userRole !== 'superadmin' && req.session.userRole !== 'manager') {
return res.status(403).json({ error: 'You can only delete your own comments' });
}
db.prepare('DELETE FROM comments WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ─── ERROR HANDLING ─────────────────────────────────────────────
// Global Express error handler
app.use((err, req, res, next) => {
console.error(`[ERROR] ${req.method} ${req.path}:`, err.message);
res.status(500).json({ error: 'Internal server error', details: err.message });
});
// Prevent process crash on unhandled errors
process.on('uncaughtException', (err) => {
console.error('[UNCAUGHT]', err.message);
});
process.on('unhandledRejection', (err) => {
console.error('[UNHANDLED REJECTION]', err);
});
// ─── START SERVER ───────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`🚀 Marketing App API running on http://localhost:${PORT}`);
console.log(`📁 Uploads directory: ${uploadsDir}`);
});