Files
marketing-app/server/server.js

2040 lines
80 KiB
JavaScript

require('dotenv').config({ path: __dirname + '/.env' });
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 nocodb = require('./nocodb');
const { authDb } = require('./auth-db');
const app = express();
const PORT = 3001;
// ─── HELPERS ────────────────────────────────────────────────────
// Extract first linked record ID from NocoDB linked field
function linkId(record, field) {
const val = record?.[field];
if (Array.isArray(val) && val.length > 0) return val[0].Id;
if (val && typeof val === 'object' && val.Id) return val.Id;
return null;
}
// Extract first linked record name
function linkName(record, field) {
const val = record?.[field];
if (Array.isArray(val) && val.length > 0) return val[0].name || val[0].title || val[0].Name || null;
if (val && typeof val === 'object') return val.name || val.title || val.Name || null;
return null;
}
// 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) {
if (!origin) return callback(null, true);
callback(null, true);
},
credentials: true
}));
app.use(express.json());
// Session middleware
app.use(session({
store: new SqliteStore({ db: 'sessions.db', dir: __dirname }),
secret: 'digital-hub-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000
}
}));
// Serve uploaded files
app.use('/api/uploads', express.static(uploadsDir));
// Multer config
const decodeOriginalName = (name) => Buffer.from(name, 'latin1').toString('utf8');
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => {
file.originalname = decodeOriginalName(file.originalname);
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`;
cb(null, uniqueName);
}
});
const upload = multer({ storage, limits: { fileSize: 50 * 1024 * 1024 } });
// ─── 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();
};
}
// NocoDB table name mapping for ownership checks
const TABLE_NAME_MAP = { posts: 'Posts', tasks: 'Tasks', campaigns: 'Campaigns', projects: 'Projects' };
function requireOwnerOrRole(table, ...allowedRoles) {
const nocoTable = TABLE_NAME_MAP[table];
if (!nocoTable) throw new Error(`requireOwnerOrRole: invalid table "${table}"`);
return async (req, res, next) => {
if (!req.session.userId) return res.status(401).json({ error: 'Authentication required' });
if (allowedRoles.includes(req.session.userRole)) return next();
try {
const row = await nocodb.get(nocoTable, req.params.id);
if (!row) return res.status(404).json({ error: 'Not found' });
// Check plain FK fields (Number columns)
if (row.created_by_user_id === req.session.userId) return next();
if (row.assigned_to_id && row.assigned_to_id === req.session.userId) return next();
if (row.owner_id && row.owner_id === req.session.userId) return next();
return res.status(403).json({ error: 'You can only modify your own items' });
} catch (err) {
console.error('Owner check error:', err);
return res.status(500).json({ error: 'Permission check failed' });
}
};
}
// ─── LINK HELPER (legacy, used only for migration) ─────────────
async function linkRecord(table, recordId, linkField, linkedRecordId) {
if (!linkedRecordId) return;
const info = await nocodb.getLinkColId(table, linkField);
if (!info) return;
await fetch(`${nocodb.url}/api/v2/tables/${info.tableId}/links/${info.colId}/records/${recordId}`, {
method: 'POST',
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
body: JSON.stringify([{ Id: linkedRecordId }]),
});
}
async function unlinkRecord(table, recordId, linkField, linkedRecordId) {
if (!linkedRecordId) return;
const info = await nocodb.getLinkColId(table, linkField);
if (!info) return;
await fetch(`${nocodb.url}/api/v2/tables/${info.tableId}/links/${info.colId}/records/${recordId}`, {
method: 'DELETE',
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
body: JSON.stringify([{ Id: linkedRecordId }]),
});
}
// ─── FK MIGRATION: Replace link columns with plain Number fields ──
const FK_COLUMNS = {
Tasks: ['project_id', 'assigned_to_id', 'created_by_user_id'],
CampaignTracks: ['campaign_id'],
CampaignAssignments: ['campaign_id', 'member_id', 'assigner_id'],
Projects: ['brand_id', 'owner_id', 'created_by_user_id'],
Campaigns: ['brand_id', 'created_by_user_id'],
Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'],
Assets: ['brand_id', 'campaign_id', 'uploader_id'],
PostAttachments: ['post_id'],
Comments: ['user_id'],
BudgetEntries: ['campaign_id'],
};
// Maps link column names to FK field names for migration
const LINK_TO_FK = {
Tasks: { Project: 'project_id', AssignedTo: 'assigned_to_id', CreatedByUser: 'created_by_user_id' },
CampaignTracks: { Campaign: 'campaign_id' },
CampaignAssignments: { Campaign: 'campaign_id', Member: 'member_id', Assigner: 'assigner_id' },
Projects: { Brand: 'brand_id', Owner: 'owner_id', CreatedByUser: 'created_by_user_id' },
Campaigns: { Brand: 'brand_id', CreatedByUser: 'created_by_user_id' },
Posts: { Brand: 'brand_id', AssignedTo: 'assigned_to_id', Campaign: 'campaign_id', Track: 'track_id', CreatedByUser: 'created_by_user_id' },
Assets: { Brand: 'brand_id', Campaign: 'campaign_id', Uploader: 'uploader_id' },
PostAttachments: { Post: 'post_id' },
Comments: { User: 'user_id' },
BudgetEntries: { Campaign: 'campaign_id' },
};
async function ensureFKColumns() {
for (const [table, columns] of Object.entries(FK_COLUMNS)) {
try {
const tableId = await nocodb.resolveTableId(table);
const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, {
headers: { 'xc-token': nocodb.token },
});
if (!res.ok) continue;
const meta = await res.json();
const existingCols = new Set((meta.columns || []).map(c => c.title));
for (const col of columns) {
if (!existingCols.has(col)) {
console.log(` Adding column ${table}.${col}...`);
await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
method: 'POST',
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: col, uidt: 'Number' }),
});
}
}
} catch (err) {
console.error(` Failed to ensure columns for ${table}:`, err.message);
}
}
}
async function backfillFKs() {
for (const [table, linkMap] of Object.entries(LINK_TO_FK)) {
try {
const linkFields = Object.keys(linkMap);
const records = await nocodb.list(table, { limit: 10000, links: linkFields });
let updated = 0;
for (const record of records) {
const patch = {};
let needsUpdate = false;
for (const [linkCol, fkField] of Object.entries(linkMap)) {
const linkedId = linkId(record, linkCol);
if (linkedId && !record[fkField]) {
patch[fkField] = linkedId;
needsUpdate = true;
}
}
if (needsUpdate) {
await nocodb.update(table, record.Id, patch);
updated++;
}
}
if (updated > 0) console.log(` Backfilled ${updated} records in ${table}`);
} catch (err) {
console.error(` Failed to backfill ${table}:`, err.message);
}
}
}
// Name lookup helper — fetches a record's display name by table and ID
const _nameCache = {};
async function getRecordName(table, id) {
if (!id) return null;
const key = `${table}:${id}`;
if (_nameCache[key] !== undefined) return _nameCache[key];
try {
const r = await nocodb.get(table, id);
const name = r?.name || r?.title || r?.Name || null;
_nameCache[key] = name;
return name;
} catch {
_nameCache[key] = null;
return null;
}
}
// Clear name cache periodically (every 60s)
setInterval(() => { Object.keys(_nameCache).forEach(k => delete _nameCache[k]); }, 60000);
// ─── 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 cred = authDb.prepare('SELECT * FROM auth_credentials WHERE email = ?').get(email);
if (!cred) return res.status(401).json({ error: 'Invalid email or password' });
const valid = await bcrypt.compare(password, cred.password_hash);
if (!valid) return res.status(401).json({ error: 'Invalid email or password' });
// Fetch profile from NocoDB
const user = await nocodb.get('Users', cred.nocodb_user_id);
if (!user) return res.status(401).json({ error: 'User profile not found' });
req.session.userId = user.Id;
req.session.userEmail = user.email;
req.session.userRole = user.role;
req.session.userName = user.name;
res.json({
user: {
id: user.Id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
team_role: user.team_role,
tutorial_completed: user.tutorial_completed,
profileComplete: !!user.team_role,
},
});
} 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, async (req, res) => {
try {
const user = await nocodb.get('Users', req.session.userId);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
Id: user.Id, id: user.Id, name: user.name, email: user.email,
role: user.role, avatar: user.avatar, team_role: user.team_role,
brands: user.brands, phone: user.phone,
tutorial_completed: user.tutorial_completed,
CreatedAt: user.CreatedAt, created_at: user.CreatedAt,
profileComplete: !!user.team_role,
});
} catch (err) {
console.error('Auth/me error:', err);
res.status(500).json({ error: 'Failed to fetch user' });
}
});
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', canAssignCampaigns: canManage,
canSetBudget: role === 'superadmin',
canCreatePosts: true, canCreateTasks: true,
canEditAnyPost: canManage, canDeleteAnyPost: canManage,
canEditAnyTask: canManage, canDeleteAnyTask: canManage,
});
});
// ─── SELF-SERVICE PROFILE ───────────────────────────────────────
app.get('/api/users/me/profile', requireAuth, async (req, res) => {
try {
const user = await nocodb.get('Users', req.session.userId);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({
Id: user.Id, id: user.Id, name: user.name, email: user.email,
role: user.role, team_role: user.team_role, brands: user.brands,
phone: user.phone, avatar: user.avatar, tutorial_completed: user.tutorial_completed,
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch profile' });
}
});
app.patch('/api/users/me/profile', requireAuth, async (req, res) => {
const data = {};
if (req.body.name !== undefined) data.name = req.body.name;
if (req.body.team_role !== undefined) data.team_role = req.body.team_role;
if (req.body.phone !== undefined) data.phone = req.body.phone;
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
try {
await nocodb.update('Users', req.session.userId, data);
if (data.name) req.session.userName = data.name;
const user = await nocodb.get('Users', req.session.userId);
res.json({
Id: user.Id, id: user.Id, name: user.name, email: user.email,
role: user.role, team_role: user.team_role, brands: user.brands,
phone: user.phone, avatar: user.avatar, tutorial_completed: user.tutorial_completed,
});
} catch (err) {
console.error('Update profile error:', err);
res.status(500).json({ error: 'Failed to update profile' });
}
});
app.patch('/api/users/me/tutorial', requireAuth, async (req, res) => {
try {
await nocodb.update('Users', req.session.userId, { tutorial_completed: !!req.body.completed });
res.json({ success: true, tutorial_completed: req.body.completed ? 1 : 0 });
} catch (err) {
res.status(500).json({ error: 'Failed to update tutorial status' });
}
});
// ─── USER MANAGEMENT (Superadmin only) ──────────────────────────
app.get('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const users = await nocodb.list('Users', { sort: '-CreatedAt' });
res.json(users);
} catch (err) {
res.status(500).json({ error: 'Failed to load users' });
}
});
app.post('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
const { name, email, password, role, avatar } = 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 = authDb.prepare('SELECT id FROM auth_credentials WHERE email = ?').get(email);
if (existing) return res.status(409).json({ error: 'Email already exists' });
const created = await nocodb.create('Users', { name, email, role, avatar: avatar || null });
const passwordHash = await bcrypt.hash(password, 10);
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
const user = await nocodb.get('Users', created.Id);
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;
try {
const existing = await nocodb.get('Users', id);
if (!existing) return res.status(404).json({ error: 'User not found' });
if (req.body.role && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) return res.status(400).json({ error: 'Invalid role' });
const data = {};
for (const f of ['name', 'email', 'role', 'avatar']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.password) {
const hash = await bcrypt.hash(req.body.password, 10);
authDb.prepare('UPDATE auth_credentials SET password_hash = ? WHERE nocodb_user_id = ?').run(hash, Number(id));
}
if (req.body.email && req.body.email !== existing.email) {
authDb.prepare('UPDATE auth_credentials SET email = ? WHERE nocodb_user_id = ?').run(req.body.email, Number(id));
}
if (Object.keys(data).length > 0) await nocodb.update('Users', id, data);
const user = await nocodb.get('Users', 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'), async (req, res) => {
const { id } = req.params;
if (Number(id) === req.session.userId) return res.status(400).json({ error: 'Cannot delete your own account' });
try {
const user = await nocodb.get('Users', id);
if (!user) return res.status(404).json({ error: 'User not found' });
await nocodb.delete('Users', id);
authDb.prepare('DELETE FROM auth_credentials WHERE nocodb_user_id = ?').run(Number(id));
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete user' });
}
});
// ─── ASSIGNABLE USERS ───────────────────────────────────────────
app.get('/api/users/assignable', requireAuth, async (req, res) => {
try {
const users = await nocodb.list('Users', {
where: '(team_role,isnot,null)',
sort: 'name',
});
res.json(users.map(u => ({ ...u, id: u.Id, _id: u.Id })));
} catch (err) {
res.status(500).json({ error: 'Failed to load assignable users' });
}
});
// ─── TEAM ───────────────────────────────────────────────────────
app.get('/api/users/team', requireAuth, async (req, res) => {
try {
const users = await nocodb.list('Users', {
where: '(team_role,isnot,null)',
sort: 'name',
});
const skipBrandFilter = req.query.all === 'true' && (req.session.userRole === 'superadmin' || req.session.userRole === 'manager');
let filtered = users;
if (req.session.userRole !== 'superadmin' && !skipBrandFilter) {
const currentUser = await nocodb.get('Users', req.session.userId);
let myBrands = [];
try { myBrands = JSON.parse(currentUser?.brands || '[]'); } catch {}
filtered = users.filter(u => {
let theirBrands = [];
try { theirBrands = JSON.parse(u.brands || '[]'); } catch {}
return u.Id === req.session.userId || theirBrands.some(b => myBrands.includes(b));
});
}
res.json(filtered.map(u => ({ ...u, id: u.Id, _id: u.Id })));
} catch (err) {
console.error('Team list error:', err);
res.status(500).json({ error: 'Failed to load team' });
}
});
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' });
let userRole = role || 'contributor';
if (req.session.userRole === 'manager' && userRole !== 'contributor') {
return res.status(403).json({ error: 'Managers can only create users with contributor role' });
}
try {
const existing = authDb.prepare('SELECT id FROM auth_credentials WHERE email = ?').get(email);
if (existing) return res.status(409).json({ error: 'Email already exists' });
const created = await nocodb.create('Users', {
name, email, role: userRole, team_role: team_role || null,
brands: JSON.stringify(brands || []), phone: phone || null,
});
const defaultPassword = password || 'changeme123';
const passwordHash = await bcrypt.hash(defaultPassword, 10);
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
const user = await nocodb.get('Users', created.Id);
res.status(201).json({ ...user, id: user.Id, _id: user.Id });
} 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'), async (req, res) => {
try {
const existing = await nocodb.get('Users', req.params.id);
if (!existing) return res.status(404).json({ error: 'User not found' });
const data = {};
for (const f of ['name', 'email', 'team_role', 'phone']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
if (data.email && data.email !== existing.email) {
authDb.prepare('UPDATE auth_credentials SET email = ? WHERE nocodb_user_id = ?').run(data.email, Number(req.params.id));
}
await nocodb.update('Users', req.params.id, data);
const user = await nocodb.get('Users', req.params.id);
res.json({ ...user, id: user.Id, _id: user.Id });
} catch (err) {
console.error('Update team error:', err);
res.status(500).json({ error: 'Failed to update team member' });
}
});
app.delete('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const user = await nocodb.get('Users', req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
await nocodb.delete('Users', req.params.id);
authDb.prepare('DELETE FROM auth_credentials WHERE nocodb_user_id = ?').run(Number(req.params.id));
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete team member' });
}
});
// ─── LEGACY TEAM API (redirects to Users) ───────────────────────
app.get('/api/team', requireAuth, async (req, res) => {
try {
const users = await nocodb.list('Users', { sort: 'name' });
res.json(users.map(u => ({ ...u, id: u.Id, _id: u.Id })));
} catch (err) {
res.status(500).json({ error: 'Failed to load team' });
}
});
// ─── BRANDS ─────────────────────────────────────────────────────
app.get('/api/brands', requireAuth, async (req, res) => {
try {
const brands = await nocodb.list('Brands', { sort: 'priority,name' });
res.json(brands);
} catch (err) {
res.status(500).json({ error: 'Failed to load brands' });
}
});
app.post('/api/brands', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const { name, priority, color, icon } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
try {
const created = await nocodb.create('Brands', { name, priority: priority || 2, color: color || null, icon: icon || null });
const brand = await nocodb.get('Brands', created.Id);
res.status(201).json(brand);
} catch (err) {
res.status(500).json({ error: 'Failed to create brand' });
}
});
app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('Brands', req.params.id);
if (!existing) return res.status(404).json({ error: 'Brand not found' });
const data = {};
for (const f of ['name', 'priority', 'color', 'icon']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
await nocodb.update('Brands', req.params.id, data);
const brand = await nocodb.get('Brands', req.params.id);
res.json(brand);
} catch (err) {
res.status(500).json({ error: 'Failed to update brand' });
}
});
// ─── POSTS ──────────────────────────────────────────────────────
app.get('/api/posts/stats', requireAuth, async (req, res) => {
try {
const posts = await nocodb.list('Posts', { fields: 'status', limit: 10000 });
const result = { total: posts.length };
for (const p of posts) {
result[p.status] = (result[p.status] || 0) + 1;
}
res.json(result);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch post stats' });
}
});
app.get('/api/posts', requireAuth, async (req, res) => {
try {
const { status, brand_id, assigned_to, platform, campaign_id } = req.query;
const whereParts = [];
if (status) whereParts.push(`(status,eq,${status})`);
if (platform) whereParts.push(`(platform,eq,${platform})`);
if (brand_id) whereParts.push(`(brand_id,eq,${brand_id})`);
if (assigned_to) whereParts.push(`(assigned_to_id,eq,${assigned_to})`);
if (campaign_id) whereParts.push(`(campaign_id,eq,${campaign_id})`);
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
const posts = await nocodb.list('Posts', { where, sort: '-UpdatedAt', limit: 500 });
// Visibility filtering for contributors
let filtered = posts;
if (req.session.userRole === 'contributor') {
filtered = filtered.filter(p =>
p.created_by_user_id === req.session.userId || p.assigned_to_id === req.session.userId
);
}
// Get thumbnails
const allAttachments = await nocodb.list('PostAttachments', {
where: "(mime_type,like,image/%)",
limit: 10000,
});
const thumbMap = {};
for (const att of allAttachments) {
if (att.post_id && !thumbMap[att.post_id]) thumbMap[att.post_id] = att.url;
}
// Collect unique IDs for name lookups
const brandIds = new Set(), userIds = new Set(), campaignIds = new Set();
for (const p of filtered) {
if (p.brand_id) brandIds.add(p.brand_id);
if (p.assigned_to_id) userIds.add(p.assigned_to_id);
if (p.created_by_user_id) userIds.add(p.created_by_user_id);
if (p.campaign_id) campaignIds.add(p.campaign_id);
}
const names = {};
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
for (const id of campaignIds) names[`campaign:${id}`] = await getRecordName('Campaigns', id);
res.json(filtered.map(p => ({
...p,
brand_id: p.brand_id,
assigned_to: p.assigned_to_id,
campaign_id: p.campaign_id,
created_by_user_id: p.created_by_user_id,
brand_name: names[`brand:${p.brand_id}`] || null,
assigned_name: names[`user:${p.assigned_to_id}`] || null,
campaign_name: names[`campaign:${p.campaign_id}`] || null,
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
thumbnail_url: thumbMap[p.Id] || null,
})));
} catch (err) {
console.error('GET /posts error:', err);
res.status(500).json({ error: 'Failed to load posts' });
}
});
app.post('/api/posts', requireAuth, async (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' });
const platformsArr = platforms || (platform ? [platform] : []);
try {
const created = await nocodb.create('Posts', {
title, description: description || null,
status: status || 'draft',
platform: platformsArr[0] || null,
platforms: JSON.stringify(platformsArr),
content_type: content_type || null,
scheduled_date: scheduled_date || null,
notes: notes || null,
publication_links: '[]',
brand_id: brand_id ? Number(brand_id) : null,
assigned_to_id: assigned_to ? Number(assigned_to) : null,
campaign_id: campaign_id ? Number(campaign_id) : null,
created_by_user_id: req.session.userId,
});
const post = await nocodb.get('Posts', created.Id);
res.status(201).json({
...post,
assigned_to: post.assigned_to_id,
brand_name: await getRecordName('Brands', post.brand_id),
assigned_name: await getRecordName('Users', post.assigned_to_id),
campaign_name: await getRecordName('Campaigns', post.campaign_id),
creator_user_name: await getRecordName('Users', post.created_by_user_id),
});
} catch (err) {
console.error('Create post error:', err);
res.status(500).json({ error: 'Failed to create post' });
}
});
app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
const { id } = req.params;
try {
const existing = await nocodb.get('Posts', id);
if (!existing) return res.status(404).json({ error: 'Post not found' });
const data = {};
for (const f of ['title', 'description', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.platforms !== undefined) {
data.platforms = JSON.stringify(req.body.platforms);
if (!req.body.platform) data.platform = req.body.platforms[0] || null;
}
if (req.body.publication_links !== undefined) {
data.publication_links = JSON.stringify(req.body.publication_links);
}
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
if (req.body.assigned_to !== undefined) data.assigned_to_id = req.body.assigned_to ? Number(req.body.assigned_to) : null;
if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null;
// Publish validation
if (req.body.status === 'published') {
let currentPlatforms, currentLinks;
try { currentPlatforms = req.body.platforms || JSON.parse(existing.platforms || '[]'); } catch { currentPlatforms = []; }
try { currentLinks = req.body.publication_links || JSON.parse(existing.publication_links || '[]'); } catch { currentLinks = []; }
const missing = currentPlatforms.filter(pl => {
const link = currentLinks.find(l => l.platform === pl);
return !link || !link.url || !link.url.trim();
});
if (missing.length > 0) {
return res.status(400).json({ error: `Cannot publish: missing publication links for: ${missing.join(', ')}`, missingPlatforms: missing });
}
if (!req.body.published_date) data.published_date = new Date().toISOString();
}
await nocodb.update('Posts', id, data);
const post = await nocodb.get('Posts', id);
res.json({
...post,
assigned_to: post.assigned_to_id,
brand_name: await getRecordName('Brands', post.brand_id),
assigned_name: await getRecordName('Users', post.assigned_to_id),
campaign_name: await getRecordName('Campaigns', post.campaign_id),
creator_user_name: await getRecordName('Users', post.created_by_user_id),
});
} catch (err) {
console.error('Update post error:', err);
res.status(500).json({ error: 'Failed to update post' });
}
});
app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
try {
await nocodb.delete('Posts', req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete post' });
}
});
// ─── POST ATTACHMENTS ───────────────────────────────────────────
app.get('/api/posts/:id/attachments', requireAuth, async (req, res) => {
try {
const attachments = await nocodb.list('PostAttachments', {
where: `(post_id,eq,${req.params.id})`,
sort: '-CreatedAt', limit: 1000,
});
res.json(attachments);
} catch (err) {
res.status(500).json({ error: 'Failed to load attachments' });
}
});
app.post('/api/posts/:id/attachments', requireAuth, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const post = await nocodb.get('Posts', req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
// Contributor check
if (req.session.userRole === 'contributor') {
if (post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
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 created = await nocodb.create('PostAttachments', {
filename: req.file.filename,
original_name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
url,
post_id: Number(req.params.id),
});
const attachment = await nocodb.get('PostAttachments', created.Id);
res.status(201).json(attachment);
} catch (err) {
console.error('Upload attachment error:', err);
res.status(500).json({ error: 'Failed to upload attachment' });
}
});
app.post('/api/posts/:id/attachments/from-asset', requireAuth, async (req, res) => {
const { asset_id } = req.body;
if (!asset_id) return res.status(400).json({ error: 'asset_id is required' });
try {
const post = await nocodb.get('Posts', req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
if (req.session.userRole === 'contributor') {
if (post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
}
}
const asset = await nocodb.get('Assets', asset_id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
const url = `/api/uploads/${asset.filename}`;
const created = await nocodb.create('PostAttachments', {
filename: asset.filename,
original_name: asset.original_name,
mime_type: asset.mime_type,
size: asset.size,
url,
post_id: Number(req.params.id),
});
const attachment = await nocodb.get('PostAttachments', created.Id);
res.status(201).json(attachment);
} catch (err) {
res.status(500).json({ error: 'Failed to create attachment from asset' });
}
});
app.delete('/api/attachments/:id', requireAuth, async (req, res) => {
try {
const attachment = await nocodb.get('PostAttachments', req.params.id);
if (!attachment) return res.status(404).json({ error: 'Attachment not found' });
// Contributor check: get linked post
if (req.session.userRole === 'contributor') {
if (attachment.post_id) {
const post = await nocodb.get('Posts', attachment.post_id);
if (post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
}
}
}
// Check if file is referenced elsewhere before deleting
const allAttachments = await nocodb.list('PostAttachments', {
where: `(filename,eq,${attachment.filename})`,
limit: 10,
});
const allAssets = await nocodb.list('Assets', {
where: `(filename,eq,${attachment.filename})`,
limit: 10,
});
if (allAttachments.length <= 1 && allAssets.length === 0) {
const filePath = path.join(uploadsDir, attachment.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
await nocodb.delete('PostAttachments', req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Delete attachment error:', err);
res.status(500).json({ error: 'Failed to delete attachment' });
}
});
// ─── ASSETS ─────────────────────────────────────────────────────
app.get('/api/assets', requireAuth, async (req, res) => {
try {
const { folder, tags } = req.query;
const whereParts = [];
if (folder) whereParts.push(`(folder,eq,${folder})`);
if (req.query.brand_id) whereParts.push(`(brand_id,eq,${req.query.brand_id})`);
if (req.query.campaign_id) whereParts.push(`(campaign_id,eq,${req.query.campaign_id})`);
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
let assets = await nocodb.list('Assets', { where, sort: '-CreatedAt', limit: 1000 });
if (tags) {
assets = assets.filter(a => (a.tags || '').includes(tags));
}
const brandIds = new Set(), userIds = new Set(), campaignIds = new Set();
for (const a of assets) {
if (a.brand_id) brandIds.add(a.brand_id);
if (a.campaign_id) campaignIds.add(a.campaign_id);
if (a.uploader_id) userIds.add(a.uploader_id);
}
const names = {};
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
for (const id of campaignIds) names[`campaign:${id}`] = await getRecordName('Campaigns', id);
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
res.json(assets.map(a => ({
...a,
brand_name: names[`brand:${a.brand_id}`] || null,
campaign_name: names[`campaign:${a.campaign_id}`] || null,
uploader_name: names[`user:${a.uploader_id}`] || null,
})));
} catch (err) {
res.status(500).json({ error: 'Failed to load assets' });
}
});
app.post('/api/assets/upload', requireAuth, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const { brand_id, campaign_id, uploaded_by, folder, tags } = req.body;
const created = await nocodb.create('Assets', {
filename: req.file.filename,
original_name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
tags: tags || '[]',
folder: folder || 'general',
brand_id: brand_id ? Number(brand_id) : null,
campaign_id: campaign_id ? Number(campaign_id) : null,
uploader_id: uploaded_by ? Number(uploaded_by) : null,
});
const asset = await nocodb.get('Assets', created.Id);
res.status(201).json({
...asset,
brand_name: await getRecordName('Brands', asset.brand_id),
campaign_name: await getRecordName('Campaigns', asset.campaign_id),
uploader_name: await getRecordName('Users', asset.uploader_id),
});
} catch (err) {
console.error('Upload asset error:', err);
res.status(500).json({ error: 'Failed to upload asset' });
}
});
app.delete('/api/assets/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const asset = await nocodb.get('Assets', req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
const refs = await nocodb.list('PostAttachments', {
where: `(filename,eq,${asset.filename})`,
limit: 1,
});
if (refs.length === 0) {
const filePath = path.join(uploadsDir, asset.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
await nocodb.delete('Assets', req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete asset' });
}
});
// ─── CAMPAIGNS ──────────────────────────────────────────────────
app.get('/api/campaigns', requireAuth, async (req, res) => {
try {
const { status } = req.query;
const whereParts = [];
if (status) whereParts.push(`(status,eq,${status})`);
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
let campaigns = await nocodb.list('Campaigns', { where, sort: '-start_date', limit: 500 });
// Filter by brand
if (req.query.brand_id) {
campaigns = campaigns.filter(c => c.brand_id === Number(req.query.brand_id));
}
// Non-superadmin scoping
if (req.session.userRole !== 'superadmin') {
const userId = req.session.userId;
const myCampaignIds = await getUserCampaignIds(userId);
campaigns = campaigns.filter(c => {
return c.created_by_user_id === userId || myCampaignIds.has(c.Id);
});
}
// Enrich with names
const brandNames = {}, userNames = {};
for (const c of campaigns) {
if (c.brand_id && !brandNames[c.brand_id]) brandNames[c.brand_id] = await getRecordName('Brands', c.brand_id);
if (c.created_by_user_id && !userNames[c.created_by_user_id]) userNames[c.created_by_user_id] = await getRecordName('Users', c.created_by_user_id);
}
res.json(campaigns.map(c => ({
...c,
brand_name: brandNames[c.brand_id] || null,
creator_user_name: userNames[c.created_by_user_id] || null,
})));
} catch (err) {
console.error('GET /campaigns error:', err);
res.status(500).json({ error: 'Failed to load campaigns' });
}
});
app.get('/api/campaigns/:id', requireAuth, async (req, res) => {
try {
const campaign = await nocodb.get('Campaigns', req.params.id);
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
// Access check
if (req.session.userRole !== 'superadmin') {
const userId = req.session.userId;
if (campaign.created_by_user_id !== userId) {
const assignments = await nocodb.list('CampaignAssignments', { limit: 10000 });
const hasAccess = assignments.some(a => a.campaign_id === campaign.Id && a.member_id === userId);
if (!hasAccess) return res.status(403).json({ error: 'You do not have access to this campaign' });
}
}
const brandName = await getRecordName('Brands', campaign.brand_id);
res.json({ ...campaign, brand_name: brandName });
} catch (err) {
res.status(500).json({ error: 'Failed to load campaign' });
}
});
app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), async (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 effectiveBudget = req.session.userRole === 'superadmin' ? (budget || null) : null;
try {
const created = await nocodb.create('Campaigns', {
name, description: description || null,
start_date, end_date,
status: status || 'planning',
color: color || null,
budget: effectiveBudget,
goals: goals || null,
platforms: JSON.stringify(platforms || []),
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0, cost_per_click: 0,
notes: '',
brand_id: brand_id ? Number(brand_id) : null,
created_by_user_id: req.session.userId,
});
// Auto-assign creator
await nocodb.create('CampaignAssignments', {
assigned_at: new Date().toISOString(),
campaign_id: created.Id,
member_id: req.session.userId,
assigner_id: req.session.userId,
});
const campaign = await nocodb.get('Campaigns', created.Id);
res.status(201).json({
...campaign,
brand_name: await getRecordName('Brands', campaign.brand_id),
});
} catch (err) {
console.error('Create campaign error:', err);
res.status(500).json({ error: 'Failed to create campaign' });
}
});
app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('Campaigns', req.params.id);
if (!existing) return res.status(404).json({ error: 'Campaign not found' });
const body = { ...req.body };
if (req.session.userRole !== 'superadmin') delete body.budget;
const data = {};
for (const f of ['name', 'description', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals',
'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes']) {
if (body[f] !== undefined) data[f] = body[f];
}
if (body.platforms !== undefined) data.platforms = JSON.stringify(body.platforms);
if (body.brand_id !== undefined) data.brand_id = body.brand_id ? Number(body.brand_id) : null;
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
await nocodb.update('Campaigns', req.params.id, data);
const campaign = await nocodb.get('Campaigns', req.params.id);
res.json({ ...campaign, brand_name: await getRecordName('Brands', campaign.brand_id) });
} catch (err) {
console.error('Update campaign error:', err);
res.status(500).json({ error: 'Failed to update campaign' });
}
});
app.delete('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const id = Number(req.params.id);
try {
// Delete related posts
const posts = await nocodb.list('Posts', { where: `(campaign_id,eq,${id})`, limit: 10000 });
for (const p of posts) await nocodb.delete('Posts', p.Id);
// Delete campaign tracks
const tracks = await nocodb.list('CampaignTracks', { where: `(campaign_id,eq,${id})`, limit: 10000 });
for (const t of tracks) await nocodb.delete('CampaignTracks', t.Id);
// Delete campaign assignments
const assignments = await nocodb.list('CampaignAssignments', { where: `(campaign_id,eq,${id})`, limit: 10000 });
for (const a of assignments) await nocodb.delete('CampaignAssignments', a.Id);
// Unlink assets (clear campaign_id)
const assets = await nocodb.list('Assets', { where: `(campaign_id,eq,${id})`, limit: 10000 });
for (const a of assets) await nocodb.update('Assets', a.Id, { campaign_id: null });
await nocodb.delete('Campaigns', id);
res.json({ success: true });
} catch (err) {
console.error('Delete campaign error:', err);
res.status(500).json({ error: 'Failed to delete campaign' });
}
});
// ─── CAMPAIGN ASSIGNMENTS ───────────────────────────────────────
app.get('/api/campaigns/:id/assignments', requireAuth, async (req, res) => {
try {
const filtered = await nocodb.list('CampaignAssignments', {
where: `(campaign_id,eq,${req.params.id})`,
limit: 10000,
});
// Enrich with user data (member_id is a plain Number field, not a link)
const enriched = [];
for (const a of filtered) {
const memberId = a.member_id;
const assignerId = a.assigner_id;
let memberData = {}, assignerName = null;
if (memberId) {
try {
const u = await nocodb.get('Users', memberId);
memberData = { user_name: u.name, user_email: u.email, user_avatar: u.avatar, user_role: u.role, user_id: u.Id };
} catch {}
}
if (assignerId) {
try {
const u = await nocodb.get('Users', assignerId);
assignerName = u.name;
} catch {}
}
enriched.push({ ...a, ...memberData, assigned_by_name: assignerName });
}
res.json(enriched);
} catch (err) {
res.status(500).json({ error: 'Failed to load assignments' });
}
});
app.post('/api/campaigns/:id/assignments', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const { user_ids } = req.body;
if (!Array.isArray(user_ids) || user_ids.length === 0) return res.status(400).json({ error: 'user_ids array is required' });
try {
const campaign = await nocodb.get('Campaigns', req.params.id);
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
if (req.session.userRole !== 'superadmin' && campaign.created_by_user_id !== req.session.userId) {
return res.status(403).json({ error: 'Only the campaign creator or superadmin can assign members' });
}
// Check existing assignments to avoid duplicates
const existingAssignments = await nocodb.list('CampaignAssignments', {
where: `(campaign_id,eq,${req.params.id})`,
limit: 10000,
});
const existing = new Set(existingAssignments.map(a => a.member_id));
for (const userId of user_ids) {
if (existing.has(userId)) continue;
await nocodb.create('CampaignAssignments', {
assigned_at: new Date().toISOString(),
member_id: userId,
assigner_id: req.session.userId,
campaign_id: Number(req.params.id),
});
}
// Return updated list
const filtered = await nocodb.list('CampaignAssignments', {
where: `(campaign_id,eq,${req.params.id})`,
limit: 10000,
});
const enriched = [];
for (const a of filtered) {
if (a.member_id) {
try {
const u = await nocodb.get('Users', a.member_id);
enriched.push({ ...a, user_name: u.name, user_email: u.email, user_avatar: u.avatar, user_role: u.role, user_id: u.Id });
} catch { enriched.push(a); }
} else {
enriched.push(a);
}
}
res.json(enriched);
} catch (err) {
console.error('Create assignment error:', err);
res.status(500).json({ error: 'Failed to create assignment' });
}
});
app.delete('/api/campaigns/:id/assignments/:userId', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const campaign = await nocodb.get('Campaigns', req.params.id);
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
if (req.session.userRole !== 'superadmin' && campaign.created_by_user_id !== req.session.userId) {
return res.status(403).json({ error: 'Only the campaign creator or superadmin can remove members' });
}
const assignments = await nocodb.list('CampaignAssignments', {
where: `(campaign_id,eq,${req.params.id})~and(member_id,eq,${req.params.userId})`,
limit: 1,
});
if (assignments.length === 0) return res.status(404).json({ error: 'Assignment not found' });
await nocodb.delete('CampaignAssignments', assignments[0].Id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete assignment' });
}
});
// ─── BUDGET ENTRIES ─────────────────────────────────────────────
app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
let entries = await nocodb.list('BudgetEntries', { sort: '-date_received', limit: 10000 });
if (req.session.userRole !== 'superadmin') {
const userId = req.session.userId;
const myCampaignIds = await getUserCampaignIds(userId);
entries = entries.filter(e => !e.campaign_id || myCampaignIds.has(e.campaign_id));
}
const campaignIds = new Set();
for (const e of entries) if (e.campaign_id) campaignIds.add(e.campaign_id);
const cNames = {};
for (const id of campaignIds) cNames[id] = await getRecordName('Campaigns', id);
res.json(entries.map(e => ({
...e,
campaign_name: cNames[e.campaign_id] || null,
})));
} catch (err) {
res.status(500).json({ error: 'Failed to load budget entries' });
}
});
app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async (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' });
try {
const created = await nocodb.create('BudgetEntries', {
label, amount, source: source || null,
category: category || 'marketing', date_received, notes: notes || '',
campaign_id: campaign_id ? Number(campaign_id) : null,
});
const entry = await nocodb.get('BudgetEntries', created.Id);
res.status(201).json({ ...entry, campaign_name: await getRecordName('Campaigns', entry.campaign_id) });
} catch (err) {
res.status(500).json({ error: 'Failed to create budget entry' });
}
});
app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('BudgetEntries', req.params.id);
if (!existing) return res.status(404).json({ error: 'Budget entry not found' });
const data = {};
for (const f of ['label', 'amount', 'source', 'category', 'date_received', 'notes']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null;
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
await nocodb.update('BudgetEntries', req.params.id, data);
const entry = await nocodb.get('BudgetEntries', req.params.id);
res.json({ ...entry, campaign_name: await getRecordName('Campaigns', entry.campaign_id) });
} catch (err) {
res.status(500).json({ error: 'Failed to update budget entry' });
}
});
app.delete('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('BudgetEntries', req.params.id);
if (!existing) return res.status(404).json({ error: 'Entry not found' });
await nocodb.delete('BudgetEntries', req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete budget entry' });
}
});
// Finance summary
app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const isSuperadmin = req.session.userRole === 'superadmin';
const userId = req.session.userId;
let campaigns = await nocodb.list('Campaigns', { limit: 10000 });
let budgetEntries = await nocodb.list('BudgetEntries', { limit: 10000 });
if (!isSuperadmin) {
const myCampaignIds = await getUserCampaignIds(userId);
campaigns = campaigns.filter(c => myCampaignIds.has(c.Id));
budgetEntries = budgetEntries.filter(e => !e.campaign_id || myCampaignIds.has(e.campaign_id));
}
const totalReceived = isSuperadmin
? budgetEntries.reduce((sum, e) => sum + (e.amount || 0), 0)
: campaigns.reduce((sum, c) => sum + (c.budget || 0), 0);
const allTracks = await nocodb.list('CampaignTracks', { limit: 10000 });
const campaignStats = campaigns.map(c => {
const cTracks = allTracks.filter(t => t.campaign_id === c.Id);
return {
id: c.Id, name: c.name, budget: c.budget, status: c.status,
tracks_allocated: cTracks.reduce((s, t) => s + (t.budget_allocated || 0), 0),
tracks_spent: cTracks.reduce((s, t) => s + (t.budget_spent || 0), 0),
tracks_revenue: cTracks.reduce((s, t) => s + (t.revenue || 0), 0),
tracks_impressions: cTracks.reduce((s, t) => s + (t.impressions || 0), 0),
tracks_clicks: cTracks.reduce((s, t) => s + (t.clicks || 0), 0),
tracks_conversions: cTracks.reduce((s, t) => s + (t.conversions || 0), 0),
};
});
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,
});
} catch (err) {
console.error('Finance summary error:', err);
res.status(500).json({ error: 'Failed to load finance summary' });
}
});
// ─── CAMPAIGN TRACKS ────────────────────────────────────────────
app.get('/api/campaigns/:id/tracks', requireAuth, async (req, res) => {
try {
const tracks = await nocodb.list('CampaignTracks', {
where: `(campaign_id,eq,${req.params.id})`,
sort: 'CreatedAt', limit: 1000,
});
res.json(tracks);
} catch (err) {
res.status(500).json({ error: 'Failed to load tracks' });
}
});
app.post('/api/campaigns/:id/tracks', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const campaign = await nocodb.get('Campaigns', req.params.id);
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
const { name, type, platform, budget_allocated, status, notes } = req.body;
const created = await nocodb.create('CampaignTracks', {
name: name || null, type: type || 'organic_social',
platform: platform || null, budget_allocated: budget_allocated || 0,
status: status || 'planned', notes: notes || '',
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0,
campaign_id: Number(req.params.id),
});
const track = await nocodb.get('CampaignTracks', created.Id);
res.status(201).json(track);
} catch (err) {
res.status(500).json({ error: 'Failed to create track' });
}
});
app.patch('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('CampaignTracks', req.params.id);
if (!existing) return res.status(404).json({ error: 'Track not found' });
const data = {};
for (const f of ['name', 'type', 'platform', 'budget_allocated', 'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'notes', 'status']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
await nocodb.update('CampaignTracks', req.params.id, data);
const track = await nocodb.get('CampaignTracks', req.params.id);
res.json(track);
} catch (err) {
res.status(500).json({ error: 'Failed to update track' });
}
});
app.delete('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('CampaignTracks', req.params.id);
if (!existing) return res.status(404).json({ error: 'Track not found' });
await nocodb.delete('CampaignTracks', req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete track' });
}
});
// Campaign posts
app.get('/api/campaigns/:id/posts', requireAuth, async (req, res) => {
try {
const filtered = await nocodb.list('Posts', {
where: `(campaign_id,eq,${req.params.id})`,
sort: '-CreatedAt', limit: 10000,
});
const allAttachments = await nocodb.list('PostAttachments', {
where: "(mime_type,like,image/%)", limit: 10000,
});
const thumbMap = {};
for (const att of allAttachments) {
if (att.post_id && !thumbMap[att.post_id]) thumbMap[att.post_id] = att.url;
}
// Collect unique IDs for name lookups
const brandIds = new Set(), userIds = new Set(), trackIds = new Set();
for (const p of filtered) {
if (p.brand_id) brandIds.add(p.brand_id);
if (p.assigned_to_id) userIds.add(p.assigned_to_id);
if (p.created_by_user_id) userIds.add(p.created_by_user_id);
if (p.track_id) trackIds.add(p.track_id);
}
const names = {};
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
for (const id of trackIds) names[`track:${id}`] = await getRecordName('CampaignTracks', id);
const campaignName = await getRecordName('Campaigns', Number(req.params.id));
res.json(filtered.map(p => ({
...p,
assigned_to: p.assigned_to_id,
campaign_name: campaignName,
brand_name: names[`brand:${p.brand_id}`] || null,
assigned_name: names[`user:${p.assigned_to_id}`] || null,
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
track_name: names[`track:${p.track_id}`] || null,
thumbnail_url: thumbMap[p.Id] || null,
})));
} catch (err) {
res.status(500).json({ error: 'Failed to load campaign posts' });
}
});
// ─── PROJECTS ───────────────────────────────────────────────────
app.get('/api/projects', requireAuth, async (req, res) => {
try {
const { status } = req.query;
const whereParts = [];
if (status) whereParts.push(`(status,eq,${status})`);
if (req.query.brand_id) whereParts.push(`(brand_id,eq,${req.query.brand_id})`);
if (req.query.owner_id) whereParts.push(`(owner_id,eq,${req.query.owner_id})`);
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
const projects = await nocodb.list('Projects', { where, sort: '-CreatedAt', limit: 500 });
const brandIds = new Set(), userIds = new Set();
for (const p of projects) {
if (p.brand_id) brandIds.add(p.brand_id);
if (p.owner_id) userIds.add(p.owner_id);
if (p.created_by_user_id) userIds.add(p.created_by_user_id);
}
const names = {};
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
res.json(projects.map(p => ({
...p,
brand_name: names[`brand:${p.brand_id}`] || null,
owner_name: names[`user:${p.owner_id}`] || null,
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
})));
} catch (err) {
res.status(500).json({ error: 'Failed to load projects' });
}
});
app.get('/api/projects/:id', requireAuth, async (req, res) => {
try {
const project = await nocodb.get('Projects', req.params.id);
if (!project) return res.status(404).json({ error: 'Project not found' });
res.json({
...project,
brand_name: await getRecordName('Brands', project.brand_id),
owner_name: await getRecordName('Users', project.owner_id),
creator_user_name: await getRecordName('Users', project.created_by_user_id),
});
} catch (err) {
res.status(500).json({ error: 'Failed to load project' });
}
});
app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const { name, description, brand_id, owner_id, status, priority, start_date, due_date } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
try {
const created = await nocodb.create('Projects', {
name, description: description || null,
status: status || 'active', priority: priority || 'medium',
start_date: start_date || null, due_date: due_date || null,
brand_id: brand_id ? Number(brand_id) : null,
owner_id: owner_id ? Number(owner_id) : null,
created_by_user_id: req.session.userId,
});
const project = await nocodb.get('Projects', created.Id);
res.status(201).json({
...project,
brand_name: await getRecordName('Brands', project.brand_id),
owner_name: await getRecordName('Users', project.owner_id),
});
} catch (err) {
console.error('Create project error:', err);
res.status(500).json({ error: 'Failed to create project' });
}
});
app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'superadmin', 'manager'), async (req, res) => {
const projectId = Number(req.params.id);
try {
const existing = await nocodb.get('Projects', projectId);
if (!existing) return res.status(404).json({ error: 'Project not found' });
const data = {};
for (const f of ['name', 'description', 'status', 'priority', 'start_date', 'due_date']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
if (req.body.owner_id !== undefined) data.owner_id = req.body.owner_id ? Number(req.body.owner_id) : null;
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
await nocodb.update('Projects', projectId, data);
const project = await nocodb.get('Projects', projectId);
res.json({
...project,
brand_name: await getRecordName('Brands', project.brand_id),
owner_name: await getRecordName('Users', project.owner_id),
});
} catch (err) {
console.error('Update project error:', err);
res.status(500).json({ error: 'Failed to update project' });
}
});
app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('Projects', req.params.id);
if (!existing) return res.status(404).json({ error: 'Project not found' });
await nocodb.delete('Projects', req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete project' });
}
});
// ─── TASKS ──────────────────────────────────────────────────────
app.get('/api/tasks', requireAuth, async (req, res) => {
try {
const { status, is_personal } = req.query;
const whereParts = [];
if (status) whereParts.push(`(status,eq,${status})`);
if (is_personal !== undefined) {
whereParts.push(`(is_personal,eq,${is_personal === 'true' || is_personal === '1' ? 'true' : 'false'})`);
}
if (req.query.project_id) whereParts.push(`(project_id,eq,${req.query.project_id})`);
if (req.query.assigned_to) whereParts.push(`(assigned_to_id,eq,${req.query.assigned_to})`);
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: 10000 });
// Visibility filtering for contributors
if (req.session.userRole === 'contributor') {
tasks = tasks.filter(t =>
t.created_by_user_id === req.session.userId || t.assigned_to_id === req.session.userId
);
}
const projectIds = new Set(), userIds = new Set();
for (const t of tasks) {
if (t.project_id) projectIds.add(t.project_id);
if (t.assigned_to_id) userIds.add(t.assigned_to_id);
if (t.created_by_user_id) userIds.add(t.created_by_user_id);
}
const names = {};
for (const id of projectIds) names[`project:${id}`] = await getRecordName('Projects', id);
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
res.json(tasks.map(t => ({
...t,
assigned_to: t.assigned_to_id,
project_name: names[`project:${t.project_id}`] || null,
assigned_name: names[`user:${t.assigned_to_id}`] || null,
creator_user_name: names[`user:${t.created_by_user_id}`] || null,
})));
} catch (err) {
console.error('GET /tasks error:', err);
res.status(500).json({ error: 'Failed to load tasks' });
}
});
app.get('/api/tasks/my/:memberId', requireAuth, async (req, res) => {
try {
const tasks = await nocodb.list('Tasks', {
where: `(is_personal,eq,true)~and(assigned_to_id,eq,${req.params.memberId})`,
limit: 10000,
});
const projectIds = new Set(), userIds = new Set();
for (const t of tasks) {
if (t.project_id) projectIds.add(t.project_id);
if (t.created_by_user_id) userIds.add(t.created_by_user_id);
}
const names = {};
for (const id of projectIds) names[`project:${id}`] = await getRecordName('Projects', id);
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
res.json(tasks.map(t => ({
...t,
assigned_to: t.assigned_to_id,
project_name: names[`project:${t.project_id}`] || null,
creator_user_name: names[`user:${t.created_by_user_id}`] || null,
})));
} catch (err) {
res.status(500).json({ error: 'Failed to load tasks' });
}
});
app.post('/api/tasks', requireAuth, async (req, res) => {
const { title, description, project_id, assigned_to, status, priority, due_date, start_date, is_personal } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
try {
const created = await nocodb.create('Tasks', {
title, description: description || null,
status: status || 'todo', priority: priority || 'medium',
start_date: start_date || null, due_date: due_date || null, is_personal: !!is_personal,
project_id: project_id ? Number(project_id) : null,
assigned_to_id: assigned_to ? Number(assigned_to) : null,
created_by_user_id: req.session.userId,
});
const task = await nocodb.get('Tasks', created.Id);
res.status(201).json({
...task,
assigned_to: task.assigned_to_id,
project_name: await getRecordName('Projects', task.project_id),
assigned_name: await getRecordName('Users', task.assigned_to_id),
creator_user_name: await getRecordName('Users', task.created_by_user_id),
});
} catch (err) {
console.error('Create task error:', err);
res.status(500).json({ error: 'Failed to create task' });
}
});
app.patch('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('Tasks', req.params.id);
if (!existing) return res.status(404).json({ error: 'Task not found' });
const data = {};
for (const f of ['title', 'description', 'status', 'priority', 'start_date', 'due_date']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.is_personal !== undefined) data.is_personal = !!req.body.is_personal;
if (req.body.project_id !== undefined) data.project_id = req.body.project_id ? Number(req.body.project_id) : null;
if (req.body.assigned_to !== undefined) data.assigned_to_id = req.body.assigned_to ? Number(req.body.assigned_to) : null;
// Handle completed_at
if (req.body.status === 'done' && existing.status !== 'done') {
data.completed_at = new Date().toISOString();
} else if (req.body.status && req.body.status !== 'done' && existing.status === 'done') {
data.completed_at = null;
}
if (Object.keys(data).length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
await nocodb.update('Tasks', req.params.id, data);
const task = await nocodb.get('Tasks', req.params.id);
res.json({
...task,
assigned_to: task.assigned_to_id,
project_name: await getRecordName('Projects', task.project_id),
assigned_name: await getRecordName('Users', task.assigned_to_id),
creator_user_name: await getRecordName('Users', task.created_by_user_id),
});
} catch (err) {
console.error('Update task error:', err);
res.status(500).json({ error: 'Failed to update task' });
}
});
app.delete('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin', 'manager'), async (req, res) => {
try {
await nocodb.delete('Tasks', req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete task' });
}
});
// ─── DASHBOARD ──────────────────────────────────────────────────
app.get('/api/dashboard', requireAuth, async (req, res) => {
try {
const isSuperadmin = req.session.userRole === 'superadmin';
const userId = req.session.userId;
// Fetch all data in parallel
const [allPosts, allCampaigns, allTasks, allProjects, allUsers, allAssignments] = await Promise.all([
nocodb.list('Posts', { limit: 10000 }),
nocodb.list('Campaigns', { limit: 10000 }),
nocodb.list('Tasks', { limit: 10000 }),
nocodb.list('Projects', { limit: 10000 }),
nocodb.list('Users', { limit: 1000 }),
nocodb.list('CampaignAssignments', { limit: 10000 }),
]);
// Build user's campaign IDs for scoping
let myCampaignIds;
if (!isSuperadmin) {
myCampaignIds = new Set();
for (const c of allCampaigns) {
if (c.created_by_user_id === userId) myCampaignIds.add(c.Id);
}
for (const a of allAssignments) {
if (a.member_id === userId && a.campaign_id) {
myCampaignIds.add(a.campaign_id);
}
}
}
// Posts
let posts = allPosts;
if (!isSuperadmin) {
posts = allPosts.filter(p => !p.campaign_id || myCampaignIds.has(p.campaign_id));
}
const postsByStatus = {};
for (const p of posts) {
postsByStatus[p.status] = (postsByStatus[p.status] || 0) + 1;
}
// Campaigns
let campaigns = allCampaigns;
if (!isSuperadmin) {
campaigns = allCampaigns.filter(c => myCampaignIds.has(c.Id));
}
const activeCampaigns = campaigns.filter(c => c.status === 'active').length;
// Tasks
let tasks = allTasks;
if (!isSuperadmin) {
tasks = allTasks.filter(t =>
t.created_by_user_id === userId || t.assigned_to_id === userId
);
}
const overdueTasks = tasks.filter(t => t.due_date && new Date(t.due_date) < new Date() && t.status !== 'done').length;
const tasksByStatus = {};
for (const t of tasks) {
tasksByStatus[t.status] = (tasksByStatus[t.status] || 0) + 1;
}
// Projects
let projects = allProjects;
if (!isSuperadmin) {
projects = allProjects.filter(p => p.created_by_user_id === userId);
}
const activeProjects = projects.filter(p => p.status === 'active').length;
// Team workload (superadmin only)
const teamWorkload = isSuperadmin ? allUsers.filter(u => u.team_role).map(u => {
const userTasks = allTasks.filter(t => t.assigned_to_id === u.Id);
const userPosts = allPosts.filter(p => p.assigned_to_id === u.Id);
return {
id: u.Id, name: u.name, role: u.team_role,
active_tasks: userTasks.filter(t => t.status !== 'done').length,
completed_tasks: userTasks.filter(t => t.status === 'done').length,
active_posts: userPosts.filter(p => !['published', 'rejected'].includes(p.status)).length,
};
}).sort((a, b) => b.active_tasks - a.active_tasks) : [];
// Recent posts (last 5)
const recentPostsRaw = posts.slice(0, 5);
const recentPosts = [];
for (const p of recentPostsRaw) {
recentPosts.push({
...p,
assigned_to: p.assigned_to_id,
brand_name: await getRecordName('Brands', p.brand_id),
assigned_name: await getRecordName('Users', p.assigned_to_id),
});
}
// Upcoming campaigns
const now = new Date().toISOString().split('T')[0];
const upcomingRaw = campaigns
.filter(c => c.end_date >= now)
.sort((a, b) => (a.start_date || '').localeCompare(b.start_date || ''))
.slice(0, 5);
const upcomingCampaigns = [];
for (const c of upcomingRaw) {
upcomingCampaigns.push({ ...c, brand_name: await getRecordName('Brands', c.brand_id) });
}
res.json({
posts: { total: posts.length, byStatus: postsByStatus },
campaigns: { total: campaigns.length, active: activeCampaigns },
tasks: { overdue: overdueTasks, byStatus: tasksByStatus },
projects: { active: activeProjects },
teamWorkload,
recentPosts,
upcomingCampaigns,
});
} catch (err) {
console.error('Dashboard error:', err);
res.status(500).json({ error: 'Failed to load dashboard' });
}
});
// ─── COMMENTS / DISCUSSIONS ─────────────────────────────────────
const COMMENT_ENTITY_TYPES = new Set(['post', 'task', 'project', 'campaign', 'asset']);
app.get('/api/comments/:entityType/:entityId', requireAuth, async (req, res) => {
const { entityType, entityId } = req.params;
if (!COMMENT_ENTITY_TYPES.has(entityType)) return res.status(400).json({ error: 'Invalid entity type' });
try {
const comments = await nocodb.list('Comments', {
where: `(entity_type,eq,${entityType})~and(entity_id,eq,${entityId})`,
sort: 'CreatedAt', limit: 1000,
});
// Enrich with user data
const enriched = [];
for (const c of comments) {
let userName = null, userAvatar = null;
if (c.user_id) {
try {
const u = await nocodb.get('Users', c.user_id);
userName = u.name;
userAvatar = u.avatar;
} catch {}
}
enriched.push({ ...c, user_name: userName, user_avatar: userAvatar });
}
res.json(enriched);
} catch (err) {
res.status(500).json({ error: 'Failed to load comments' });
}
});
app.post('/api/comments/:entityType/:entityId', requireAuth, async (req, res) => {
const { entityType, entityId } = req.params;
const { content } = req.body;
if (!COMMENT_ENTITY_TYPES.has(entityType)) return res.status(400).json({ error: 'Invalid entity type' });
if (!content || !content.trim()) return res.status(400).json({ error: 'Content is required' });
try {
const created = await nocodb.create('Comments', {
entity_type: entityType,
entity_id: Number(entityId),
content: content.trim(),
user_id: req.session.userId,
});
const comment = await nocodb.get('Comments', created.Id);
const user = await nocodb.get('Users', req.session.userId);
res.status(201).json({
...comment, user_id: req.session.userId,
user_name: user.name, user_avatar: user.avatar,
});
} catch (err) {
res.status(500).json({ error: 'Failed to create comment' });
}
});
app.patch('/api/comments/:id', requireAuth, async (req, res) => {
try {
const comment = await nocodb.get('Comments', req.params.id);
if (!comment) return res.status(404).json({ error: 'Comment not found' });
if (comment.user_id !== req.session.userId) return res.status(403).json({ error: 'You can only edit your own comments' });
const { content } = req.body;
if (!content || !content.trim()) return res.status(400).json({ error: 'Content is required' });
await nocodb.update('Comments', req.params.id, { content: content.trim() });
const updated = await nocodb.get('Comments', req.params.id);
const user = await nocodb.get('Users', req.session.userId);
res.json({ ...updated, user_id: req.session.userId, user_name: user.name, user_avatar: user.avatar });
} catch (err) {
res.status(500).json({ error: 'Failed to update comment' });
}
});
app.delete('/api/comments/:id', requireAuth, async (req, res) => {
try {
const comment = await nocodb.get('Comments', req.params.id);
if (!comment) return res.status(404).json({ error: 'Comment not found' });
if (comment.user_id !== req.session.userId && req.session.userRole !== 'superadmin' && req.session.userRole !== 'manager') {
return res.status(403).json({ error: 'You can only delete your own comments' });
}
await nocodb.delete('Comments', req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to delete comment' });
}
});
// ─── SHARED HELPERS ─────────────────────────────────────────────
// Get set of campaign IDs a user has access to
async function getUserCampaignIds(userId) {
const [campaigns, assignments] = await Promise.all([
nocodb.list('Campaigns', { limit: 10000 }),
nocodb.list('CampaignAssignments', { limit: 10000 }),
]);
const ids = new Set();
for (const c of campaigns) {
if (c.created_by_user_id === userId) ids.add(c.Id);
}
for (const a of assignments) {
if (a.member_id === userId && a.campaign_id) {
ids.add(a.campaign_id);
}
}
return ids;
}
// ─── ERROR HANDLING ─────────────────────────────────────────────
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 });
});
process.on('uncaughtException', (err) => {
console.error('[UNCAUGHT]', err.message);
});
process.on('unhandledRejection', (err) => {
console.error('[UNHANDLED REJECTION]', err);
});
// ─── START SERVER ───────────────────────────────────────────────
async function startServer() {
console.log('Running FK column migration...');
await ensureFKColumns();
await backfillFKs();
console.log('Migration complete.');
app.listen(PORT, () => {
console.log(`Digital Hub API running on http://localhost:${PORT}`);
console.log(`Uploads directory: ${uploadsDir}`);
});
}
startServer();