Features: - Full RBAC with 3 roles (superadmin/manager/contributor) - Ownership tracking on posts, tasks, campaigns, projects - Task system: assign to anyone, filter combobox, visibility scoping - Team members merged into users table (single source of truth) - Post thumbnails on kanban cards from attachments - Publication link validation before publishing - Interactive onboarding tutorial with Settings restart - Full Arabic/English i18n with RTL layout support - Language toggle in sidebar, IBM Plex Sans Arabic font - Brand-based visibility filtering for non-superadmins - Manager can only create contributors - Profile completion flow for new users - Cookie-based sessions (express-session + SQLite)
1404 lines
59 KiB
JavaScript
1404 lines
59 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;
|
|
|
|
// 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}`);
|
|
});
|