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:
477
server/server.js
477
server/server.js
@@ -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.');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user