fix: code review — security, dead code, performance, consistency

Critical fixes:
- XSS: escapeHtml() on all user-supplied text in email notifications
- Budget PATCH: added mutex lock + availability validation (prevents corruption)
- batchResolveNames: fixed wrong signature for budget request earmark names

Dead code cleanup:
- Deleted 8 unused PostComposition* files (replaced by PostDetail full page)

Performance:
- budget-helpers: single-fetch with computeFromEntries(), optional prefetch param
- post-composition: parallelized text + thumbnail fetches with Promise.all

Consistency:
- PostDetail.jsx: native <select> → PortalSelect (matches all panels)
- Finance.jsx: 11 hardcoded English table headers → t() with i18n keys
- PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys
- App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback)
- UploadZone: proper useRef pattern, no vanilla JS document.createElement
- All file inputs: className="hidden" → absolute w-0 h-0 opacity-0
- ArtefactDetailPanel: removed campaign/project selects (inherited from post)
- TranslationDetailPanel: removed brand/linked post selects (inherited from post)
- ApproverMultiSelect: portal-based dropdown (fixes clipping in modals)
- Thumbnail fix: post-composition constructs URL from filename (was undefined)
- Upload fix: UploadZone with drag-and-drop for design + video artefacts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-16 14:17:08 +03:00
parent ce4d6025d7
commit 49e1a796ed
34 changed files with 622 additions and 1172 deletions
+36 -4
View File
@@ -1,3 +1,4 @@
// TODO: Decompose routes into separate files by domain (posts, campaigns, tasks, artefacts, budget, auth)
require('dotenv').config({ path: __dirname + '/.env' });
const express = require('express');
@@ -1312,6 +1313,8 @@ app.get('/api/posts/:id', requireAuth, async (req, res) => {
});
app.post('/api/posts', requireAuth, async (req, res) => {
// NOTE: Client sends `assigned_to` but NocoDB column is `assigned_to_id`.
// Response includes both `assigned_to: post.assigned_to_id` for backward compat.
const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids, caption } = req.body;
const platformsArr = platforms || (platform ? [platform] : []);
@@ -1374,6 +1377,8 @@ app.post('/api/posts/bulk-delete', requireAuth, requireRole('superadmin', 'manag
});
app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
// NOTE: Client sends `assigned_to` but NocoDB column is `assigned_to_id`.
// Mapping: req.body.assigned_to → data.assigned_to_id (see below).
const { id } = req.params;
try {
const existing = await nocodb.get('Posts', id);
@@ -2525,6 +2530,7 @@ app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async
});
app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
let releaseLock = null;
try {
const existing = await nocodb.get('BudgetEntries', req.params.id);
if (!existing) return res.status(404).json({ error: 'Budget entry not found' });
@@ -2538,6 +2544,29 @@ app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
// Validate amount > 0 if being changed
if (data.amount !== undefined && (isNaN(Number(data.amount)) || Number(data.amount) <= 0)) {
return res.status(400).json({ error: 'Amount must be greater than 0' });
}
// Budget validation: check availability when changing to expense or increasing expense amount
const newType = data.type || existing.type || 'income';
const oldType = existing.type || 'income';
const newAmount = data.amount !== undefined ? Number(data.amount) : (existing.amount || 0);
const oldAmount = existing.amount || 0;
const needsCheck = (newType === 'expense' && oldType !== 'expense') ||
(newType === 'expense' && newAmount > oldAmount);
if (needsCheck) {
releaseLock = await acquireBudgetLock();
const { available } = await getMainAvailable();
// Add back the old expense amount if it was already an expense, then check new amount
const effectiveAvailable = oldType === 'expense' ? available + oldAmount : available;
if (newAmount > effectiveAvailable) {
return res.status(400).json({ error: 'Insufficient budget', available: effectiveAvailable });
}
}
await nocodb.update('BudgetEntries', req.params.id, data);
const entry = await nocodb.get('BudgetEntries', req.params.id);
@@ -2548,6 +2577,8 @@ app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
});
} catch (err) {
res.status(500).json({ error: 'Failed to update budget entry' });
} finally {
if (releaseLock) releaseLock();
}
});
@@ -2721,10 +2752,11 @@ app.get('/api/budget-requests', requireAuth, requireRole('superadmin'), async (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' },
});
// Resolve earmarked campaign/project names
for (const r of enriched) {
if (r.earmarked_campaign_id) r.earmarked_campaign_name = await getRecordName('Campaigns', Number(r.earmarked_campaign_id));
if (r.earmarked_project_id) r.earmarked_project_name = await getRecordName('Projects', Number(r.earmarked_project_id));
}
res.json(enriched);
} catch (err) {
console.error('Budget requests list error:', err);