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; // 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 storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadsDir), filename: (req, file, cb) => { 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) function requireOwnerOrRole(table, ...allowedRoles) { 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 { name, team_role, brands, phone } = req.body; const updates = []; const values = []; if (name !== undefined) { updates.push('name = ?'); values.push(name); } if (team_role !== undefined) { updates.push('team_role = ?'); values.push(team_role); } if (phone !== undefined) { updates.push('phone = ?'); values.push(phone); } if (brands !== undefined) { updates.push('brands = ?'); values.push(JSON.stringify(brands)); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } try { values.push(req.session.userId); db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values); // Also update session if name changed if (name !== undefined) { req.session.userName = 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' }); } const { name, email, password, role, avatar } = req.body; const updates = []; const values = []; if (name !== undefined) { updates.push('name = ?'); values.push(name); } if (email !== undefined) { updates.push('email = ?'); values.push(email); } if (role !== undefined) { if (!['superadmin', 'manager', 'contributor'].includes(role)) { return res.status(400).json({ error: 'Invalid role' }); } updates.push('role = ?'); values.push(role); } if (avatar !== undefined) { updates.push('avatar = ?'); values.push(avatar); } if (req.body.team_member_id !== undefined) { updates.push('team_member_id = ?'); values.push(req.body.team_member_id || null); } if (password) { const passwordHash = await bcrypt.hash(password, 10); updates.push('password_hash = ?'); values.push(passwordHash); } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } try { values.push(id); db.prepare(`UPDATE users SET ${updates.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, }); }); // ─── 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 updates = []; const values = []; if (req.body.name !== undefined) { updates.push('name = ?'); values.push(req.body.name); } if (req.body.email !== undefined) { updates.push('email = ?'); values.push(req.body.email); } if (req.body.team_role !== undefined) { updates.push('team_role = ?'); values.push(req.body.team_role); } if (req.body.phone !== undefined) { updates.push('phone = ?'); values.push(req.body.phone); } if (req.body.brands !== undefined) { updates.push('brands = ?'); values.push(JSON.stringify(req.body.brands)); } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); // Also update team_members table for backward compatibility if (existing.team_member_id) { const tmUpdates = []; const tmValues = []; if (req.body.name !== undefined) { tmUpdates.push('name = ?'); tmValues.push(req.body.name); } if (req.body.email !== undefined) { tmUpdates.push('email = ?'); tmValues.push(req.body.email); } if (req.body.team_role !== undefined) { tmUpdates.push('role = ?'); tmValues.push(req.body.team_role); } if (req.body.phone !== undefined) { tmUpdates.push('phone = ?'); tmValues.push(req.body.phone); } if (req.body.brands !== undefined) { tmUpdates.push('brands = ?'); tmValues.push(JSON.stringify(req.body.brands)); } if (tmUpdates.length > 0) { tmValues.push(existing.team_member_id); db.prepare(`UPDATE team_members SET ${tmUpdates.join(', ')} WHERE id = ?`).run(...tmValues); } } values.push(id); db.prepare(`UPDATE users SET ${updates.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 fields = ['name', 'email', 'role', 'avatar_url']; const updates = []; const values = []; for (const field of fields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (req.body.brands !== undefined) { updates.push('brands = ?'); values.push(JSON.stringify(req.body.brands)); } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(id); db.prepare(`UPDATE team_members SET ${updates.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 fields = ['name', 'priority', 'color', 'icon']; const updates = []; const values = []; for (const field of fields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(id); db.prepare(`UPDATE brands SET ${updates.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 = '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 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); // Add thumbnail for each post const postsWithThumbs = posts.map(p => { const thumb = db.prepare("SELECT url, mime_type FROM post_attachments WHERE post_id = ? AND mime_type LIKE 'image/%' ORDER BY created_at ASC LIMIT 1").get(p.id); return { ...p, platforms: JSON.parse(p.platforms || '[]'), publication_links: JSON.parse(p.publication_links || '[]'), thumbnail_url: thumb?.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('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 WHERE p.id = ?').get(result.lastInsertRowid); res.status(201).json({ ...post, platforms: JSON.parse(post.platforms || '[]'), publication_links: JSON.parse(post.publication_links || '[]') }); }); 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 fields = ['title', 'description', 'brand_id', 'assigned_to', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'campaign_id']; const updates = ['updated_at = CURRENT_TIMESTAMP']; const values = []; for (const field of fields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (req.body.platforms !== undefined) { updates.push('platforms = ?'); values.push(JSON.stringify(req.body.platforms)); // Also keep platform field in sync (first platform) if (!req.body.platform) { updates.push('platform = ?'); values.push(req.body.platforms[0] || null); } } if (req.body.publication_links !== undefined) { updates.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 ? req.body.platforms : JSON.parse(existing.platforms || '[]'); const currentLinks = req.body.publication_links ? req.body.publication_links : JSON.parse(existing.publication_links || '[]'); if (currentPlatforms.length > 0) { 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 }); } } } // Auto-set published_date when status changes to published if (req.body.status === 'published' && !req.body.published_date) { updates.push('published_date = CURRENT_TIMESTAMP'); } values.push(id); db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...values); const post = db.prepare('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 WHERE p.id = ?').get(id); res.json({ ...post, platforms: JSON.parse(post.platforms || '[]'), publication_links: JSON.parse(post.publication_links || '[]') }); }); 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.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' }); } } // Delete file from disk 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' }); // Delete file from disk 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 = 'SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id'; 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('SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id 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 fields = ['name', 'description', 'brand_id', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals', 'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes']; const updates = []; const values = []; for (const field of fields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (req.body.platforms !== undefined) { updates.push('platforms = ?'); values.push(JSON.stringify(req.body.platforms)); } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(id); db.prepare(`UPDATE campaigns SET ${updates.join(', ')} WHERE id = ?`).run(...values); const campaign = db.prepare('SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id 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 fields = ['label', 'amount', 'source', 'campaign_id', 'category', 'date_received', 'notes']; const updates = []; const values = []; for (const field of fields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(req.params.id); db.prepare(`UPDATE budget_entries SET ${updates.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 fields = ['name', 'type', 'platform', 'budget_allocated', 'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'notes', 'status']; const updates = []; const values = []; for (const field of fields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(req.params.id); db.prepare(`UPDATE campaign_tracks SET ${updates.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(p => ({ ...p, platforms: JSON.parse(p.platforms || '[]'), publication_links: JSON.parse(p.publication_links || '[]') }))); }); // ─── PROJECTS ─────────────────────────────────────────────────── app.get('/api/projects', requireAuth, (req, res) => { const { brand_id, owner_id, status } = req.query; let 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 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('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 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('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 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 fields = ['name', 'description', 'brand_id', 'owner_id', 'status', 'priority', 'due_date']; const updates = []; const values = []; for (const field of fields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(id); db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE id = ?`).run(...values); const project = db.prepare('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 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 = `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 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: non-superadmins only see tasks they created or are assigned to if (req.session.userRole !== 'superadmin') { 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(`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 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' }); const fields = ['title', 'description', 'project_id', 'assigned_to', 'created_by', 'status', 'priority', 'due_date', 'is_personal']; const updates = []; const values = []; for (const field of fields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(field === 'is_personal' ? (req.body[field] ? 1 : 0) : req.body[field]); } } // Auto-set completed_at when status changes to done if (req.body.status === 'done' && existing.status !== 'done') { updates.push('completed_at = CURRENT_TIMESTAMP'); } else if (req.body.status && req.body.status !== 'done' && existing.status === 'done') { updates.push('completed_at = NULL'); } if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(id); db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...values); const task = db.prepare(`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 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 }); }); // ─── 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}`); });