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>
22 KiB
Budget Allocation Redesign — Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the dual budget system with a single source of truth (BudgetEntries), add validation at all levels, and implement a CEO approval workflow for new income.
Architecture: BudgetEntries table is the only source for all budget calculations. Campaign/project allocations are income entries with a FK set. A new BudgetRequests table + public approval page handles CEO approval for new income. Budget mutex prevents race conditions.
Tech Stack: Express.js (server), React (client), NocoDB (database), nodemailer (emails)
Spec: docs/superpowers/specs/2026-03-15-budget-allocation-redesign.md
Chunk 1: Server — Budget Model Fix + Validation
Task 1: Add budget mutex utility
Files:
-
Create:
server/budget-mutex.js -
Step 1: Create the mutex module
// server/budget-mutex.js
let _lock = null;
async function acquireBudgetLock() {
while (_lock) await _lock;
let resolve;
_lock = new Promise(r => { resolve = r; });
return () => { _lock = null; resolve(); };
}
module.exports = { acquireBudgetLock };
- Step 2: Commit
git add server/budget-mutex.js
git commit -m "feat: add budget mutex for race condition prevention"
Task 2: Add budget availability helper
Files:
- Create:
server/budget-helpers.js
This module computes mainAvailable and campaignAvailable from BudgetEntries — the single source of truth. Every route that modifies budget will call these.
- Step 1: Create the helper module
// server/budget-helpers.js
const nocodb = require('./nocodb');
const QUERY_LIMITS = { max: 10000 };
async function getMainAvailable() {
const entries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max });
const income = entries.filter(e => (e.type || 'income') === 'income');
const expenses = entries.filter(e => e.type === 'expense');
const totalReceived = income.reduce((s, e) => s + (e.amount || 0), 0);
const totalExpenses = expenses.reduce((s, e) => s + (e.amount || 0), 0);
const totalCampaignBudget = income.filter(e => e.campaign_id).reduce((s, e) => s + (e.amount || 0), 0);
const totalProjectBudget = income.filter(e => e.project_id).reduce((s, e) => s + (e.amount || 0), 0);
return {
totalReceived,
totalExpenses,
totalCampaignBudget,
totalProjectBudget,
available: totalReceived - totalExpenses - totalCampaignBudget - totalProjectBudget,
};
}
async function getCampaignAvailable(campaignId) {
const entries = await nocodb.list('BudgetEntries', {
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
limit: QUERY_LIMITS.max,
});
const allocated = entries.reduce((s, e) => s + (e.amount || 0), 0);
const tracks = await nocodb.list('CampaignTracks', {
where: `(campaign_id,eq,${campaignId})`,
limit: QUERY_LIMITS.max,
});
const trackAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0);
return { allocated, trackAllocated, available: allocated - trackAllocated };
}
async function getCampaignAllocatedFromEntries(campaignId) {
const entries = await nocodb.list('BudgetEntries', {
where: `(campaign_id,eq,${campaignId})~and(type,neq,expense)`,
limit: QUERY_LIMITS.max,
});
return entries.reduce((s, e) => s + (e.amount || 0), 0);
}
module.exports = { getMainAvailable, getCampaignAvailable, getCampaignAllocatedFromEntries };
- Step 2: Commit
git add server/budget-helpers.js
git commit -m "feat: add budget availability helpers (single source of truth)"
Task 3: Fix finance summary endpoint
Files:
-
Modify:
server/server.js— theGET /api/finance/summaryhandler (~lines 2405-2488) -
Step 1: Rewrite the finance summary to use BudgetEntries only
Replace the entire handler body. Key changes:
totalReceived= sum of ALL income entries (same for all roles — remove the superadmin/manager fork)totalCampaignBudget= sum of income entries withcampaign_idset (notCampaign.budget)remaining= mainAvailable (no track double-counting)- Keep track aggregations (spent, revenue, impressions) for the campaign breakdown table
- Add
mainAvailableto the response
The handler still filters by user's campaign access for managers. Managers see a subset of campaigns but the SAME calculation logic.
- Step 2: Verify build
cd client && npx vite build --logLevel error
- Step 3: Commit
git add server/server.js
git commit -m "fix: finance summary uses BudgetEntries as single source of truth"
Task 4: Add budget validation to campaign creation
Files:
-
Modify:
server/server.js—POST /api/campaigns(~line 2097) -
Step 1: Add validation + auto-create BudgetEntry
In the campaign creation handler, after creating the campaign:
- Import
{ acquireBudgetLock }from./budget-mutex - Import
{ getMainAvailable }from./budget-helpers - If
budget > 0:- Acquire lock
- Check
mainAvailable >= budget - If insufficient: delete the just-created campaign, release lock, return 400
- If OK: create BudgetEntry
{ type: 'income', amount: budget, campaign_id: created.Id, label: 'Campaign allocation', source: 'Campaign creation', date_received: new Date().toISOString().slice(0,10) } - Release lock
- Step 2: Add validation to campaign PATCH for budget changes
Modify: server/server.js — PATCH /api/campaigns/:id
If budget field is being updated:
- Get current allocated = sum of income BudgetEntries for this campaign
- If increasing: check
mainAvailable >= (newBudget - currentAllocated) - If decreasing: check
newBudget >= sum(tracks.budget_allocated)for this campaign - Update (or create) the BudgetEntry to match new budget amount
- Step 3: Commit
git add server/server.js
git commit -m "feat: validate campaign budget against main available, auto-create BudgetEntry"
Task 5: Add budget validation to track creation/edit
Files:
-
Modify:
server/server.js—POST /api/campaigns/:id/tracks(~line 2504) andPATCH /api/campaigns/:id/tracks/:trackId -
Step 1: Add campaignAvailable check to track POST
Before creating the track:
- Import
{ getCampaignAvailable }from./budget-helpers - If
budget_allocated > 0: checkcampaignAvailable >= budget_allocated - If insufficient: return 400
{ error: 'Insufficient campaign budget', available: campaignAvailable }
- Step 2: Add same check to track PATCH
If budget_allocated is being updated:
- Get current track's
budget_allocated - Delta = newAmount - currentAmount
- If delta > 0: check
campaignAvailable >= delta
- Step 3: Commit
git add server/server.js
git commit -m "feat: validate track budget against campaign available"
Task 6: Add budget validation to expense creation
Files:
-
Modify:
server/server.js—POST /api/budget(~line 2343) -
Step 1: Add mainAvailable check for expenses
In the budget entry creation handler:
- Validate
amount > 0 - If
type === 'expense': acquire lock, checkmainAvailable >= amount, release - If insufficient: return 400
- Step 2: Commit
git add server/server.js
git commit -m "feat: validate expense entries against available budget"
Task 7: Handle campaign/project deletion — release budget
Files:
-
Modify:
server/server.js—DELETE /api/campaigns/:id(~line 2174) andDELETE /api/projects/:id -
Step 1: Null out BudgetEntry FKs on campaign delete
In the campaign delete handler, before deleting the campaign:
// Release budget entries back to main
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 });
- Step 2: Same for project delete
const budgetEntries = await nocodb.list('BudgetEntries', { where: `(project_id,eq,${id})`, limit: QUERY_LIMITS.max });
for (const e of budgetEntries) await nocodb.update('BudgetEntries', e.Id, { project_id: null });
- Step 3: Commit
git add server/server.js
git commit -m "feat: release budget on campaign/project deletion"
Task 8: Migration — create BudgetEntries for existing campaigns
Files:
-
Modify:
server/server.js— add migration instartServer()function -
Step 1: Add migration after ensureTextColumns
// Migrate Campaign.budget → BudgetEntries (one-time, idempotent)
async function migrateCampaignBudgets() {
const campaigns = await nocodb.list('Campaigns', { limit: 10000 });
const entries = await nocodb.list('BudgetEntries', { limit: 10000 });
for (const c of campaigns) {
if (!c.budget || c.budget <= 0) continue;
const existing = entries.find(e => e.campaign_id && Number(e.campaign_id) === c.Id && (e.type || 'income') === 'income');
if (existing) continue;
await nocodb.create('BudgetEntries', {
label: `Campaign allocation: ${c.name}`,
amount: c.budget,
type: 'income',
campaign_id: c.Id,
source: 'Migrated from Campaign.budget',
date_received: c.CreatedAt ? c.CreatedAt.slice(0, 10) : new Date().toISOString().slice(0, 10),
category: 'marketing',
});
console.log(` ✓ Migrated budget $${c.budget} for campaign "${c.name}"`);
}
}
Call await migrateCampaignBudgets() in startServer() after table creation.
- Step 2: Commit
git add server/server.js
git commit -m "feat: migrate Campaign.budget to BudgetEntries (idempotent)"
Chunk 2: Server — Budget Request Workflow + CEO Approval
Task 9: Add BudgetRequests table schema + CEO email setting
Files:
-
Modify:
server/server.js— REQUIRED_TABLES, TEXT_COLUMNS, appSettings -
Step 1: Add BudgetRequests to REQUIRED_TABLES
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' },
],
Add to TEXT_COLUMNS:
BudgetRequests: [
{ name: 'token_expires_at', uidt: 'SingleLineText' },
{ name: 'resolved_at', uidt: 'SingleLineText' },
],
- Step 2: Add ceoEmail to appSettings default
In the defaultSettings object (wherever uploadMaxSizeMB is initialized), add:
ceoEmail: ''
In PATCH /api/settings/app, add handling for ceoEmail:
if (ceoEmail !== undefined) {
appSettings.ceoEmail = String(ceoEmail).trim();
}
- Step 3: Commit
git add server/server.js
git commit -m "feat: add BudgetRequests table schema + ceoEmail setting"
Task 10: Add budget request CRUD routes
Files:
-
Modify:
server/server.js -
Step 1: Add GET /api/budget-requests
app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const requests = await nocodb.list('BudgetRequests', { limit: 200, sort: '-CreatedAt' });
// Enrich with requester name, campaign/project names
res.json(requests);
} catch (err) {
res.status(500).json({ error: 'Failed to load budget requests' });
}
});
- Step 2: Add POST /api/budget-requests
app.post('/api/budget-requests', requireAuth, requireRole('superadmin'), async (req, res) => {
const { amount, justification, earmarked_campaign_id, earmarked_project_id } = req.body;
if (!amount || amount <= 0) return res.status(400).json({ error: 'Amount must be positive' });
if (!justification?.trim()) return res.status(400).json({ error: 'Justification is required' });
const ceoEmail = appSettings.ceoEmail;
if (!ceoEmail) return res.status(400).json({ error: 'CEO email not configured. Go to Settings.' });
const token = require('crypto').randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
try {
const created = await nocodb.create('BudgetRequests', {
amount,
justification: justification.trim(),
status: 'pending',
requested_by_user_id: req.session.userId,
approval_token: token,
token_expires_at: expiresAt,
earmarked_campaign_id: earmarked_campaign_id ? Number(earmarked_campaign_id) : null,
earmarked_project_id: earmarked_project_id ? Number(earmarked_project_id) : null,
});
// Send email to CEO
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || 'http://localhost:3001';
const approvalUrl = `${appUrl}/approve-budget/${token}`;
const requesterName = req.session.userName || 'Team member';
// Use notifications.js pattern for email
const { sendMail } = require('./mail');
await sendMail({
to: ceoEmail,
subject: `Rawaj — Budget Request: ${amount}`,
html: renderBudgetRequestEmail({ amount, requesterName, justification: justification.trim(), approvalUrl }),
text: `${requesterName} is requesting ${amount}. Justification: ${justification.trim()}\n\nReview: ${approvalUrl}`,
});
res.status(201).json(created);
} catch (err) {
console.error('Budget request error:', err);
res.status(500).json({ error: 'Failed to create budget request' });
}
});
Add renderBudgetRequestEmail helper near the route (uses the same branded template pattern as notifications.js).
- Step 3: Add PATCH /api/budget-requests/:id/cancel
app.patch('/api/budget-requests/:id/cancel', requireAuth, requireRole('superadmin'), async (req, res) => {
try {
const request = await nocodb.get('BudgetRequests', req.params.id);
if (!request) return res.status(404).json({ error: 'Not found' });
if (request.status !== 'pending') return res.status(400).json({ error: 'Can only cancel pending requests' });
await nocodb.update('BudgetRequests', request.Id, { status: 'cancelled', resolved_at: new Date().toISOString() });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to cancel request' });
}
});
- Step 4: Commit
git add server/server.js
git commit -m "feat: add budget request CRUD routes"
Task 11: Add public approval endpoints
Files:
-
Modify:
server/server.js -
Step 1: Add GET /api/budget-approval/:token (public, no auth)
Returns request details for the approval page. Validates token exists and hasn't expired.
- Step 2: Add POST /api/budget-approval/:token/respond (public, no auth)
Body: { action: 'approve' | 'reject', note?: string }
On approve:
- Check status === 'pending' and token not expired (idempotent: if already approved, return 200 with existing result)
- Auto-create income BudgetEntry with campaign_id/project_id from earmarked fields
- Update request: status='approved', resolved_at=now, created_budget_entry_id=entry.Id
- Send notification email to requester (superadmin)
On reject:
- Update request: status='rejected', response_note=note, resolved_at=now
- Send notification email to requester
- Step 3: Add notification helpers for budget approval/rejection
Add to server/notifications.js:
-
notifyBudgetApproved({ request, entryId })— emails the requesting superadmin -
notifyBudgetRejected({ request, note })— emails the requesting superadmin -
Step 4: Commit
git add server/server.js server/notifications.js
git commit -m "feat: add public budget approval endpoints + notifications"
Chunk 3: Client — Finance Page + Budget Request UI
Task 12: Update Finance page to show budget requests
Files:
-
Modify:
client/src/pages/Finance.jsx -
Step 1: Add budget requests fetch and state
Add budgetRequests state, fetch from GET /api/budget-requests alongside the finance summary.
- Step 2: Add "Request Budget" button in header (superadmin only)
Next to the page title, show a teal button that opens a modal.
- Step 3: Add budget request modal
Modal with fields: amount (number input), justification (textarea), optional earmark dropdown (campaign or project). Submit calls POST /api/budget-requests.
- Step 4: Add budget requests section
Below the existing finance sections, add a "Budget Requests" list:
- Pending: amber badge, shows cancel button
- Approved: green badge, shows linked entry amount
- Rejected: red badge, shows CEO's note
- Cancelled: gray badge
If any pending requests exist, show a banner at the top: "N budget request(s) pending CEO approval"
- Step 5: Add i18n keys for budget requests
Add to both en.json and ar.json:
-
finance.requestBudget,finance.budgetRequests,finance.pendingApproval,finance.justification,finance.earmarkFor,finance.submitRequest,finance.cancelRequest,finance.approved,finance.rejected,finance.cancelled,finance.pending,finance.ceoNote,finance.requestPending -
Step 6: Commit
git add client/src/pages/Finance.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: budget requests UI on Finance page"
Task 13: Update Settings page — CEO email field
Files:
-
Modify:
client/src/pages/Settings.jsx -
Step 1: Add CEO email field in settings (superadmin only)
In the settings form, add a section "Budget Approval":
-
Label: "CEO / Budget Approver Email"
-
Input: email type, bound to
appSettings.ceoEmail -
Save alongside existing settings via
PATCH /api/settings/app -
Step 2: Add i18n keys
settings.ceoEmail, settings.ceoEmailHint, settings.budgetApproval
- Step 3: Commit
git add client/src/pages/Settings.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: CEO email setting for budget approval"
Task 14: Update Dashboard BudgetSummary
Files:
-
Modify:
client/src/pages/Dashboard.jsx -
Step 1: Update BudgetSummary to use new response shape
The finance summary response now has mainAvailable instead of computing remaining from the old formula. Update the component to use the new field. The spent field from tracks is no longer subtracted from main — it lives within campaign allocations.
- Step 2: Commit
git add client/src/pages/Dashboard.jsx
git commit -m "fix: dashboard budget uses new single-source response"
Chunk 4: Client — Public Approval Page + Campaign Budget Validation UI
Task 15: Create public budget approval page
Files:
-
Create:
client/src/pages/PublicBudgetApproval.jsx -
Modify:
client/src/App.jsx— add route/approve-budget/:token -
Step 1: Create the page component
Follow the same pattern as PublicReview.jsx:
- Fetch request via
GET /api/budget-approval/:token - Show: amount, requester name, justification, earmarked for (if set)
- Approve / Reject buttons + optional note textarea
- Submit via
POST /api/budget-approval/:token/respond - States: loading, active, success (with approved/rejected message), already-handled, expired, error
Use the teal brand color for the approve button, red for reject.
- Step 2: Add route in App.jsx
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
Add this alongside other public routes (before the auth-protected layout).
- Step 3: Add i18n keys
budgetApproval.title, budgetApproval.amount, budgetApproval.requestedBy, budgetApproval.justification, budgetApproval.earmarkedFor, budgetApproval.approve, budgetApproval.reject, budgetApproval.addNote, budgetApproval.approved, budgetApproval.rejected, budgetApproval.expired, budgetApproval.alreadyHandled
- Step 4: Commit
git add client/src/pages/PublicBudgetApproval.jsx client/src/App.jsx client/src/i18n/en.json client/src/i18n/ar.json
git commit -m "feat: public budget approval page"
Task 16: Add budget validation feedback to campaign creation UI
Files:
-
Modify:
client/src/pages/Campaigns.jsx(or wherever campaign creation modal lives) -
Step 1: Show available budget near the budget input
When user enters a budget amount for a new campaign, fetch mainAvailable from the finance summary (or a lightweight endpoint) and show: "Available: $X". If the entered amount exceeds available, show error inline and disable the submit button.
- Step 2: Handle 400 error from server
If campaign creation returns 400 with { error: 'Insufficient budget', available: X }, show a toast or inline error with the available amount and a suggestion to request more.
- Step 3: Same for track creation in CampaignDetail
When adding a track, show campaign available budget. Handle 400 insufficient errors.
- Step 4: Commit
git add client/src/pages/Campaigns.jsx client/src/pages/CampaignDetail.jsx
git commit -m "feat: budget validation UI for campaigns and tracks"
Task 17: Final verification
- Step 1: Build check
cd client && npx vite build --logLevel error
- Step 2: Manual test checklist
- Create income via budget request → CEO approves → funds appear
- Create campaign with budget > available → blocked
- Create campaign with budget ≤ available → succeeds, BudgetEntry created
- Create track exceeding campaign budget → blocked
- Delete campaign → funds return to main
- Create expense > available → blocked
- Reduce campaign budget below track allocations → blocked
- Finance summary shows correct numbers (same for superadmin and manager)
- Step 3: Commit any final fixes