feat: comprehensive UI overhaul + budget allocation redesign

Audit & Quality:
- RTL: replaced 121 LTR-only utilities (text-left, pl-, left-) with logical properties
- A11y: focus traps + ARIA on Modal/SlidePanel/TabbedModal, clickable divs→buttons
- Theming: bg-white→bg-surface (170 instances), text-gray→semantic tokens
- Performance: useMemo on filters, loading="lazy" on 24 images
- CSS: prefers-reduced-motion, removed dead animations

Component Splits:
- PostDetailPanel: 1332→623 lines + 4 sub-components
- ArtefactDetailPanel: 972→590 lines + 1 sub-component

Brand Identity — Rawaj (رواج):
- New name, DM Sans font, deep teal palette (#0d9488)
- Custom SVG logo, forest-tinted dark mode
- All emails branded with app name in subject line

Design Refinement:
- Dashboard: merged posts+deadlines into tabbed ActivityFeed, inline stats
- Quieter: removed card lift, brand glow, gradient text, mesh backgrounds
- CampaignDetail: prominent budget card, compact team avatars, Lucide icons
- Consistent page titles via Header.jsx, standardized section headers
- Finance page fully i18n'd (20+ hardcoded strings replaced)

Budget Allocation Redesign:
- Single source of truth: BudgetEntries (Campaign.budget deprecated)
- Validation at all levels: main→campaign→track, expenses blocked if insufficient
- Budget request workflow with CEO approval via public link
- BudgetRequests table, CRUD routes, public approval page
- Budget mutex for race condition prevention
- Idempotent migration for existing campaign budgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-15 15:36:19 +03:00
parent 3c857856c5
commit e1d1c392eb
77 changed files with 4351 additions and 2108 deletions
+457 -23
View File
@@ -13,6 +13,8 @@ const crypto = require('crypto');
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers');
const notify = require('./notifications');
const { acquireBudgetLock } = require('./budget-mutex');
const { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries } = require('./budget-helpers');
const app = express();
@@ -61,7 +63,7 @@ app.use(session({
app.use('/api/uploads', express.static(uploadsDir));
// ─── APP SETTINGS (persisted to JSON) ────────────────────────────
const defaultSettings = { uploadMaxSizeMB: DEFAULTS.uploadMaxSizeMB };
const defaultSettings = { uploadMaxSizeMB: DEFAULTS.uploadMaxSizeMB, ceoEmail: '' };
function loadSettings() {
try { return { ...defaultSettings, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')) }; }
catch (err) { console.error('Load settings error:', err.message); return { ...defaultSettings }; }
@@ -313,6 +315,17 @@ const REQUIRED_TABLES = {
{ title: 'notes', uidt: 'LongText' },
{ title: 'campaign_id', uidt: 'Number' },
],
BudgetRequests: [
{ title: 'amount', uidt: 'Decimal' },
{ title: 'justification', uidt: 'LongText' },
{ title: 'status', uidt: 'SingleLineText' },
{ title: 'requested_by_user_id', uidt: 'Number' },
{ title: 'approval_token', uidt: 'SingleLineText' },
{ title: 'response_note', uidt: 'LongText' },
{ title: 'earmarked_campaign_id', uidt: 'Number' },
{ title: 'earmarked_project_id', uidt: 'Number' },
{ title: 'created_budget_entry_id', uidt: 'Number' },
],
TaskAttachments: [
{ title: 'filename', uidt: 'SingleLineText' },
{ title: 'original_name', uidt: 'SingleLineText' },
@@ -515,6 +528,10 @@ const TEXT_COLUMNS = {
{ name: 'review_version', uidt: 'Number' },
],
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
BudgetRequests: [
{ name: 'token_expires_at', uidt: 'SingleLineText' },
{ name: 'resolved_at', uidt: 'SingleLineText' },
],
};
async function ensureTextColumns() {
@@ -746,17 +763,21 @@ app.post('/api/auth/forgot-password', async (req, res) => {
const { sendMail } = require('./mail');
await sendMail({
to: email,
subject: 'Password Reset',
html: `<div style="font-family:sans-serif;max-width:500px;margin:0 auto">
<h2>Password Reset</h2>
<p>Hello ${user.name || ''},</p>
<p>Click below to reset your password:</p>
<p style="text-align:center;margin:30px 0">
<a href="${resetUrl}" style="background:#3b82f6;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold">Reset Password</a>
</p>
<p style="color:#666;font-size:14px">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
subject: 'Rawaj — Password Reset',
html: `<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px">
<div style="background:#0a1f1c;color:#fff;padding:16px 24px;border-radius:12px 12px 0 0;font-size:16px;font-weight:600">Rawaj</div>
<div style="background:#fff;padding:32px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none">
<h2 style="margin:0 0 16px;color:#1e293b;font-size:20px">Password Reset</h2>
<p style="color:#475569;font-size:15px;line-height:1.6">Hello ${user.name || ''},</p>
<p style="color:#475569;font-size:15px;line-height:1.6">Click below to reset your password:</p>
<div style="margin:24px 0 8px">
<a href="${resetUrl}" style="display:inline-block;background:#0d9488;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px">Reset Password</a>
</div>
<p style="color:#94a3b8;font-size:13px">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
</div>
<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">This is an automated notification from Rawaj</div>
</div>`,
text: `Hello ${user.name || ''},\n\nVisit this link to reset your password:\n${resetUrl}\n\nExpires in 1 hour.`,
text: `Rawaj — Password Reset\n\nHello ${user.name || ''},\n\nVisit this link to reset your password:\n${resetUrl}\n\nExpires in 1 hour.`,
});
}
} catch (err) {
@@ -2096,8 +2117,19 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
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;
const numericBudget = Number(effectiveBudget) || 0;
let releaseLock;
try {
// Budget validation: if allocating budget, check main availability
if (numericBudget > 0) {
releaseLock = await acquireBudgetLock();
const main = await getMainAvailable();
if (main.available < numericBudget) {
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
}
}
const created = await nocodb.create('Campaigns', {
name, description: description || null,
start_date, end_date,
@@ -2113,6 +2145,18 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
created_by_user_id: req.session.userId,
});
// Auto-create BudgetEntry for campaign allocation
if (numericBudget > 0) {
await nocodb.create('BudgetEntries', {
type: 'income', amount: numericBudget,
campaign_id: created.Id,
label: 'Campaign allocation',
source: 'Campaign creation',
date_received: new Date().toISOString().slice(0, 10),
category: 'marketing',
});
}
// Auto-assign creator
await nocodb.create('CampaignAssignments', {
assigned_at: new Date().toISOString(),
@@ -2131,10 +2175,13 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
} catch (err) {
console.error('Create campaign error:', err);
res.status(500).json({ error: 'Failed to create campaign' });
} finally {
if (releaseLock) releaseLock();
}
});
app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), async (req, res) => {
let releaseLock;
try {
const existing = await nocodb.get('Campaigns', req.params.id);
if (!existing) return res.status(404).json({ error: 'Campaign not found' });
@@ -2153,6 +2200,51 @@ app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'su
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
// Budget validation when budget is being updated
if (data.budget !== undefined) {
const newBudget = Number(data.budget) || 0;
const currentAllocated = await getCampaignAllocatedFromEntries(req.params.id);
const delta = newBudget - currentAllocated;
if (delta > 0) {
// Increasing: check main pool has enough
releaseLock = await acquireBudgetLock();
const main = await getMainAvailable();
if (main.available < delta) {
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
}
} else if (delta < 0) {
// Decreasing: check new budget covers existing track allocations
const campAvail = await getCampaignAvailable(req.params.id);
if (newBudget < campAvail.trackAllocated) {
return res.status(400).json({
error: 'Cannot reduce below track allocations',
tracks_allocated: campAvail.trackAllocated,
});
}
}
// Update or create the BudgetEntry for this campaign
if (newBudget > 0) {
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
const existingEntry = entries.find(e =>
e.campaign_id && Number(e.campaign_id) === Number(req.params.id) && (e.type || 'income') === 'income'
);
if (existingEntry) {
await nocodb.update('BudgetEntries', existingEntry.Id, { amount: newBudget });
} else {
await nocodb.create('BudgetEntries', {
type: 'income', amount: newBudget,
campaign_id: Number(req.params.id),
label: 'Campaign allocation',
source: 'Campaign budget update',
date_received: new Date().toISOString().slice(0, 10),
category: 'marketing',
});
}
}
}
await nocodb.update('Campaigns', req.params.id, data);
const campaign = await nocodb.get('Campaigns', req.params.id);
@@ -2164,6 +2256,8 @@ app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'su
} catch (err) {
console.error('Update campaign error:', err);
res.status(500).json({ error: 'Failed to update campaign' });
} finally {
if (releaseLock) releaseLock();
}
});
@@ -2186,6 +2280,10 @@ app.delete('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager
const assets = await nocodb.list('Assets', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const a of assets) await nocodb.update('Assets', a.Id, { campaign_id: null });
// Release budget — null out campaign_id on linked BudgetEntries
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { campaign_id: null });
await nocodb.delete('Campaigns', id);
res.json({ success: true });
} catch (err) {
@@ -2340,13 +2438,27 @@ app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
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' });
const numericAmount = Number(amount);
if (!numericAmount || numericAmount <= 0) return res.status(400).json({ error: 'Amount must be greater than 0' });
const entryType = type || 'income';
let releaseLock;
try {
// Validate expense against main available budget
if (entryType === 'expense') {
releaseLock = await acquireBudgetLock();
const main = await getMainAvailable();
if (main.available < numericAmount) {
return res.status(400).json({ error: 'Insufficient budget', available: main.available });
}
}
const created = await nocodb.create('BudgetEntries', {
label, amount, source: source || null, destination: destination || null,
label, amount: numericAmount, 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',
type: entryType,
});
const entry = await nocodb.get('BudgetEntries', created.Id);
res.status(201).json({
@@ -2356,6 +2468,8 @@ app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
});
} catch (err) {
res.status(500).json({ error: 'Failed to create budget entry' });
} finally {
if (releaseLock) releaseLock();
}
});
@@ -2397,6 +2511,258 @@ app.delete('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
}
});
// ─── BUDGET REQUESTS ────────────────────────────────────────────
// Public routes first (no auth)
app.get('/api/budget-approval/:token', async (req, res) => {
try {
const requests = await nocodb.list('BudgetRequests', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
const request = requests[0];
if (!request) return res.status(404).json({ error: 'Request not found' });
// Already handled
if (request.status !== 'pending') {
return res.json({ status: request.status });
}
// Check expiry
if (request.token_expires_at && new Date(request.token_expires_at) < new Date()) {
return res.json({ status: 'expired' });
}
// Enrich with names
let requester_name = 'Unknown';
try {
const u = await nocodb.get('Users', request.requested_by_user_id);
if (u) requester_name = u.name;
} catch {}
let earmarked_campaign_name = null;
let earmarked_project_name = null;
if (request.earmarked_campaign_id) {
try {
const c = await nocodb.get('Campaigns', request.earmarked_campaign_id);
if (c) earmarked_campaign_name = c.name;
} catch {}
}
if (request.earmarked_project_id) {
try {
const p = await nocodb.get('Projects', request.earmarked_project_id);
if (p) earmarked_project_name = p.name;
} catch {}
}
res.json({
amount: request.amount,
requester_name,
justification: request.justification,
earmarked_campaign_name,
earmarked_project_name,
status: request.status,
});
} catch (err) {
console.error('Budget approval GET error:', err);
res.status(500).json({ error: 'Failed to load budget request' });
}
});
app.post('/api/budget-approval/:token/respond', async (req, res) => {
try {
const { action, note } = req.body;
if (!action || !['approve', 'reject'].includes(action)) {
return res.status(400).json({ error: 'action must be "approve" or "reject"' });
}
const requests = await nocodb.list('BudgetRequests', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
const request = requests[0];
if (!request) return res.status(404).json({ error: 'Request not found' });
// Idempotent: already handled
if (request.status === 'approved' || request.status === 'rejected') {
const existing = await nocodb.get('BudgetRequests', request.Id);
return res.json(existing);
}
if (request.status !== 'pending') {
return res.status(400).json({ error: `Request is ${request.status}` });
}
// Check expiry
if (request.token_expires_at && new Date(request.token_expires_at) < new Date()) {
return res.status(400).json({ error: 'Token has expired' });
}
const now = new Date().toISOString();
// Get requester for notifications
let requester = null;
try { requester = await nocodb.get('Users', request.requested_by_user_id); } catch {}
if (action === 'approve') {
// Create income BudgetEntry
const entryData = {
type: 'income',
amount: request.amount,
source: 'CEO Approved',
label: `Budget request #${request.Id}`,
date_received: now.split('T')[0],
};
if (request.earmarked_campaign_id) entryData.campaign_id = request.earmarked_campaign_id;
if (request.earmarked_project_id) entryData.project_id = request.earmarked_project_id;
const entry = await nocodb.create('BudgetEntries', entryData);
await nocodb.update('BudgetRequests', request.Id, {
status: 'approved',
resolved_at: now,
created_budget_entry_id: entry.Id,
response_note: note || null,
});
if (requester && requester.email) {
notify.notifyBudgetApproved({
request: { ...request, status: 'approved', response_note: note || null },
requesterEmail: requester.email,
requesterLang: requester.preferred_language || 'en',
});
}
} else {
// reject
await nocodb.update('BudgetRequests', request.Id, {
status: 'rejected',
resolved_at: now,
response_note: note || null,
});
if (requester && requester.email) {
notify.notifyBudgetRejected({
request: { ...request, status: 'rejected', response_note: note || null },
requesterEmail: requester.email,
requesterLang: requester.preferred_language || 'en',
});
}
}
const updated = await nocodb.get('BudgetRequests', request.Id);
res.json(updated);
} catch (err) {
console.error('Budget approval respond error:', err);
res.status(500).json({ error: 'Failed to process budget response' });
}
});
// Authenticated budget request routes
app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' });
const userIds = [...new Set(requests.map(r => r.requested_by_user_id).filter(Boolean))];
const users = {};
for (const uid of userIds) {
try { const u = await nocodb.get('Users', uid); if (u) users[uid] = u.name; } catch {}
}
const enriched = requests.map(r => ({
...r,
requester_name: users[r.requested_by_user_id] || 'Unknown',
}));
await batchResolveNames(enriched, {
earmarked_campaign_id: { table: 'Campaigns', as: 'earmarked_campaign_name' },
earmarked_project_id: { table: 'Projects', as: 'earmarked_project_name' },
});
res.json(enriched);
} catch (err) {
console.error('Budget requests list error:', err);
res.status(500).json({ error: 'Failed to load budget requests' });
}
});
app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body;
const numAmount = Number(amount);
if (!numAmount || numAmount <= 0) {
return res.status(400).json({ error: 'amount must be greater than 0' });
}
if (!justification || !String(justification).trim()) {
return res.status(400).json({ error: 'justification is required' });
}
if (!appSettings.ceoEmail) {
return res.status(400).json({ error: 'CEO email is not configured in settings' });
}
const token = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const created = await nocodb.create('BudgetRequests', {
amount: numAmount,
justification: String(justification).trim(),
status: 'pending',
requested_by_user_id: req.session.userId,
approval_token: token,
token_expires_at: expiresAt,
earmarked_campaign_id: earmarked_campaign_id || null,
earmarked_project_id: earmarked_project_id || null,
});
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
const approvalUrl = `${appUrl}/budget-approval/${token}`;
// Build earmarked label
let earmarkedFor = null;
if (earmarked_campaign_id) {
try {
const c = await nocodb.get('Campaigns', earmarked_campaign_id);
if (c) earmarkedFor = `Campaign: ${c.name}`;
} catch {}
}
if (earmarked_project_id) {
try {
const p = await nocodb.get('Projects', earmarked_project_id);
if (p) earmarkedFor = earmarkedFor ? `${earmarkedFor}, Project: ${p.name}` : `Project: ${p.name}`;
} catch {}
}
notify.notifyBudgetRequest({
ceoEmail: appSettings.ceoEmail,
amount: numAmount,
requesterName: req.session.userName || 'Unknown',
justification: String(justification).trim(),
earmarkedFor,
approvalUrl,
});
const record = await nocodb.get('BudgetRequests', created.Id);
res.status(201).json(record);
} catch (err) {
console.error('Budget request create error:', err);
res.status(500).json({ error: 'Failed to create budget request' });
}
});
app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const existing = await nocodb.get('BudgetRequests', req.params.id);
if (!existing) return res.status(404).json({ error: 'Request not found' });
if (existing.status !== 'pending') {
return res.status(400).json({ error: `Cannot cancel — request is already ${existing.status}` });
}
await nocodb.update('BudgetRequests', req.params.id, {
status: 'cancelled',
resolved_at: new Date().toISOString(),
});
const updated = await nocodb.get('BudgetRequests', req.params.id);
res.json(updated);
} catch (err) {
console.error('Budget request cancel error:', err);
res.status(500).json({ error: 'Failed to cancel budget request' });
}
});
// Finance summary
app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
try {
@@ -2414,11 +2780,8 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
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 totalReceived = incomeEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
const totalExpenses = expenseEntries.reduce((sum, e) => sum + (e.amount || 0), 0);
const totalReceived = totalIncome;
const allTracks = await nocodb.list('CampaignTracks', { limit: QUERY_LIMITS.max });
const campaignStats = campaigns.map(c => {
@@ -2447,7 +2810,10 @@ 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);
// Campaign budget = sum of income BudgetEntries with campaign_id set
const totalCampaignBudget = incomeEntries
.filter(e => e.campaign_id)
.reduce((s, e) => s + (e.amount || 0), 0);
// Project budget breakdown
let projects = await nocodb.list('Projects', { limit: QUERY_LIMITS.max });
@@ -2465,17 +2831,20 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
});
const totalProjectBudget = projectStats.reduce((s, p) => s + p.budget_allocated, 0);
const unallocated = totalReceived - totalCampaignBudget - totalProjectBudget;
// remaining = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget
const remaining = totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget;
const mainAvailable = remaining;
res.json({
totalReceived, ...totals, totalExpenses,
remaining: totalReceived - totalCampaignBudget - totalProjectBudget - totals.spent - totalExpenses,
remaining,
mainAvailable,
roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0,
campaigns: campaignStats,
projects: projectStats,
totalCampaignBudget,
totalProjectBudget,
unallocated,
unallocated: remaining,
});
} catch (err) {
console.error('Finance summary error:', err);
@@ -2503,9 +2872,19 @@ app.post('/api/campaigns/:id/tracks', requireAuth, requireRole('superadmin', 'ma
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
const { name, type, platform, budget_allocated, status, notes } = req.body;
const numericAlloc = Number(budget_allocated) || 0;
// Validate track allocation against campaign available budget
if (numericAlloc > 0) {
const campAvail = await getCampaignAvailable(req.params.id);
if (campAvail.available < numericAlloc) {
return res.status(400).json({ error: 'Insufficient campaign budget', available: campAvail.available });
}
}
const created = await nocodb.create('CampaignTracks', {
name: name || null, type: type || 'organic_social',
platform: platform || null, budget_allocated: budget_allocated || 0,
platform: platform || null, budget_allocated: numericAlloc,
status: status || 'planned', notes: notes || '',
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0,
campaign_id: Number(req.params.id),
@@ -2529,6 +2908,19 @@ app.patch('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'),
}
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
// Validate budget_allocated increase against campaign available budget
if (data.budget_allocated !== undefined && existing.campaign_id) {
const newAlloc = Number(data.budget_allocated) || 0;
const currentAlloc = existing.budget_allocated || 0;
const delta = newAlloc - currentAlloc;
if (delta > 0) {
const campAvail = await getCampaignAvailable(existing.campaign_id);
if (campAvail.available < delta) {
return res.status(400).json({ error: 'Insufficient campaign budget', available: campAvail.available });
}
}
}
await nocodb.update('CampaignTracks', req.params.id, data);
const track = await nocodb.get('CampaignTracks', req.params.id);
res.json(track);
@@ -2729,6 +3121,12 @@ app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'
try {
const existing = await nocodb.get('Projects', req.params.id);
if (!existing) return res.status(404).json({ error: 'Project not found' });
// Release budget — null out project_id on linked BudgetEntries
const projId = Number(req.params.id);
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${projId})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null });
await nocodb.delete('Projects', req.params.id);
res.json({ success: true });
} catch (err) {
@@ -5351,6 +5749,9 @@ app.patch('/api/settings/app', requireAuth, requireRole('superadmin'), (req, res
}
appSettings.uploadMaxSizeMB = val;
}
if (req.body.ceoEmail !== undefined) {
appSettings.ceoEmail = String(req.body.ceoEmail).trim();
}
saveSettings(appSettings);
res.json(appSettings);
});
@@ -5425,6 +5826,37 @@ async function migrateAuthToNocoDB() {
// ─── START SERVER ───────────────────────────────────────────────
// Idempotent migration: create BudgetEntries for campaigns with budget > 0 that lack one
async function migrateCampaignBudgets() {
try {
const campaigns = await nocodb.list('Campaigns', { limit: QUERY_LIMITS.max });
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
const campaignIdsWithEntry = new Set(
entries
.filter(e => e.campaign_id && (e.type || 'income') === 'income')
.map(e => Number(e.campaign_id))
);
for (const c of campaigns) {
const budget = Number(c.budget) || 0;
if (budget <= 0) continue;
if (campaignIdsWithEntry.has(c.Id)) continue;
console.log(`[migrateCampaignBudgets] Creating BudgetEntry for campaign "${c.name}" (Id=${c.Id}, budget=${budget})`);
await nocodb.create('BudgetEntries', {
type: 'income', amount: budget,
campaign_id: c.Id,
label: 'Campaign allocation',
source: 'Budget migration',
date_received: new Date().toISOString().slice(0, 10),
category: 'marketing',
});
}
} catch (err) {
console.error('migrateCampaignBudgets error:', err);
}
}
async function startServer() {
// Validate required env vars
const REQUIRED_ENV = {
@@ -5461,6 +5893,8 @@ async function startServer() {
await ensureFKColumns();
await ensureTextColumns();
await backfillFKs();
console.log('Running campaign budget migration...');
await migrateCampaignBudgets();
console.log('Checking auth migration...');
await migrateAuthToNocoDB();
console.log('Migration complete.');
@@ -5547,7 +5981,7 @@ async function startServer() {
}
app.listen(PORT, () => {
console.log(`Digital Hub API running on http://localhost:${PORT}`);
console.log(`Rawaj API running on http://localhost:${PORT}`);
console.log(`Uploads directory: ${uploadsDir}`);
});
}