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:
+457
-23
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user