Dashboard fix, expense system, currency settings, visual upgrade

- Fix Dashboard stat card: show "Budget Remaining" instead of "Budget Spent"
  with correct remaining value accounting for campaign allocations
- Add expense system: budget entries now have income/expense type with
  server-side split, per-campaign and per-project expense tracking,
  colored amounts, type filters, and summary bar in Budgets page
- Add configurable currency in Settings (SAR ⃁ default, supports 10
  currencies) replacing all hardcoded SAR references across the app
- Replace PiggyBank icon with Landmark (culturally appropriate for KSA)
- Visual upgrade: mesh background, gradient text, premium stat cards with
  accent bars, section-card containers, sidebar active glow
- UX polish: consistent text-2xl headers, skeleton loaders for Finance
  and Budgets pages
- Finance page: expenses column in campaign/project breakdown tables,
  ROI accounts for expenses, expense stat card

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-02-15 15:49:28 +03:00
parent f3e6fc848d
commit e76be78498
17 changed files with 2817 additions and 1379 deletions

View File

@@ -152,8 +152,9 @@ const FK_COLUMNS = {
Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'],
Assets: ['brand_id', 'campaign_id', 'uploader_id'],
PostAttachments: ['post_id'],
TaskAttachments: ['task_id'],
Comments: ['user_id'],
BudgetEntries: ['campaign_id'],
BudgetEntries: ['campaign_id', 'project_id'],
};
// Maps link column names to FK field names for migration
@@ -166,10 +167,109 @@ const LINK_TO_FK = {
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' },
TaskAttachments: { Task: 'task_id' },
Comments: { User: 'user_id' },
BudgetEntries: { Campaign: 'campaign_id' },
BudgetEntries: { Campaign: 'campaign_id', Project: 'project_id' },
};
// ─── TABLE CREATION: Ensure required tables exist ────────────────
const REQUIRED_TABLES = {
TaskAttachments: [
{ title: 'filename', uidt: 'SingleLineText' },
{ title: 'original_name', uidt: 'SingleLineText' },
{ title: 'mime_type', uidt: 'SingleLineText' },
{ title: 'size', uidt: 'Number' },
{ title: 'url', uidt: 'SingleLineText' },
{ title: 'task_id', uidt: 'Number' },
],
Teams: [
{ title: 'name', uidt: 'SingleLineText' },
{ title: 'description', uidt: 'LongText' },
],
TeamMembers: [
{ title: 'team_id', uidt: 'Number' },
{ title: 'user_id', uidt: 'Number' },
],
};
async function ensureRequiredTables() {
// Fetch existing tables
const res = await fetch(`${nocodb.url}/api/v2/meta/bases/${nocodb.baseId}/tables`, {
headers: { 'xc-token': nocodb.token },
});
if (!res.ok) {
console.error('Failed to fetch tables for ensureRequiredTables');
return;
}
const data = await res.json();
const existingTables = new Set((data.list || []).map(t => t.title));
for (const [tableName, columns] of Object.entries(REQUIRED_TABLES)) {
if (existingTables.has(tableName)) continue;
console.log(` Creating table ${tableName}...`);
try {
const createRes = await fetch(`${nocodb.url}/api/v2/meta/bases/${nocodb.baseId}/tables`, {
method: 'POST',
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
body: JSON.stringify({
table_name: tableName,
title: tableName,
columns: [
{ title: 'Id', uidt: 'ID' },
...columns,
],
}),
});
if (createRes.ok) {
console.log(` Created table ${tableName}`);
nocodb.clearCache(); // clear table ID cache so it picks up the new table
} else {
const err = await createRes.text();
console.error(` Failed to create table ${tableName}:`, err);
}
} catch (err) {
console.error(` Failed to create table ${tableName}:`, err.message);
}
}
}
// Text/string columns that must exist on tables (not FKs — those are Number type)
const TEXT_COLUMNS = {
Projects: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
Tasks: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
Users: [{ name: 'modules', uidt: 'LongText' }],
BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
};
const ALL_MODULES = ['marketing', 'projects', 'finance'];
async function ensureTextColumns() {
for (const [table, columns] of Object.entries(TEXT_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.name)) {
console.log(` Adding text column ${table}.${col.name}...`);
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.name, uidt: col.uidt }),
});
}
}
} catch (err) {
console.error(` Failed to ensure text columns for ${table}:`, err.message);
}
}
}
async function ensureFKColumns() {
for (const [table, columns] of Object.entries(FK_COLUMNS)) {
try {
@@ -271,6 +371,10 @@ app.post('/api/auth/login', async (req, res) => {
req.session.userRole = user.role;
req.session.userName = user.name;
let modules = ALL_MODULES;
if (user.role !== 'superadmin' && user.modules) {
try { modules = JSON.parse(user.modules); } catch { modules = ALL_MODULES; }
}
res.json({
user: {
id: user.Id,
@@ -281,6 +385,7 @@ app.post('/api/auth/login', async (req, res) => {
team_role: user.team_role,
tutorial_completed: user.tutorial_completed,
profileComplete: !!user.team_role,
modules,
},
});
} catch (err) {
@@ -301,6 +406,10 @@ 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' });
let modules = ALL_MODULES;
if (user.role !== 'superadmin' && user.modules) {
try { modules = JSON.parse(user.modules); } catch { modules = ALL_MODULES; }
}
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,
@@ -308,6 +417,7 @@ app.get('/api/auth/me', requireAuth, async (req, res) => {
tutorial_completed: user.tutorial_completed,
CreatedAt: user.CreatedAt, created_at: user.CreatedAt,
profileComplete: !!user.team_role,
modules,
});
} catch (err) {
console.error('Auth/me error:', err);
@@ -492,7 +602,21 @@ app.get('/api/users/team', requireAuth, async (req, res) => {
});
}
res.json(filtered.map(u => ({ ...u, id: u.Id, _id: u.Id })));
// Attach teams to each user
let allTeamMembers = [];
let allTeams = [];
try {
allTeamMembers = await nocodb.list('TeamMembers', { limit: 10000 });
allTeams = await nocodb.list('Teams', { limit: 500 });
} catch {}
const teamMap = {};
for (const t of allTeams) teamMap[t.Id] = t.name;
res.json(filtered.map(u => {
const userTeamEntries = allTeamMembers.filter(tm => tm.user_id === u.Id);
const teams = userTeamEntries.map(tm => ({ id: tm.team_id, name: teamMap[tm.team_id] || 'Unknown' }));
return { ...u, id: u.Id, _id: u.Id, teams };
}));
} catch (err) {
console.error('Team list error:', err);
res.status(500).json({ error: 'Failed to load team' });
@@ -516,6 +640,7 @@ app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), a
const created = await nocodb.create('Users', {
name, email, role: userRole, team_role: team_role || null,
brands: JSON.stringify(brands || []), phone: phone || null,
modules: JSON.stringify(req.body.modules || ALL_MODULES),
});
const defaultPassword = password || 'changeme123';
@@ -540,6 +665,7 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules);
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
@@ -1329,13 +1455,20 @@ app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
}
const campaignIds = new Set();
for (const e of entries) if (e.campaign_id) campaignIds.add(e.campaign_id);
const projectIds = new Set();
for (const e of entries) {
if (e.campaign_id) campaignIds.add(e.campaign_id);
if (e.project_id) projectIds.add(e.project_id);
}
const cNames = {};
for (const id of campaignIds) cNames[id] = await getRecordName('Campaigns', id);
const pNames = {};
for (const id of projectIds) pNames[id] = await getRecordName('Projects', id);
res.json(entries.map(e => ({
...e,
campaign_name: cNames[e.campaign_id] || null,
project_name: pNames[e.project_id] || null,
})));
} catch (err) {
res.status(500).json({ error: 'Failed to load budget entries' });
@@ -1343,17 +1476,23 @@ app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
});
app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const { label, amount, source, campaign_id, category, date_received, notes } = req.body;
const { label, amount, source, destination, campaign_id, project_id, category, date_received, notes, type } = 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,
label, amount, source: source || null, destination: destination || null,
category: category || 'marketing', date_received, notes: notes || '',
campaign_id: campaign_id ? Number(campaign_id) : null,
project_id: project_id ? Number(project_id) : null,
type: type || 'income',
});
const entry = await nocodb.get('BudgetEntries', created.Id);
res.status(201).json({ ...entry, campaign_name: await getRecordName('Campaigns', entry.campaign_id) });
res.status(201).json({
...entry,
campaign_name: await getRecordName('Campaigns', entry.campaign_id),
project_name: await getRecordName('Projects', entry.project_id),
});
} catch (err) {
res.status(500).json({ error: 'Failed to create budget entry' });
}
@@ -1365,17 +1504,22 @@ app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
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']) {
for (const f of ['label', 'amount', 'source', 'destination', 'category', 'date_received', 'notes', 'type']) {
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 (req.body.project_id !== undefined) data.project_id = req.body.project_id ? Number(req.body.project_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) });
res.json({
...entry,
campaign_name: await getRecordName('Campaigns', entry.campaign_id),
project_name: await getRecordName('Projects', entry.project_id),
});
} catch (err) {
res.status(500).json({ error: 'Failed to update budget entry' });
}
@@ -1407,15 +1551,23 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
budgetEntries = budgetEntries.filter(e => !e.campaign_id || myCampaignIds.has(e.campaign_id));
}
const totalReceived = isSuperadmin
? budgetEntries.reduce((sum, e) => sum + (e.amount || 0), 0)
const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income');
const expenseEntries = budgetEntries.filter(e => e.type === 'expense');
const totalIncome = isSuperadmin
? incomeEntries.reduce((sum, e) => sum + (e.amount || 0), 0)
: campaigns.reduce((sum, c) => sum + (c.budget || 0), 0);
const totalExpenses = expenseEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
const totalReceived = totalIncome;
const allTracks = await nocodb.list('CampaignTracks', { limit: 10000 });
const campaignStats = campaigns.map(c => {
const cTracks = allTracks.filter(t => t.campaign_id === c.Id);
const cEntries = incomeEntries.filter(e => e.campaign_id && Number(e.campaign_id) === c.Id);
const cExpenses = expenseEntries.filter(e => e.campaign_id && Number(e.campaign_id) === c.Id);
return {
id: c.Id, name: c.name, budget: c.budget, status: c.status,
budget_from_entries: cEntries.reduce((s, e) => s + (e.amount || 0), 0),
expenses: cExpenses.reduce((s, e) => s + (e.amount || 0), 0),
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),
@@ -1434,11 +1586,35 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
conversions: acc.conversions + c.tracks_conversions,
}), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 });
const totalCampaignBudget = campaignStats.reduce((s, c) => s + (c.budget || 0), 0);
// Project budget breakdown
let projects = await nocodb.list('Projects', { limit: 10000 });
if (!isSuperadmin) {
projects = projects.filter(p => p.owner_id === userId || p.created_by_user_id === userId);
}
const projectStats = projects.map(p => {
const pEntries = incomeEntries.filter(e => e.project_id && Number(e.project_id) === p.Id);
const pExpenses = expenseEntries.filter(e => e.project_id && Number(e.project_id) === p.Id);
return {
id: p.Id, name: p.name, status: p.status,
budget_allocated: pEntries.reduce((s, e) => s + (e.amount || 0), 0),
expenses: pExpenses.reduce((s, e) => s + (e.amount || 0), 0),
};
});
const totalProjectBudget = projectStats.reduce((s, p) => s + p.budget_allocated, 0);
const unallocated = totalReceived - totalCampaignBudget - totalProjectBudget;
res.json({
totalReceived, ...totals,
remaining: totalReceived - totals.spent,
totalReceived, ...totals, totalExpenses,
remaining: totalReceived - totalCampaignBudget - totalProjectBudget - totals.spent - totalExpenses,
roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0,
campaigns: campaignStats,
projects: projectStats,
totalCampaignBudget,
totalProjectBudget,
unallocated,
});
} catch (err) {
console.error('Finance summary error:', err);
@@ -1584,6 +1760,7 @@ app.get('/api/projects', requireAuth, async (req, res) => {
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,
thumbnail_url: p.thumbnail ? `/api/uploads/${p.thumbnail}` : null,
})));
} catch (err) {
res.status(500).json({ error: 'Failed to load projects' });
@@ -1599,6 +1776,7 @@ app.get('/api/projects/:id', requireAuth, async (req, res) => {
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),
thumbnail_url: project.thumbnail ? `/api/uploads/${project.thumbnail}` : null,
});
} catch (err) {
res.status(500).json({ error: 'Failed to load project' });
@@ -1673,6 +1851,30 @@ app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'
}
});
app.post('/api/projects/:id/thumbnail', requireAuth, requireRole('superadmin', 'manager'), upload.single('file'), async (req, res) => {
try {
const existing = await nocodb.get('Projects', req.params.id);
if (!existing) return res.status(404).json({ error: 'Project not found' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
await nocodb.update('Projects', req.params.id, { thumbnail: req.file.filename });
const project = await nocodb.get('Projects', req.params.id);
res.json(project);
} catch (err) {
res.status(500).json({ error: 'Failed to upload thumbnail' });
}
});
app.delete('/api/projects/:id/thumbnail', 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.update('Projects', req.params.id, { thumbnail: null });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to remove thumbnail' });
}
});
// ─── TASKS ──────────────────────────────────────────────────────
app.get('/api/tasks', requireAuth, async (req, res) => {
@@ -1685,6 +1887,8 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
}
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})`);
if (req.query.priority) whereParts.push(`(priority,eq,${req.query.priority})`);
if (req.query.created_by) whereParts.push(`(created_by_user_id,eq,${req.query.created_by})`);
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: 10000 });
@@ -1696,6 +1900,17 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
);
}
// Post-fetch date range filters
if (req.query.due_date_from) {
const from = new Date(req.query.due_date_from);
tasks = tasks.filter(t => t.due_date && new Date(t.due_date) >= from);
}
if (req.query.due_date_to) {
const to = new Date(req.query.due_date_to);
to.setHours(23, 59, 59, 999);
tasks = tasks.filter(t => t.due_date && new Date(t.due_date) <= to);
}
const projectIds = new Set(), userIds = new Set();
for (const t of tasks) {
if (t.project_id) projectIds.add(t.project_id);
@@ -1706,12 +1921,48 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
for (const id of projectIds) names[`project:${id}`] = await getRecordName('Projects', id);
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
// Resolve brand info from projects
const projectData = {};
for (const pid of projectIds) {
try {
const proj = await nocodb.get('Projects', pid);
if (proj) {
projectData[pid] = {
brand_id: proj.brand_id,
brand_name: proj.brand_id ? await getRecordName('Brands', proj.brand_id) : null,
};
}
} catch {}
}
// Post-fetch brand filter (brand lives on the project)
if (req.query.brand_id) {
const brandId = Number(req.query.brand_id);
tasks = tasks.filter(t => t.project_id && projectData[t.project_id]?.brand_id === brandId);
}
// Batch comment counts for all tasks
const commentCounts = {};
try {
const allComments = await nocodb.list('Comments', {
where: '(entity_type,eq,task)',
fields: ['entity_id'],
limit: 50000,
});
for (const c of allComments) {
commentCounts[c.entity_id] = (commentCounts[c.entity_id] || 0) + 1;
}
} catch {}
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,
brand_id: t.project_id && projectData[t.project_id] ? projectData[t.project_id].brand_id : null,
brand_name: t.project_id && projectData[t.project_id] ? projectData[t.project_id].brand_name : null,
comment_count: commentCounts[t.Id || t.id] || 0,
})));
} catch (err) {
console.error('GET /tasks error:', err);
@@ -1823,6 +2074,95 @@ app.delete('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmi
}
});
// ─── TASK ATTACHMENTS ───────────────────────────────────────────
app.get('/api/tasks/:id/attachments', requireAuth, async (req, res) => {
try {
const attachments = await nocodb.list('TaskAttachments', {
where: `(task_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/tasks/:id/attachments', requireAuth, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const task = await nocodb.get('Tasks', req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
const url = `/api/uploads/${req.file.filename}`;
const created = await nocodb.create('TaskAttachments', {
filename: req.file.filename,
original_name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
url,
task_id: Number(req.params.id),
});
const attachment = await nocodb.get('TaskAttachments', created.Id);
res.status(201).json(attachment);
} catch (err) {
console.error('Upload task attachment error:', err);
res.status(500).json({ error: 'Failed to upload attachment' });
}
});
app.delete('/api/task-attachments/:id', requireAuth, async (req, res) => {
try {
const attachment = await nocodb.get('TaskAttachments', req.params.id);
if (!attachment) return res.status(404).json({ error: 'Attachment not found' });
// Check if file is referenced elsewhere before deleting from disk
const otherTaskAtts = await nocodb.list('TaskAttachments', {
where: `(filename,eq,${attachment.filename})`, limit: 10,
});
const otherPostAtts = await nocodb.list('PostAttachments', {
where: `(filename,eq,${attachment.filename})`, limit: 10,
});
const assets = await nocodb.list('Assets', {
where: `(filename,eq,${attachment.filename})`, limit: 10,
});
if (otherTaskAtts.length <= 1 && otherPostAtts.length === 0 && assets.length === 0) {
const filePath = path.join(uploadsDir, attachment.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
await nocodb.delete('TaskAttachments', req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Delete task attachment error:', err);
res.status(500).json({ error: 'Failed to delete attachment' });
}
});
// Set a task's thumbnail from one of its image attachments
app.patch('/api/tasks/:id/thumbnail', requireAuth, async (req, res) => {
try {
const { attachment_id } = req.body;
const task = await nocodb.get('Tasks', req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
if (attachment_id) {
const att = await nocodb.get('TaskAttachments', attachment_id);
if (!att) return res.status(404).json({ error: 'Attachment not found' });
await nocodb.update('Tasks', req.params.id, { thumbnail: att.url || `/api/uploads/${att.filename}` });
} else {
await nocodb.update('Tasks', req.params.id, { thumbnail: null });
}
const updated = await nocodb.get('Tasks', req.params.id);
res.json(updated);
} catch (err) {
res.status(500).json({ error: 'Failed to set thumbnail' });
}
});
// ─── DASHBOARD ──────────────────────────────────────────────────
app.get('/api/dashboard', requireAuth, async (req, res) => {
@@ -2055,6 +2395,114 @@ async function getUserCampaignIds(userId) {
// ─── ERROR HANDLING ─────────────────────────────────────────────
// ─── TEAMS ──────────────────────────────────────────────────────
app.get('/api/teams', requireAuth, async (req, res) => {
try {
const teams = await nocodb.list('Teams', { sort: 'name', limit: 500 });
const members = await nocodb.list('TeamMembers', { limit: 10000 });
const result = teams.map(t => {
const teamMembers = members.filter(m => m.team_id === t.Id);
return {
...t, id: t.Id, _id: t.Id,
member_ids: teamMembers.map(m => m.user_id),
member_count: teamMembers.length,
};
});
res.json(result);
} catch (err) {
console.error('Teams list error:', err);
res.status(500).json({ error: 'Failed to load teams' });
}
});
app.post('/api/teams', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const { name, description, member_ids } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
try {
const created = await nocodb.create('Teams', { name, description: description || null });
if (member_ids && member_ids.length > 0) {
await nocodb.bulkCreate('TeamMembers', member_ids.map(uid => ({ team_id: created.Id, user_id: uid })));
}
const team = await nocodb.get('Teams', created.Id);
res.status(201).json({ ...team, id: team.Id, _id: team.Id, member_ids: member_ids || [] });
} catch (err) {
console.error('Create team error:', err);
res.status(500).json({ error: 'Failed to create team' });
}
});
app.patch('/api/teams/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('Teams', req.params.id);
if (!existing) return res.status(404).json({ error: 'Team not found' });
const data = {};
if (req.body.name !== undefined) data.name = req.body.name;
if (req.body.description !== undefined) data.description = req.body.description;
if (Object.keys(data).length > 0) await nocodb.update('Teams', req.params.id, data);
// Sync members if provided
if (req.body.member_ids !== undefined) {
const oldMembers = await nocodb.list('TeamMembers', { where: `(team_id,eq,${req.params.id})`, limit: 10000 });
if (oldMembers.length > 0) {
await nocodb.bulkDelete('TeamMembers', oldMembers.map(m => ({ Id: m.Id })));
}
if (req.body.member_ids.length > 0) {
await nocodb.bulkCreate('TeamMembers', req.body.member_ids.map(uid => ({ team_id: Number(req.params.id), user_id: uid })));
}
}
const team = await nocodb.get('Teams', req.params.id);
const members = await nocodb.list('TeamMembers', { where: `(team_id,eq,${req.params.id})`, limit: 10000 });
res.json({ ...team, id: team.Id, _id: team.Id, member_ids: members.map(m => m.user_id) });
} catch (err) {
console.error('Update team error:', err);
res.status(500).json({ error: 'Failed to update team' });
}
});
app.delete('/api/teams/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('Teams', req.params.id);
if (!existing) return res.status(404).json({ error: 'Team not found' });
const members = await nocodb.list('TeamMembers', { where: `(team_id,eq,${req.params.id})`, limit: 10000 });
if (members.length > 0) {
await nocodb.bulkDelete('TeamMembers', members.map(m => ({ Id: m.Id })));
}
await nocodb.delete('Teams', req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Delete team error:', err);
res.status(500).json({ error: 'Failed to delete team' });
}
});
app.post('/api/teams/:id/members', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id is required' });
try {
await nocodb.create('TeamMembers', { team_id: Number(req.params.id), user_id: Number(user_id) });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to add member' });
}
});
app.delete('/api/teams/:id/members/:userId', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
const entries = await nocodb.list('TeamMembers', {
where: `(team_id,eq,${req.params.id})~and(user_id,eq,${req.params.userId})`,
limit: 10,
});
if (entries.length > 0) {
await nocodb.bulkDelete('TeamMembers', entries.map(e => ({ Id: e.Id })));
}
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to remove member' });
}
});
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 });
@@ -2070,8 +2518,11 @@ process.on('unhandledRejection', (err) => {
// ─── START SERVER ───────────────────────────────────────────────
async function startServer() {
console.log('Ensuring required tables...');
await ensureRequiredTables();
console.log('Running FK column migration...');
await ensureFKColumns();
await ensureTextColumns();
await backfillFKs();
console.log('Migration complete.');