Files
marketing-app/docs/superpowers/plans/2026-03-15-budget-allocation-redesign.md
T
fahed e1d1c392eb 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>
2026-03-15 15:36:19 +03:00

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 — the GET /api/finance/summary handler (~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 with campaign_id set (not Campaign.budget)
  • remaining = mainAvailable (no track double-counting)
  • Keep track aggregations (spent, revenue, impressions) for the campaign breakdown table
  • Add mainAvailable to 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.jsPOST /api/campaigns (~line 2097)

  • Step 1: Add validation + auto-create BudgetEntry

In the campaign creation handler, after creating the campaign:

  1. Import { acquireBudgetLock } from ./budget-mutex
  2. Import { getMainAvailable } from ./budget-helpers
  3. 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.jsPATCH /api/campaigns/:id

If budget field is being updated:

  1. Get current allocated = sum of income BudgetEntries for this campaign
  2. If increasing: check mainAvailable >= (newBudget - currentAllocated)
  3. If decreasing: check newBudget >= sum(tracks.budget_allocated) for this campaign
  4. 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.jsPOST /api/campaigns/:id/tracks (~line 2504) and PATCH /api/campaigns/:id/tracks/:trackId

  • Step 1: Add campaignAvailable check to track POST

Before creating the track:

  1. Import { getCampaignAvailable } from ./budget-helpers
  2. If budget_allocated > 0: check campaignAvailable >= budget_allocated
  3. If insufficient: return 400 { error: 'Insufficient campaign budget', available: campaignAvailable }
  • Step 2: Add same check to track PATCH

If budget_allocated is being updated:

  1. Get current track's budget_allocated
  2. Delta = newAmount - currentAmount
  3. 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.jsPOST /api/budget (~line 2343)

  • Step 1: Add mainAvailable check for expenses

In the budget entry creation handler:

  1. Validate amount > 0
  2. If type === 'expense': acquire lock, check mainAvailable >= amount, release
  3. 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.jsDELETE /api/campaigns/:id (~line 2174) and DELETE /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 in startServer() 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:

  1. Check status === 'pending' and token not expired (idempotent: if already approved, return 200 with existing result)
  2. Auto-create income BudgetEntry with campaign_id/project_id from earmarked fields
  3. Update request: status='approved', resolved_at=now, created_budget_entry_id=entry.Id
  4. Send notification email to requester (superadmin)

On reject:

  1. Update request: status='rejected', response_note=note, resolved_at=now
  2. 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:

  1. Fetch request via GET /api/budget-approval/:token
  2. Show: amount, requester name, justification, earmarked for (if set)
  3. Approve / Reject buttons + optional note textarea
  4. Submit via POST /api/budget-approval/:token/respond
  5. 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
  1. Create income via budget request → CEO approves → funds appear
  2. Create campaign with budget > available → blocked
  3. Create campaign with budget ≤ available → succeeds, BudgetEntry created
  4. Create track exceeding campaign budget → blocked
  5. Delete campaign → funds return to main
  6. Create expense > available → blocked
  7. Reduce campaign budget below track allocations → blocked
  8. Finance summary shows correct numbers (same for superadmin and manager)
  • Step 3: Commit any final fixes